docs/adr/0093-subscription-plan-from-price.md
Rama master · réplica de lectura del repositorio original.
# 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.