@ssheleg/sheleg-dev 0.9.1 → 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 CHANGED
@@ -4,6 +4,85 @@ All notable changes to this project are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
  Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.10.0 — the cancel flow that offers a discount, and the two ways it leaks money
8
+
9
+ Cursor's cancel page — "Before you go… 50% off your next invoice" — is Stripe's
10
+ customer portal with `flow_data[subscription_cancel][retention]`, and `stripe-billing`
11
+ said nothing about it. Verified against the Stripe OpenAPI spec at `2026-07-29.dahlia`
12
+ and `docs.stripe.com` on 2026-08-25.
13
+
14
+ ### Two defects that no screen shows
15
+
16
+ **A `duration=once` discount deletes itself, so the offer can be taken forever.** From
17
+ Stripe's own coupon documentation: once the discounted invoice finalizes, the discount is
18
+ **removed from the subscription's `discounts` array** — "a subscription may appear to have
19
+ no discount even though a coupon was applied". So the obvious eligibility check, asking
20
+ Stripe whether this customer was already discounted, answers no every cycle. A monthly
21
+ subscriber who reopens the cancel flow rides half price indefinitely, and every renewal
22
+ looks ordinary in the logs. Compounding it: `retention.coupon_offer` takes a **coupon**,
23
+ and a coupon cannot be restricted to a customer, capped per customer, or deactivated —
24
+ `max_redemptions` is a total across all customers and reading it is a race. Eligibility
25
+ is a row in your database or it does not exist.
26
+
27
+ **Under flexible `billing_mode`, a portal cancellation does not set
28
+ `cancel_at_period_end`.** It sets `cancel_at` and leaves the flag `false`, so a billing
29
+ page whose banner reads the boolean tells a customer who cancelled ten seconds ago that
30
+ their plan renews. Nothing errors. Because `billing_mode` is fixed at creation, an
31
+ established account holds both kinds of row at once.
32
+
33
+ Both are now invariants with fixtures and a mutant each —
34
+ `retention-offer-is-consumed-once` (mutant: no ledger, eligibility read from
35
+ `subscription.discounts`) and `scheduled-cancellation-survives-billing-mode` (mutant:
36
+ the flag alone). The pack goes 12 → 14 invariants, 45 → 57 assertions, 9 → 13 event
37
+ bodies, 9 → 11 mutants, each still isolated by a fixture.
38
+
39
+ ### New: `references/cancellation-and-retention.md`
40
+
41
+ The single home for cancellation and the save offer: the exact `flow_data` shape and why
42
+ there is **no** `retention` field on the portal *configuration* (so a targeted offer
43
+ exists only per session); the Dashboard "Retention Coupon" switch that offers every
44
+ cancelling customer a discount with no code review and no test; the eligibility ledger
45
+ and its four conditions; the absent event (`billing_portal.session.created` exists,
46
+ `…completed` does not — absence of a cancellation is not a save); `discounts` on an
47
+ update being a **replacement**, which silently deletes a negotiated discount; the five
48
+ conditions under which the portal cancel page is not available at all, including a
49
+ subscription schedule, which is how a downgrade-at-period-end implementation removes its
50
+ own cancel page.
51
+
52
+ Also recorded: discounts read through one more level since `2025-09-30.clover` —
53
+ `discount.coupon` → `discount.source.coupon`, `promotion_code.coupon` →
54
+ `promotion_code.promotion.coupon`, `subscription.discount` → `subscription.discounts[]`.
55
+ All three fail as `undefined` rather than as an error.
56
+
57
+ ### The split behind it
58
+
59
+ `stripe-billing/SKILL.md` was at ~4747 tokens against a 4750 working limit — three tokens
60
+ of headroom, stated in the gate for exactly this moment. Cancellation moved into the new
61
+ reference and three body code blocks that had a second home in
62
+ `references/subscription-lifecycle.md` (get-or-create, the seat update, the refund
63
+ compare-and-swap) now live only there: 4747 → 4683 tokens, 409 → 381 lines. The rule with
64
+ two homes is the one that drifts at one of them.
65
+
66
+ `references/stripe-agent-toolchain.md` re-read against plugin 0.6.1: Stripe ships **eight**
67
+ agent skills now, not the seven this file listed, and a grep for `retention`, `coupon` and
68
+ `churn` across all eight returns nothing about cancellation deflection — so the save offer
69
+ is this skill's ground by absence rather than by preference, and that is now stated where
70
+ the division of labour is.
71
+
72
+ `package.json` also stopped describing six skills. There have been seven since v0.9.0, and
73
+ `error-tracking` was missing from the sentence npm shows.
74
+
75
+
76
+ ## v0.9.2 — the README stops claiming a command the package cannot run
77
+
78
+ The README told a reader to run commands the published package cannot run: it ships no
79
+ `test/` directory, so `npm test` resolves in a clone and nowhere else. Measured against the
80
+ published tarball on 2026-08-25. Shipping the suite does not fix it — the plants live in
81
+ `.github/workflows/`, which no packaging npm can express puts in a tarball — so the document
82
+ now names where the command runs instead of claiming it, beside a marker the umbrella's
83
+ validator reads. Naming a dead command is this family's own rule; claiming one is the defect.
84
+
85
+
7
86
  ## v0.9.1 — 2026-08-24
