fix(entitlement): race-safe grant and activation on a late order.placed
Summary
Closes the two gaps #11023 lists in the entitlement grant path:
- A.
grant()is now race-safe. A new migration addsUQ_entitlement_live_line_grant, one live (not revoked, not deleted) right per tenant, order line and kind.grant()answers the database's refusal by returning the winner's right as a replay. A line can still be granted again after its right is revoked: a derived number that the revoked right still holds takes the next revision (…-L-2). - B. A late
order.placedfollows where the order is now. The placement asks the order package for the order's state:- already cancelled or refunded in full: it grants nothing and logs why;
- settled since: it puts into force, line by line, the rights of the lines still paid for in full, at their granted quantity. A line refunded in full or in part before the placement keeps its right
PENDING.
Design (confirmed by the owner): B uses option 2 from the issue, with the existing order and order-line rows as the durable memo instead of a new memo table.
order.completedis appended in the same transaction that writesstatus = COMPLETED, so the row cannot disagree with the event.order_line.refundedQuantityis the order package's own cache over its succeeded refunds.- The entitlement package does not read those tables itself. It reads them through a new optional port that the order package answers, following the same pattern as
ENTITLEMENT_CATALOG_PORT.
Why
- The grant consumer is at-least-once and
REORDERABLE. Whenorder.completed/payment.capturedis handled before a retriedorder.placed, the settlement finds nothingPENDINGto activate. The rights the late placement then grants stayedPENDING, and no event was left to move them. grant()read before it inserted (findExistingGrant), and nothing in the database backed that read:IDX_entitlement_order_lineis not unique. Two concurrent grants of one line could both insert, giving one purchase two licences. When the numbering series is unreachable, both grants derive the same number, so the second failed onUQ_entitlement_numberwith a raw driver error instead of a replay. The same derived-number collision also made a re-grant after revocation fail.
What changed (by package)
@gauzy/plugin-entitlement
-
database/migrations/1791000003580-AddEntitlementLiveLineGrantUniqueIndex.ts(new), registered inENTITLEMENT_MIGRATIONS:- PostgreSQL / SQLite: partial unique index on
("tenantId", "orderLineId", "kind") WHERE "orderLineId" IS NOT NULL AND "status" <> 'REVOKED' AND "deletedAt" IS NULL. - MySQL: a
VIRTUALgenerated columnliveLineGrantKey(tenantId:orderLineId:kindwhile the row is a live line grant, otherwiseNULL) plus a unique index on it. Same approach as1791000000793in the order package. - Fail-safe: if duplicate live rights already exist, it creates nothing, changes no row and logs the affected order lines. It never deletes or updates.
- Every statement is guarded, and
down()drops exactly whatup()created, on all three dialects.
- PostgreSQL / SQLite: partial unique index on
-
entitlement/entitlement.service.ts:grant()writes throughinsertGrant/writeGrant. On a unique violation (kernelisUniqueViolation:23505,ER_DUP_ENTRY,SQLITE_CONSTRAINT_UNIQUE, or the driver message) it re-reads:- a live right for the provenance exists → it is returned as a replay: no event, no key, no conditions;
- the number was derived from the provenance → the next revision is tried, at most 10 attempts;
- otherwise → the original error is rethrown. A number the caller stated or the series allocated is a real conflict.
- Any other error is rethrown untouched.
-
entitlement-number.ts(new):deriveEntitlementNumber(input, revision), moved out of the service. Revision 0 is byte-identical to the oldderiveNumber. -
entitlement.types.ts:ENTITLEMENT_ORDER_STATE_PORT,IEntitlementOrderStatePort,IEntitlementOrderState(status, payment status,completedAt,canceledAt,lines) andIEntitlementOrderLineState(id,quantity,refundedQuantity). -
events/entitlement-order-settlement.ts(new): pure rules and the status lists they decide on.orderWithdrawalReason/isOrderWithdrawn: the order isCANCELED(or hascanceledAt) →ORDER_CANCELED; the payment isREFUNDED→REFUNDED.isOrderSettled: false when the order is withdrawn or its payment isCANCELED. Otherwise true forCOMPLETED,completedAtor paymentCAPTURED. AnARCHIVEDorder is decided by its timestamps only.isOrderLinePaidInFull/activatableOrderLines: on a settled order, only the lines with something bought and nothing refunded; a line the port does not list is left out. Without line state, nothing is activated onPARTIALLY_REFUNDED, and every line is activated otherwise.- A partly refunded line is left
PENDINGrather than lowered. The refund handler subtracts what it is told; it does not reconcile to a total. Lowering the right here would therefore apply a refund a second time when that refund's own delivery, possibly still queued, arrives.
-
entitlement/entitlement.service.ts:activateGrantedtakes an optionalorderLineIdthat narrows an order (equality filter, the same transaction and events). -
entitlement/entitlement.service.ts:reduceanswers a right that is alreadyREVOKEDas it stands, with no write and noREDUCEDevent, decided under the row lock (asrevoke, and a reduction to zero, already did). Before, a reduction rewrote a revoked row's quantity and announced it; a REST or GraphQL reduction of a revoked right now returns it unchanged instead. -
entitlement/entitlement.service.ts:findGranted({ orderId | orderLineId | subscriptionId }, scope)reads the rights one provenance granted, in any status, under the scope stated. It goes through the service's own context-safe read path: the TypeORM repository under TypeORM (the samefindthe consumer made before, since a delivery has no request user to scope by), and a fresh MikroORM fork under MikroORM. With no provenance named it answers[]. -
events/entitlement-grant.consumer.ts: optional@Inject(ENTITLEMENT_ORDER_STATE_PORT). For a placement with at least one granting line:- It reads the order before granting. If the order is withdrawn, it grants nothing and logs a warning with the reason. It also finishes the idempotent withdrawal of anything an earlier attempt granted, so a retry after a part-way withdrawal does not leave rights in place.
- It grants as before.
- It reads the order again after granting:
- If the order is now withdrawn (a cancellation landed while it was granting), it withdraws what the order granted, with the reason that event gives.
- Otherwise it calls
activateGranted({ orderId, orderLineId })for each admitted line.
A failed read fails the delivery, so the delivery is retried. The withdrawal loops of
order.canceledand the full refund now share onewithdrawOrderhelper.A partial refund on a line granted again after a revocation now lowers only the live right: the revoked one comes out unchanged through
reduce's own check. Withdrawal replays still see every row.Every read of an event's rights (order, order line, subscription) now goes through
findGranted. The consumer used the CRUDfind, which under MikroORM reads on the global entity manager; outbox deliveries create no MikroORM request context, andallowGlobalContextis only forced on under TypeORM, so those reads could fail underDB_ORM=mikro-orm. Under TypeORM the statement is unchanged. -
README.md: the replay rule and the late-placement rules.
@gauzy/plugin-order
order/order-state.reader.ts(new):OrderStateReader.findState(orderId, scope)returns{ id, status, paymentStatus, fulfillmentStatus, completedAt, canceledAt, lines: [{ id, quantity, refundedQuantity }] }ornull.- The tenant is required; without one it answers
nulland never reads across tenants. The organization narrows the read when given, and the lines are read under the same scope. linesisnullunless the lines'refundedAmountadds up (exact decimal sum) to at least the order's ledgerrefundedTotal. The payment package reports line refunds after the refund commits and only logs a lost report, and a refund need not name a line. A short register would understate a refund, so the caller is told it cannot decide per line.- The order and its lines are read through the configured ORM inside one
OrderUnitOfWork, so it works under MikroORM without a request context. - Provided and exported by
OrderModule, and exported from the package index.
- The tenant is required; without one it answers
apps/api
plugin-composition.ts:{ provide: ENTITLEMENT_ORDER_STATE_PORT, useExisting: OrderStateReader }, added to the exports.
No new dependency, no removed API, endpoint, file or setting. deriveNumber was a private method; its logic moved to the exported helper unchanged.
Flags/settings and their defaults
- No new runtime flag. B is gated by the port binding. Unbind
ENTITLEMENT_ORDER_STATE_PORTinapps/api/src/plugin-composition.tsand the consumer behaves exactly as before: it grantsPENDINGand reads nothing. Any other host that composes the plugins without binding the port keeps today's behaviour. - With the port bound, B does only what the missed events would have done had they come in order:
- it puts into force the rights that the settlement event would have activated, and only for lines still paid for;
- it does not grant for an order that a cancellation or full refund would already have withdrawn.
- A is the database stating the invariant
grant()already enforced by reading: one live right per line and kind. No supported path writes a second one on purpose. FEATURE_ENTITLEMENT(default off) still gates every route, as before.
Tests
Node 26, from the worktree root, with NODE_OPTIONS=--experimental-vm-modules:
npx jest -c packages/plugins/entitlement/jest.config.ts
→ Test Suites: 21 passed, 21 total; Tests: 475 passed, 475 total (develop baseline: 17 suites, 383 tests)
npx jest -c packages/plugins/entitlement/jest.config.ts packages/plugins/entitlement/src/lib/entitlement-lifecycle.dual-orm.spec.ts
→ Test Suites: 1 passed; Tests: 47 passed (real SQLite, TypeORM and MikroORM halves)
npx jest -c packages/plugins/entitlement/jest.config.ts src/lib/events
→ Test Suites: 4 passed; Tests: 52 passed
npx jest -c packages/plugins/order/jest.config.ts packages/plugins/order/src/lib/order/order-state.reader.spec.ts packages/plugins/order/src/lib/order-totals
→ Test Suites: 3 passed; Tests: 73 passed
npx tsc -p packages/plugins/entitlement/tsconfig.lib.json --noEmit → clean
npx tsc -p packages/plugins/entitlement/tsconfig.spec.json --noEmit → clean
npx tsc -p packages/plugins/order/tsconfig.lib.json --noEmit → clean
npx tsc -p apps/api/tsconfig.app.json --noEmit → clean
npx eslint <touched files> → 0 errors in the lines this PR adds (the one error reported, `prefer-const` on `let manager` in entitlement.service.spec.ts, is on develop already)
prettier --check → new files clean; in the pre-existing files only the hunks this PR touches were formatted (develop's copies are not prettier-clean, so reformatting whole files would add unrelated churn)
What the new specs cover:
- Migration (unit, all three dialects): the exact DDL per dialect, catalogue probes, reuse of a MySQL column left behind by an interrupted run, no DDL when the index is present, the table absent or duplicates exist (never a
DELETE/UPDATE), the duplicate report,down(), an unsupported dialect, run order. - Real SQLite on both TypeORM and MikroORM (
entitlement-lifecycle.dual-orm.spec.ts; MikroORM withallowGlobalContext: false):- the migration's own
up/down; - the database refuses a second live right for a line and kind, and admits one after revocation or of another kind;
- a grant that lost the race returns the winner's right, writing no row, event or key;
- a derived number after revocation becomes
E-…-L-2; order.completedthenorder.placedthrough the real consumer endsACTIVE, and staysPENDINGwhen the order has not settled or no port is bound;- on an order refunded in part, only the line still paid for ends
ACTIVE, and the refunded line staysPENDING. Without line state, both stayPENDING; - on a line that bought 2 with 1 already recorded as refunded, the refund delivered after the placement leaves the right
PENDINGwith quantity 1, so the refund is applied once; - a cancelled order (
order.canceleddelivered first) or a fully refunded order gets no right at all; - a partial refund on a line granted again after a revocation lowers only the live right; the revoked row keeps its quantity and gets no
REDUCEDevent; - all of these run the consumer's real
findGrantedread on both ORMs, so the MikroORM half reads rights inside a delivery without a request context.
- the migration's own
grant()unit: replay for the Postgres (driverError.code 23505), MySQL (ER_DUP_ENTRY), SQLite (SQLITE_CONSTRAINT_UNIQUE) and MikroORM (message only) error shapes; non-unique errors and caller- or series-number conflicts rethrown; revision numbering and its bound.- Settlement rules: withdrawal reasons; an
ARCHIVEDorder read by its timestamps; the paid-in-full check, including numeric strings and values that are not numbers; per-line activation with and without line state; deduplication. - Consumer:
- per-line
activateGrantedcalls; - the partial refund, with and without line state: a partly refunded line is neither lowered nor activated;
- a replay never lowers a right raised since;
- no grant, with a warning, for
CANCELED,REFUNDEDand archived-cancelled orders; - withdrawal when the order is withdrawn between the two reads;
- read failures before and after granting;
- no reads when the port is unbound or nothing grants.
- per-line
activateGranted: narrowing to one line of an order; a line given without its order is ignored.findGranted: by order, by line and by subscription, in any status; another tenant's scope reads nothing; no provenance reads nothing.reduce: a revoked right is answered unchanged, with no event, whether or not the quantity asked is lower.OrderStateReader:- lines and timestamps on both ORM arms, read in one MikroORM fork;
lines: nullfor a lost line report or a refund on no line;- an exact decimal match, and a register ahead of the ledger;
- tenant required; both reads propagate errors.
- Number helper.
Jest prints "A worker process has failed to exit gracefully" for suites that import @gauzy/contracts enum values. --detectOpenHandles (in band) reports no open handle and the warning goes away, so it comes from worker teardown, not a leak.
Manual verification still needed
- The migration has not been run against real PostgreSQL or MySQL here. The statements are asserted per dialect, and SQLite runs them for real on both ORMs. The MySQL key column is
VIRTUAL(no table rewrite, same as1791000000793). The lane plan saidSTORED; InnoDB supports unique secondary indexes on virtual generated columns, but this is worth one real MySQL run. - No end-to-end run with the real outbox dispatcher delivering events out of order. The consumer is exercised directly with the real services on SQLite.
Risk and rollback
- Existing duplicates: an install that already holds two live rights for one line and kind keeps them, and the index is not created there. The migration names the affected order lines. Once an operator has revoked the extra rights, the logged statements create the index, and
grant()keeps its read-before-insert until then. payment.capturedhas no producer on develop.order.completedis the settlement event in practice, and it is atomic withstatus = COMPLETED. A futurepayment.capturedproducer should commitpaymentStatus = CAPTUREDbefore or with the event. Otherwise a placement handled in between reads "not settled" and leaves the rightsPENDING, which is today's behaviour, not a regression.- Late placement on a refunded line: the line's right is still granted
PENDING, as the review asked: it is leftPENDING, not skipped. A later full refund event, if any, revokes it as before. - Partly refunded line: in the reordered case it stays
PENDING, which is develop's behaviour for every line, rather than activating its remaining seats. Activating the remainder safely needs the refund handler to reconcile to totals (durable per-refund tracking); that is a possible follow-up. - Refund register lag: if both the ledger mirror and the line mirror of a refund were lost, the order reads as unrefunded. The late placement then activates what an in-order
order.completedwould have activated on develop.recomputeRefundCountersis the convergence path. - Two reads per granting placement while the port is bound: one before granting and one after. They are primary-key and
orderId-indexed reads. - Subscription grants: subscription-provenance grants are not covered by the new index; their duplicate key is subscription + product + variant.
- Rollback: revert the PR (
down()drops the index, and the MySQL column). To switch off B alone, remove the one binding inplugin-composition.ts.
Owner answers (applied)
- Option 2 through the order row and an order-package port: confirmed.
- Order already
CANCELEDor fullyREFUNDEDat late placement: the placement skips the grant and logs it (consumer and dual-ORM specs). The read after granting also closes the race with a cancellation that lands while it is granting. - MySQL key column: kept
VIRTUAL, consistent with the order package.
Review follow-up:
- Round 1: Greptile and Copilot flagged that a partial refund would activate the refunded line's right. Activation is now decided per line from the order port's line state. Without line state, nothing is activated on
PARTIALLY_REFUNDED. Copilot also noted thatARCHIVEDhides completion or cancellation, socompletedAtandcanceledAtare now exposed and used. - Round 2 (Copilot):
- a retry finishes a part-way withdrawal;
- the line register is trusted only while it accounts for the ledger's refunded total;
- partly refunded lines are no longer activated at full quantity.
- Round 3 (Copilot): lowering a partly refunded line's right double-applied a refund whose delivery is still queued, and could undo an operator's extension on a retry. Such a line now stays
PENDING, and nothing is ever lowered by the placement. - Round 4 (Copilot overview): the consumer's reads of an event's rights went through the CRUD
find, which needs a MikroORM request context that deliveries do not have. They now go throughEntitlementService.findGranted, the service's context-safe read, on both ORMs. - Round 5 (CodeRabbit):
- a partial refund no longer lowers a revoked right (fixed; this also happened on develop);
- a reduction now checks the status under the row lock, so a revocation that commits after the refund handler's read is honoured (fixed in
reduce, the one place the rule lives); - the migration still logs and skips, rather than failing, when duplicate live grants exist. Failing would block the upgrade of an install with legacy duplicates (answered in a PR comment);
- a settlement delivered after a late placement can still activate a line left
PENDINGfor a refund. That happens on develop too, and the PR does not make it worse. Withholding such lines at settlement would also hold back rights a refund already reduced correctly, so it is left for the refund-reconciliation follow-up (answered in a PR comment).
Closes #11023
🤖 Generated with Claude Code