@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.
- package/CHANGELOG.md +69 -0
- package/README.md +7 -0
- package/SECURITY.md +9 -9
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +68 -95
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +13 -4
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +66 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-discount-created-retention.json +47 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-subscription-updated-cancel-classic.json +59 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-subscription-updated-cancel-flexible.json +59 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-subscription-updated-discount-consumed.json +57 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +28 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +145 -3
- package/plugins/sheleg-dev/skills/stripe-billing/references/cancellation-and-retention.md +418 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +8 -1
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +36 -15
|
@@ -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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|