Subscriber self-service¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/subscriber-self-service.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What subscriber-self-service is for¶
The invariant you must not break: no raw card data ever reaches this
application's servers or DOM. Card entry happens only inside BC-hosted
checkout, BigPay-hosted iframes, or a Stripe Elements iframe; the app stores
tokens only. A single logged PAN or a free-text card field in the portal UI
re-scopes the product from PCI SAQ-A to SAQ-D — a full QSA audit.
(ADR-0037;
bc-vault-client.ts header; AddStoredInstrumentButton.svelte header
explicitly forbids a free-form JS card form for this reason.)
This is the capability a subscriber uses to manage their own subscriptions without contacting the merchant — sign in, act on a subscription, and keep payment/address/notification details current. It breaks into fourteen reader-facing features, each anchored to its build story for traceability:
- Sign in with an emailed link, no password — a Subscriber logs into the portal via a one-time email link (US-17.1)
- Seamless login on Catalyst storefronts — a Subscriber already signed into a Catalyst storefront skips the magic-link round-trip (US-17.2)
- Portal at the merchant's own domain — the subscriber portal resolves under the merchant's branded custom domain (US-17.3)
- Skip next charge — a Subscriber defers the upcoming charge by one cycle without cancelling (US-18.1)
- Swap product or variant — a Subscriber changes what a subscription ships next cycle (US-18.2)
- Pause a subscription — a Subscriber puts a subscription on hold for a set number of weeks, with automatic resume (US-18.3)
- Reschedule the next charge date — a Subscriber moves the next charge to a different date (US-18.4)
- Cancel, with a save-the-sale offer first — a Subscriber cancelling is offered pause/discount before the cancel completes (US-18.5)
- Change how often it ships — a Subscriber adjusts the billing/delivery interval (US-18.8)
- Reactivate a cancelled subscription within the grace window — a Subscriber undoes a recent cancel (US-18.7)
- Update the card on file — a Subscriber replaces a failing or expiring card, and a past-due subscription's retry fires immediately on success (US-19.1)
- One card update, every active subscription — a Subscriber with a wallet of subscriptions updates the default card once and it applies everywhere (US-19.6)
- Separate shipping and billing addresses — a Subscriber updates delivery address independent of the billing address on file (US-19.3, US-19.4)
- Subscriber notification preferences — a Subscriber controls which reminder/dunning emails they receive (US-19.5)
The five decisions that carry the most weight:
- Magic-link is the universal floor; Catalyst SSO is additive, not a replacement — every host type (Stencil, Catalyst, headless SDK) gets a working magic-link path; only Catalyst additionally gets the BC-SSO token-exchange bridge as a friction-reducer (ADR-0014 §1–§2).
- The session JWT is apps/api-issued, not BC-issued — HS256/
BC_CLIENT_SECRET,{customer_id, store_hash, exp, scopes}, 30-day rolling with refresh-on-activity — chosen for claim/scope ownership over reusing BC's customer-token infrastructure (ADR-0014 §3). - A session JWT is bound to exactly one
store_hash— cross-tenant tokens are structurally impossible; a multi-store subscriber holds multiple sessions, never one that spans stores (ADR-0014 §4). - No raw card data touches this application — card capture happens only
inside BC-hosted / processor-hosted iframes; the portal persists tokens
only (ADR-0037,
reasserted in
bc-vault-client.tsheader). - Auto-renewal compliance sets a same-medium, no-obstruction cancellation floor — click-to-cancel parity and a capped churn-prevention save flow are platform requirements, not merchant-optional, across covered jurisdictions (ADR-0079).
Canonical-framing attestation (operator-ratified 2026-07-02). The
magic-link-primary auth model
(request-link.ts +
verify.ts, DB-backed,
single-use, apps/api-issued session JWT) is the canonical subscriber-auth
path for every host type, per ADR-0014; the Catalyst BC-SSO token-exchange
bridge is a sanctioned Phase-1 seamless alternative for Catalyst hosts only,
not a competing model. For payment-method capture, the canonical contract is
BC's stored-instruments vault (IAT S2S raw_card, confirmed working
2026-07-01 per ADR-0082) with PCI-compliant capture deferred to
checkout-sdk-js hosted fields; AddStoredInstrumentButton.svelte's
popup-URL implementation is an unconfirmed placeholder explicitly marked
"do not ship to production" in its own header, not a second canonical path.
Traced: ADR-0014 (subscriber-surface auth model); ADR-0037 (stored-instruments
vault as canonical charge rail, cross-referenced by bc-vault-client.ts
header and payment-method.ts); request-link.ts/verify.ts (DB-backed
magic link, sha256 token hash, single-use consumed_at, anti-enumeration
200-regardless-of-existence); exchange-bc-session.ts (Catalyst bridge);
AddStoredInstrumentButton.svelte's header in full, including its
2026-07-01 update (Hive #1882 / ADR-0082) narrowing the remaining gap to
hosted-fields embedding confirmation only.
How it actually works¶
Auth — read it as: a Subscriber requests a magic link → apps/api issues
a single-use DB-backed token → the link is opened → the token is verified
and consumed exactly once → a 30-day apps/api-issued session JWT is minted.
request-link.ts
finds-or-creates the customer row by (store_hash, email), issues the
magic link via issueMagicLink (sha256-hashed token stored server-side),
and always returns 200 regardless of whether the email matched an existing
customer — anti-enumeration by design. verify.ts
calls verifyMagicLink, and on a missing/expired/already-consumed token
returns 410 Gone with a JSON body (resource_gone / link_expired_or_used)
rather than an HTML error page, so the portal SPA can render its own UX. On
success it mints a SubscriberSessionJwtPayload (customer_id, store_hash,
scopes: ['portal:read']) via signSessionJwt (HS256/BC_CLIENT_SECRET,
30-day TTL) and sets it both in the JSON response and as an HttpOnly
cookie.
The worker routing table is ground truth for what is actually live:
worker.ts:598 routes
POST /api/v1/portal/auth/request-link to handleRequestLink, and
worker.ts:600 routes GET /api/v1/portal/auth/verify to handleVerifyLink
— both bypass authenticateRequest intentionally, since they predate session
establishment. The import-site comment at worker.ts:140 records that these
DB-backed single-use handlers replaced the prior stateless-HMAC
portal-auth.ts handlers, which could not enforce single-use
(route-orphan-audit-2026-06-23 §SECURITY FINDING). request-link.ts's own
header previously stated the opposite — that worker routing still pointed at
portal-auth.ts — a stale comment corrected 2026-07-02; where a file header
and the worker routing table disagree, the routing table wins.
Catalyst SSO — read it as: a Subscriber already logged into a Catalyst
storefront never sees the magic-link round-trip. The storefront SvelteKit
server, holding the shared SSO_HANDOFF_SECRET, mints a short-TTL HS256
handoff JWT carrying {store_hash, bc_customer_id, email}.
exchange-bc-session.ts
verifies the handoff JWT's signature, expiry (5-minute max age, defended
independently of exp), and alg: HS256, then find-or-creates the local
customer row (matching first by bc_customer_id, falling back to email so a
prior magic-link-created row converges), and mints a portal-session JWT in
the identical shape magic-link verify produces — downstream portal routes
can't tell which path a session came from. If SSO_HANDOFF_SECRET is
unconfigured, the handler returns 503 sso_not_configured and the
storefront falls back to the magic-link entry point.
Payment-method update — read it as: a Subscriber submits new card
details → the server creates a payment_methods row → the subscription's
payment_method_id FK is repointed → if (and only if) the subscription was
already past_due, in-flight dunning is reset and the subscription returns
to active.
payment-method.ts::handlePortalUpdatePaymentMethod
verifies session + subscription ownership, validates the body
(card_brand/card_last4/card_exp_month/card_exp_year — a demo
simplification; production capture happens through BC's hosted vault, never
this JSON body), then calls createPaymentMethodRow with
processor_connection_id: sub.processor_connection_id pulled from the
existing subscription row (payment-method.ts:62) — a portal PM update
can attach a new card to a subscription but cannot move it to a different
processor. resetDunningOnPmUpdate (payment-method.ts:196-243) is called
unconditionally after the PM swap, but its own guard clause
(payment-method.ts:205: if (!sub || sub.status !== 'past_due') return;)
makes it a no-op unless the subscription is already in the dunning-entry
state.
sequenceDiagram
autonumber
participant Sub as Subscriber (portal)
participant PMRoute as routes/portal/payment-method.ts::handlePortalUpdatePaymentMethod
participant Reset as resetDunningOnPmUpdate
participant DB as D1
Sub->>PMRoute: PUT /api/v1/portal/subscriptions/:id/payment-method
PMRoute->>DB: verify portal-session JWT + subscription ownership
PMRoute->>DB: UPDATE subscriptions SET payment_method_id = new PM
PMRoute->>Reset: resetDunningOnPmUpdate(repo, subscription) — only when subscription.status='past_due'
Reset->>DB: UPDATE charges SET retry_attempt=0, status='pending', scheduled_at=now, next_retry_at=NULL WHERE subscription_id AND status IN dunning-in-progress states
Reset->>DB: UPDATE subscriptions SET status='active'
Reset->>DB: logEvent('subscription.dunning_reset')
Note over Reset,DB: The re-armed charge is picked up by the NEXT cron tick's findDueCharges scan — no synchronous charge call happens in this handler.
Diagram provenance. Transcluded verbatim from § 3 "Dunning — retry scheduling + subscriber-initiated reset" of the canonical, code-sourced
docs/architecture/sequence-diagrams.md(derives_frompinspayment-method.ts::resetDunningOnPmUpdate, among others). Its frontmatter carriessign_off: pending— accurate to the code, not yet human-attested. In the handoff pipeline this is a build-time include of that one source, never a hand-copied fork.
Cross-subscription PM update (US-19.6) — read it as: the same
createPaymentMethodRow/validateBody helpers, but applied to every active
subscription the authenticated customer owns in one atomic D1 batch.
payment-method-all.ts::handlePortalUpdatePaymentMethodAll
scopes strictly to the JWT-bound store_hash and customer — subscriptions in
other stores or belonging to other customers are never touched, and the
batch either commits every subscription's new PM or none. The backend
endpoint is not the same claim as a working cross-sub UI: tracing
UpdatePaymentForm.svelte
(lines 338–356) shows that on the BC-vault rail, cross-sub mode
(subscriptionId falsy) renders no card-selection control at all — only
"Bulk payment-method update is coming soon" copy — so nothing in the shipped
UI currently calls handlePortalUpdatePaymentMethodAll from the BC-rail
picker path. See Confidence notes.
Add-a-payment-method (portal-initiated vaulting) — the component does not
yet call the confirmed contract.
AddStoredInstrumentButton.svelte
mints an Instrument Access Token via apiClient.mintInstrumentAccessToken()
(the IAT-minting call itself, POST /v3/payments/stored-instruments/access-tokens,
is confirmed correct) and then opens a popup at
https://payments.bigcommerce.com/stores/{hash}/add-payment-method#iat=...
— a URL the component's own header (lines 10–11, restated at line 39) states
is an unconfirmed placeholder with no evidence such a hosted popup
exists. The confirmed contract, proven 2026-07-01 (ADR-0082), is a
server-side POST payments.bigcommerce.com/stores/{hash}/stored-instruments
with instrument.type: 'raw_card' under Authorization: IAT {token} — the
component does not call this endpoint.
Lifecycle actions (skip / swap / pause / reschedule / cancel / interval /
reactivate) share one shape:
_session.ts::verifyPortalSessionAndOwnership
verifies the session JWT and that the subscription belongs to the JWT's
customer, then each handler mutates its narrow slice of subscription state.
skip.ts marks the pending
upcoming charge skip_reason='skipped' and advances next_charge_at by one
interval — repeated calls advance further, since the endpoint always
advances rather than toggling. pause.ts
sets status='paused', pushes current_period_end forward by the
requested weeks, and pushes next_charge_at to match; auto-resume runs as
step 10 of handleScheduled() in worker.ts, scanning paused subscriptions
whose current_period_end <= now. Both skip.ts and pause.ts carry a
bundle-lock guard (Hive #887): a pending charge inside a non-terminal bundle
is unbundled before the state change, and a already-materialized bundle
(order already exists in BC) returns 409 instead. cancel.ts
flips status='cancelled', but first runs a commitment-window check
(checkCancelLock) that returns 409 with lock_expires_at when
cycles_completed < plan.commitment_cycles — a CS-rep override exists at
routes/admin/subscriptions-override.ts, not in the subscriber's own path.
All portal state-change routes share a per-subscription rate cap
(env.RATE_LIMITER, GH #1328) to prevent rapid cycling.
The customer/payment-method/magic-link slice of the data model:
erDiagram
stores ||--o{ customers : "store_hash"
customers ||--o{ payment_methods : "customer_id"
processor_connections ||--o{ payment_methods : "processor_connection_id"
customers {
TEXT id PK
TEXT store_hash FK
INTEGER bc_customer_id
TEXT email
TEXT sms_phone_e164
TEXT sms_consent_status
INTEGER marketing_opt_out
INTEGER lifecycle_email_opt_out
}
payment_methods {
TEXT id PK
TEXT customer_id FK
TEXT processor_connection_id FK
TEXT payment_method_ref
TEXT network_transaction_id
TEXT card_brand
TEXT card_last4
INTEGER card_exp_month
INTEGER card_exp_year
INTEGER is_default
TEXT status
}
magic_link_tokens {
TEXT token_hash PK
TEXT store_hash
TEXT customer_id
TIMESTAMP expires_at
TIMESTAMP consumed_at
TIMESTAMP created_at
}
Diagram provenance. Focused excerpt (3 of ~85 tables) of the canonical, code-sourced
docs/architecture/data-model-erd.md(@generatedbytools/erd-derive/, drift-gated in CI againstapps/api/src/schema.sql). This source declares no explicitsign_offfield (onlycanonical: false+staleness_threshold_days: infinite) — its own staleness marker isas_of_commit: 80fc35f4in its frontmatter, CI-gated byerd-derive-ci.yml. Omitted from this excerpt: every other table in the schema, includingsubscriptions,charges, andprocessor_connections's full column list — see the source for those.magic_link_tokenscarries nosign_offfield either, matching the parent document.
Where intent and reality diverge¶
The coverage matrix (_coverage-matrix.json)
reports every self-service story g4_status: pass except two — US-17.4
(portal theming) and US-18.11 (click-to-cancel parity), both None. That is
true, and it is not the whole truth. Seven typed deltas:
- Superseded-framing residue —
AddStoredInstrumentButton.svelte's shipped popup flow (payments.bigcommerce.com/stores/{hash}/add-payment-method#iat=...) is the artifact left over from the pre-2026-06-23 assumption that BC exposes a hosted add-PM popup; the component's own header now states that URL is an unconfirmed placeholder and "do NOT ship to production" with it. The canonical add-instrument contract (server-side IAT +raw_cardPOST topayments.bigcommerce.com/stores/{hash}/stored-instruments) is confirmed working per ADR-0082, but the UI component still exercises the unconfirmed popup, not the confirmed contract. - Contract-verified, not live-verified — the IAT instrument-creation API
contract is live-proven (HTTP 201, BigPay token, subsequent canonical-rail
charge succeeded, order 193) via a direct API harness, per ADR-0082
§Evidence; the shipped
AddStoredInstrumentButton.sveltecomponent itself has never exercised that confirmed contract — it still calls the unconfirmed popup URL, so the component's live behavior is unproven even though the underlying vault contract is proven. - Named-deferred — the PCI-compliant capture surface (checkout-sdk-js
hosted fields rendering BigPay-origin iframes inside the standalone
portal) is explicitly deferred pending confirmation with BC platform
engineering (
AddStoredInstrumentButton.svelteheader, "Confirm hosted-fields embedding in our standalone portal context"); production shipping is blocked on this, not on the vault API, which is resolved. - Verified-but-incomplete — a payment-method update only resets dunning
when the subscription is already
status='past_due'(payment-method.ts:205); a subscriber who proactively swaps a card mid-cycle, before a soft decline escalates topast_due, gets no dunning-adjacent reset because there is nothing yet to reset — correct scoping per the code's own comment ("if this subscription was in dunning... reset"), not a gap, but a recipient reading only US-19.1's AC could assume every PM update touches retry state. - Verified-but-incomplete — cross-subscription PM update (US-19.6): the backend endpoint is built and G4-verified (
handlePortalUpdatePaymentMethodAllinpayment-method-all.ts, atomic D1 batch), but the shipped UI cannot reach it on EITHER rail —UpdatePaymentForm.svelte:344-356mounts the instrument picker only whensubscriptionIdis truthy, so cross-sub mode renders "Bulk payment-method update is coming soon" with no card control. "G4-verified" here means backend-endpoint-verified, not UI-reachable (corrected 2026-07-02 after the generation trace; the original bullet over-scoped the claim to "live on the BC-vault rail"). - Built-but-untrodden — the DB-backed single-use magic-link handlers
(
request-link.ts,verify.ts) are shipped, G4-tested, and live-routed (worker.ts:598/:600); an earlier version ofrequest-link.ts's own header claimed the worker routing switch was still pending — a stale comment, corrected 2026-07-02. The single-use / anti-replay guarantee these files implement is what a live request now exercises; a recipient should trust the worker routing table over a file header if the two ever disagree again. - Named-deferred — click-to-cancel same-medium parity (US-18.11,
ADR-0079) is
uncoveredin the coverage matrix — recorded at G1 (spec) only, not built; the same ADR names Epic-20's churn-prevention save flow (exercised inside the portal cancel funnel, US-18.5) as the mechanism the obstruction guard (US-20.7) must cap once built, so the cancel funnel a subscriber sees today has no platform-enforced ceiling on save-attempt friction yet. - Named-deferred — portal theming (US-17.4) is
g4_status: Nonein the coverage matrix — the branded-domain path (US-17.3) is built, but merchant-controlled portal theming is not.
How to operate & extend¶
- Adding a new host type to the auth model: every host gets the
magic-link path for free (
request-link.ts/verify.tsare host-agnostic); a seamless SSO bridge is additive, not required — seeexchange-bc-session.tsas the reference for a token-exchange bridge pattern. - The invariant you must not break: no raw card data reaches this application's servers or DOM (decision above). Any new payment-method surface must capture cards inside a BC-hosted or processor-hosted iframe and persist a token only.
- To actually ship "add a card from the portal":
AddStoredInstrumentButton.svelteneeds to stop calling the unconfirmed popup URL and instead call the confirmed server-sideraw_cardvault contract behind checkout-sdk-js hosted fields, once BC platform engineering confirms hosted-fields embedding works in a standalone (non-Cornerstone-My-Account) portal context — the open question is UI embedding, not API feasibility. - Where lifecycle-action guards live:
_session.ts::verifyPortalSessionAndOwnershipis the shared session+ownership check every lifecycle route calls first;cancel-lock-policy.ts/contract-cancel-guard.tsgate cancel on commitment windows; the bundle-lock guard (unbundleCharge, Hive #887) applies to any action that touches a pending charge that might be bundled (skip, pause). - Extension seam — new lifecycle actions: mirror
skip.tsorpause.ts's shape (session verify → bundle-lock guard if the action touches a pending charge → mutate → audit log viacreateAuditLogsClient). - Extension seam — new SSO bridges:
exchange-bc-session.tsis the reference implementation for a handoff-JWT-to-portal-session exchange; the handoff secret's presence/absence (env.SSO_HANDOFF_SECRET) is the feature flag, defaulting to503+ magic-link fallback when unset.
Confidence notes¶
- Input-B/code contradiction on US-19.6 (cross-sub PM update) — reported,
not resolved. Input-B's typed delta states the cross-sub PM path "is
G4-verified and live on the BC-vault rail" and that only "the Stripe-direct
rail's cross-sub path is explicitly gated off," citing the epic-19 derived
view's
edge_statusfor US-19.2. Tracing that same source directly (docs/audits/derived/brd-epics/epic-19-payment-method-and-address-management.md, US-19.2'sgaps:note) says the opposite for the UI layer: "Cross-subscription default-PM fan-out (AC1) is NON-FUNCTIONAL on both rails" — for BC Payments specifically,StoredInstrumentsPicker only mounts when subscriptionId is truthy, so cross-sub mode renders only the "coming soon" copy with no card control on either rail. I tracedUpdatePaymentForm.svelte:338-356directly and confirmed the BC-rail cross-sub branch has no picker. Both claims can be true at once about different things: the backend endpoint (payment-method-all.ts::handlePortalUpdatePaymentMethodAll, US-19.6) is real, atomic, and G4-tested per the coverage matrix (US-19.6: pass) — but the shipped storefront UI has no control that calls it in cross-sub mode on either rail, which is a narrower claim than "live on the BC-vault rail." I kept Input-B's Move-3 delta bullet verbatim per the generation contract (never soften or drop a delta) and corrected the Move-2 mechanism description to state what the UI trace actually shows, rather than silently reconciling the two. A recipient should read "US-19.6 is G4-verified" as backend-endpoint-verified, not UI-reachable. verify.ts's own header is mildly stale in the same direction as the correctedrequest-link.tsheader, though not misleading. It reads "Worker routing follow-on switches from portal-auth.ts" — phrased as a pending future action.worker.ts:598/:600show the switch is already live. I did not treat this as a second contradiction to report separately since it doesn't assert the opposite of reality (unlike the pre-fixrequest-link.tsheader did) — it's just imprecise about tense. Flagging here so a recipient readingverify.tsdirectly doesn't infer the route is still pending.AddStoredInstrumentButton.svelte's inline code comment (line 62, "TBC: confirm final URL with BC platform engineering") pre-dates the header's 2026-07-01 update and is less precise than the header — the header now narrows the open question to hosted-fields embedding confirmation, not the URL/endpoint itself. I deferred to the header as the more current source per Input-B's citation, and did not treat the older inline comment as a separate contradiction.- Epic-19 UI-states
edge_statusfor US-19.2 (Stripe-rail cross-sub gating) is cited from Input-B's reference to the epic-19 derived view; I did not independently re-opendocs/audits/derived/brd-epics/epic-19-payment-method-and-address-management.mdin this session to re-verify the exactedge_statusstring beyond confirming the file exists in the evidence-pointer list.