feat(order,entitlement): a purchase grants the rights its lines carry (Q7-W5)

Merged
EV
10d ago
+917-7

Q7-W5 (part 1 of 2) — entitlements issued from purchases. Plan: HANDOVER-2026-10-17-gauzy-px-improvements.md §5.2/§5.4 W5.

Gap (measured on develop dd8b7b640c): EntitlementGrantConsumer.onOrderPlaced grants a PENDING right per data.lines[].entitlement, but order.placed carried no lines, so a purchase never granted anything.

Change

  • order — new port ORDER_LINE_ENTITLEMENT_POLICY (order-line/order-line-entitlement.ts). OrderService.place() asks OrderLineService.placedLinesFor(order, lines) before the placing write, and order.placed gains lines[{orderLineId, productId, variantId, quantity, entitlement?}] + linesTotal / linesTruncated. A line that grants a right is always carried; the cap (500) applies only to lines that grant nothing. A policy from the catalogue is snapshotted onto orderLine.metadata.entitlement (other metadata kept), so what was bought does not change when the catalogue does. Port unbound (worker), capability off, or a port error → no lines, placement unaffected (logged ORDER_ENTITLEMENT_POLICY_UNAVAILABLE). The hook is in place(), so every placement path (checkout, staff draft, quote) is covered.
  • entitlement — EntitlementOrderLinePolicyAdapter: null when FEATURE_ENTITLEMENT is off; otherwise the variant's metadata.entitlement, else the product's (never the line's own, which a cart editor writes; review fix a932afa610) (through the existing ENTITLEMENT_CATALOG_PORT, each item read once). A policy is checked before it travels (known kind, positive whole quantity, whole gracePeriodDays/activationLimit ≥ 0, readable dates, endsAt > startsAt); an unusable one makes the line grant nothing (ENTITLEMENT_POLICY_INVALID logged) instead of dead-lettering the event; unknown members are dropped. IEntitlementCatalogItem.metadata? added (additive).
  • order.canceled now revokes with the new reason ORDER_CANCELED (was REFUNDED, which the refund path also uses).
  • apps/api composition binds the port to the adapter (EntitlementModule imported).

Already in place, wired by this PR: idempotency per order line (EntitlementService.grant → findExistingGrant on orderLineId + kind, non-revoked; the delivery record dedupes replays); activation on order.completed (W1b #10890 calls completeIfSettled) and payment.captured (W3, not yet produced) → activateGranted({orderId}); refunds → payment.refunded lines (W3).

Not changed / residuals: no unique index on (orderLineId, kind) — two concurrent deliveries of one order.placed could still both insert (check-then-insert); a request-less placement reads the flag's default (off) → no lines; no migrations; no REST/GraphQL surface change (read back via existing GET /entitlements?orderId= / entitlements).

Hunks kept apart from #10890 / #10897 (they rewrite createFromCart and the OrderService constructor; this touches place() only and adds the port to OrderLineService; composition hunks sit next to ENTITLEMENT_CATALOG_PORT).

Tests (red-first, new files): order-line/order-line-entitlement.spec.ts (8) and events/entitlement-order-line-policy.adapter.spec.ts (6) — both fail on develop (missing modules / reason), 14/14 + existing consumer spec 3/3 green locally. tsc order + entitlement: no new errors. Static gates 30/30.

🤖 Generated with Claude Code


Summary by cubic

Fixes purchases never granting rights: order.placed carried no lines, so a purchase granted nothing. Placing an order now asks what right each line grants, carries those lines on the event, and the grant consumer issues a PENDING right per line.

  • New ORDER_LINE_ENTITLEMENT_POLICY port, asked by OrderService.place() before the placement write.
  • A line's policy comes from the variant's metadata.entitlement, else the product's; the line's own metadata.entitlement is never read or asked about, since line metadata is cart-editor-written and honoring it would let a cart editor choose the right a purchase grants.
  • Catalogue policies are snapshotted onto the line after the placement commits; a placement refused on a stale version leaves the draft untouched.
  • A line that grants a right is always carried; the 500-line cap applies only to lines that grant nothing.
  • A term with an end and no start starts at the placement (startsAt pinned in the event); a policy whose end has already passed makes the line grant nothing, so a late delivery can't fail the whole event.
  • Unbound port, feature off, or port error means no lines and the placement is unaffected.
  • order.canceled now revokes with the new ORDER_CANCELED reason instead of REFUNDED, which the refund path also uses.
  • No migrations or REST/GraphQL surface changes.