@ssheleg/sheleg-dev 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ # Cancellation, and the offer that deflects it
2
+
3
+ **Load this when** implementing cancel or reactivation, or building the "before
4
+ you go — 50% off your next invoice" card at the cancel step.
5
+
6
+ *Every parameter path, enum and required-field claim below is read from the
7
+ Stripe OpenAPI spec at API version `2026-07-29.dahlia` (the latest, confirmed
8
+ against `docs.stripe.com/changelog`) and from `docs.stripe.com` on 2026-08-25.
9
+ The two facts the documentation does not answer are marked as such, with the
10
+ commands that answer them.*
11
+
12
+ ## Contents
13
+
14
+ - [Cancellation](#cancellation)
15
+ - [`cancel_at_period_end` is not the flag it used to be](#cancel_at_period_end-is-not-the-flag-it-used-to-be)
16
+ - [The save offer: two channels, one of which cannot say no](#the-save-offer-two-channels-one-of-which-cannot-say-no)
17
+ - [A coupon cannot say "once per customer"](#a-coupon-cannot-say-once-per-customer)
18
+ - [The trap: a `duration=once` discount deletes itself](#the-trap-a-durationonce-discount-deletes-itself)
19
+ - [Nobody tells you the customer stayed](#nobody-tells-you-the-customer-stayed)
20
+ - [Applying it yourself: `discounts` is a replacement, not an append](#applying-it-yourself-discounts-is-a-replacement-not-an-append)
21
+ - [Reading a discount now costs one more hop](#reading-a-discount-now-costs-one-more-hop)
22
+ - [Where the flow is simply not available](#where-the-flow-is-simply-not-available)
23
+ - [A save is not a renewal, and it is not a churn event either](#a-save-is-not-a-renewal-and-it-is-not-a-churn-event-either)
24
+ - [The test matrix](#the-test-matrix)
25
+
26
+ ## Cancellation
27
+
28
+ ```ts
29
+ await stripe.subscriptions.update(subId, { cancel_at_period_end: true });
30
+ // local: cancelAtPeriodEnd = true, status stays "active"
31
+ ```
32
+
33
+ - **At period end is the default.** The user keeps what they paid for; `status`
34
+ stays `active` and a separate flag drives the UI.
35
+ - **Do the teardown in `customer.subscription.deleted`**, never beside the API
36
+ call, so one path serves your UI, the portal and dunning alike.
37
+ - **The portal is a second writer.** Everything a user does there reaches you
38
+ only as a webhook; a billing page that assumes otherwise goes stale in a week.
39
+ - On `invoice.payment_failed`, mark `past_due` and notify — do not cancel.
40
+ Stripe's dunning decides the retries and the terminal state; cancelling early
41
+ cancels customers whose next attempt would have cleared.
42
+ - **Reactivation** is `cancel_at_period_end: false`, and is only meaningful while
43
+ the period is still running. Past that it is a new subscription.
44
+ - If the local write fails after Stripe succeeded, say so honestly ("submitted,
45
+ syncing shortly") and let reconciliation fix it. Reverting Stripe here would
46
+ un-cancel a subscription the user asked to end.
47
+ - Immediate cancellation (`stripe.subscriptions.cancel`) is for refunds and
48
+ abuse, and everything it must clean up still belongs in the
49
+ `customer.subscription.deleted` handler.
50
+
51
+ ## `cancel_at_period_end` is not the flag it used to be
52
+
53
+ Under **flexible** `billing_mode`, a cancellation scheduled *from the customer
54
+ portal* does not set `cancel_at_period_end` at all:
55
+
56
+ | | **classic** | **flexible** |
57
+ |---|---|---|
58
+ | `cancel_at_period_end` | `true` | **`false`** |
59
+ | `cancel_at` | the `current_period_end` timestamp, and it **follows** `current_period_end` when that moves | the maximum `current_period_end` across all items, and it does **not** follow |
60
+
61
+ So a billing page whose banner reads `if (sub.cancelAtPeriodEnd)` shows
62
+ "active, renews on the 2nd" to a customer who cancelled ten seconds ago, on a
63
+ subscription Stripe will end. Nothing errors, and the customer's next move is a
64
+ support ticket or a dispute.
65
+
66
+ **Derive the flag, in one function, from both fields:**
67
+
68
+ ```ts
69
+ export function cancellationScheduled(sub: Stripe.Subscription): number | null {
70
+ if (sub.cancel_at) return sub.cancel_at; // flexible, and classic too
71
+ if (sub.cancel_at_period_end) return sub.items.data // classic, cancel_at unset
72
+ .reduce((max, i) => Math.max(max, i.current_period_end), 0) || null;
73
+ return null;
74
+ }
75
+ ```
76
+
77
+ Store the timestamp, not the boolean. `cancel_at` under classic mode moves when
78
+ the period moves; under flexible mode it does not — a stored boolean cannot
79
+ express either, and a stored timestamp refreshed from every
80
+ `customer.subscription.updated` expresses both.
81
+
82
+ Since `billing_mode` cannot be changed after creation, an account that has been
83
+ selling for a while holds **both kinds of row at once**. Code that handles only
84
+ the mode you create today is correct for new customers and wrong for the ones
85
+ who have been paying longest.
86
+
87
+ ## The save offer: two channels, one of which cannot say no
88
+
89
+ Stripe's cancel page can carry a coupon card — headline, the amount saved, the
90
+ terms, and a **Redeem discount** button. It is configured in two places, and the
91
+ difference is not cosmetic.
92
+
93
+ | | **Dashboard "Retention Coupon"** | **`flow_data` retention** |
94
+ |---|---|---|
95
+ | Where | Settings → customer portal → Cancellations | the API call that mints the portal session |
96
+ | Who gets it | **every customer who reaches the cancel page** | whoever your server decided to offer it to |
97
+ | Per-customer targeting | impossible | the whole point |
98
+ | In code review, in a test, in git | no | yes |
99
+ | Turning it off during an incident | a human in the Dashboard | a deploy, or a feature flag |
100
+
101
+ The API path, with the exact parameter names (`flow_data_subscription_cancel_param`
102
+ → `retention_param` → `coupon_offer_param` in the spec):
103
+
104
+ ```ts
105
+ const session = await stripe.billingPortal.sessions.create({
106
+ customer: customerId,
107
+ return_url: `${origin}/billing`, // the "not now" exit, always live
108
+ flow_data: {
109
+ type: "subscription_cancel",
110
+ subscription_cancel: {
111
+ subscription: subId,
112
+ ...(offer && {
113
+ retention: {
114
+ type: "coupon_offer", // the only value the enum takes
115
+ coupon_offer: { coupon: offer.couponId },
116
+ },
117
+ }),
118
+ },
119
+ after_completion: {
120
+ type: "redirect",
121
+ redirect: { return_url: `${origin}/billing/cancelled` },
122
+ },
123
+ },
124
+ });
125
+ ```
126
+
127
+ `retention` requires **both** `type` and `coupon_offer` — the spec marks the pair
128
+ required, so a call carrying only `type: "coupon_offer"` is rejected rather than
129
+ silently offering nothing.
130
+
131
+ **There is no `retention` field on the portal *configuration*.** The
132
+ configuration's `features.subscription_cancel` holds exactly four keys —
133
+ `enabled`, `mode` (`at_period_end` | `immediately`), `proration_behavior`
134
+ (`none` | `create_prorations` | `always_invoice`) and `cancellation_reason`. That
135
+ asymmetry is the whole design: **a targeted offer exists only on the session, so
136
+ eligibility is code you own, on your side of the boundary, or it does not exist.**
137
+
138
+ `after_completion.type` is one of `redirect`, `hosted_confirmation` or
139
+ `portal_homepage`. Send a cancellation somewhere that can say what happens next;
140
+ the default hosted page cannot tell the customer what they still have access to.
141
+
142
+ ## A coupon cannot say "once per customer"
143
+
144
+ `retention.coupon_offer` takes a **coupon** id. Stripe's own comparison of the
145
+ two discount primitives:
146
+
147
+ | | **Coupon** | **Promotion code** |
148
+ |---|---|---|
149
+ | Restrict to a specific customer | ❌ | ✓ |
150
+ | First purchase only | ❌ | ✓ |
151
+ | Minimum spend | ❌ | ✓ |
152
+ | Deactivate without deleting | ❌ delete only | ✓ `active: false` |
153
+
154
+ None of the three restrictions you want on a save offer can be expressed by the
155
+ object the offer accepts. And `max_redemptions` is **not** a per-customer cap:
156
+ it is a total across all customers, shared with every promotion code on the same
157
+ coupon, so as a guard it is both wrong and a race — the fiftieth and
158
+ fifty-first cancelling customers both pass a read of `times_redeemed`.
159
+
160
+ Two consequences, and neither is optional:
161
+
162
+ 1. **Eligibility is a decision your server makes before it mints the session.**
163
+ Not a Stripe setting, not a coupon field.
164
+ 2. **A coupon cannot be deactivated.** Deleting it prevents new applications and
165
+ leaves every existing discount in place — which is the right behaviour, and
166
+ means an over-generous `forever` coupon cannot be recalled from the customers
167
+ who took it. Choose `duration` as if it were permanent for whoever redeems.
168
+
169
+ ## The trap: a `duration=once` discount deletes itself
170
+
171
+ Straight from the coupon documentation:
172
+
173
+ > When a subscription uses a coupon with `duration=once`, the coupon is
174
+ > considered used after the invoice finalizes and is removed from the
175
+ > subscription's `discounts` array. […] This means a subscription may appear to
176
+ > have no discount even though a coupon was applied.
177
+
178
+ So the obvious eligibility check is the expensive one:
179
+
180
+ ```ts
181
+ // WRONG. After the discounted invoice finalizes this is empty again.
182
+ const alreadyDiscounted = sub.discounts.length > 0;
183
+ ```
184
+
185
+ A monthly subscriber cancels, takes 50% off, pays the discounted invoice — and
186
+ the array is clean. Next month they open the cancel flow again and your server,
187
+ reading Stripe, offers 50% off again. **The customer now rides half price
188
+ forever, one cancel-flow visit at a time, and every renewal looks ordinary in
189
+ the logs.** At $200/month that is $1,200 a year per customer who works it out,
190
+ and the flow's own copy — "You can still cancel at any time" — teaches them.
191
+
192
+ The guard is a row of your own, written when the discount arrives and never read
193
+ back from Stripe:
194
+
195
+ ```ts
196
+ // retention_offers: (customerId, offerId) unique
197
+ type RetentionOffer = {
198
+ customerId: string;
199
+ offerId: string; // yours: "cancel-50-once", not the coupon id
200
+ couponId: string; // what was actually offered, for the audit trail
201
+ offeredAt: Date;
202
+ redeemedAt: Date | null; // set from customer.discount.created
203
+ subscriptionId: string;
204
+ };
205
+
206
+ const COOLDOWN_DAYS = 365;
207
+
208
+ export async function offerFor(customerId: string, sub: Stripe.Subscription) {
209
+ const history = await db.retentionOffer.findMany({ where: { customerId } });
210
+
211
+ if (history.some((o) => o.redeemedAt)) {
212
+ const last = Math.max(...history.filter((o) => o.redeemedAt)
213
+ .map((o) => o.redeemedAt!.getTime()));
214
+ if (Date.now() - last < COOLDOWN_DAYS * 86_400_000) return null; // took one already
215
+ }
216
+ if (history.filter((o) => !o.redeemedAt).length >= 2) return null; // declined twice
217
+ if (sub.discounts.length > 0) return null; // already discounted RIGHT NOW
218
+ if (sub.status !== "active") return null; // never discount out of dunning
219
+ if (!(await isProfitableAtHalfPrice(sub))) return null;
220
+
221
+ return { offerId: "cancel-50-once", couponId: process.env.STRIPE_RETENTION_COUPON! };
222
+ }
223
+ ```
224
+
225
+ Four properties of that function, each of which is a defect if dropped:
226
+
227
+ - **The ledger is the source of truth, Stripe is the second opinion.** `discounts`
228
+ answers "is there a discount right now", which is a different question from
229
+ "has this person been given one".
230
+ - **A cooldown, not a lifetime ban.** A customer who took a save two years ago is
231
+ a different risk from one who took it last month, and a lifetime ban makes the
232
+ offer useless as a retention instrument.
233
+ - **Declining counts.** Someone walking the cancel flow repeatedly without
234
+ redeeming is measuring you.
235
+ - **`status !== "active"` excludes dunning.** A customer whose card is failing is
236
+ an involuntary-churn problem; discounting them halves the revenue you were
237
+ already not collecting. Keep the two churn kinds apart —
238
+ [`provider-concentration.md`](provider-concentration.md).
239
+
240
+ Write the row **before** the session is created, with `redeemedAt: null`. A
241
+ session minted and never recorded is an offer you cannot count, and the count is
242
+ the only thing that stops the loop.
243
+
244
+ ## Nobody tells you the customer stayed
245
+
246
+ There is **no** portal event for a completed flow. The spec's webhook catalogue
247
+ holds `billing_portal.session.created` and the two
248
+ `billing_portal.configuration.*` events, and nothing else — no
249
+ `billing_portal.session.completed`, no per-flow outcome. What you get:
250
+
251
+ | Signal | Means |
252
+ |---|---|
253
+ | `customer.discount.created` | a Discount object now exists — this is the redemption |
254
+ | `customer.subscription.updated` | the subscription changed; `event.data.previous_attributes.discounts` says whether the discount is what changed |
255
+ | `customer.subscription.deleted` | the teardown, when they cancelled immediately |
256
+ | *nothing arriving at all* | the customer closed the tab |
257
+
258
+ **Absence of a cancellation is not a save.** A funnel that infers "retained"
259
+ from "no `customer.subscription.deleted` within an hour" counts every abandoned
260
+ tab as a win, and the number it produces is the one somebody will use to justify
261
+ widening the discount. Record the save from `customer.discount.created`, matched
262
+ to the open row in your ledger:
263
+
264
+ ```ts
265
+ case "customer.discount.created": {
266
+ const discount = event.data.object as Stripe.Discount;
267
+ const couponId = typeof discount.source?.coupon === "string"
268
+ ? discount.source.coupon
269
+ : discount.source?.coupon?.id; // see the next section
270
+ if (!couponId || !discount.customer || !discount.subscription) break;
271
+
272
+ const { count } = await db.retentionOffer.updateMany({
273
+ where: { customerId: idOf(discount.customer), couponId, redeemedAt: null },
274
+ data: { redeemedAt: new Date() },
275
+ });
276
+ if (count === 0) { // a discount we never offered
277
+ log.warn("discount applied with no open retention offer", { couponId });
278
+ }
279
+ break;
280
+ }
281
+ ```
282
+
283
+ That `count === 0` branch is the one worth keeping: it fires when a coupon is
284
+ applied by support, by the Dashboard, or by a Dashboard-configured retention
285
+ coupon nobody told the code about. Silently swallowing it is how a discount
286
+ programme becomes untraceable.
287
+
288
+ ## Applying it yourself: `discounts` is a replacement, not an append
289
+
290
+ The hosted flow is the only way to get Stripe's cancel page. Any other offer —
291
+ pause, downgrade, service credit, a human — means you own the screen and apply
292
+ the coupon yourself. Three rules, and the first one destroys revenue:
293
+
294
+ **`discounts` on an update is the complete new list.** From the documentation:
295
+ "When updating `discounts`, you need to pass in any previously set `coupon`,
296
+ `promotion_code` or `discount` you want to keep on the subscription." So:
297
+
298
+ ```ts
299
+ const fresh = await stripe.subscriptions.retrieve(subId, { expand: ["discounts"] });
300
+
301
+ await stripe.subscriptions.update(subId, {
302
+ discounts: [
303
+ ...fresh.discounts.map((d) => ({ discount: typeof d === "string" ? d : d.id })),
304
+ { coupon: offer.couponId }, // the save, added to what exists
305
+ ],
306
+ });
307
+ ```
308
+
309
+ Read it fresh inside the same request. A list you loaded when the page rendered
310
+ can be missing a discount sales negotiated since, and passing the stale list
311
+ **deletes** it — a 30%-off enterprise agreement replaced by a one-month 50%,
312
+ with no error and no invoice until the next cycle. `discounts: ""` clears them
313
+ all, which is a deliberate action and never a default.
314
+
315
+ - **Up to 20 discounts** stack on a subscription or item, and **order matters**
316
+ when `amount_off` and `percent_off` mix: 20% then $5 off is not $5 off then 20%.
317
+ - **Adding a discount creates no invoice and no proration.** "The new discounts
318
+ are applied the next time the subscription creates an invoice" — which is
319
+ exactly why the honest copy is *"50% off your next invoice"* and why a card
320
+ promising money back today is a lie the API will not tell.
321
+ - **Quote the number from Stripe.** `stripe.invoices.createPreview` returns what
322
+ the next invoice will actually be. A percentage multiplied in your own code is
323
+ a second home for a price — [`price-integrity.md`](price-integrity.md) — and
324
+ the discrepancy surfaces on the customer's card statement, not in your tests.
325
+
326
+ ## Reading a discount now costs one more hop
327
+
328
+ Three shapes moved, and all three fail as `undefined` rather than as an error —
329
+ so a save card renders "% off" with nothing in front of it:
330
+
331
+ | Reading | Then | Now | Moved in |
332
+ |---|---|---|---|
333
+ | the coupon behind a discount | `discount.coupon` | `discount.source.coupon`, with `discount.source.type === "coupon"` | `2025-09-30.clover` |
334
+ | the coupon behind a promotion code | `promotion_code.coupon` | `promotion_code.promotion.coupon`, with `promotion.type === "coupon"` | `2025-09-30.clover` |
335
+ | a subscription's discount | `subscription.discount` | `subscription.discounts[]`, and it needs `expand: ["discounts"]` | the `discounts` array |
336
+
337
+ `Discount` objects cannot be fetched by id — `expand` is the only way to read
338
+ one — and both new wrapper objects carry a `type` enum whose only current value
339
+ is `"coupon"`. Switch on it rather than assuming it: that enum exists because
340
+ something else is coming.
341
+
342
+ ## Where the flow is simply not available
343
+
344
+ Each of these makes the cancel page — and therefore the offer — disappear for
345
+ some of your customers. The button either 400s or the page renders without the
346
+ action, and a cancel flow that dead-ends is worse than one that never existed.
347
+
348
+ - **A subscription with a subscription schedule attached can be neither updated
349
+ nor cancelled in the portal.** If you implement downgrade-at-period-end with
350
+ schedules — the standard way — you have removed your own cancel page from
351
+ every customer who downgraded. Check for a schedule before offering the portal
352
+ route, and keep an owned cancel path for those subscriptions.
353
+ - **Multiple products, usage-based prices, `collection_method: send_invoice`, or
354
+ an unsupported payment method:** cancel works, *update* does not.
355
+ - **Modifying a `trialing` subscription ends the trial and invoices immediately.**
356
+ Never route a trialing customer into a flow that can update.
357
+ - **A portal session expires 5 minutes after creation if unused**, and an hour
358
+ after the last activity. Mint it in the request that redirects; a session id
359
+ put in an email is dead on arrival.
360
+ - **The portal cannot be displayed in an iframe.** A modal cancel flow has to be
361
+ a redirect or your own screen.
362
+
363
+ ## A save is not a renewal, and it is not a churn event either
364
+
365
+ - **MRR** falls by the discount for as long as it runs. A discounted month is
366
+ real revenue at a lower amount, not a deferred full month.
367
+ - **Churn** must not count the save. But the offer's *acceptance rate* is a
368
+ number worth having on its own, and it comes from your ledger — Stripe cannot
369
+ tell you about an offer it never saw the customer decline.
370
+ - `cancellation_details.feedback` is the customer's reason, one of
371
+ `too_expensive`, `missing_features`, `switched_service`, `unused`,
372
+ `customer_service`, `low_quality`, `too_complex`, `other` — configured per
373
+ portal configuration and readable on the subscription and in Sigma. It is the
374
+ input to *which* offer to make: `too_expensive` is a discount, `missing_features`
375
+ is not.
376
+ - `cancellation_details.reason` is why the subscription ended, one of
377
+ `cancellation_requested`, `payment_failed`, `payment_disputed`,
378
+ `canceled_by_retention_policy`. **The last one is Stripe's test-data retention
379
+ policy deleting old sandbox data, not your retention offer** — the names
380
+ collide and the meanings do not.
381
+
382
+ ## The test matrix
383
+
384
+ Shipped, and runnable now. `fixtures/` carries both invariants this file states,
385
+ each with the mutant that makes it fail:
386
+
387
+ | Invariant | Mutant | What the mutant ships |
388
+ |---|---|---|
389
+ | `retention-offer-is-consumed-once` | `retention-eligibility` | no ledger at all — eligibility read from `subscription.discounts`, so the offer returns every cycle and a save cannot be told from a support discount |
390
+ | `scheduled-cancellation-survives-billing-mode` | `cancellation-timestamp` | `cancel_at_period_end` alone, which reads as "renews" on every flexible-mode cancellation |
391
+
392
+ ```bash
393
+ node fixtures/assert-money-invariants.mjs # both, against the reference handler
394
+ node fixtures/assert-money-invariants.mjs --self-test # watch each assertion go red on its own
395
+ ```
396
+
397
+ Beyond that, two things the documentation does not state and a sandbox does —
398
+ run them before shipping a live offer, and record what you saw:
399
+
400
+ ```bash
401
+ stripe listen --forward-to localhost:3000/api/billing/webhook
402
+ # then walk the flow: does redeeming emit customer.discount.created BEFORE
403
+ # customer.subscription.updated, and does the cancellation get abandoned
404
+ # entirely (no cancel_at, no cancel_at_period_end)?
405
+ stripe subscriptions retrieve sub_... --expand discounts
406
+ ```
407
+
408
+ - [ ] cancel from the portal, classic and flexible `billing_mode`; the stored
409
+ cancellation timestamp is right in **both**
410
+ - [ ] redeem the offer; the ledger row gets `redeemedAt` and the subscription is
411
+ **not** flagged for cancellation
412
+ - [ ] pay the discounted invoice, then re-enter the flow: **no second offer**
413
+ - [ ] a subscription carrying a negotiated discount: the save is added, the
414
+ negotiated one survives
415
+ - [ ] a subscription with a schedule attached: the flow is refused with a real
416
+ message, not a Stripe 400
417
+ - [ ] `past_due`: no offer
418
+ - [ ] a coupon applied from the Dashboard: the `count === 0` warning fires
@@ -7,7 +7,8 @@ accurate than recalling the API from training data.
7
7
 
8
8
  *Read from `docs.stripe.com/agents`, `/skills`, `/mcp` and the CLI's own
9
9
  `--help` on 2026-08-11, against Stripe CLI 1.45.2 and the Claude Code plugin
10
- `stripe@claude-plugins-official` 0.5.1. Re-check before quoting a version.*
10
+ `stripe@claude-plugins-official` 0.5.1; the plugin re-read at 0.6.1 on
11
+ 2026-08-25. Re-check before quoting a version.*
11
12
 
12
13
  ## Contents
13
14
 
@@ -100,6 +101,12 @@ hygiene). `stripe-docs` looks things up. This skill covers what happens on
100
101
  *your* side of the boundary — reconciling Stripe's state into your database.
101
102
  When they disagree about anything Stripe-side, Stripe's skill wins.
102
103
 
104
+ What Stripe's own skills do **not** cover, measured over plugin 0.6.1 on
105
+ 2026-08-25: a grep for `retention`, `coupon` and `churn` across all eight returns
106
+ nothing about cancellation deflection. The save offer is this skill's ground by
107
+ absence, not by preference — see
108
+ [`cancellation-and-retention.md`](cancellation-and-retention.md).
109
+
103
110
  ## MCP server
104
111
 
105
112
  ```bash
@@ -7,6 +7,7 @@ shape that survived production; the reasoning for each is in `SKILL.md`.
7
7
  ## Contents
8
8
 
9
9
  - [Data model](#data-model)
10
+ - [Get-or-create customer](#get-or-create-customer)
10
11
  - [Creating the checkout session](#creating-the-checkout-session)
11
12
  - [The verify fallback](#the-verify-fallback)
12
13
  - [Granting on renewal](#granting-on-renewal)
@@ -52,6 +53,29 @@ export function mapStatus(s: string): Status {
52
53
  }
53
54
  ```
54
55
 
56
+ ## Get-or-create customer
57
+
58
+ Two tabs, two requests, two customers, and the second silently owns the
59
+ subscription the first is charging. The conditional update is the whole guard:
60
+
61
+ ```ts
62
+ const customer = await stripe.customers.create({ email, metadata: { userId } });
63
+
64
+ const { count } = await db.user.updateMany({ // write only if nobody did
65
+ where: { id: userId, stripeCustomerId: null },
66
+ data: { stripeCustomerId: customer.id },
67
+ });
68
+
69
+ if (count === 0) { // lost the race
70
+ await stripe.customers.del(customer.id).catch(log); // delete the orphan
71
+ return (await db.user.findUnique({ where: { id: userId } }))!.stripeCustomerId!;
72
+ }
73
+ return customer.id;
74
+ ```
75
+
76
+ Before creating, retrieve the stored id and treat `resource_missing` or
77
+ `deleted: true` as "create a new one" — the normal state after a key rotation.
78
+
55
79
  ## Creating the checkout session
56
80
 
57
81
  ```ts
@@ -197,21 +221,11 @@ converts, `invoice.paid` arrives with `billing_reason: "subscription_cycle"`.
197
221
 
198
222
  ## Cancellation and reactivation
199
223
 
200
- ```ts
201
- await stripe.subscriptions.update(subId, { cancel_at_period_end: true });
202
- // local: cancelAtPeriodEnd = true, status stays "active"
203
- ```
204
-
205
- If the local write fails after Stripe succeeded, say so honestly ("submitted,
206
- syncing shortly") and let reconciliation fix it. Reverting Stripe here would
207
- un-cancel a subscription the user asked to end.
208
-
209
- Reactivation is `cancel_at_period_end: false`, and is only meaningful while the
210
- period is still running. Past that, it is a new subscription.
211
-
212
- Immediate cancellation (`stripe.subscriptions.cancel`) is for refunds and abuse.
213
- Everything it must clean up belongs in the
214
- `customer.subscription.deleted` handler, not next to the API call.
224
+ Its own file, because the save offer that now sits on the cancel page changes what
225
+ the code around it has to do:
226
+ [`cancellation-and-retention.md`](cancellation-and-retention.md) — the call, the
227
+ timestamp that has to be derived from two fields since flexible `billing_mode`,
228
+ reactivation, immediate cancellation, and the eligibility ledger behind the coupon.
215
229
 
216
230
  ## The customer portal
217
231
 
@@ -230,6 +244,13 @@ configured in the portal too, or it can be violated there.
230
244
 
231
245
  Users with no `stripeCustomerId` get a 400 with a plain message, not a crash.
232
246
 
247
+ `flow_data` turns the same session into a **deep link** to one action —
248
+ `payment_method_update`, `subscription_cancel`, `subscription_update`,
249
+ `subscription_update_confirm` — with the rest of the portal's navigation hidden. The
250
+ cancel flow and the coupon it can carry are in
251
+ [`cancellation-and-retention.md`](cancellation-and-retention.md); note that a session
252
+ expires 5 minutes after creation if unused, so mint it in the request that redirects.
253
+
233
254
  ## Refund clawback
234
255
 
235
256
  ```ts