# 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](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:

```jsonc
"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_*`).

```bash
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).

```bash
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.

```bash
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:

```jsonc
// stripe = Stripe(session.publishable_key)
// elements = stripe.elements({ clientSecret: session.client_secret })
// mount PaymentElement, then:
// await stripe.confirmSetup({ elements, redirect: "if_required" })
```

3. `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.

```bash
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](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 <date>" from the order's subscription metadata.
- **PDP purchase options**: `GET /api/store/products/:id/selling-plans`
  (see [products.md](products.md)) — the plan chosen at PDP rides the cart
  line as `selling_plan_id`.
