feat(order,promotion): an order uses the promotions it was granted, and their limits hold (Q7-W1c)

Merged
EV
10d ago
+1095-112

Item: Q7-W1c (commerce flow wiring, wave 1c) — handover HANDOVER-2026-10-17-gauzy-px-improvements.md §5.4. Stacked on #10890 (W1b) ← #10863 (W1a): until they merge, this diff includes them.

What was wrong

Since R3-07 the cart asks the promotion engine what a code grants (CART_PROMOTION_EVALUATION), but nothing recorded that the promotion was used: PromotionService.reserveUsage, PromotionUsageService.register and revertUsage had no caller outside the package, so promotion_usage stayed empty, usageCount never moved, and a promotion limited to N orders could be redeemed by every order.

What this changes (additive)

  • Port ORDER_PROMOTION_USAGE (declared in order) → PromotionOrderUsageAdapter (new, promotion; provided + exported by PromotionModule), bound in apps/api/src/plugin-composition.ts.
  • createFromCart(cart, { recordPromotionUsage: true }) (asked by the checkout handler only):
    • before the order row is written, reserves each cart promotion snapshot that names a promotion (reserveUsage reuses the cart's live reservation, so a retried checkout costs nothing); a reached limit refuses the checkout with 409 PROMOTION_USAGE_LIMIT_REACHED and no order is written;
    • after placement, registers the reservations against the order (logged, never failing a placed order).
  • OrderService.cancel() reverts the order's redemptions after the cancellation committed (logged on failure).
  • The subscription path does not ask (it builds its own cart).
  • Coupons: the cart refuses issued coupon codes since R3-07, so no couponId snapshot reaches here today; a snapshot that carries one is reserved with it. Campaign budget consumption is not part of this PR (recorded in §5).

Tests

Ran locally: order.service.spec.ts +5 (reserve-before-write and register order; refusal writes no order; no recording when not asked; revert on cancel; cancel stands when revert fails) and checkout specs — 41/41; promotion-order-usage.adapter.spec.ts 5/5.
E2E (written, not run as a suite): tools/scripts/commerce-promotion-usage-e2e.mjs — fresh promotion limited to one order: REST complete registers one redemption (REST ledger) and usageCount 1 (GraphQL); a second cart completed over GraphQL is refused and no second live redemption exists; REST cancel reverts it and usageCount is 0.

Self-reviewed the diff.

🤖 Generated with Claude Code


Summary by cubic

Orders now record the promotions they were granted and enforce their usage limits at checkout. This diff also carries the stacked W1a (cart discounts and taxes onto orders) and W1b (payments reach orders, paid orders complete) work, since they are not yet merged.

Promotion usage

  • Reserves each cart promotion before the order row is written; a reached limit refuses checkout with 409 PROMOTION_USAGE_LIMIT_REACHED and no order is written.
  • Enforces per-customer usage limits at reservation, with 409 PROMOTION_CUSTOMER_LIMIT_REACHED.
  • Registers reservations against the order after placement, reverts them on cancellation, and releases them when a checkout fails after taking them.
  • Reuses a cart's live reservation on retried checkouts, so a retried checkout costs nothing; the R3-10 recovery path registers a recovered order's uses.
  • The subscription path does not ask for recording.
  • The limit refusal carries a stable code, and a partial revert reports the refused redemptions together.
  • Adds e2e scripts covering reservation, limit refusal, revert, a checkout race, payments reaching orders, and completion.

W1b completeness follow-ups

  • A repeated money fact re-derives an order whose stored money state disagrees with its ledger and then asks for completion.
  • Fulfillment asks the order to complete after a shipment is counted, so orders paid before they ship complete too.
  • The payment row takes the collection's orderId as read under the authorisation's lock; a refund naming only its payment is attributed to that payment's order.