@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.
- package/CHANGELOG.md +172 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/SECURITY.md +63 -0
- package/bin/sheleg-dev.js +99 -0
- package/package.json +45 -0
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
- package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
- 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
|