Skip to content

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.ts header).
  • 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_from pins payment-method.ts::resetDunningOnPmUpdate, among others). Its frontmatter carries sign_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 (@generated by tools/erd-derive/, drift-gated in CI against apps/api/src/schema.sql). This source declares no explicit sign_off field (only canonical: false + staleness_threshold_days: infinite) — its own staleness marker is as_of_commit: 80fc35f4 in its frontmatter, CI-gated by erd-derive-ci.yml. Omitted from this excerpt: every other table in the schema, including subscriptions, charges, and processor_connections's full column list — see the source for those. magic_link_tokens carries no sign_off field 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 residueAddStoredInstrumentButton.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_card POST to payments.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.svelte component 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.svelte header, "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 to past_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 (handlePortalUpdatePaymentMethodAll in payment-method-all.ts, atomic D1 batch), but the shipped UI cannot reach it on EITHER rail — UpdatePaymentForm.svelte:344-356 mounts the instrument picker only when subscriptionId is 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 of request-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 uncovered in 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: None in 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.ts are host-agnostic); a seamless SSO bridge is additive, not required — see exchange-bc-session.ts as 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.svelte needs to stop calling the unconfirmed popup URL and instead call the confirmed server-side raw_card vault 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::verifyPortalSessionAndOwnership is the shared session+ownership check every lifecycle route calls first; cancel-lock-policy.ts / contract-cancel-guard.ts gate 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.ts or pause.ts's shape (session verify → bundle-lock guard if the action touches a pending charge → mutate → audit log via createAuditLogsClient).
  • Extension seam — new SSO bridges: exchange-bc-session.ts is 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 to 503 + 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_status for US-19.2. Tracing that same source directly (docs/audits/derived/brd-epics/epic-19-payment-method-and-address-management.md, US-19.2's gaps: 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 traced UpdatePaymentForm.svelte:338-356 directly 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 corrected request-link.ts header, though not misleading. It reads "Worker routing follow-on switches from portal-auth.ts" — phrased as a pending future action. worker.ts:598/:600 show 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-fix request-link.ts header did) — it's just imprecise about tense. Flagging here so a recipient reading verify.ts directly 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_status for 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-open docs/audits/derived/brd-epics/epic-19-payment-method-and-address-management.md in this session to re-verify the exact edge_status string beyond confirming the file exists in the evidence-pointer list.