feat(order,entitlement): a purchase grants the rights its lines carry (Q7-W5)
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()asksOrderLineService.placedLinesFor(order, lines)before the placing write, andorder.placedgainslines[{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 ontoorderLine.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 → nolines, placement unaffected (loggedORDER_ENTITLEMENT_POLICY_UNAVAILABLE). The hook is inplace(), so every placement path (checkout, staff draft, quote) is covered. - entitlement —
EntitlementOrderLinePolicyAdapter:nullwhenFEATURE_ENTITLEMENTis off; otherwise the variant'smetadata.entitlement, else the product's (never the line's own, which a cart editor writes; review fixa932afa610) (through the existingENTITLEMENT_CATALOG_PORT, each item read once). A policy is checked before it travels (knownkind, positive wholequantity, wholegracePeriodDays/activationLimit≥ 0, readable dates,endsAt > startsAt); an unusable one makes the line grant nothing (ENTITLEMENT_POLICY_INVALIDlogged) instead of dead-lettering the event; unknown members are dropped.IEntitlementCatalogItem.metadata?added (additive). order.cancelednow revokes with the new reasonORDER_CANCELED(wasREFUNDED, which the refund path also uses).- apps/api composition binds the port to the adapter (
EntitlementModuleimported).
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_POLICYport, asked byOrderService.place()before the placement write. - A line's policy comes from the variant's
metadata.entitlement, else the product's; the line's ownmetadata.entitlementis 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 (
startsAtpinned 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.cancelednow revokes with the newORDER_CANCELEDreason instead ofREFUNDED, which the refund path also uses.- No migrations or REST/GraphQL surface changes.