feat(order,payment): a payment reaches its order, and a paid order completes (Q7-W1b)
Item: Q7-W1b (commerce flow wiring, wave 1b) — handover HANDOVER-2026-10-17-gauzy-px-improvements.md §5.4. Stacked on #10863 (W1a): until it merges, this diff includes it.
What was wrong
- Nothing wrote the core
paymentrow an authorised session stands for, socapture()refusedPAYMENT_NOT_AUTHORIZEDfor every session. - A cart's payment collection was never handed to the order the cart became (
paymentSessionIdwas dropped by the checkout handler). - Nothing wrote AUTHORIZATION / CAPTURE / REFUND / VOID rows on
order_transaction, so an order paid in full answeredNOT_PAIDwith its whole total outstanding. OrderService.completeIfSettledhad no caller: no order ever reachedCOMPLETED.
What this changes (additive)
- Payment:
authorizeSessionwrites the corepaymentrow in its own transaction (winner of the conditional transition only);voidSessionreleases it (canceledAmount,CANCELED).PaymentOrderLedgerMirrorreports authorise / capture / void / refund to the order after the payment's commit through the optional portPAYMENT_ORDER_LEDGER, never failing the payment (PAYMENT_ORDER_LEDGER_UNAVAILABLE/_FAILEDlogged).PaymentOrderLinkService.attachCart+PaymentCollectionService.attachToOrder(the one writer of the server-ownedorderId, only while unattached). - Order:
OrderPaymentLedgerServicewrites each fact at most once per(order, type, referenceType, referenceId)(refund as a negative row, currency checked against the order), recomputes and callscompleteIfSettled. Checkout (OrderCheckoutHandler, incl. the R3-10 recovery path) attaches the cart's payments through the optional portORDER_CHECKOUT_PAYMENTS. Manual corrections (POST /order-transactions) also try completion. - Operator door:
POST /orders/:id/complete+ GraphQLcompleteOrder(id, version, idempotencyKey)— same grant (ORDERS_EDIT), same@Idempotent('order.complete'), same@Versioned; refused409 ORDER_NOT_COMPLETABLEwith{status, statusAllows, hasOpenShippableLines, paymentSettled, hasOpenChange}. Added to the composedschema.graphqland the route↔mutation parity table. - Composition:
PAYMENT_ORDER_LEDGER → OrderPaymentLedgerService,ORDER_CHECKOUT_PAYMENTS → PaymentOrderLinkServiceinapps/api/src/plugin-composition.ts(worker binds nothing → no-op). - New constructor params are
@Optional()and last everywhere (suites constructing by position unchanged).
Known limit (documented in the service): the at-most-once check is a read before the write; the unique index belongs to the order migration set and comes with W3.
Tests
Ran locally: order (ledger 9, service incl. 3 completion cases, resolver parity, checkout) 58/58; payment (session +3 cases, capture +2, mirror 5, refund) 193/193. tsc --noEmit on the order and payment libs: nothing new (7 pre-existing errors in core/bootstrap/tracer.ts).
E2E (written, not run as a suite): tools/scripts/commerce-payment-order-e2e.mjs — REST: collection for the cart, session, authorise, complete the cart; GraphQL: order AUTHORIZED, capturePayment; REST: order CAPTURED, nothing outstanding, COMPLETED, each fact once on the ledger; operator door refused on an unpaid order over REST and GraphQL.
Self-reviewed the diff.
🤖 Generated with Claude Code
Summary by cubic
Wires payments through to their orders: authorizing a session now writes the core payment row, money facts land on the order's ledger, checkout hands the cart's payments to the order, and a paid order can reach COMPLETED when its last payment or shipment lands.
Payment → order (PAYMENT_ORDER_LEDGER, ORDER_CHECKOUT_PAYMENTS)
- Authorizing a session writes the
paymentrow it stands for in the same transaction, naming the order as read under the authorisation's lock; a void releases it; a refund reaches the order through its payment's order when the refund names no order. PaymentOrderLedgerMirrorreports authorization/capture/void/refund facts after the payment commits and never fails the payment when the ledger is unavailable.PaymentOrderLinkServiceattaches a cart's collection, payments, and refunds to the order at checkout, and records what they already did.
Order completion
OrderPaymentLedgerServicewrites each fact at most once per(order, type, referenceType, referenceId), recomputes totals, and callscompleteIfSettled; a repeated fact re-derives a stale order and its stored counters, finishing a recompute that failed after the insert.- A unique index now enforces one fact per
(order, type, referenceType, referenceId), existing duplicates reported but never deleted; the ledger answers a refused insert as a repeat. - Fulfillment asks the order to complete once the shipment that closed it is counted, retrying on a version conflict; a refused completion never fails the shipment.
- New operator door to the same rule:
POST /orders/:id/completeand GraphQLcompleteOrder, refused withORDER_NOT_COMPLETABLEand what still stands in the way.