@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
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ 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
|
+
|
|
7
76
|
## v0.9.2 — the README stops claiming a command the package cannot run
|
|
8
77
|
|
|
9
78
|
The README told a reader to run commands the published package cannot run: it ships no
|
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# sheleg-dev
|
|
2
2
|
|
|
3
|
+
[](https://github.com/ssheleg/sheleg-dev/actions/workflows/validate.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/@ssheleg/sheleg-dev)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](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
|
|
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 **
|
|
21
|
-
files of the manual gate, and
|
|
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 |
|
|
30
|
-
| `references/` files, loaded on demand |
|
|
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 |
|
|
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` —
|
|
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:
|
|
172
|
+
# The shipped payload: 61 files.
|
|
173
173
|
git ls-files plugins
|
|
174
174
|
|
|
175
|
-
#
|
|
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:
|
|
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.
|
|
4
|
-
"description": "
|
|
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.
|
|
6
|
+
"version": "0.10.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "ssheleg",
|
|
9
9
|
"url": "https://x.com/sshlg93"
|
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
across
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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", "
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
`
|
|
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
|
-
|
|
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
|
-
-
|
|
332
|
-
|
|
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
|
|
339
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
386
|
-
|
|
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
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
5
|
-
|
|
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 #
|
|
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
|
package/plugins/sheleg-dev/skills/stripe-billing/fixtures/customer-discount-created-retention.json
ADDED
|
@@ -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
|
+
}
|