Add SVEA deployment support to the WIX integration - #68
Add SVEA deployment support to the WIX integration#68dokmanovicsofija wants to merge 13 commits into
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 HttpRequestException | ||
| * @throws OrderNotFoundException | ||
| */ | ||
| public function getPaymentMethodsInCategories( |
There was a problem hiding this comment.
The endpoint's primary failure mode degrades to a generic unhandled error.
OrderNotFoundException extends Infrastructure\Exceptions\BaseException, so ErrorHandlingAspect misses its BaseTranslatableException catch and falls through to the generic Throwable catch. Verified output:
['statusCode' => 0, 'errorCode' => 'general.errors.unknown',
'errorMessage' => 'Unhandled error occurred: SeQura order with reference X is not found.']
The 404 that OrderService::getSeQuraOrder() deliberately sets is discarded, the storefront cannot distinguish "unknown order" from "seQura is down", and every stale checkout page hit is logged at ERROR as an unhandled error.
There was a problem hiding this comment.
Added proper handling for OrderNotFoundException, returning a translated 404 response instead of treating it as an unhandled error, with corresponding test coverage. Since the same error-handling aspect is used by both AdminAPI and CheckoutAPI, this behavior now applies consistently to all endpoints that can throw this exception.
| 'title' => $category->getTitle(), | ||
| 'description' => $category->getDescription(), | ||
| 'icon' => $category->getIcon(), | ||
| 'methods' => array_map(static function (SeQuraPaymentMethod $paymentMethod) { |
There was a problem hiding this comment.
The public checkout payload delegates to the ORM persistence serializer.
SeQuraPaymentMethod::toArray() is what DataAccess/PaymentMethod/Entities/PaymentMethod::toArray() persists: it emits long_title, starts_at, ends_at, cost_description, min_amount.
The sibling CachedPaymentMethodsResponse::toArray() in this same Responses/ folder emits longTitle, startsAt, endsAt, costDescription, minAmount for the same model. Storefront JS consuming CheckoutAPI now gets two different shapes for one entity, and any future change to the entity storage format silently changes the public checkout API.
Map the fields explicitly here, matching CachedPaymentMethodsResponse.
There was a problem hiding this comment.
Updated the payment methods response to use an explicit public payload structure instead of the model’s toArray(), preventing internal storage fields from being exposed. Updated the test to validate the exact response structure.
|
|
||
| return $this->proxy->getAvailablePaymentMethodsInCategories( | ||
| new GetAvailablePaymentMethodsRequest($orderRef, $merchantId) | ||
| new GetAvailablePaymentMethodsRequest($orderRef, (string)$order->getMerchant()->getId()) |
There was a problem hiding this comment.
The (string) cast turns a missing merchant id into ''.
Merchant::getId() is untyped (@return int|string over an untyped property). For a stored order whose merchant record lost its id, (string) null yields '', which OrderProxy hands to authorizedProxyFactory->build(''); that fails deep in credentials lookup with a confusing CredentialsNotFoundException rather than a clear "order has no merchant".
Note getAvailablePaymentMethods() about 20 lines above does not cast at all — the same expression handled two different ways in adjacent methods. An explicit check/exception would be clearer than either.
There was a problem hiding this comment.
Added explicit validation for the order merchant ID and reused it across both payment methods flows. Orders without a merchant now throw a clear OrderMerchantNotFoundException instead of failing later with an unrelated credentials or type error, with test coverage added.
| * | ||
| * @throws Exception | ||
| */ | ||
| public function testGetPaymentMethodsInCategoriesUsesMerchantOfStoredOrder(): void |
There was a problem hiding this comment.
Third copy of the same setup block.
This block and the two around it (~393 and ~522) repeat new OrderService(new MockOrderProxy(), new MockSeQuraOrderRepository(), $this->merchantOrderBuilder, TestServiceRegister::getService(OrderCreationInterface::class)) plus the same SeQuraOrder.json json_decode/file_get_contents/setReference/setSeQuraOrder sequence, with only the merchant id varying.
A private function storeOrderWithMerchant(string $merchantId): void plus a small service factory would collapse all three; as written they have to be edited in lockstep whenever the OrderService constructor changes.
There was a problem hiding this comment.
Added two private test helpers to centralize the repeated service setup and order creation with a merchant. Updated the new payment-method category tests to use these helpers and reduce duplicated setup code.
| * | ||
| * @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.
| /** | ||
| * @return SeQuraPaymentMethodCategory[] | ||
| */ | ||
| public function getPaymentMethodCategories(): array |
There was a problem hiding this comment.
getPaymentMethodCategories() has no caller.
Grep across src/ and tests/ finds only the declaration. CLAUDE.md working principle 2: "Write the minimum code that solves the problem — nothing speculative... no abstractions for single-use code." Drop it, or add the test that justifies it.
There was a problem hiding this comment.
The getter is used by the Wix integration to retrieve the category models for rendering. Added testGetPaymentMethodsInCategoriesReturnsCategoryModels to cover this behavior.
| * | ||
| * @return object | ||
| */ | ||
| public function paymentMethods(string $storeId): object |
There was a problem hiding this comment.
Naming: the generic name went to the narrower feature.
CheckoutAPI::paymentMethods() and CheckoutAPI::cachedPaymentMethods() now sit side by side, resolving PaymentMethodsCheckoutController and CachedPaymentMethodsController from the same CheckoutAPI\PaymentMethods namespace.
An integrator picking paymentMethods() by name has no way to know it requires an already-solicited order reference while the other takes a merchant id. Consider naming it for what it does (e.g. solicitedOrderPaymentMethods()), or adding it as a second method on the existing controller.
There was a problem hiding this comment.
Renamed the method to solicitedOrderPaymentMethods() for clarity and consistency with the existing core terminology, and updated its tests and Wix caller accordingly.
ISSUE: LIS-116
54ef0df to
4393880
Compare
ISSUE: LIS-116
| ); | ||
|
|
||
| $response = TranslatableErrorResponse::fromError(new WrongCredentialsException()); | ||
| } catch (OrderNotFoundException $e) { |
There was a problem hiding this comment.
OrderMerchantNotFoundException — added by this same PR — has no mapping here, so it surfaces as general.errors.unknown.
This PR adds a dedicated exception precisely so that a merchant-less order produces a meaningful error, and PaymentMethodsCheckoutController documents @throws OrderMerchantNotFoundException. But OrderMerchantNotFoundException extends BaseException (infrastructure), not BaseTranslatableException — so it falls straight through to the catch (Throwable) branch below.
Result: a stored order whose merchant lost its id answers the storefront with statusCode: 0, errorCode: 'general.errors.unknown', logged as "Unhandled error occurred." — exactly the outcome the new exception's own docblock says it exists to prevent ("fails with no way back to the order that caused it").
Either add a TranslatableOrderMerchantNotFoundException and a catch branch alongside the one added here, or make OrderMerchantNotFoundException extend BaseTranslatableException so the first catch picks it up. There is currently no test covering this exception at the API boundary, which is why it went unnoticed.
| * @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>
What is the goal?
References
How is it being implemented?
How is it tested?