Skip to content

[LIS-116] CheckoutAPI endpoint for the payment methods of a solicited order - #68

Merged
m1k3lm merged 17 commits into
masterfrom
LIS-116
Sep 8, 2026
Merged

m1k3lm merged 17 commits into
masterfrom
LIS-116

Conversation

@dokmanovicsofija

Copy link
Copy Markdown
Collaborator

What is the goal?

  • The goal is to expose payment methods for an already solicited order through the Checkout API, without requiring integrations to provide the merchant ID.
  • Merchant resolution is now handled internally based on the stored seQura order.

References

How is it being implemented?

  • Added a new payment methods endpoint to the Checkout API
  • Added controller and request/response models for retrieving payment methods in categories
  • Updated the Order Service to resolve the merchant ID from the stored order
  • Registered the new controller in the Core bootstrap
  • Added handling to determine whether available payment methods exist
  • Adjusted disconnect logic to avoid unnecessary country configuration updates

How is it tested?

  • Unit tests

@m1k3lm m1k3lm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. hasAvailablePaymentMethods() returns a truthy object on the error path, so a storefront guarding on it renders seQura on every failed call.
  2. Dropping $merchantId from the public OrderService::getAvailablePaymentMethodsInCategories() is a silent BC break for host integrations.
  3. The new $storeId-parameterised endpoint gives no actual store isolation, because SeQuraOrderRepository is not store-scoped.
  4. OrderNotFoundException degrades to statusCode: 0 / general.errors.unknown instead 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() after isSuccessful(); 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/BusinessLogic/Domain/Order/Service/OrderService.php Outdated
Comment thread tests/BusinessLogic/Domain/Order/Services/OrderServiceTest.php
*
* @throws Exception
*/
public function testGetPaymentMethodsInCategoriesForUnknownOrder(): void

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the duplicated inline repository setup; both tests now use the shared useMockOrderProxy() helper.

Comment thread src/BusinessLogic/CheckoutAPI/CheckoutAPI.php Outdated
m1k3lm

This comment was marked as duplicate.

ISSUE: LIS-116
ISSUE: LIS-116
Comment thread src/BusinessLogic/AdminAPI/Aspects/ErrorHandlingAspect.php
* @throws DeploymentNotFoundException
*/
public function getAvailablePaymentMethodsInCategories(string $orderRef, string $merchantId): array
public function getAvailablePaymentMethodsInCategories(string $orderRef, string $merchantId = ''): array

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'' 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:

  1. does an extra repository read that the caller had already avoided by holding the merchant, and
  2. reads it off the previously stored order ($updatedSeQuraOrder is only persisted a few lines later), and
  3. throws OrderMerchantNotFoundException where 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'] ?? '');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."

Suggested change
$queueItem->setFailureDescription($item['failureDescription'] ?? '');
$queueItem->setFailureDescription($item['failureDescription']);

new GetAvailablePaymentMethodsRequest(
$order->getReference(),
$order->getMerchant()->getId()
$this->getOrderMerchantId($order)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 === '') {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 by WebhookValidator::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.

m1k3lm and others added 9 commits September 3, 2026 13:38
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>
@m1k3lm

m1k3lm commented Sep 8, 2026

Copy link
Copy Markdown
Member

@copilot resolve the merge conflicts in this pull request

# Conflicts:
#	CHANGELOG.md

Co-authored-by: m1k3lm <1660934+m1k3lm@users.noreply.github.com>

Copilot AI commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved the conflict by merging the latest master and retaining both changelog entries in merge commit 29b350b.

Copilot AI requested a review from m1k3lm September 8, 2026 11:18
Documented changes for version 5.8.0, including new features and exceptions.
@m1k3lm
m1k3lm merged commit e597063 into master Sep 8, 2026
5 checks passed
@m1k3lm
m1k3lm deleted the LIS-116 branch September 8, 2026 11:22
@m1k3lm m1k3lm changed the title Add SVEA deployment support to the WIX integration [LIS-116] CheckoutAPI endpoint for the payment methods of a solicited order Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants