Conversation
…connect ISSUE: LIS-116
m1k3lm
left a comment
There was a problem hiding this comment.
Code review (automated, xhigh effort)
15 findings below, most severe first, posted inline. Verified against the PR head: full suite green (951 tests / 2723 assertions, including the 6 new PaymentMethodsCheckoutApiTest cases) and PHPStan level 6 clean — these are design/correctness issues the gates do not catch.
The four worth acting on before merge:
hasAvailablePaymentMethods()returns a truthy object on the error path, so a storefront guarding on it renders seQura on every failed call.- Dropping
$merchantIdfrom the publicOrderService::getAvailablePaymentMethodsInCategories()is a silent BC break for host integrations. - The new
$storeId-parameterised endpoint gives no actual store isolation, becauseSeQuraOrderRepositoryis not store-scoped. OrderNotFoundExceptiondegrades tostatusCode: 0/general.errors.unknowninstead of the 404 the service sets.
1 and 4 were confirmed by executing the error path, not inferred from reading.
Two non-code notes: the PR title ("Add SVEA deployment support to the WIX integration") does not match its contents (a checkout payment-methods endpoint), and the "Adjusted disconnect logic" bullet is unrelated scope inside a LIS-116 PR.
Unrelated to this PR but worth knowing: ./bin/phpcs cannot run as configured — .phpcs.xml.dist references SlevomatCodingStandard.Namespaces.FullyQualifiedGlobalFunctions and slevomat is absent from vendor/, so phpcs aborts with "Referenced sniff does not exist". The style gate is not actually running for anyone.
[Generated with Claude Code]
| * | ||
| * @return bool | ||
| */ | ||
| public function hasAvailablePaymentMethods(): bool |
There was a problem hiding this comment.
hasAvailablePaymentMethods() is truthy on the error path.
On a failed call the facade returns TranslatableErrorResponse (via ErrorHandlingAspect), and ErrorResponse::__call() swallows unknown methods and returns $this. Verified by executing it: get_class() is TranslatableErrorResponse and (bool) $response->hasAvailablePaymentMethods() is true.
Any storefront doing if ($response->hasAvailablePaymentMethods()) { render seQura } renders seQura payment methods on every failed call (unknown order ref, seQura down). The helper is only safe behind an isSuccessful() guard, which nothing enforces and no test covers.
There was a problem hiding this comment.
isSuccessful() must always be checked before accessing the response data, as this is part of the original CheckoutAPI design. We can remove hasAvailablePaymentMethods() and check the toArray() response in the integration to determine whether payment methods were returned.
There was a problem hiding this comment.
Following up on this thread rather than opening a new one: the reply says hasAvailablePaymentMethods() can be removed, but it is still on the response in the current head — and the round of changes since has hardened the trap rather than closed it.
testHasAvailablePaymentMethodsIsNoGuardOnAFailedCall now asserts self::assertSame($response, $response->hasAvailablePaymentMethods()), and the docblock documents the behaviour. So a bool-declared method returning a truthy TranslatableErrorResponse is now specified and regression-locked.
The practical failure is unchanged: SeQura API down -> HttpRequestException -> an integration's if ($response->hasAvailablePaymentMethods()) passes -> the storefront renders ['statusCode' => 0, 'errorCode' => 'general.errors.unknown', ...] where it expected categories. The predicate is most likely to be consulted exactly when it is least trustworthy, and "always call isSuccessful() first" is a convention the type signature actively contradicts.
Two options that actually close it:
- drop the method (as suggested above) and let callers read
toArray()/getPaymentMethodCategories()afterisSuccessful(); or - move the flag into the payload, e.g.
['categories' => [...], 'hasAvailablePaymentMethods' => bool], which also fixes the fact that the flag currently never crosses the wire for HTTP consumers.
Either way the test should assert the chosen contract instead of the __call fallthrough.
| * @throws OrderNotFoundException | ||
| */ | ||
| public function getAvailablePaymentMethodsInCategories(string $orderRef, string $merchantId): array | ||
| public function getAvailablePaymentMethodsInCategories(string $orderRef): array |
There was a problem hiding this comment.
Silent BC break for host integrations.
This is a consumed library, and dropping $merchantId from a public method changes its contract. PHP does not error on extra args to userland functions, so an existing getAvailablePaymentMethodsInCategories($ref, $merchantId) call in WooCommerce/PrestaShop keeps compiling while the merchant id is silently ignored and resolved from local storage instead.
It also now calls getSeQuraOrder() and throws OrderNotFoundException for any order not persisted in the host DB — a case that previously worked precisely because the caller supplied the merchant. Consider keeping the parameter as an optional deprecation shim.
There was a problem hiding this comment.
Restored the optional $merchantId parameter so existing callers can provide the merchant directly without requiring a stored order. Also updated the webhook flow to reuse the available merchant information and added test coverage for this case.
| * | ||
| * @return object | ||
| */ | ||
| public function paymentMethods(string $storeId): object |
There was a problem hiding this comment.
The new store-scoped endpoint provides no actual store isolation.
StoreContextAspect($storeId) only constrains store-scoped repositories, and SeQuraOrderRepository is not one: it has no StoreContext and getByOrderReference() filters on reference alone (src/BusinessLogic/DataAccess/Order/Repositories/SeQuraOrderRepository.php).
So in a multistore install, CheckoutAPI::get()->paymentMethods('storeA')->getPaymentMethodsInCategories(new PaymentMethodsInCategoriesRequest($refFromStoreB)) resolves store B's order and builds the authorized proxy from store B's merchant id.
This contradicts .claude/docs/codingStandard.md §8 ("Repositories are store-scoped: inject StoreContext, filter every query by storeId") and CLAUDE.md ("Anything reading/writing per-store config must respect the active store"). The gap is pre-existing, but this PR is what turns it into a storefront-facing, $storeId-parameterised endpoint.
There was a problem hiding this comment.
Would you prefer us to add storeId as an index in the SeQuraOrderRepository as part of this PR? This would affect existing merchants across all integrations and would require additional migrations for the existing data.
| * | ||
| * @throws Exception | ||
| */ | ||
| public function testGetPaymentMethodsInCategoriesForUnknownOrder(): void |
There was a problem hiding this comment.
Setup asymmetry with the sibling test.
This test passes a fresh MockSeQuraOrderRepository inline while leaving $this->orderRepository pointing at the container's repository; the test directly above it assigns $this->orderRepository.
It passes today only because it never touches that property. The next person adding an assertion through $this->orderRepository would be inspecting a different object from the one the service under test uses, and would get a confusing false negative.
There was a problem hiding this comment.
Removed the duplicated inline repository setup; both tests now use the shared useMockOrderProxy() helper.
ISSUE: LIS-116
54ef0df to
4393880
Compare
ISSUE: LIS-116
| * @throws DeploymentNotFoundException | ||
| */ | ||
| public function getAvailablePaymentMethodsInCategories(string $orderRef, string $merchantId): array | ||
| public function getAvailablePaymentMethodsInCategories(string $orderRef, string $merchantId = ''): array |
There was a problem hiding this comment.
'' as the "omitted" sentinel makes an explicitly-passed empty merchant id indistinguishable from no argument at all.
$merchantId = '' plus if ($merchantId === '') means the method cannot tell "caller omitted it" from "caller passed an empty string".
Concrete caller inside this class — createOrder(Webhook $webhook), line 339:
$this->getOrderPaymentMethodInfo(
$updatedSeQuraOrder->getReference(),
$webhook->getProductCode(),
(string)$updatedSeQuraOrder->getMerchant()->getId() // may be ''
);When that cast yields '', the new branch fires and:
- does an extra repository read that the caller had already avoided by holding the merchant, and
- reads it off the previously stored order (
$updatedSeQuraOrderis only persisted a few lines later), and - throws
OrderMerchantNotFoundExceptionwhere the call previously reached the proxy.
The same applies to any integration already calling this public method with an empty string. ?string $merchantId = null with if ($merchantId === null) removes the ambiguity for one character of extra code.
| $queueItem->setLastExecutionProgressBasePoints($item['lastExecutionProgress']); | ||
| $queueItem->setRetries($item['retries']); | ||
| $queueItem->setFailureDescription($item['failureDescription']); | ||
| $queueItem->setFailureDescription($item['failureDescription'] ?? ''); |
There was a problem hiding this comment.
This is a null -> '' conversion, not a missing-key guard, and it silently deletes the null case from every platform's test coverage.
tests/Infrastructure/Common/EntityData/QueueItems.json has failureDescription present on all 50 fixture entries; several of them are explicitly null (entries 4 and 5, for instance). And QueueItem::setFailureDescription(?string $failureDescription) accepts null. So ?? '' never guards a missing key — it rewrites the legitimate null fixtures into empty strings.
This is a shared abstract test that every platform's repository test extends. In sequra/integration-middleware, tests/Unit/GenericQueueItemRepositoryTest.php extends it, and that platform's transformer does:
// src/ORM/Transformers/QueueItemEntityTransformer.php:85
$preparedEntity['failure_description'] = substr($entity->getFailureDescription(), 0, 64000);substr(null, ...) is deprecated from PHP 8.1 and fatal under strict types — while the column is ->nullable() in the migration. So the null case is a real defect in the platform transformer, and this line makes the shared fixture stop producing it, hiding the bug for every integration at once.
It is also unrelated to this PR. CLAUDE.md, working principle 3: "Surgical changes. Touch only what the request requires... Every changed line should trace to the request."
| $queueItem->setFailureDescription($item['failureDescription'] ?? ''); | |
| $queueItem->setFailureDescription($item['failureDescription']); |
| new GetAvailablePaymentMethodsRequest( | ||
| $order->getReference(), | ||
| $order->getMerchant()->getId() | ||
| $this->getOrderMerchantId($order) |
There was a problem hiding this comment.
This turns an existing, non-throwing solicitation path into a throwing one — and the new exception is unmapped.
getAvailablePaymentMethods() is called by SolicitationController::solicitFor() on every checkout solicitation. Previously a solicited order with an empty merchant id passed '' to the proxy; now it raises OrderMerchantNotFoundException before the request is built.
Because that exception has no branch in ErrorHandlingAspect (see my comment there), the whole solicitation answers with statusCode: 0 / general.errors.unknown and is logged as an unhandled error — no SeQura payment methods at checkout, and no diagnostic pointing at the merchant id.
The repo has testSolicitationWithoutMerchant, which shows merchant-less solicitation is a scenario the codebase deliberately exercises. If tightening this path is intended, it needs the aspect mapping plus a test at the solicitation boundary; if it isn't, getAvailablePaymentMethods() should keep its previous lenient behaviour and only the new endpoint should demand a merchant.
| * | ||
| * @return mixed[] | ||
| */ | ||
| protected function paymentMethodToArray(SeQuraPaymentMethod $paymentMethod): array |
There was a problem hiding this comment.
paymentMethodToArray() is a byte-for-byte copy of the loop body in CachedPaymentMethodsResponse::toArray().
Compare src/BusinessLogic/CheckoutAPI/PaymentMethods/Responses/CachedPaymentMethodsResponse.php lines 35-54 — same 13 keys, same nested cost shape, same 'Y-m-d H:i:s' formats, in the same order.
The docblock justifies not delegating to SeQuraPaymentMethod::toArray() (different format, fair enough) — but it does not justify duplicating the sibling response. The stated goal is that "a storefront reads one shape whichever checkout endpoint it calls", and two independent copies is the one arrangement that cannot guarantee that: add a field to one and the shapes silently diverge.
Extract the serializer once (a small trait or a static method in this namespace) and have both responses call it.
| * @param GetAvailablePaymentMethodsRequest $request | ||
| * | ||
| * @throws HttpRequestException | ||
| * @throws ConnectionDataNotFoundException |
There was a problem hiding this comment.
Only one of the two methods got the new @throws tags, though both go through the same credentials path.
getAvailablePaymentMethods() (line 34) is built by the same AuthorizedProxyFactory and resolves the same connection data / credentials / deployment, so it throws exactly these three as well — it still documents only @throws HttpRequestException.
Since this PR also made OrderService::getAvailablePaymentMethods() newly throw (OrderMerchantNotFoundException), the two sibling methods now document their failure modes inconsistently.
| PaymentMethodsInCategoriesRequest $request | ||
| ): PaymentMethodsInCategoriesResponse { | ||
| return new PaymentMethodsInCategoriesResponse( | ||
| $this->orderService->getAvailablePaymentMethodsInCategories($request->getOrderRef()) |
There was a problem hiding this comment.
The serialized payload cannot express "categories exist but none has a method".
toArray() returns a bare JSON array of categories on success, but the error path returns a JSON object (statusCode / errorCode / ...). A storefront consuming this endpoint has to branch on the JSON top-level type to tell success from failure, and hasAvailablePaymentMethods() — the one piece of information the response class adds over the raw list — never crosses the wire, so every HTTP client has to re-derive it.
testGetPaymentMethodsInCategoriesCategoryWithoutMethods shows this is a real state: toArray() non-empty, hasAvailablePaymentMethods() false.
Returning a keyed object (['categories' => [...], 'hasAvailablePaymentMethods' => bool]) fixes both, and would also give the predicate a safe home (see my comment on the response class).
| $orderReference, | ||
| $merchantId | ||
| ); | ||
| $methodCategories = $this->getAvailablePaymentMethodsInCategories($orderReference, $merchantId); |
There was a problem hiding this comment.
Pure reformat, unrelated to the change.
The 4-line call was collapsed to one line while the arguments stayed identical. It adds a line to the diff that reviewers have to check for a behaviour change that isn't there.
CLAUDE.md, working principle 3: "Surgical changes. Touch only what the request requires. Don't refactor working code, reformat adjacent lines... Every changed line should trace to the request."
| { | ||
| $merchantId = (string)$order->getMerchant()->getId(); | ||
|
|
||
| if ($merchantId === '') { |
There was a problem hiding this comment.
Two different OrderNotFoundException classes exist; the new 404 mapping only covers one of them.
SeQura\Core\BusinessLogic\Domain\Order\Exceptions\OrderNotFoundException(this one,extends BaseException)SeQura\Core\BusinessLogic\Webhook\Exceptions\OrderNotFoundException(extends \Exception), thrown byWebhookValidator::validate()
The catch added to ErrorHandlingAspect matches only the first. WebhookAPI does not route through ErrorHandlingAspect today, so there is no live failure — but the collision is now load-bearing: anyone who later wires the webhook facade through the aspect, or fixes an import by IDE autocomplete, gets silently different behaviour depending on which of two identically named classes was picked.
Worth collapsing to one class (or renaming the webhook one) while this area is being touched.
The getter is typed string while the setter accepts null, so a null kept as-is made getFailureDescription() throw. The repository tests were coercing it away in the fixture, hiding the fault from every integration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Availability was a method on the response, which a failed call answers as an ErrorResponse whose __call returns itself — so callers read a failure as truthy. Carrying it in the payload also makes success and error agree on returning an object. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It extends the infrastructure base exception, so it carried no label and fell through to the generic handler as an unknown error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The merchant id is read off an untyped field, so callers holding it may pass an empty string; '' as the omitted-argument sentinel sent those to the stored-order lookup they had already avoided. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the new CheckoutAPI endpoint, the optional merchant argument and the two order exceptions now answered as 404, plus the queue item and partial disconnect fixes. Filed under Unreleased, the section the previous entries use before a tag is cut. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createOrder re-derived the merchant id with the same raw cast that getOrderMerchantId() replaces, so an order stored without one still reached the credentials lookup as an empty string and failed there with nothing pointing back at the order. It now goes through the guard like every other caller. The @throws on getOrderPaymentMethodInfo listed two exceptions it cannot raise: it is always handed a merchant id, so neither the stored order lookup nor the guard runs inside it. Replaced with what the proxy call can actually throw. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@copilot resolve the merge conflicts in this pull request |
# Conflicts: # CHANGELOG.md Co-authored-by: m1k3lm <1660934+m1k3lm@users.noreply.github.com>
Resolved the conflict by merging the latest |
Documented changes for version 5.8.0, including new features and exceptions.
What is the goal?
References
How is it being implemented?
How is it tested?