8
87
 
9
88
  ### The skill was wrong at the exact place its author then tripped
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # sheleg-dev
2
2
 
3
+ [![validate](https://github.com/ssheleg/sheleg-dev/actions/workflows/validate.yml/badge.svg)](https://github.com/ssheleg/sheleg-dev/actions/workflows/validate.yml)
4
+ [![npm](https://img.shields.io/npm/v/%40ssheleg%2Fsheleg-dev)](https://www.npmjs.com/package/@ssheleg/sheleg-dev)
5
+ [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
+ [![site](https://img.shields.io/badge/docs-ssheleg.github.io-8ab0ff)](https://ssheleg.github.io/sshlg-skills/skills/sheleg-dev/)
7
+
8
+ **[Docs, and all 8 skills →](https://ssheleg.github.io/sshlg-skills/)** · [this skill's page](https://ssheleg.github.io/sshlg-skills/skills/sheleg-dev/) · [follow @sshlg93 on X](https://x.com/intent/follow?screen_name=sshlg93)
9
+
3
10
  The integration layer a product reaches once it has users: **money in, tracking,
4
11
  sign-in, and speed.**
5
12
 
@@ -222,6 +229,10 @@ correctly. It does not tell you which one to trust.
222
229
 
223
230
  ## Verify
224
231
 
232
+ <!-- commands-run-in: a clone -->
233
+ These run **in a clone of this repository**. The published npm package ships no
234
+ `test/` directory, so from an install they are names, not commands.
235
+
225
236
  ```bash
226
237
  npm test # the validator, the manual gate's 65 fixtures, the money fixtures' 13 checks
227
238
  ```
package/SECURITY.md CHANGED
@@ -17,8 +17,8 @@ documents name that does not exist here.
17
17
 
18
18
  ## What ships, and what of it executes
19
19
 
20
- `git ls-files plugins` returns **56 files: 34 markdown, one plugin manifest, three
21
- files of the manual gate, and 18 files of money fixtures.** Two of the seven skills now
20
+ `git ls-files plugins` returns **61 files: 35 markdown, one plugin manifest, three
21
+ files of the manual gate, and 22 files of money fixtures.** Two of the seven skills now
22
22
  ship code you can run — the assertion packs under `fixtures/` — and that is a change
23
23
  from every release before v0.7.0, so it is stated here rather than left to be noticed.
24
24
  They run only when you invoke them, read only the JSON fixtures beside them, and open
@@ -26,20 +26,20 @@ no socket; the greps below prove all three.
26
26
 
27
27
  | Component | Count | Runtime behavior |
28
28
  |---|---|---|
29
- | `SKILL.md`, one per skill | 6 | Text. Read by the agent, executes nothing. |
30
- | `references/` files, loaded on demand | 25 | Text. Same. |
29
+ | `SKILL.md`, one per skill | 7 | Text. Read by the agent, executes nothing. |
30
+ | `references/` files, loaded on demand | 26 | Text. Same. |
31
31
  | `plugins/sheleg-dev/.claude-plugin/plugin.json` | 1 | Manifest read by Claude Code. |
32
32
  | `plugins/sheleg-dev/hooks/hooks.json` | 1 | Hook manifest read by Claude Code. Declares one `PreToolUse` hook and no `if` filter. |
33
33
  | `plugins/sheleg-dev/hooks/money-gate.js` | 1 | Runs on a tool call, when the plugin is enabled. Requires `path` and its own decision module — nothing else. Reads stdin, writes one JSON line, exits 0 on every path. |
34
34
  | `plugins/sheleg-dev/hooks/lib/moneygate.js` | 1 | Pure. Payload and environment in, verdict out. No `require` at all, no filesystem, no clock. |
35
35
  | `bin/sheleg-dev.js` — the npm installer | 1 | Runs only when you invoke it. Node built-ins only: `fs`, `path`, `os`. |
36
36
  | `install.sh` — the shell installer | 1 | **Not in the tarball** — `files` ships `bin/` and `plugins/`, so this one reaches you only through a clone. Runs when you invoke it. Coreutils only: `cd`, `pwd`, `dirname`, `basename`, `mkdir`, `rm`, `cp`, `echo`. It is the destructive channel: `rm -rf` per skill, then `cp -R`. |
37
- | `plugins/sheleg-dev/skills/*/fixtures/*.json` — webhook payloads | 12 | Data. Placeholder ids only; no key, token, signing secret or real customer id. |
37
+ | `plugins/sheleg-dev/skills/*/fixtures/*.json` — webhook payloads | 16 | Data. Placeholder ids only; no key, token, signing secret or real customer id. |
38
38
  | `plugins/sheleg-dev/skills/*/fixtures/*.mjs` — the assertion packs and their reference implementations | 4 | Runs only when you invoke it. Reads the JSON beside it with `readFileSync` and nothing else: no `child_process`, no `fetch`, no write, no `process.env`. |
39
39
  | `plugins/sheleg-dev/skills/*/fixtures/manifest.json` and its `README.md` | 4 | Text. The invariant-to-fixture-to-document map, which `test/validate.py` reads. |
40
40
 
41
41
  The published tarball is `bin/` and `plugins/` plus `README.md`, `CHANGELOG.md`,
42
- `SECURITY.md`, `LICENSE` and `package.json` — 62 files, listed by
42
+ `SECURITY.md`, `LICENSE` and `package.json` — 67 files, listed by
43
43
  `npm pack --dry-run`. `install.sh`, `test/`, `docs/` and `CONTRIBUTING.md` are **not**
44
44
  in it; `test/validate.py` now refuses a path these shipped documents name that the
45
45
  tarball does not carry, because resolving in a clone is the wrong question for a
@@ -169,10 +169,10 @@ git clone https://github.com/ssheleg/sheleg-dev && cd sheleg-dev
169
169
  # self-describing documents name -- this file included.
170
170
  python3 test/validate.py
171
171
 
172
- # The shipped payload: 56 files.
172
+ # The shipped payload: 61 files.
173
173
  git ls-files plugins
174
174
 
175
- # 22 lines: the plugin manifest, the three files of the manual gate, and the 18
175
+ # 26 lines: the plugin manifest, the three files of the manual gate, and the 22
176
176
  # files of money fixtures. Everything else in the payload is markdown.
177
177
  git ls-files plugins | grep -v '\.md$'
178
178
 
@@ -206,6 +206,6 @@ grep -nE "require|child_process|exec|spawn|fetch|socket|rm -rf|cp -R|copyFileSyn
206
206
  # Live-key shapes across the skill payload: one line, and it is a placeholder.
207
207
  grep -rnE "sk_live_[A-Za-z0-9]|rk_live_[A-Za-z0-9]|whsec_[A-Za-z0-9]{8}|BEGIN [A-Z ]*PRIVATE KEY" plugins
208
208
 
209
- # What npm publishes, and nothing else: 62 files.
209
+ # What npm publishes, and nothing else: 67 files.
210
210
  npm pack --dry-run
211
211
  ```
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ssheleg/sheleg-dev",
3
- "version": "0.9.1",
4
- "description": "Six integration skills for the layer a product reaches once it has users: Stripe subscription billing, crypto payments, ad and conversion tracking under Consent Mode v2, Google sign-in and the wider Google auth surface, and Core Web Vitals. This package is the installer CLI.",
3
+ "version": "0.10.0",
4
+ "description": "Seven integration skills for the layer a product reaches once it has users: Stripe subscription billing and cancellation retention, crypto payments, ad and conversion tracking under Consent Mode v2, Google sign-in and the wider Google auth surface, error tracking, and Core Web Vitals. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "sheleg-dev": "bin/sheleg-dev.js"
7
7
  },
@@ -3,7 +3,7 @@
3
3
  "name": "sheleg-dev",
4
4
  "displayName": "Sheleg Dev",
5
5
  "description": "The integration layer a product needs once it has users: Stripe subscription billing reconciled into your own database, crypto payments that survive over-payment and duplicate webhooks, ad and conversion tracking under Consent Mode v2, Google sign-in with the account-linking guards, the wider Google auth surface, and Core Web Vitals work that moves the score, and Sentry error tracking wired so it does not forward your own credentials to a third party and so a stack trace names the commit that caused it.",
6
- "version": "0.9.1",
6
+ "version": "0.10.0",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -14,7 +14,7 @@ description: >-
14
14
  uptime vendor.
15
15
  license: MIT
16
16
  metadata:
17
- version: 0.9.1
17
+ version: 0.9.2
18
18
  ---
19
19
 
20
20
  # error-tracking — Sentry wired so it does not leak, and so a stack trace names a commit
@@ -2,18 +2,19 @@
2
2
  name: stripe-billing
3
3
  description: >-
4
4
  Use when connecting a product to Stripe, or auditing a live integration:
5
- checkout sessions, subscriptions and renewals, seats, proration, refunds, the
6
- portal, and the webhook that turns a payment into an entitlement in the
7
- application's own database. Covers Stripe's agent toolchain (CLI, MCP,
8
- skills), the pinned API version and SDK retries, product-to-price resolution
9
- across test and live, the get-or-create customer race, metadata written
10
- twice, claim-first webhook idempotency, what invoice billing_reason decides,
11
- cumulative refund totals, write ordering across two systems with compensating
12
- reverts, reconciliation, and price drift only customers see. Triggers - "add
5
+ checkout, renewals, seats, proration, refunds, cancellation and the
6
+ coupon offered at the cancel step, the portal, and the webhook that
7
+ turns a payment into an entitlement in your database. Covers Stripe's
8
+ agent toolchain, the pinned API version and SDK retries, price
9
+ resolution across modes, claim-first webhook idempotency, what invoice
10
+ billing_reason decides, cumulative refunds, the cancellation field
11
+ flexible billing_mode moved, retention eligibility Stripe cannot
12
+ express, and write ordering with compensating reverts. Triggers - "add
13
13
  Stripe", "Stripe checkout", "subscription billing", "webhook signature",
14
- "invoice.paid", "proration", "seats", "refund", "подключить Stripe", "оплата
15
- подпиской", "вебхук Stripe", "биллинг". Not for choosing between Stripe
16
- products (stripe-best-practices) or reading Stripe docs (stripe-docs).
14
+ "invoice.paid", "proration", "refund", "cancel subscription", "retention
15
+ coupon", "подключить Stripe", "оплата подпиской", "вебхук Stripe",
16
+ "скидка при отмене", "биллинг". Not for choosing between Stripe products
17
+ (stripe-best-practices) or reading Stripe docs (stripe-docs).
17
18
  license: MIT
18
19
  ---
19
20
 
@@ -28,8 +29,7 @@ callback that arrived twice, the renewal that granted a month of product for a
28
29
  $0.40 proration invoice, the upgrade that charged the card and then failed to
29
30
  write the row.
30
31
 
31
- This skill is that seam. For **which** Stripe primitive to use (Checkout vs
32
- PaymentIntents, Connect, Tax, usage-based billing) the official
32
+ This skill is that seam. For **which** Stripe primitive to use, the official
33
33
  `stripe-best-practices` skill is the authority and wins any disagreement; for
34
34
  lookups, `stripe-docs`. Examples are TypeScript; the rules are
35
35
  language-neutral.
@@ -38,12 +38,13 @@ Deep material, loaded on demand:
38
38
 
39
39
  | Read | When |
40
40
  |---|---|
41
- | [`references/stripe-agent-toolchain.md`](references/stripe-agent-toolchain.md) | starting from nothing, or about to guess an API shape — CLI, MCP tools, skills index, key handling |
41
+ | [`references/stripe-agent-toolchain.md`](references/stripe-agent-toolchain.md) | starting from nothing, or about to guess an API shape — CLI, MCP, skills index, key handling |
42
42
  | [`references/webhook-events.md`](references/webhook-events.md) | writing or reviewing the handler — event catalogue, payload shapes, ordering, failure semantics |
43
- | [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md) | implementing checkout, verify, renewal, seats, plan change, trials, cancellation, clawback |
43
+ | [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md) | implementing checkout, verify, renewal, seats, plan change, trials, clawback |
44
+ | [`references/cancellation-and-retention.md`](references/cancellation-and-retention.md) | implementing cancel or reactivation, or offering a coupon at the cancel step |
44
45
  | [`references/price-integrity.md`](references/price-integrity.md) | pricing lives in more than one file, or an advertised price must be proved against Stripe |
45
46
  | [`references/testing-and-local-dev.md`](references/testing-and-local-dev.md) | local webhooks and mocks, and the shipped `fixtures/` — the suite, already written |
46
- | [`references/provider-concentration.md`](references/provider-concentration.md) | growing revenue, opening a second market, separating involuntary churn, or asking what happens if the payment account is limited |
47
+ | [`references/provider-concentration.md`](references/provider-concentration.md) | growing revenue, opening a second market, separating involuntary churn, or if the payment account is limited |
47
48
 
48
49
  ---
49
50
 
@@ -130,25 +131,12 @@ replaced when you reprice; products are stable.
130
131
  ## Get-or-create customer is a race
131
132
 
132
133
  Two tabs, two requests, two customers, and the second silently owns the
133
- subscription the first is charging.
134
-
135
- ```ts
136
- const customer = await stripe.customers.create({ email, metadata: { userId } });
137
-
138
- const { count } = await db.user.updateMany({ // write only if nobody did
139
- where: { id: userId, stripeCustomerId: null },
140
- data: { stripeCustomerId: customer.id },
141
- });
142
-
143
- if (count === 0) { // lost the race
144
- await stripe.customers.del(customer.id).catch(log); // delete the orphan
145
- return (await db.user.findUnique({ where: { id: userId } }))!.stripeCustomerId!;
146
- }
147
- return customer.id;
148
- ```
149
-
150
- Before creating, retrieve the stored id and treat `resource_missing` or
151
- `deleted: true` as "create a new one" — the normal state after a key rotation.
134
+ subscription the first is charging. Create, then claim the id with a conditional
135
+ update; if the update matched no row you lost the race, so delete the orphan
136
+ customer and read the winner's id. Before creating, retrieve the stored id and
137
+ treat `resource_missing` or `deleted: true` as "create a new one" — the normal
138
+ state after a key rotation. The code is in
139
+ [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
152
140
 
153
141
  ---
154
142
 
@@ -291,28 +279,22 @@ Stripe creates every subscription in one of two billing modes. The choice is mad
291
279
  at `subscriptions.create` (or `subscription_data` on a Checkout session), **cannot
292
280
  be reversed**, and Stripe recommends **flexible** for new subscriptions.
293
281
 
294
- It is in the body rather than a reference because it changes the arithmetic the
295
- next two sections teach: under flexible mode a credit proration is computed from
296
- the amount **originally debited**, so one change can emit **several** credit
297
- prorations where classic emitted one. Code that takes `[0]` of the proration lines,
298
- or assumes one credit per change, breaks quietly in the refund clawback path — the
299
- one place here where a wrong number is money. The detail is in
300
- `references/subscription-lifecycle.md`; choosing between the modes is a product
301
- decision that `stripe-best-practices` owns.
282
+ It is in the body because it changes arithmetic the next sections teach: under
283
+ flexible mode a credit proration is computed from the amount **originally
284
+ debited**, so one change can emit **several** credit prorations where classic
285
+ emitted one. Code that takes `[0]` of the proration lines breaks quietly in the
286
+ clawback path — the one place here where a wrong number is money. It also decides
287
+ which field records a scheduled cancellation. Choosing between the modes is a
288
+ product decision `stripe-best-practices` owns.
302
289
 
303
290
  ## Seats and proration
304
291
 
305
- ```ts
306
- await stripe.subscriptions.update(subId, {
307
- items: [{ id: itemId, quantity: newQuantity }],
308
- proration_behavior: "always_invoice", // charge the difference now
309
- payment_behavior: "error_if_incomplete", // a declined card FAILS this call
310
- });
311
- ```
312
292
 
313
293
  - `proration_behavior`: `always_invoice` bills now, `create_prorations` defers
314
294
  to the next invoice, `none` adjusts nothing — the right choice for a *revert*.
315
- - **`error_if_incomplete` on upgrades.** Without it a declined card leaves the
295
+ The call, with its compensating revert, is in
296
+ [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
297
+ - **`payment_behavior: "error_if_incomplete"` on upgrades.** Without it a declined card leaves the
316
298
  subscription upgraded and unpaid while your database agrees with the upgrade.
317
299
  Catch `StripeCardError` and answer 402.
318
300
  - **Write ordering:** Stripe first, then your database. If the database write
@@ -326,65 +308,56 @@ await stripe.subscriptions.update(subId, {
326
308
 
327
309
  ---
328
310
 
329
- ## Cancellation
311
+ ## Cancellation, and the offer that deflects it
330
312
 
331
- - **`cancel_at_period_end: true`** is the default — the user keeps what they
332
- paid for. `status` stays `active`; a separate flag drives the UI.
313
+ - **At period end is the default** — the user keeps what they paid for and
314
+ `status` stays `active`. Under **flexible** `billing_mode` a portal
315
+ cancellation sets `cancel_at` and leaves `cancel_at_period_end` **false**, so a
316
+ banner reading that boolean tells a customer who cancelled ten seconds ago that
317
+ their plan renews. Store the timestamp, derived from both fields.
333
318
  - **Do the teardown in `customer.subscription.deleted`**, never beside the API
334
319
  call, so one path serves your UI, the portal and dunning alike.
335
- - **The portal is a second writer.** Everything a user does there reaches you
336
- only as a webhook; a billing page that assumes otherwise goes stale in a week.
337
320
  - On `invoice.payment_failed`, mark `past_due` and notify — do not cancel.
338
- Stripe's dunning decides the retries and the terminal state; cancelling early
339
- cancels customers whose next attempt would have cleared.
321
+ Stripe's dunning decides the retries and the terminal state.
322
+ - **A save offer's eligibility is yours.** Stripe's cancel page shows a coupon
323
+ card when you pass `flow_data.subscription_cancel.retention`, but a coupon
324
+ cannot be restricted to one customer and a `duration=once` discount leaves
325
+ `subscription.discounts` once its invoice finalizes. Ask Stripe whether this
326
+ customer was already discounted and the answer is no, every month, forever.
340
327
 
341
- ---
328
+ Both, with the code:
329
+ [`references/cancellation-and-retention.md`](references/cancellation-and-retention.md).
342
330
 
343
331
  ## Refunds arrive cumulative
344
332
 
345
333
  `charge.amount_refunded` is **the total refunded so far**, not this refund. Two
346
- partial refunds deliver `4000` then `9000`; read as an increment, that claws
347
- back $130 against a $90 charge.
348
-
349
- ```ts
350
- const totalRefunded = charge.amount_refunded / 100;
351
- const increment = totalRefunded - stored.refundedTotal;
352
- if (increment <= 0) return; // replay or reorder
353
-
354
- const { count } = await db.purchase.updateMany({
355
- where: { id: stored.id, refundedTotal: stored.refundedTotal }, // compare-and-swap
356
- data: { refundedTotal: totalRefunded },
357
- });
358
- if (count === 0) return; // a concurrent delivery won
359
- await clawBack(increment); // clamp at zero; log if it would go negative
360
- ```
334
+ partial refunds deliver `4000` then `9000`; read as an increment, that claws back
335
+ $130 against a $90 charge. Compute the increment against a stored total and write
336
+ it with a compare-and-swap, so a concurrent delivery loses rather than clawing
337
+ back twice — the code is in
338
+ [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
361
339
 
362
340
  A refund belongs either to a one-off payment (find it by `payment_intent`) or to
363
341
  a subscription invoice (no purchase row — resolve `charge.invoice` → invoice →
364
342
  subscription). Handle both, or subscription refunds silently leave the customer
365
343
  holding the product.
366
344
 
367
- ---
368
-
369
345
  ## Reconciliation
370
346
 
371
- Webhooks are best-effort and outages are not hypothetical. Run a job that asks
372
- Stripe what is true and repairs the difference:
347
+ Webhooks are best-effort and outages are not hypothetical. Run a job that lists
348
+ subscriptions with `status: "all"`, creates what is missing, and updates status,
349
+ period, quantity and price where they differ — sequentially, because each
350
+ iteration opens a transaction and may call an external API. Any grant it performs
351
+ reuses the webhook's idempotency marker, or a nightly job becomes a nightly gift.
373
352
 
374
- - list subscriptions with `status: "all"`; create what is missing, update
375
- status, period, quantity and price where they differ;
376
- - mark local rows canceled when Stripe has no such subscription — **excluding
377
- rows that were never Stripe's**. Comped, manual and other-provider plans carry
378
- synthetic ids, and cancelling them is a self-inflicted outage;
379
- - keep the loop **sequential**: each iteration opens a transaction and may call
380
- an external API, and `Promise.all` exhausts the connection pool under exactly
381
- the conditions you wrote the job for;
382
- - any grant it performs reuses the webhook's idempotency marker, or a nightly
383
- job becomes a nightly gift.
353
+ The guard that matters: mark local rows canceled when Stripe has no such
354
+ subscription, **excluding rows that were never Stripe's**. Comped, manual and
355
+ other-provider plans carry synthetic ids, and cancelling them is a self-inflicted
356
+ outage. The code, and the "Sync now" button it doubles as, are in
357
+ [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
384
358
 
385
- The same function is the "Sync now" button on the billing page. Money is minor
386
- units: convert once, at the boundary, and compare in integer cents —
387
- `Math.abs(20.83 - 20.84) > 0.01` is `true` in floating point.
359
+ Money is minor units: convert once, at the boundary, and compare in integer cents
360
+ — `Math.abs(20.83 - 20.84) > 0.01` is `true` in floating point.
388
361
 
389
362
  ---
390
363
 
@@ -392,10 +365,10 @@ units: convert once, at the boundary, and compare in integer cents —
392
365
 
393
366
  One provider for card payments is a single point of failure for revenue, and the
394
367
  decision belongs to the business rather than to this skill. What the code owes it
395
- is a seam: keep the charge behind an interface, keep the customer id yours rather
396
- than Stripe's, and never let a Stripe object id be the only key to a paying
397
- account. The full argument, the migration shapes and what a second provider costs
398
- are in `references/provider-concentration.md`.
368
+ is a seam: keep the charge behind an interface, keep the customer id yours, and
369
+ never let a Stripe object id be the only key to a paying account. The argument,
370
+ the migration shapes and the cost are in
371
+ [`references/provider-concentration.md`](references/provider-concentration.md).
399
372
 
400
373
  ## Local development and the test matrix
401
374
 
@@ -1,15 +1,16 @@
1
1
  # Money fixtures and the assertion pack
2
2
 
3
3
  **Copy this whole directory into your repository.** It is the executable half of what
4
- `SKILL.md` and `references/webhook-events.md` state in prose: nine real Stripe event
5
- bodies, twelve invariants a correct webhook handler must hold — 45 assertions between
4
+ `SKILL.md`, `references/webhook-events.md` and
5
+ `references/cancellation-and-retention.md` state in prose: thirteen real Stripe event
6
+ bodies, fourteen invariants a correct webhook handler must hold — 57 assertions between
6
7
  them — and a self-test that breaks the handler one rule at a time so you can watch each
7
8
  assertion fail on its own.
8
9
 
9
10
  Two commands, no dependencies, no network, no key:
10
11
 
11
12
  ```bash
12
- node assert-money-invariants.mjs # 12 invariants / 45 assertions
13
+ node assert-money-invariants.mjs # 14 invariants / 57 assertions
13
14
  node assert-money-invariants.mjs --self-test # break one rule at a time; EACH ASSERTION must go red
14
15
  ```
15
16
 
@@ -42,6 +43,10 @@ Every file is a webhook event body shaped the way Stripe sends it on
42
43
  | `checkout-session-completed-async-unpaid.json` | a bank-debit session, `payment_status: "unpaid"` | the session completed and the money has not moved |
43
44
  | `checkout-session-async-payment-failed.json` | days later: it never cleared | the redirect proved a browser and nothing else |
44
45
  | `checkout-session-completed-paid.json` | a card session that did clear | the positive control: without it, a handler refusing every session would pass the two above |
46
+ | `customer-discount-created-retention.json` | the save offer redeemed: a `duration=once`, 50%-off coupon read through `source.coupon` | there is no portal event for a completed flow, so this is the ONLY signal that a cancellation was deflected rather than abandoned |
47
+ | `customer-subscription-updated-discount-consumed.json` | the discounted invoice has finalized and `discounts` is `[]` again, with the old value in `previous_attributes` | Stripe's answer to "was this customer already discounted" is now no — the fixture that makes the re-offer loop visible |
48
+ | `customer-subscription-updated-cancel-flexible.json` | a portal cancellation under **flexible** `billing_mode`: `cancel_at` set, `cancel_at_period_end` **false** | the flag alone reads as "renews" on a subscription Stripe will end |
49
+ | `customer-subscription-updated-cancel-classic.json` | the same cancellation under **classic**: both fields set | the control — both readings agree here, which is why the flag alone survived until flexible mode |
45
50
 
46
51
  The refund pair resolves to a **purchase row**, not a Stripe object: a $90 credit pack with
47
52
  `refundedTotal: 0`, seeded by the assertion pack because it is your database's, not
@@ -65,6 +70,8 @@ Run `node assert-money-invariants.mjs` and each line names the invariant it hold
65
70
  | `unpaid-session-grants-nothing` | unpaid + failed | grants the product and reports a purchase for a charge that failed |
66
71
  | `paid-session-grants-once` | `checkout-session-completed-paid.json` | answers 200 and writes nothing — the positive control, without which refusing every session would pass the row above |
67
72
  | `conversion-id-survives-the-session` | cycle | generates its own conversion id, and the browser event never deduplicates against it |
73
+ | `retention-offer-is-consumed-once` | discount created + discount consumed | offers 50% off again every cycle, because it asked Stripe instead of its own ledger |
74
+ | `scheduled-cancellation-survives-billing-mode` | cancel flexible + cancel classic | tells a customer who cancelled ten seconds ago that their plan renews |
68
75
 
69
76
  ## What each mutant deletes
70
77
 
@@ -82,6 +89,8 @@ a line a generated handler routinely omits:
82
89
  | `conversion-id-from-metadata` | reading `conversionEventId` out of the subscription metadata | generates an id at emission time |
83
90
  | `grant-on-renewal` | the grant itself | treats `invoice.paid` as information |
84
91
  | `grant-on-checkout` | the first grant | answers 200 to `checkout.session.completed` and writes nothing |
92
+ | `retention-eligibility` | the whole offer ledger — the row written before the session, the history read, and the redemption mark | asks `subscription.discounts` whether this customer was already discounted, which a `duration=once` discount empties as soon as its invoice finalizes |
93
+ | `cancellation-timestamp` | `if (sub.cancel_at) return sub.cancel_at` ahead of the flag | reads `cancel_at_period_end` alone, correct for classic rows and wrong for every flexible one |
85
94
 
86
95
  ## What masks what
87
96
 
@@ -105,7 +114,7 @@ separate them are here because of it rather than by design.
105
114
  ## Placeholders
106
115
 
107
116
  Every id spells `PLACEHOLDER`: `sub_PLACEHOLDER_alice`, `ch_PLACEHOLDER_creditpack`,
108
- `cus_PLACEHOLDER_bob`. There is no key, token, signing secret or real customer id anywhere
117
+ `cus_PLACEHOLDER_bob`, `cpn_PLACEHOLDER_save50`. There is no key, token, signing secret or real customer id anywhere
109
118
  in this directory — signature verification happens before any of this and is
110
119
  `SKILL.md`'s subject, not these fixtures'. The email is `placeholder@example.invalid` and
111
120
  the one 64-hex string is its SHA-256.
@@ -43,6 +43,10 @@ const REFUND_REMAINDER = 'charge-refunded-remainder.json';
43
43
  const SESSION_UNPAID = 'checkout-session-completed-async-unpaid.json';
44
44
  const SESSION_FAILED = 'checkout-session-async-payment-failed.json';
45
45
  const SESSION_PAID = 'checkout-session-completed-paid.json';
46
+ const DISCOUNT_CREATED = 'customer-discount-created-retention.json';
47
+ const DISCOUNT_CONSUMED = 'customer-subscription-updated-discount-consumed.json';
48
+ const CANCEL_FLEXIBLE = 'customer-subscription-updated-cancel-flexible.json';
49
+ const CANCEL_CLASSIC = 'customer-subscription-updated-cancel-classic.json';
46
50
 
47
51
  /** A fresh store and handler per invariant: no assertion inherits another's state. */
48
52
  function harness(without = []) {
@@ -285,6 +289,68 @@ export const INVARIANTS = [
285
289
  assert.equal(conversion.currency, 'USD');
286
290
  },
287
291
  },
292
+ {
293
+ id: 'retention-offer-is-consumed-once',
294
+ fixtures: [DISCOUNT_CREATED, DISCOUNT_CONSUMED],
295
+ states: 'a customer who took the save offer is not offered it again after the '
296
+ + 'duration=once discount has left subscription.discounts',
297
+ breaks: ['retention-eligibility'],
298
+ async run({ store, handler, assert }) {
299
+ const before = { id: 'sub_PLACEHOLDER_alice', status: 'active', discounts: [] };
300
+
301
+ const first = await handler.offerRetention('cus_PLACEHOLDER_alice', before);
302
+ // unmutated, and the positive control: every mutant here still makes the FIRST
303
+ // offer. Without it, a handler that offers nobody anything would pass the rest.
304
+ assert.unmutated.equal((first || {}).offerId, 'cancel-50-once');
305
+ assert.equal(store.retentionOffers.length, 1,
306
+ 'the offer was shown and no row recorded it — an offer nobody wrote down cannot '
307
+ + 'be counted, and the count is the only thing that stops the loop');
308
+
309
+ await handler.deliver(fixture(DISCOUNT_CREATED));
310
+ assert.equal(store.saves.length, 1,
311
+ 'the redemption was not recorded as a save — there is no portal event for a '
312
+ + 'completed flow, so nothing else will ever say the cancellation was deflected');
313
+ assert.equal((store.retentionOffers[0] || {}).redeemedAt, 1767225000);
314
+
315
+ // The discounted invoice finalizes and Stripe REMOVES the discount from the array.
316
+ const consumed = fixture(DISCOUNT_CONSUMED);
317
+ await handler.deliver(consumed);
318
+ const mirror = store.subscriptions.get('sub_PLACEHOLDER_alice') || {};
319
+ assert.unmutated.deepEqual(mirror.discountIds, [],
320
+ 'the fixture is the moment the duration=once discount leaves the subscription');
321
+
322
+ const second = await handler.offerRetention('cus_PLACEHOLDER_alice', consumed.data.object);
323
+ assert.equal(second, null,
324
+ 'the offer was made a second time — one cancel-flow visit per cycle then rides '
325
+ + 'half price forever, and every renewal looks ordinary in the logs');
326
+ assert.equal(store.retentionOffers.length, 1, 'a second offer row was opened');
327
+ },
328
+ },
329
+ {
330
+ id: 'scheduled-cancellation-survives-billing-mode',
331
+ fixtures: [CANCEL_FLEXIBLE, CANCEL_CLASSIC],
332
+ states: 'a portal cancellation is stored under BOTH billing modes — flexible sets '
333
+ + 'cancel_at and leaves cancel_at_period_end false',
334
+ breaks: ['cancellation-timestamp'],
335
+ async run({ store, handler, assert }) {
336
+ await handler.deliver(fixture(CANCEL_FLEXIBLE));
337
+ const flexible = store.subscriptions.get('sub_PLACEHOLDER_alice') || {};
338
+ // unmutated: no rule here varies the mode or the reason the customer gave.
339
+ assert.unmutated.equal(flexible.billingMode, 'flexible');
340
+ assert.unmutated.equal(flexible.cancellationFeedback, 'too_expensive');
341
+ assert.equal(flexible.cancelAt, 1769904000,
342
+ 'a cancellation scheduled under flexible billing_mode was stored as "not '
343
+ + 'cancelling": the banner tells a customer who cancelled ten seconds ago that '
344
+ + 'their plan renews, and nothing errors');
345
+
346
+ await handler.deliver(fixture(CANCEL_CLASSIC));
347
+ const classic = store.subscriptions.get('sub_PLACEHOLDER_bob') || {};
348
+ // unmutated, and deliberately so: this is the control. Both readings agree on a
349
+ // classic row, which is exactly why the flag alone survived until flexible mode.
350
+ assert.unmutated.equal(classic.billingMode, 'classic');
351
+ assert.unmutated.equal(classic.cancelAt, 1769904000);
352
+ },
353
+ },
288
354
  ];
289
355
 
290
356
  // ------------------------------------------------- one assertion, one measurement
@@ -0,0 +1,47 @@
1
+ {
2
+ "id": "evt_PLACEHOLDER_discount_created",
3
+ "object": "event",
4
+ "api_version": "2026-07-29.dahlia",
5
+ "created": 1767225000,
6
+ "livemode": false,
7
+ "pending_webhooks": 1,
8
+ "request": { "id": null, "idempotency_key": null },
9
+ "type": "customer.discount.created",
10
+ "data": {
11
+ "object": {
12
+ "id": "di_PLACEHOLDER_save50",
13
+ "object": "discount",
14
+ "customer": "cus_PLACEHOLDER_alice",
15
+ "customer_account": null,
16
+ "subscription": "sub_PLACEHOLDER_alice",
17
+ "subscription_item": null,
18
+ "invoice": null,
19
+ "invoice_item": null,
20
+ "checkout_session": null,
21
+ "promotion_code": null,
22
+ "start": 1767225000,
23
+ "end": null,
24
+ "source": {
25
+ "type": "coupon",
26
+ "coupon": {
27
+ "id": "cpn_PLACEHOLDER_save50",
28
+ "object": "coupon",
29
+ "name": "50% off your next invoice",
30
+ "percent_off": 50,
31
+ "amount_off": null,
32
+ "currency": null,
33
+ "duration": "once",
34
+ "duration_in_months": null,
35
+ "max_redemptions": null,
36
+ "times_redeemed": 1,
37
+ "redeem_by": null,
38
+ "applies_to": null,
39
+ "valid": true,
40
+ "livemode": false,
41
+ "created": 1767224000,
42
+ "metadata": {}
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }