Subscriptions — the customer portal
The "My subscriptions" surface (subscriptions-portal card): list, detail,
schedule control, contract edits, cancel/reactivate and payment-method
recovery. Every endpoint requires a customer session
(authorization: Bearer <supabase jwt> — see auth.md) plus
x-client-id. Missing/invalid JWT → 401 {code: "unauthenticated"};
a subscription that isn't the caller's own → 404 not_found (never
403 — existence is not confirmed across accounts).
The API contract (all routes, shapes, error codes):
docs/contracts/store-api.md § Subscriptions portal. This doc is the
component-facing guide.
The executable blocks prove the auth boundary — the docs harness is anonymous; happy paths are pinned by
tests/store/subscriptions-portal.test.tsandtests/store/subscription-payment-update.test.tswith real sessions.
Render actions from permissions — never hardcode
Every detail payload carries the merchant's live portal policy:
"permissions": {
"allow_skip": true, // merchant toggles (Settings → Subscriptions)
"allow_reschedule": true,
"allow_pause": true,
"allow_frequency_change": true,
"allow_line_edits": true,
"allow_address_change": true,
"allow_cancel": true, // constant — cancel is a customer right
"can_update_payment": false // true only for card (Stripe) contracts
}Components MUST render conditionally from this object — a toggled-off
action answers 403 {code: "portal_action_disabled"}, so hiding the button
is UX, the server is the enforcement. Cancel is ALWAYS shown (the cancel
law: retention offers may render alongside, never instead). Payment update
renders only when can_update_payment — COD/offline contracts have no card.
GET /api/store/subscriptions — my contracts
{ subscriptions: [...], count } — newest first. payment_method is the
merchant-facing display name from the payments registry (never pp_*).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/subscriptions" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401GET /api/store/subscriptions/:id — the receipt view
Sanitized detail: plan, lines (titles + contracted unit_price),
cycles (index, status, date, linked order display id — no internal error
strings), upcoming (next 3 PROJECTED charge dates; empty unless active)
and permissions. Render the cycle list as order history; a failed cycle
plus can_update_payment is the cue to surface the payment-update flow
prominently (that pairing IS dunning recovery).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/subscriptions/sub_doesnotexist" \
-H "x-client-id: $CLIENT_ID")
test "$STATUS" = 401Actions
All POST, all answer { subscription } (the fresh detail — re-render from
it, no refetch needed):
| Route | Body | Gate |
|---|---|---|
/:id/skip |
— | allow_skip |
/:id/charge-date |
{ next_charge_at } |
allow_reschedule |
/:id/pause |
{ until? } |
allow_pause |
/:id/resume |
— | right |
/:id/cancel |
{ reason? } |
right |
/:id/reactivate |
{ next_charge_at? } |
right |
/:id/address |
{ shipping_address } |
allow_address_change |
/:id/lines/:lineId |
{ quantity?, variant_id? } |
allow_line_edits |
/:id/plan |
{ selling_plan_id } |
allow_frequency_change |
Component notes:
- Skip — confirm-dialog copy should show the NEW next date (current
next date + one plan interval). A cycle mid-payment-retry cannot skip
(400) — hide skip while the latest cycle is
failed. - Pause — offer preset durations (1/2/3 months →
until); anuntilpause auto-resumes server-side, no customer action needed. Indefinite pause (no body) needs an explicit Resume. - Reactivate — render on canceled contracts; default schedule is now + interval and it NEVER charges immediately — say so in the copy.
- Swap / frequency — variant options come from the product's variants
(same product only); frequency options are the product's other selling
plans (
GET /api/store/products/:id/selling-plans). Both re-price server-side through the one price engine — display the returnedunit_price, never compute prices client-side.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/subscriptions/sub_doesnotexist/cancel" \
-H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
test "$STATUS" = 401Payment-method update (dunning recovery)
Two steps, card contracts only:
POST /:id/payment-method/session→{ session: { setup_intent_id, client_secret, publishable_key } }.- Confirm client-side with Stripe.js — card fields never touch Cartbase:
// stripe = Stripe(session.publishable_key)
// elements = stripe.elements({ clientSecret: session.client_secret })
// mount PaymentElement, then:
// await stripe.confirmSetup({ elements, redirect: "if_required" })POST /:id/payment-methodwith{ setup_intent_id }→ verified + stamped; the response is the fresh detail. The next renewal charge (the automatic retry ladder or the merchant's "Retry now") uses the new card.
Entry points to build: the payment-failed email links here; the detail view
surfaces it on failed cycles; the account shell may badge past-due
subscriptions.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/store/subscriptions/sub_doesnotexist/payment-method/session" \
-H "x-client-id: $CLIENT_ID" -d '')
test "$STATUS" = 401Checkout + confirmation touchpoints
- Consent line at checkout: subscription carts save the card for future
charges (
setup_future_usage: off_session) — the checkout MUST show the mandate text next to the pay button. checkout.md documents the duty; the component ships with the portal family. - Order confirmation: a completed subscription checkout returns
contracts (cycle 1 = that order) — show "subscription started, next
charge on
" from the order's subscription metadata. - PDP purchase options:
GET /api/store/products/:id/selling-plans(see products.md) — the plan chosen at PDP rides the cart line asselling_plan_id.