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.ts and tests/store/subscription-payment-update.test.ts with 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" = 401

GET /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" = 401

Actions

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); an until pause 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 returned unit_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" = 401

Payment-method update (dunning recovery)

Two steps, card contracts only:

  1. POST /:id/payment-method/session{ session: { setup_intent_id, client_secret, publishable_key } }.
  2. 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" })
  1. POST /:id/payment-method with { 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" = 401

Checkout + 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 as selling_plan_id.