@ssheleg/sheleg-dev 0.4.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.
Files changed (32) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/SECURITY.md +63 -0
  5. package/bin/sheleg-dev.js +99 -0
  6. package/package.json +45 -0
  7. package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
  8. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
  9. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
  11. package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
  14. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
  15. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
  16. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
  17. package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
  18. package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
  19. package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
  20. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
  21. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
  22. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
  23. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
  24. package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
  25. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
  26. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +211 -0
@@ -0,0 +1,173 @@
1
+ # Stripe's own agent toolchain
2
+
3
+ **Load this when** starting a Stripe integration from nothing, or when the
4
+ agent is about to guess an API shape, a parameter name or a current best
5
+ practice. Stripe ships tooling built for agents; using it is faster and more
6
+ accurate than recalling the API from training data.
7
+
8
+ *Read from `docs.stripe.com/agents`, `/skills`, `/mcp` and the CLI's own
9
+ `--help` on 2026-08-11, against Stripe CLI 1.45.2 and the Claude Code plugin
10
+ `stripe@claude-plugins-official` 0.5.1. Re-check before quoting a version.*
11
+
12
+ ## Contents
13
+
14
+ - [The four pieces](#the-four-pieces)
15
+ - [CLI](#cli)
16
+ - [Getting keys without an account](#getting-keys-without-an-account)
17
+ - [Skills and plugins](#skills-and-plugins)
18
+ - [MCP server](#mcp-server)
19
+ - [Plain-text documentation](#plain-text-documentation)
20
+ - [Key handling for agents](#key-handling-for-agents)
21
+ - [When none of it is available](#when-none-of-it-is-available)
22
+
23
+ ## The four pieces
24
+
25
+ | Piece | What it gives an agent | Entry point |
26
+ |---|---|---|
27
+ | **CLI** | resource discovery, webhook forwarding, event triggers, logs, sandboxes | `npm install -g @stripe/cli` |
28
+ | **Skills / plugin** | Stripe's own instructions: product choice, billing, tax, security, Connect | `stripe agent setup` |
29
+ | **MCP server** | live calls against the account, doc search, an implementation planner | `https://mcp.stripe.com` |
30
+ | **Plain-text docs** | any page as Markdown | append `.md` to a `docs.stripe.com` URL |
31
+
32
+ ## CLI
33
+
34
+ ```bash
35
+ npm install -g @stripe/cli # v1.43.3+ for `stripe docs`
36
+ stripe login # browser consent — a human step
37
+ stripe agent setup # detects Claude Code / Codex / Cursor, installs plugins
38
+ stripe agent setup --status --json # read-only: what is already installed
39
+ ```
40
+
41
+ Discovery commands worth knowing before writing any call — they answer from the
42
+ installed CLI rather than from memory:
43
+
44
+ ```bash
45
+ stripe --map # the whole command tree
46
+ stripe resources # every API resource
47
+ stripe <resource> --help # operations and common parameters
48
+ stripe help <resource> <operation> # the full parameter list
49
+ stripe docs # read docs.stripe.com in the terminal
50
+ ```
51
+
52
+ Development loop:
53
+
54
+ ```bash
55
+ stripe listen --forward-to localhost:3000/api/billing/webhook
56
+ stripe trigger checkout.session.completed
57
+ stripe logs tail # watch API requests, including 403s from a restricted key
58
+ ```
59
+
60
+ ## Getting keys without an account
61
+
62
+ ```bash
63
+ stripe sandbox create --from-git # resolves the email from git config
64
+ stripe sandbox create --email you@example.com
65
+ ```
66
+
67
+ Provisions a sandbox through a proof-of-work challenge — **no browser, no
68
+ signup** — and writes the test keys into the current CLI profile, so subsequent
69
+ commands work immediately. Falls back to browser login on server errors. If a
70
+ key is already configured, the same command opens the sandbox management page
71
+ instead.
72
+
73
+ This is the honest answer to "I want to try the integration before I have an
74
+ account", and it is far better than inventing placeholder ids.
75
+
76
+ ## Skills and plugins
77
+
78
+ `stripe agent setup` installs the official plugin for each detected harness. The
79
+ per-harness commands, if you prefer them explicit:
80
+
81
+ ```bash
82
+ claude plugin install stripe@claude-plugins-official # Claude Code
83
+ codex plugin add stripe@openai-curated # Codex
84
+ # Cursor: run /add-plugin stripe inside the agent
85
+ npx skills add https://docs.stripe.com # any other agent — manual, no auto-update
86
+ ```
87
+
88
+ Manually installed skills do not auto-update: `npx skills update -y`.
89
+
90
+ The machine-readable index is
91
+ `https://docs.stripe.com/.well-known/skills/index.json`; individual files sit
92
+ under `https://docs.stripe.com/.well-known/skills/<filepath>`. Seven skills at
93
+ the time of writing: `stripe-best-practices`, `stripe-docs`, `stripe-apps`,
94
+ `stripe-projects`, `stripe-directory`, `connect-recommend`, `upgrade-stripe`.
95
+
96
+ **Division of labour with this skill.** `stripe-best-practices` decides *which
97
+ Stripe primitive* and states Stripe's own rules (product catalogue modelling,
98
+ tax, dynamic payment methods, Metronome for new usage-based billing, key
99
+ hygiene). `stripe-docs` looks things up. This skill covers what happens on
100
+ *your* side of the boundary — reconciling Stripe's state into your database.
101
+ When they disagree about anything Stripe-side, Stripe's skill wins.
102
+
103
+ ## MCP server
104
+
105
+ ```bash
106
+ claude mcp add --transport http stripe https://mcp.stripe.com/
107
+ claude /mcp # OAuth consent — a human step
108
+ ```
109
+
110
+ Other clients take `{"url": "https://mcp.stripe.com"}` (Cursor `~/.cursor/mcp.json`,
111
+ VS Code `.vscode/mcp.json` with `"type": "http"`). Clients without OAuth may
112
+ send a **restricted** key as `Authorization: Bearer rk_…`; connected-account
113
+ calls add a `Stripe-Account: acct_…` header.
114
+
115
+ Tools worth reaching for by name:
116
+
117
+ | Tool | Use |
118
+ |---|---|
119
+ | `stripe_implementation_planner` | a tailored integration plan before any code |
120
+ | `search_stripe_documentation` | current docs, in preference to a web search |
121
+ | `stripe_api_search` / `stripe_api_details` | find a method, then read its exact parameters |
122
+ | `stripe_api_read` / `stripe_api_write` | call the API without a per-endpoint tool |
123
+ | `create_refund`, `get_stripe_account_info`, `stripe_report` | the dedicated ones |
124
+
125
+ Two cautions from Stripe's own page, both worth honouring: **enable human
126
+ confirmation for tool calls**, and be careful combining this server with other
127
+ MCP servers — tool output is untrusted input and prompt injection is the
128
+ documented risk. OAuth sessions are listed and revocable in Dashboard user
129
+ settings; administrators can revoke other users' sessions and enable or disable
130
+ MCP access per environment.
131
+
132
+ ## Plain-text documentation
133
+
134
+ Append `.md` to any documentation URL:
135
+
136
+ ```
137
+ https://docs.stripe.com/billing/subscriptions/webhooks.md
138
+ https://docs.stripe.com/api/subscriptions/update.md
139
+ ```
140
+
141
+ Cheaper and cleaner than fetching the rendered page, and it is the same content
142
+ `stripe docs` reads in the terminal.
143
+
144
+ ## Key handling for agents
145
+
146
+ Stripe's security reference is blunt about this, and it applies doubly to code
147
+ an agent writes:
148
+
149
+ - Prefer **restricted keys** (`rk_`) over secret keys (`sk_`), one per service,
150
+ minimum permissions. Migrate by watching `stripe logs tail` and adding
151
+ permissions until the 403s stop.
152
+ - Keys live in a **secrets vault** where the platform has one (AWS Secrets
153
+ Manager, GCP Secret Manager, Azure Key Vault); environment variables are the
154
+ fallback, not the goal. On Vercel, mark them sensitive.
155
+ - Never in source, never in logs, never in error messages, never in client-side
156
+ code. `sk_live_…` or `rk_live_…` in a repository is an incident, not a lint
157
+ warning — a pre-commit hook for `sk_`/`rk_` is cheap.
158
+ - Separate keys per environment, and rotate when someone with access leaves.
159
+
160
+ ## When none of it is available
161
+
162
+ The toolchain is an accelerator, not a dependency. With no CLI, no MCP and no
163
+ network:
164
+
165
+ - write the integration from the invariants in `SKILL.md` — they are the part
166
+ that does not change between API versions;
167
+ - pin the API version the installed SDK ships with, and say in a comment that
168
+ it was not verified against the changelog;
169
+ - use the SDK's own type definitions as the parameter reference (they are
170
+ generated from the same spec the docs are);
171
+ - state plainly which facts could not be verified, rather than asserting a
172
+ parameter name you recall. A wrong parameter fails loudly; a wrong *default*
173
+ bills someone.
@@ -0,0 +1,294 @@
1
+ # Subscription lifecycle — code for each stage
2
+
3
+ **Load this when** implementing checkout, the verify fallback, renewal grants,
4
+ seat or plan changes, cancellation, or refund clawback. Every snippet is the
5
+ shape that survived production; the reasoning for each is in `SKILL.md`.
6
+
7
+ ## Contents
8
+
9
+ - [Data model](#data-model)
10
+ - [Creating the checkout session](#creating-the-checkout-session)
11
+ - [The verify fallback](#the-verify-fallback)
12
+ - [Granting on renewal](#granting-on-renewal)
13
+ - [Seat and quantity changes](#seat-and-quantity-changes)
14
+ - [Plan changes](#plan-changes)
15
+ - [Trials](#trials)
16
+ - [Cancellation and reactivation](#cancellation-and-reactivation)
17
+ - [The customer portal](#the-customer-portal)
18
+ - [Refund clawback](#refund-clawback)
19
+ - [Reconciliation job](#reconciliation-job)
20
+ - [Non-Stripe subscriptions](#non-stripe-subscriptions)
21
+
22
+ ## Data model
23
+
24
+ The minimum that makes every later question answerable:
25
+
26
+ | Column | Note |
27
+ |---|---|
28
+ | `stripeSubscriptionId` | **unique** — this constraint is your idempotency |
29
+ | `stripeSubscriptionItemId` | needed for every quantity update and usage report |
30
+ | `stripePriceId`, `stripeProductId` | what they are on, denormalised for queries |
31
+ | `status` | your own enum, mapped from Stripe's — never Stripe's string raw |
32
+ | `currentPeriodStart`, `currentPeriodEnd` | from the **item**, not the subscription |
33
+ | `cancelAtPeriodEnd` | drives the UI; distinct from `status` |
34
+ | `quantity` | seats; 1 for flat plans |
35
+ | `pricingType` | `flat` \| `tiered` \| `metered` — decides which paths apply |
36
+ | `lastGrantedPeriodStart` | the double-grant guard |
37
+ | `paymentSource` | `stripe` \| something else; the reconciliation job reads it |
38
+
39
+ Map status once, in one function, and log the unknown case rather than
40
+ defaulting silently:
41
+
42
+ ```ts
43
+ const MAP: Record<string, Status> = {
44
+ active: "active", trialing: "trialing", past_due: "past_due",
45
+ unpaid: "unpaid", canceled: "canceled", incomplete: "incomplete",
46
+ incomplete_expired: "canceled", paused: "canceled",
47
+ };
48
+ export function mapStatus(s: string): Status {
49
+ const m = MAP[s];
50
+ if (!m) log.warn("unknown Stripe subscription status", { s });
51
+ return m ?? "incomplete";
52
+ }
53
+ ```
54
+
55
+ ## Creating the checkout session
56
+
57
+ ```ts
58
+ const metadata = { userId, productId, quantity: String(quantity) };
59
+
60
+ const session = await stripe.checkout.sessions.create({
61
+ customer: customerId,
62
+ mode: "subscription",
63
+ line_items: [{ price: priceId, quantity }],
64
+ // Do NOT pass payment_method_types — Stripe picks eligible methods from
65
+ // Dashboard settings, and hardcoding ['card'] costs conversion.
66
+ metadata,
67
+ subscription_data: { metadata },
68
+ success_url: `${origin}/onboarding?session_id={CHECKOUT_SESSION_ID}`,
69
+ cancel_url: `${origin}/pricing`,
70
+ });
71
+ ```
72
+
73
+ Before that call, in order:
74
+
75
+ 1. authenticate, and check the account is in a state allowed to transact
76
+ (blocked, deletion-scheduled and delinquent accounts should not reach Stripe);
77
+ 2. rate-limit per user — this endpoint creates objects in someone else's system;
78
+ 3. validate `productId` against an allowlist; reject anything else with 400;
79
+ 4. clamp quantity server-side (`Math.floor`, min 1, a hard max);
80
+ 5. refuse a duplicate: an active subscription to the same product answers 409
81
+ with the existing id, not a second checkout;
82
+ 6. validate any caller-supplied `successUrl`/`cancelUrl` against your own
83
+ origin, and fall back to the default on mismatch.
84
+
85
+ ## The verify fallback
86
+
87
+ The success page calls this; it is also how local development works at all.
88
+
89
+ ```ts
90
+ const s = await stripe.checkout.sessions.retrieve(sessionId);
91
+
92
+ if (s.metadata?.userId !== callerId) return json({ error: "not yours" }, 403);
93
+ if (s.status !== "complete") return json({ error: "not complete" }, 400);
94
+ if (s.payment_status === "unpaid") return json({ error: "not paid" }, 402);
95
+
96
+ const existing = await db.subscription.findUnique({ where: { stripeSubscriptionId } });
97
+ if (existing) return json({ subscription: existing, created: false });
98
+
99
+ try {
100
+ const created = await grantExactlyAsTheWebhookWould();
101
+ return json({ subscription: created, created: true });
102
+ } catch (err) {
103
+ if (isUniqueViolation(err)) { // the webhook won the race
104
+ return json({ subscription: await reread(), created: false });
105
+ }
106
+ throw err;
107
+ }
108
+ ```
109
+
110
+ The ownership check is the security-critical line: without it, any authenticated
111
+ user who learns a `cs_…` id claims someone else's purchase. Rate-limit it too —
112
+ it costs an API call per request.
113
+
114
+ While the race is open, render a "confirming your payment" state. Sending the
115
+ user back to the plan chooser shows a paywall to somebody who just paid.
116
+
117
+ ## Granting on renewal
118
+
119
+ ```ts
120
+ if (invoice.billing_reason !== "subscription_cycle") return; // see webhook-events.md
121
+
122
+ await db.$transaction(async (tx) => {
123
+ const sub = await tx.subscription.findUnique({ where: { id }, select: { lastGrantedPeriodStart: true } });
124
+ if (sub?.lastGrantedPeriodStart && sub.lastGrantedPeriodStart >= periodStart) return; // replay
125
+
126
+ await tx.subscription.update({ where: { id }, data: { lastGrantedPeriodStart: periodStart } });
127
+ await tx.wallet.update({ where: { userId }, data: { credit: { increment: allowance } } });
128
+ await tx.auditLog.create({ data: { userId, action: "grant", amount: allowance,
129
+ source: "subscription-renewal", metadata: { invoiceId: invoice.id } } });
130
+ });
131
+ ```
132
+
133
+ Marker and grant in **one** transaction. Two statements outside a transaction is
134
+ the same bug as read-then-write, one level up.
135
+
136
+ ## Seat and quantity changes
137
+
138
+ ```ts
139
+ const item = (await stripe.subscriptions.retrieve(subId)).items.data[0];
140
+
141
+ try {
142
+ await stripe.subscriptions.update(subId, {
143
+ items: [{ id: item.id, quantity: newQuantity }],
144
+ proration_behavior: "always_invoice",
145
+ payment_behavior: "error_if_incomplete", // upgrades only
146
+ });
147
+ } catch (err) {
148
+ if (err.type === "StripeCardError" || err.code === "invoice_payment_intent_requires_action") {
149
+ return json({ error: "payment failed", code: "payment_failed" }, 402);
150
+ }
151
+ throw err;
152
+ }
153
+
154
+ try {
155
+ await db.subscription.update({ where: { id }, data: { quantity: newQuantity } });
156
+ } catch (dbErr) {
157
+ await stripe.subscriptions.update(subId, { // compensating revert
158
+ items: [{ id: item.id, quantity: oldQuantity }],
159
+ proration_behavior: "none", // do not re-bill the revert
160
+ }).catch((e) => log.error("CRITICAL: revert failed, Stripe and DB disagree", { subId, e }));
161
+ throw dbErr;
162
+ }
163
+ ```
164
+
165
+ - **Downgrades** need no `payment_behavior`; they produce a credit.
166
+ - **Reducing below what is in use** is a business decision, not an API call.
167
+ Answer 409 with the list of things that must be released first, and let the
168
+ user choose which — picking for them deletes the wrong one.
169
+ - **`preview` before charging.** A separate read-only action that quotes the
170
+ prorated amount (`stripe.invoices.createPreview`) turns "why was I charged
171
+ $17.43" into a number the user already agreed to.
172
+ - Do not add seats to a subscription flagged `cancel_at_period_end` — reactivate
173
+ first, or the seats vanish at period end.
174
+
175
+ ## Plan changes
176
+
177
+ Same shape as a quantity change, with `items: [{ id: item.id, price: newPriceId }]`.
178
+ Two extra decisions:
179
+
180
+ - **Immediate or at period end.** Immediate is `proration_behavior:
181
+ "always_invoice"`. At period end is a subscription schedule, not an update —
182
+ the difference is whether the customer is charged today.
183
+ - **What happens to the current period's allowance.** Grant the difference
184
+ pro-rata for the remaining time, and write the marker so the next renewal does
185
+ not grant twice. Granting the full new allowance on an upgrade is a free month
186
+ for anyone who upgrades on the last day.
187
+
188
+ ## Trials
189
+
190
+ `subscription_data: { trial_period_days: N }` on the session. A trialing
191
+ subscription has `status: "trialing"` — entitlement logic must treat it as
192
+ active or trials do not work, and `mapStatus` must not collapse it into
193
+ `active` or you cannot tell them apart in reporting.
194
+
195
+ `customer.subscription.trial_will_end` fires three days out. When the trial
196
+ converts, `invoice.paid` arrives with `billing_reason: "subscription_cycle"`.
197
+
198
+ ## Cancellation and reactivation
199
+
200
+ ```ts
201
+ await stripe.subscriptions.update(subId, { cancel_at_period_end: true });
202
+ // local: cancelAtPeriodEnd = true, status stays "active"
203
+ ```
204
+
205
+ If the local write fails after Stripe succeeded, say so honestly ("submitted,
206
+ syncing shortly") and let reconciliation fix it. Reverting Stripe here would
207
+ un-cancel a subscription the user asked to end.
208
+
209
+ Reactivation is `cancel_at_period_end: false`, and is only meaningful while the
210
+ period is still running. Past that, it is a new subscription.
211
+
212
+ Immediate cancellation (`stripe.subscriptions.cancel`) is for refunds and abuse.
213
+ Everything it must clean up belongs in the
214
+ `customer.subscription.deleted` handler, not next to the API call.
215
+
216
+ ## The customer portal
217
+
218
+ ```ts
219
+ const portal = await stripe.billingPortal.sessions.create({
220
+ customer: customerId,
221
+ return_url: `${origin}/billing?portal_return=true`,
222
+ });
223
+ ```
224
+
225
+ The portal is a second writer with no CSRF token and no notion of your database.
226
+ Everything a user does there reaches you as a webhook, later. Two consequences:
227
+ the billing page must refresh from the webhook-updated row on return, and any
228
+ invariant you enforce in your own UI (seat minimums, plan eligibility) must be
229
+ configured in the portal too, or it can be violated there.
230
+
231
+ Users with no `stripeCustomerId` get a 400 with a plain message, not a crash.
232
+
233
+ ## Refund clawback
234
+
235
+ ```ts
236
+ const totalRefunded = charge.amount_refunded / 100;
237
+ const increment = totalRefunded - stored.refundedTotal;
238
+ if (increment <= 0) return;
239
+
240
+ const { count } = await db.purchase.updateMany({
241
+ where: { id: stored.id, refundedTotal: stored.refundedTotal },
242
+ data: { refundedTotal: totalRefunded },
243
+ });
244
+ if (count === 0) return; // concurrent delivery won
245
+
246
+ try {
247
+ await clawBack(stored.userId, increment); // idempotent, keyed on charge id + total
248
+ } catch (err) {
249
+ await db.purchase.update({ where: { id: stored.id },
250
+ data: { refundedTotal: stored.refundedTotal } }); // put the marker back
251
+ throw err;
252
+ }
253
+ ```
254
+
255
+ Clamp the deduction at zero. A user who already spent the credit goes to a
256
+ negative balance or to a collections decision — pick one deliberately and log
257
+ which. Silently deducting into negative is how a support ticket becomes a
258
+ dispute.
259
+
260
+ If you pay referral or affiliate commission on purchases, reverse it
261
+ proportionally, computing the target from the **original** commission rather
262
+ than the current balance — otherwise partial refunds compound.
263
+
264
+ ## Reconciliation job
265
+
266
+ ```ts
267
+ for await (const sub of stripe.subscriptions.list({ customer, status: "all", limit: 100 })) {
268
+ // create / update / grant-if-period-advanced — sequential on purpose:
269
+ // each iteration opens a transaction and may call an external API.
270
+ }
271
+ for (const local of localRows) {
272
+ if (local.status === "canceled") continue;
273
+ if (seenInStripe.has(local.stripeSubscriptionId)) continue;
274
+ if (isNonStripeBacked(local)) continue; // ← the guard that matters
275
+ await cancelLocally(local);
276
+ }
277
+ ```
278
+
279
+ Run it nightly per active customer, and expose it as a "Sync now" button. Any
280
+ grant it performs must reuse the webhook's idempotency marker.
281
+
282
+ ## Non-Stripe subscriptions
283
+
284
+ Comped accounts, manual invoices, crypto and app-store purchases all end up in
285
+ the same table. Give them a `paymentSource` and synthetic ids with a recognisable
286
+ prefix, and make every Stripe-facing path skip them explicitly:
287
+
288
+ ```ts
289
+ const isDev = (id: string) => id.startsWith("dev_sub_");
290
+ const isNonStripe = (row) => row.paymentSource !== "stripe" || row.stripeSubscriptionId.startsWith("bal_sub_");
291
+ ```
292
+
293
+ The reconciliation job is where this bites: without the guard it finds rows
294
+ Stripe has never heard of and cancels every one of them.
@@ -0,0 +1,214 @@
1
+ # Testing and local development
2
+
3
+ **Load this when** setting up local webhooks, writing the first billing test, or
4
+ deciding whether an existing suite would actually catch a money defect.
5
+
6
+ ## Contents
7
+
8
+ - [Local webhooks](#local-webhooks)
9
+ - [Triggering events](#triggering-events)
10
+ - [The skip-billing branch](#the-skip-billing-branch)
11
+ - [Test cards](#test-cards)
12
+ - [What to mock](#what-to-mock)
13
+ - [A webhook idempotency test](#a-webhook-idempotency-test)
14
+ - [Mutation testing — the only proof that counts](#mutation-testing--the-only-proof-that-counts)
15
+ - [Asserting on source](#asserting-on-source)
16
+ - [Staging verification by hand](#staging-verification-by-hand)
17
+ - [Production readiness](#production-readiness)
18
+
19
+ ## Local webhooks
20
+
21
+ Stripe cannot reach `localhost`. Two ways in, and the first is better:
22
+
23
+ ```bash
24
+ stripe listen --forward-to localhost:3000/api/billing/webhook
25
+ # ⇒ prints "Ready! Your webhook signing secret is whsec_…"
26
+ ```
27
+
28
+ That secret is **not** the one on the Dashboard endpoint. Using the Dashboard's
29
+ value against forwarded events produces a signature failure on every delivery,
30
+ which looks exactly like a code bug. Put the `stripe listen` value in the local
31
+ env and nowhere else.
32
+
33
+ Filter the firehose when debugging one flow:
34
+
35
+ ```bash
36
+ stripe listen --events checkout.session.completed,invoice.paid \
37
+ --forward-to localhost:3000/api/billing/webhook
38
+ ```
39
+
40
+ A public tunnel (`cloudflared tunnel --url http://localhost:3000`, `ngrok`) plus
41
+ a real Dashboard endpoint is the other path. Use it when you need Stripe's own
42
+ retry behaviour, or when the flow involves a hosted redirect back to a public
43
+ URL.
44
+
45
+ ## Triggering events
46
+
47
+ ```bash
48
+ stripe trigger checkout.session.completed
49
+ stripe trigger invoice.payment_failed
50
+ stripe trigger customer.subscription.deleted
51
+ stripe trigger --help # the full list of supported fixtures
52
+ ```
53
+
54
+ `stripe trigger` creates real objects in test mode, which is its strength and
55
+ its limit: the payload is Stripe's fixture, not yours, so `metadata.userId` is
56
+ absent and a handler that requires it will bail. For the paths that depend on
57
+ your own metadata, drive the handler directly from a saved payload (below).
58
+
59
+ Re-delivery is the cheapest idempotency check you own: in the Dashboard, find a
60
+ delivered event and press **Resend**. The balance must not move, and the log
61
+ must say "duplicate".
62
+
63
+ ## The skip-billing branch
64
+
65
+ ```ts
66
+ const SKIP_BILLING = process.env.SKIP_BILLING === "true";
67
+ if (SKIP_BILLING) {
68
+ if (process.env.NODE_ENV === "production") throw new Error("SKIP_BILLING in production");
69
+ // create a local subscription with an obviously fake id
70
+ }
71
+ ```
72
+
73
+ - Assert at the **branch**, not only in configuration. A config file is one
74
+ merge away from being wrong; the throw is not.
75
+ - Fake ids get a recognisable prefix (`dev_sub_…`) and a predicate
76
+ (`isDevSubscription`) that every Stripe-facing path consults. Without it,
77
+ cancel, portal and reconciliation all call Stripe with an id it has never
78
+ seen.
79
+ - Provide the upgrade path: when billing is switched on for an account that
80
+ already holds a fake subscription, replace it with a real one in a transaction
81
+ rather than leaving two.
82
+
83
+ ## Test cards
84
+
85
+ Full list: the official `stripe:test-cards` skill, or
86
+ `docs.stripe.com/testing.md`. The four that matter for billing:
87
+
88
+ | Card | Behaviour |
89
+ |---|---|
90
+ | `4242 4242 4242 4242` | succeeds |
91
+ | `4000 0000 0000 0341` | attaches fine, **fails on the first charge** — the renewal path |
92
+ | `4000 0000 0000 9995` | declined for insufficient funds — the upgrade 402 path |
93
+ | `4000 0025 0000 3155` | requires 3DS authentication |
94
+
95
+ The second one is the one people skip and the one that catches the most: it is
96
+ how you see dunning, `past_due` and your own recovery messaging without waiting
97
+ a month.
98
+
99
+ Test clocks (`stripe.testHelpers.testClocks`) advance a customer through
100
+ renewals, trial ends and dunning in seconds. They are the only honest way to
101
+ test a yearly plan.
102
+
103
+ ## What to mock
104
+
105
+ Mock at the **SDK boundary**, never at your own handler's. The point of these
106
+ tests is that your handler does the right thing with a payload Stripe would
107
+ actually send.
108
+
109
+ ```ts
110
+ vi.mock("@/lib/stripe", () => ({
111
+ getStripe: () => ({
112
+ webhooks: { constructEvent: () => savedEventFixture }, // signature already verified
113
+ subscriptions: { retrieve: vi.fn().mockResolvedValue(savedSubscription) },
114
+ }),
115
+ }));
116
+ ```
117
+
118
+ Save real payloads: run the flow once against test mode, copy the event JSON out
119
+ of the Dashboard, and commit it as a fixture. Hand-written payloads drift from
120
+ reality in exactly the fields that matter — `parent.subscription_details`, the
121
+ item-level period, `billing_reason`.
122
+
123
+ Make the database mock **stateful** where the test is about state. An
124
+ idempotency test against a mock that returns "not found" every time proves
125
+ nothing:
126
+
127
+ ```ts
128
+ const seen = new Set<string>();
129
+ processedWebhookEvent: {
130
+ create: async ({ data }) => {
131
+ if (seen.has(data.id)) throw p2002(); // reproduce the unique constraint
132
+ seen.add(data.id); return data;
133
+ },
134
+ }
135
+ ```
136
+
137
+ ## A webhook idempotency test
138
+
139
+ ```ts
140
+ it("credits exactly once when the same event is delivered twice", async () => {
141
+ const req = () => new Request("http://x/api/billing/webhook", {
142
+ method: "POST", headers: { "stripe-signature": "t=1,v1=deadbeef" }, body: "{}",
143
+ });
144
+
145
+ const first = await POST(req());
146
+ const second = await POST(req());
147
+
148
+ expect(await first.json()).toMatchObject({ received: true });
149
+ expect(await second.json()).toMatchObject({ duplicate: true });
150
+ expect(creditSpy).toHaveBeenCalledTimes(1); // ← the assertion that matters
151
+ });
152
+ ```
153
+
154
+ Drive the **real** exported handler. A test that calls a private helper proves
155
+ the helper works and says nothing about the route Stripe posts to.
156
+
157
+ ## Mutation testing — the only proof that counts
158
+
159
+ For every guard, delete it and re-run. A test that still passes is decoration.
160
+
161
+ | Delete this | A real test fails with |
162
+ |---|---|
163
+ | the ownership check in the verify route | "session does not belong to user" |
164
+ | `if (billing_reason !== "subscription_cycle") return` | a grant on a proration invoice |
165
+ | the claim-before-work call | two credits for one event |
166
+ | `releaseEventClaim` in the catch | the retry finds it "processed" and nothing happens |
167
+ | the cumulative subtraction in the refund path | a refund larger than the charge |
168
+ | `payment_behavior: "error_if_incomplete"` | an upgrade recorded despite a decline |
169
+ | the non-Stripe guard in reconciliation | comped subscriptions cancelled |
170
+
171
+ Do this once per guard, write down what failed, and you have a suite you can
172
+ believe. Skip it and you have a green build.
173
+
174
+ ## Asserting on source
175
+
176
+ Some invariants are one line whose *absence* is the whole defect, and no runtime
177
+ assertion is cheaper than reading the file:
178
+
179
+ ```ts
180
+ const SRC = readFileSync("src/lib/stripe.ts", "utf8");
181
+ it("constructs the client with retries and a pinned version", () => {
182
+ expect(SRC).toMatch(/maxNetworkRetries:\s*[1-3]\b/);
183
+ expect(SRC).toMatch(/apiVersion:\s*"[\d-]+\.\w+"/);
184
+ });
185
+ ```
186
+
187
+ Use this sparingly — it is a ratchet against regression, not a substitute for
188
+ behavioural tests — but it is the right tool for "this option must never be
189
+ removed".
190
+
191
+ ## Staging verification by hand
192
+
193
+ Before the first real charge, on staging, with a real key in test mode:
194
+
195
+ - [ ] complete a checkout; the subscription appears locally within seconds
196
+ - [ ] in the Dashboard, **Resend** that event; nothing changes and the log says duplicate
197
+ - [ ] cancel from the **customer portal**; local state follows
198
+ - [ ] pay a renewal with the `…0341` card; `past_due` is set and the user is told
199
+ - [ ] refund half a charge in the Dashboard; the clawback is half, not all
200
+ - [ ] refund the other half; the total clawed back equals the charge, not more
201
+ - [ ] add a seat with the `…9995` card; a 402, and nothing changed locally
202
+ - [ ] stop the app, deliver an event, restart; Stripe's retry lands it
203
+ - [ ] point the app at an empty database and run reconciliation; nothing is cancelled
204
+
205
+ ## Production readiness
206
+
207
+ - [ ] webhook endpoint registered for live mode, subscribed to exactly the events you handle
208
+ - [ ] `STRIPE_WEBHOOK_SECRET` is the **live endpoint's** secret, not `stripe listen`'s
209
+ - [ ] the live key is a restricted key with only the permissions the app uses
210
+ - [ ] `STRIPE_PRODUCT_MODE` and the key are both live, and something asserts they agree
211
+ - [ ] the price conformance job is scheduled and its findings reach a human
212
+ - [ ] the reconciliation job is scheduled
213
+ - [ ] alerts exist for: signature failures, handler 5xx, endpoint disabled by Stripe
214
+ - [ ] a runbook names who to page when Stripe and the database disagree