feat(commerce): serve cartByToken (hashed guest-access token) and upcomingSubscriptionBilling
Follow-ups queue Q10 (owner, 2026-10-09: "build every promised-but-unserved GraphQL name"). Two names the design promises and develop does not serve: cartByToken (declared in the SDL, no resolver — graphql-field-binding-check carried it in UNBOUND) and upcomingSubscriptionBilling (a build row in design-name-triage.md). One commit each, so either can be taken alone; each carries its own share of the regenerated schema snapshot.
Q10a — cartByToken (d0db8df70af)
- Token: 256 random bits, base64url (
commerce-cart/cart-token.ts). The cart stores only its SHA-256 digest in a new nullabletokenHashcolumn (unique where not null;select: false/ MikroORMhidden; blanked in exports). Migration1791000000660-AddCartTokenHashColumnfor Postgres, MySQL and SQLite, numbered above develop's highest (655). - Issued by
create, which answers the plaintext once intoken. No read answers it again. A token or digest the caller sends is discarded, not trusted. - Lookup:
CommerceCartService.findByTokenhashes the token and looks the digest up inside the caller's tenant. It answers nothing when no tenant is in context (fails closed). - REST twin:
GET /carts/by-token/:token→ 404 on a miss. - GraphQL:
cartByToken(token)→ null on a miss. - Grant: both run under the class-level
CARTS_VIEW, the same grant asGET /carts/:id. - The token is a lookup key, not a login. The route needs the same sign-in and grant as every other cart route. A caller holding
CARTS_VIEWcan already list every cart of the tenant, so a token that ends up in an access log (REST path) opens nothing that caller could not already read. - Where the plaintext also rests: a create sent with an idempotency key keeps its first answer, token included, in the idempotency record for the key's retention (≤ 7 days). That replay is how a client that lost the answer gets the token back, and no read route returns the record. The docs say this rather than claim the token is never stored.
UNBOUNDingraphql-field-binding-check.mjsis now empty.
Q10b — upcomingSubscriptionBilling (8e61860f301)
SubscriptionService.previewNextBilling(id): the REST twin isGET /subscriptions/:id/upcoming-billing, the GraphQL field isupcomingSubscriptionBilling(id), both underSUBSCRIPTIONS_VIEW. Tenant- and organization-scoped (another tenant's id → NotFound). It writes nothing: no claim, row, order or event.- It cannot drift from the bill it previews:
- The amount comes from
priceCycle(subscription.cycle.ts), the single functionbillCyclenow also calls. - The period comes from
nextCyclePeriod, whichbillCyclealso uses. - For a paused subscription, the calendar comes from the rule
resumewrites. That rule was extracted into the privateresumedCalendar, now called byresumeand the preview. Behaviour is unchanged and the existing resume specs pass.
- The amount comes from
- Answers null for anything no billing run would pick up:
- pending without a trial, failed, cancelled or expired, or paused with no end date;
- a cancel-at-period-end subscription whose period has ended by the time the run bills;
- a subscription that has reached the plan's cycle ceiling;
- a period that is already settled.
- Scope of the amount: it is the amount the cycle record holds. A first cycle's setup fee and any carried credit go onto the order separately and are not included.
design-name-triage.md: the row moves to delivered andBUILD_BASELINEgoes from 7 to 6.
Red first
Each red run used develop's versions of the source files with the new specs kept. ts-jest diagnostics were off, so the failures show at runtime.
- Subscription: 34 of 34 new tests fail on develop. Example:
SubscriptionService — previewing the next billing › previews exactly what the cycle then bills — at the plan's pricefails withTypeError: fixture.service.previewNextBilling is not a function. - Cart: 14 of 16 new tests fail on develop. Example:
CommerceCartService — the guest-access token › answers the token once, on the create, and stores only its digestfails withexpect(received).toMatch … Received has value: undefined. The 2 that pass assert behaviour develop already had: the field is declared, and no input accepts a token. - Gates on develop:
graphql-field-binding-check:FAILED — root fields the schema declares and no resolver implements: Query.cartByToken.design-name-triage-check:upcomingSubscriptionBilling is recorded as delivered and the composed schema does not serve it.
Verification
| Check | Result |
|---|---|
jest packages/plugins/cart | 10 suites, 196 tests passed |
jest packages/plugins/subscription | 11 suites, 344 tests passed |
tsc --noEmit (contracts, cart, subscription, order) | 0 errors each (order calls cart create) |
| static gates (all 29) | failed: 0 |
migration-timestamps.spec | 7/7 passed |
migration-duplicate-column-check | passed |
| schema snapshot | regenerated; diff = cartByToken description, Cart.token, upcomingSubscriptionBilling + its type |
Notes for the merge
- Overlap with #10644 (refunds, other lane): that PR also regenerates
schema.graphql. Whichever of the two merges second regenerates the snapshot. No other file overlaps with #10638 or #10641–#10644. - Docs mirror: the published copy of
design-name-triage.mdinever-co/ever-gauzy-docsis not part of this PR. - Demo: the new column is nullable and the migration runs on demo's SQLite. Stage and prod get it only through a later cascade.
🤖 Generated with Claude Code
Summary by cubic
Serves two GraphQL names the design promised but the API did not: cartByToken (declared with no resolver) and upcomingSubscriptionBilling (recorded as to-build). Each ships with a REST twin.
cartByToken and GET /carts/by-token
createCartnow issues a 256-bit random base64url token, answers it once, and stores only its SHA-256 digest in a new nullabletokenHashcolumn (unique index, hidden from exports; migration for Postgres, MySQL and SQLite). Preexisting carts keep a null digest — no token is back-filled.findByTokenhashes the token and looks up the digest within the caller's tenant, failing closed without one; a miss is null in GraphQL and 404 on REST.- The REST twin reads the token from the
X-Cart-Tokenheader, never the URL (a URL would land in access logs); a missing header is a 400, and CORS and the API-call-log redaction now both recognize the header. - The token is a lookup key, not a login — both routes run under the existing
CARTS_VIEWgrant. The only place the plaintext rests is the create's idempotency record, for the key's retention (≤ 7 days); no read returns it.
upcomingSubscriptionBilling and GET /subscriptions/:id/upcoming-billing
previewNextBillingbuilds the amount throughpriceCycleand the period throughnextCyclePeriod— the same functionsbillCycleuses — and writes nothing: no claim, row, order or event.- Returns null for anything no billing run would pick up: failed, cancelled, expired, pending without a trial, paused with no end date, a cancel-at-period-end subscription past its period, a plan cycle ceiling reached, or an already settled period.
- After a failed charge, the preview answers the dunning retry instead of the past period start: the cycle row's
nextRetryAtbecomesnextBillingAt, the gates are read there, and the period stays the one being retried. - Both routes run under
SUBSCRIPTIONS_VIEW, tenant- and organization-scoped (a foreign id is a NotFound, as with billing).