Ctrl K
ComunidadGuías…

docs/adr/0093-subscription-plan-from-price.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ADR-0093: The granted plan is derived from the price, not from the metadata

## Metadata

| Field          | Value                                                     |
| -------------- | --------------------------------------------------------- |
| **Status**     | Accepted                                                  |
| **Date**       | 2026-10-20                                                |
| **Authors**    | Alessio Brillo                                            |
| **Deciders**   | Alessio Brillo                                            |
| **Supersedes** | —                                                         |
| **Relates to** | ADR-0053, ADR-0090                                        |
| **Project**    | DOMINUS                                                   |

## Context

When a checkout completes, Stripe sends `checkout.session.completed` and the
handler decides which plan the tenant has bought. The handler read the plan
from `session.metadata.plan`, and used the price id — when it had one — only
to look up a negotiated "custom price".

Two problems, both invisible until the paid tiers were about to open:

1. **The price id was usually not there.** Stripe does not include
   `line_items` in the `checkout.session.completed` payload unless the
   session is retrieved with `expand: ['line_items']`. The handler never
   expanded it. So the price id was `undefined` on the normal path, and the
   branch that verified the custom price — tenant match and amount match —
   was dead code. The verification that the first-party security review
   called "operator-config error only, not exploitable by clients" was, in
   practice, not running.
2. **The plan was not derived from what was paid.** The plan came from a
   metadata field the server wrote at checkout creation. A client cannot
   forge it, but a misconfigured price id grants a plan the customer did not
   pay for, and nothing in the handler compared the two.

The subscription lifecycle events (`customer.subscription.created/updated`)
already derived the plan from the price id. The two paths disagreed, and the
one that granted the entitlement first was the weaker one.

## Decision Drivers

1. **The grant must follow the payment.** A plan is a claim about money;
   the price id is the receipt. Everything else is a copy of it.
2. **A failure must not grant.** An unknown price id is an operator
   configuration error, and the correct answer to it is "no entitlement",
   not "a default plan".
3. **The negotiated prices must be verified.** The custom-price path exists
   for a reason: a tenant on a negotiated price must pay that price.
4. **The webhook must not retry forever.** An event that can never succeed
   must not hold the claim open.

## Considered Options

### Option A: Keep trusting `session.metadata.plan`

**Advantages:** no change.

**Disadvantages:** the plan and the payment are two independent statements,
and only one of them is a receipt. The custom-price verification does not
run.

### Option B: Read the price id from the event payload only

**Advantages:** one lookup, no extra API call.

**Disadvantages:** the payload does not carry the price id. The branch would
still be dead code.

### Option C: Retrieve the session with the line items expanded, and derive the plan from the price

**Advantages:** the price id is authoritative and always available; the
custom-price verification becomes reachable; the plan and the receipt cannot
disagree.

**Disadvantages:** one extra Stripe API call per completed checkout.

## Decision

**Option C.**

- The price id is read from the event payload when it is there, and
  otherwise by retrieving the session with
  `stripe.checkout.sessions.retrieve(session.id, { expand: ['line_items.data.price'] })`.
  A retrieval failure is logged and the handler falls back to the metadata
  path, so a Stripe outage cannot block a subscription that Stripe has
  already confirmed.
- The plan is derived with the existing `resolvePlanForPriceId(priceId,
  tenantId)` — the same resolution the subscription lifecycle events use.
  One helper, one truth.
- An unresolvable price id grants **nothing**: the handler logs the event
  id, the tenant id and the price id, and returns. It does not throw, so
  Stripe does not retry a permanently unknown price id forever, and the
  event claim is not held open.
- The custom-price verification is unchanged in intent and now reachable:
  the tenant must match, and `amount_total` must equal the expected amount,
  or the grant is refused.

## Consequences

### Positive

- The entitlement follows the payment.
- A misconfigured price id cannot grant a plan.
- The negotiated-price verification actually runs.
- The two webhook paths agree on how a plan is determined.

### Negative

- One extra Stripe API call per completed checkout. It happens once per
  subscription, on a path that is not latency-sensitive.
- A Stripe API failure at that moment falls back to the metadata path, so
  the grant is still possible in that narrow window. The fallback is logged.
- A price id that is configured for a plan that has been retired now grants
  nothing instead of the previous plan. That is the intended behaviour, and
  it surfaces as a warning in the logs.

## Links

- `src/services/billing-service.ts` — the handler and the price resolution.
- `src/services/__tests__/billing-service.test.ts` — the plan-derivation
  cases.
- `docs/security/audit-2026-08.md` — the first-party review that flagged the
  metadata trust.