feat(order,payment): a payment reaches its order, and a paid order completes (Q7-W1b)

Merged
EV
11d ago
+2613-46

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 payment row an authorised session stands for, so capture() refused PAYMENT_NOT_AUTHORIZED for every session.
  • A cart's payment collection was never handed to the order the cart became (paymentSessionId was dropped by the checkout handler).
  • Nothing wrote AUTHORIZATION / CAPTURE / REFUND / VOID rows on order_transaction, so an order paid in full answered NOT_PAID with its whole total outstanding.
  • OrderService.completeIfSettled had no caller: no order ever reached COMPLETED.

What this changes (additive)

  • Payment: authorizeSession writes the core payment row in its own transaction (winner of the conditional transition only); voidSession releases it (canceledAmount, CANCELED). PaymentOrderLedgerMirror reports authorise / capture / void / refund to the order after the payment's commit through the optional port PAYMENT_ORDER_LEDGER, never failing the payment (PAYMENT_ORDER_LEDGER_UNAVAILABLE / _FAILED logged). PaymentOrderLinkService.attachCart + PaymentCollectionService.attachToOrder (the one writer of the server-owned orderId, only while unattached).
  • Order: OrderPaymentLedgerService writes each fact at most once per (order, type, referenceType, referenceId) (refund as a negative row, currency checked against the order), recomputes and calls completeIfSettled. Checkout (OrderCheckoutHandler, incl. the R3-10 recovery path) attaches the cart's payments through the optional port ORDER_CHECKOUT_PAYMENTS. Manual corrections (POST /order-transactions) also try completion.
  • Operator door: POST /orders/:id/complete + GraphQL completeOrder(id, version, idempotencyKey) — same grant (ORDERS_EDIT), same @Idempotent('order.complete'), same @Versioned; refused 409 ORDER_NOT_COMPLETABLE with {status, statusAllows, hasOpenShippableLines, paymentSettled, hasOpenChange}. Added to the composed schema.graphql and the route↔mutation parity table.
  • Composition: PAYMENT_ORDER_LEDGER → OrderPaymentLedgerService, ORDER_CHECKOUT_PAYMENTS → PaymentOrderLinkService in apps/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 payment row 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.
  • PaymentOrderLedgerMirror reports authorization/capture/void/refund facts after the payment commits and never fails the payment when the ledger is unavailable.
  • PaymentOrderLinkService attaches a cart's collection, payments, and refunds to the order at checkout, and records what they already did.

Order completion

  • OrderPaymentLedgerService writes each fact at most once per (order, type, referenceType, referenceId), recomputes totals, and calls completeIfSettled; 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/complete and GraphQL completeOrder, refused with ORDER_NOT_COMPLETABLE and what still stands in the way.