feat(commerce): serve cartByToken (hashed guest-access token) and upcomingSubscriptionBilling

Merged
EV
11d ago
+2384-131

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 nullable tokenHash column (unique where not null; select: false / MikroORM hidden; blanked in exports). Migration 1791000000660-AddCartTokenHashColumn for Postgres, MySQL and SQLite, numbered above develop's highest (655).
  • Issued by create, which answers the plaintext once in token. No read answers it again. A token or digest the caller sends is discarded, not trusted.
  • Lookup: CommerceCartService.findByToken hashes 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 as GET /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_VIEW can 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.
  • UNBOUND in graphql-field-binding-check.mjs is now empty.

Q10b — upcomingSubscriptionBilling (8e61860f301)

  • SubscriptionService.previewNextBilling(id): the REST twin is GET /subscriptions/:id/upcoming-billing, the GraphQL field is upcomingSubscriptionBilling(id), both under SUBSCRIPTIONS_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 function billCycle now also calls.
    • The period comes from nextCyclePeriod, which billCycle also uses.
    • For a paused subscription, the calendar comes from the rule resume writes. That rule was extracted into the private resumedCalendar, now called by resume and the preview. Behaviour is unchanged and the existing resume specs pass.
  • 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 and BUILD_BASELINE goes 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 price fails with TypeError: 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 digest fails with expect(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

CheckResult
jest packages/plugins/cart10 suites, 196 tests passed
jest packages/plugins/subscription11 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.spec7/7 passed
migration-duplicate-column-checkpassed
schema snapshotregenerated; 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.md in ever-co/ever-gauzy-docs is 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

  • createCart now issues a 256-bit random base64url token, answers it once, and stores only its SHA-256 digest in a new nullable tokenHash column (unique index, hidden from exports; migration for Postgres, MySQL and SQLite). Preexisting carts keep a null digest — no token is back-filled.
  • findByToken hashes 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-Token header, 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_VIEW grant. 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

  • previewNextBilling builds the amount through priceCycle and the period through nextCyclePeriod — the same functions billCycle uses — 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 nextRetryAt becomes nextBillingAt, 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).