@ssheleg/sheleg-dev 0.6.0 → 0.8.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 +318 -0
- package/README.md +115 -1
- package/SECURITY.md +199 -52
- package/bin/sheleg-dev.js +18 -0
- package/package.json +2 -2
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
- package/plugins/sheleg-dev/hooks/hooks.json +29 -0
- package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
- package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +77 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +397 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
- package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +17 -1
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +82 -77
- package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
- package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +111 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +564 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
- package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +39 -2
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
|
@@ -9,6 +9,9 @@ one provider's wire format looks like when you apply them.
|
|
|
9
9
|
- [1. What is Heleket and when to use it](#1-what-is-heleket-and-when-to-use-it)
|
|
10
10
|
- [2. Architecture overview](#2-architecture-overview)
|
|
11
11
|
- [3. Environment variables](#3-environment-variables)
|
|
12
|
+
- [The test/live boundary — and why it cannot be a sentence](#the-testlive-boundary--and-why-it-cannot-be-a-sentence)
|
|
13
|
+
- [The boot assertion](#the-boot-assertion)
|
|
14
|
+
- [Residual exposure](#residual-exposure)
|
|
12
15
|
- [4. Database schema (Prisma)](#4-database-schema-prisma)
|
|
13
16
|
- [5. Heleket REST API reference](#5-heleket-rest-api-reference)
|
|
14
17
|
- [6. Webhook (IPN) payload reference](#6-webhook-ipn-payload-reference)
|
|
@@ -61,6 +64,7 @@ spot rate and settles to your merchant wallet in your chosen target coin (typica
|
|
|
61
64
|
| Default webhook IP | `31.133.220.8` (single static IP) |
|
|
62
65
|
| Auth | Per-merchant API key + `merchant` UUID header |
|
|
63
66
|
| Signature | MD5 over base64 of JSON body, suffixed with API key |
|
|
67
|
+
| **Test credential** | **none.** One key per merchant, which is *also* the webhook signing secret; no sandbox host, no restricted scope, no `test`/`live` marker in the key itself. "Test mode" is a toggle on the merchant account — see [Residual exposure](#residual-exposure) |
|
|
64
68
|
| Dashboard | https://heleket.com → Merchant settings |
|
|
65
69
|
| Settles to | USDT, USDC, BTC, ETH, BNB, TRX, etc. (configurable per invoice) |
|
|
66
70
|
| Pricing currency | Any fiat ISO code (USD recommended) |
|
|
@@ -123,27 +127,245 @@ is the only authoritative source for status changes; client polling is for UX on
|
|
|
123
127
|
|
|
124
128
|
| Variable | Required | Default | Source / Notes |
|
|
125
129
|
|----------|----------|---------|----------------|
|
|
126
|
-
| `
|
|
130
|
+
| `HELEKET_ENV` | yes | **none, deliberately** | `test` \| `production`. The environment this run *declares*. Separate from the key on purpose — one variable that both carries the secret and names its environment cannot be checked against itself. No default: a default is the control disappearing the first time somebody copies a `.env`. |
|
|
131
|
+
| `HELEKET_API_KEY` | yes | — | Merchant settings → Payment API. Trim whitespace on read. **Never log this value**, it is also the webhook signing secret. There is no test variant of it — see [Residual exposure](#residual-exposure). |
|
|
127
132
|
| `HELEKET_MERCHANT_ID` | yes | — | Merchant settings → Merchant UUID. Sent as `merchant` header on every API call. |
|
|
133
|
+
| `HELEKET_LIVE_MERCHANT_ID` | yes | — | The **production** merchant UUID, pinned. Not a secret (it travels in a header on every call); it is the thing that lets a boot assertion tell a live credential from a sandbox one. |
|
|
134
|
+
| `HELEKET_LIVE_KEY_FINGERPRINT` | no | — | First 12 hex of `sha256(live key)`. The second discriminator, for when the merchant UUID is shared or unset. A one-way 48-bit prefix over a provider-issued high-entropy secret — see the caveat under [the boot assertion](#the-boot-assertion). |
|
|
128
135
|
| `HELEKET_WEBHOOK_IP` | no | `31.133.220.8` | Override only if Heleket changes their callback IP. Localhost (`127.0.0.1` / `::1`) is always allowed for tests. |
|
|
129
|
-
| `SKIP_BILLING` | no | `false` | When `true` the checkout endpoint short-circuits, marks the payment paid immediately, and credits the balance — for local dev / staging without hitting Heleket. |
|
|
136
|
+
| `SKIP_BILLING` | no | `false` | When `true` the checkout endpoint short-circuits, marks the payment paid immediately, and credits the balance — for local dev / staging without hitting Heleket. **Refused when `HELEKET_ENV=production`**: there it is not a shortcut, it is a free-money path. |
|
|
130
137
|
| `NEXTAUTH_URL` or `NEXT_PUBLIC_APP_URL` | yes | — | Public origin used to build `url_callback`, `url_success`, `url_return`. Must be HTTPS in production. |
|
|
131
138
|
|
|
132
139
|
`.env.example` snippet:
|
|
133
140
|
|
|
134
141
|
```bash
|
|
135
142
|
# ── Heleket (crypto payments) ─────────────────────────────────
|
|
143
|
+
# Which environment this run declares. No default; the boot assertion refuses an
|
|
144
|
+
# unset value rather than guessing, and refuses a credential that disagrees with it.
|
|
145
|
+
HELEKET_ENV="test"
|
|
136
146
|
# Payment API key and merchant UUID from Heleket merchant settings (https://heleket.com).
|
|
147
|
+
# In a test run these MUST be the sandbox merchant's, not production's.
|
|
137
148
|
HELEKET_API_KEY=""
|
|
138
149
|
HELEKET_MERCHANT_ID=""
|
|
150
|
+
# The production merchant, pinned so a test run holding it can be refused at startup.
|
|
151
|
+
# Not a secret. Without it, a test run cannot prove it is not live — and is refused.
|
|
152
|
+
HELEKET_LIVE_MERCHANT_ID=""
|
|
153
|
+
# Optional second discriminator: printf %s "$LIVE_KEY" | shasum -a 256 | cut -c1-12
|
|
154
|
+
HELEKET_LIVE_KEY_FINGERPRINT=""
|
|
139
155
|
# Whitelist IP for webhook verification (Heleket callback origin).
|
|
140
156
|
HELEKET_WEBHOOK_IP="31.133.220.8"
|
|
141
157
|
```
|
|
142
158
|
|
|
143
|
-
**Security**: rotate the API key quarterly, store in a secret manager (Doppler,
|
|
159
|
+
**Security**: rotate the API key quarterly, store in a secret manager (Doppler,
|
|
144
160
|
AWS Secrets Manager, Cloudflare Workers Secret, K8s Secret), never commit `.env`,
|
|
145
161
|
never expose to the client bundle (no `NEXT_PUBLIC_` prefix).
|
|
146
162
|
|
|
163
|
+
### The test/live boundary — and why it cannot be a sentence
|
|
164
|
+
|
|
165
|
+
Read the provider's credential model before designing around it, because it is worse
|
|
166
|
+
than Stripe's and the difference decides the design:
|
|
167
|
+
|
|
168
|
+
| | Stripe | Heleket |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| Separate test credential | yes, a whole second account | **no** |
|
|
171
|
+
| Environment readable from the key | yes, `sk_test_` / `sk_live_` prefix | **no** — an opaque string with no marker |
|
|
172
|
+
| Sandbox host | no, same host | no, same host (`api.heleket.com`) |
|
|
173
|
+
| What "test mode" is | a property of the key | **a toggle on the merchant account** |
|
|
174
|
+
| Key also signs webhooks | no, a separate `whsec_` | **yes, the same value** |
|
|
175
|
+
|
|
176
|
+
So the house pattern — declare the environment in a variable separate from the secret,
|
|
177
|
+
then assert at boot that the two agree (the `stripe-billing` skill, `references/price-integrity.md`,
|
|
178
|
+
*Test and live mode*) — applies, but its Stripe implementation does not: there is no key
|
|
179
|
+
prefix to read. The comparison has to be made against something else, and Heleket gives
|
|
180
|
+
exactly two candidates, both non-secret:
|
|
181
|
+
|
|
182
|
+
- **the merchant UUID** (`HELEKET_MERCHANT_ID`), which is what distinguishes one merchant
|
|
183
|
+
account from another and travels in a header on every call;
|
|
184
|
+
- **a fingerprint of the key**, for when the UUID is shared or absent.
|
|
185
|
+
|
|
186
|
+
Pin the production values and the assertion below can refuse a mismatch in **both**
|
|
187
|
+
directions. One direction is half a boundary: a production key in a dev run is the
|
|
188
|
+
obvious loss, and a sandbox key in production is the quiet one — invoices created
|
|
189
|
+
against a merchant nobody reconciles, money settling to the wrong wallet, and no error
|
|
190
|
+
until somebody asks where the week's revenue went.
|
|
191
|
+
|
|
192
|
+
**Strongest control first.** The assertion is the *second* line. The first is a
|
|
193
|
+
**separate sandbox merchant account**, so the credential a dev machine holds authorises
|
|
194
|
+
nothing on the live merchant — M-06's point exactly: a credential that cannot reach
|
|
195
|
+
production beats a sentence saying not to use it there, because it still works after
|
|
196
|
+
everyone has forgotten the sentence. Check whether your Heleket account permits a second
|
|
197
|
+
merchant before assuming it does; this document cannot confirm it for you, and if it does
|
|
198
|
+
not, the exposure below is what you are left holding.
|
|
199
|
+
|
|
200
|
+
### The boot assertion
|
|
201
|
+
|
|
202
|
+
Fails at **startup**, not at the first charge. Copy it whole:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
// src/lib/heleket-env.ts
|
|
206
|
+
import { createHash } from "node:crypto";
|
|
207
|
+
|
|
208
|
+
export type HeleketEnv = "test" | "production";
|
|
209
|
+
|
|
210
|
+
/** Codes, not sentences. A code survives a rewording, greps out of a log, and is what
|
|
211
|
+
* an alert should fire on. */
|
|
212
|
+
export type HeleketEnvErrorCode =
|
|
213
|
+
| "HELEKET_ENV_MISSING"
|
|
214
|
+
| "HELEKET_ENV_INVALID"
|
|
215
|
+
| "HELEKET_ENV_UNPINNED"
|
|
216
|
+
| "HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL"
|
|
217
|
+
| "HELEKET_ENV_LIVE_HOLDS_TEST_CREDENTIAL"
|
|
218
|
+
| "HELEKET_ENV_LIVE_WITH_SKIP_BILLING";
|
|
219
|
+
|
|
220
|
+
export class HeleketEnvError extends Error {
|
|
221
|
+
constructor(readonly code: HeleketEnvErrorCode, message: string) {
|
|
222
|
+
super(`${code}: ${message}`);
|
|
223
|
+
this.name = "HeleketEnvError";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* 48-bit prefix of SHA-256 over the key.
|
|
229
|
+
*
|
|
230
|
+
* Safe to keep in configuration ONLY because a Heleket API key is a long
|
|
231
|
+
* provider-issued random string: the digest is one-way and 12 hex characters carry
|
|
232
|
+
* nothing recoverable. Never fingerprint a value a human chose — for a guessable
|
|
233
|
+
* secret this publishes a confirmation oracle. If in doubt, pin the merchant UUID
|
|
234
|
+
* instead and leave this unset.
|
|
235
|
+
*/
|
|
236
|
+
export function keyFingerprint(key: string): string {
|
|
237
|
+
return createHash("sha256").update(key.trim()).digest("hex").slice(0, 12);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
type Identity = "live" | "not-live" | "unknown";
|
|
241
|
+
|
|
242
|
+
/** Which credential is this, judged only against pinned non-secret values. */
|
|
243
|
+
function identify(env: NodeJS.ProcessEnv): Identity {
|
|
244
|
+
const key = env.HELEKET_API_KEY?.trim();
|
|
245
|
+
const merchant = env.HELEKET_MERCHANT_ID?.trim();
|
|
246
|
+
const liveMerchant = env.HELEKET_LIVE_MERCHANT_ID?.trim();
|
|
247
|
+
const liveFinger = env.HELEKET_LIVE_KEY_FINGERPRINT?.trim().toLowerCase();
|
|
248
|
+
|
|
249
|
+
if (!liveMerchant && !liveFinger) return "unknown";
|
|
250
|
+
if (liveMerchant && merchant && merchant === liveMerchant) return "live";
|
|
251
|
+
if (liveFinger && key && keyFingerprint(key) === liveFinger) return "live";
|
|
252
|
+
if ((liveMerchant && merchant) || (liveFinger && key)) return "not-live";
|
|
253
|
+
return "unknown";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function assertHeleketEnv(env: NodeJS.ProcessEnv = process.env): HeleketEnv {
|
|
257
|
+
const declared = env.HELEKET_ENV?.trim();
|
|
258
|
+
|
|
259
|
+
if (!declared) {
|
|
260
|
+
throw new HeleketEnvError(
|
|
261
|
+
"HELEKET_ENV_MISSING",
|
|
262
|
+
"HELEKET_ENV is unset. It has no default on purpose: a default is the control " +
|
|
263
|
+
'disappearing the first time somebody copies a .env. Set "test" or "production".',
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (declared !== "test" && declared !== "production") {
|
|
267
|
+
throw new HeleketEnvError(
|
|
268
|
+
"HELEKET_ENV_INVALID",
|
|
269
|
+
`HELEKET_ENV="${declared}" — expected "test" or "production".`,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (declared === "production" && env.SKIP_BILLING === "true") {
|
|
274
|
+
throw new HeleketEnvError(
|
|
275
|
+
"HELEKET_ENV_LIVE_WITH_SKIP_BILLING",
|
|
276
|
+
"SKIP_BILLING=true credits balances with no payment behind them. In production " +
|
|
277
|
+
"that is not a shortcut, it is a free-money path reachable by anyone who can " +
|
|
278
|
+
"read the checkout URL.",
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const identity = identify(env);
|
|
283
|
+
|
|
284
|
+
// Direction 1 — a live credential declared test. The dev machine, the CI job and the
|
|
285
|
+
// agent run all hold a key that creates real invoices the moment the dashboard
|
|
286
|
+
// toggle moves.
|
|
287
|
+
if (declared === "test" && identity === "live") {
|
|
288
|
+
throw new HeleketEnvError(
|
|
289
|
+
"HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL",
|
|
290
|
+
"HELEKET_ENV=test but this is the pinned production credential. Heleket has no " +
|
|
291
|
+
"test key: the same value that signs your webhooks creates real invoices as " +
|
|
292
|
+
"soon as merchant Test mode is off. Use the sandbox merchant account.",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Cannot tell is refused, not assumed — on the test side, which is where the control
|
|
297
|
+
// has to hold. "We could not prove it was safe" must never read as "it was safe".
|
|
298
|
+
if (declared === "test" && identity === "unknown") {
|
|
299
|
+
throw new HeleketEnvError(
|
|
300
|
+
"HELEKET_ENV_UNPINNED",
|
|
301
|
+
"HELEKET_ENV=test but neither HELEKET_LIVE_MERCHANT_ID nor " +
|
|
302
|
+
"HELEKET_LIVE_KEY_FINGERPRINT is pinned, so nothing here can prove this " +
|
|
303
|
+
"credential is not the production one.",
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Direction 2 — a test credential declared live. The quiet one: invoices settle to a
|
|
308
|
+
// merchant nobody reconciles and no error surfaces until revenue is missing.
|
|
309
|
+
if (declared === "production" && identity === "not-live") {
|
|
310
|
+
throw new HeleketEnvError(
|
|
311
|
+
"HELEKET_ENV_LIVE_HOLDS_TEST_CREDENTIAL",
|
|
312
|
+
"HELEKET_ENV=production but this credential is not the pinned live merchant's. " +
|
|
313
|
+
"Invoices would be created against the wrong merchant and settle to a wallet " +
|
|
314
|
+
"your reconciliation never reads.",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (declared === "production" && identity === "unknown") {
|
|
319
|
+
console.warn(
|
|
320
|
+
"[heleket] HELEKET_ENV=production with no live pin. Set HELEKET_LIVE_MERCHANT_ID " +
|
|
321
|
+
"— it is what lets a test run be refused.",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return declared;
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Wire it at module load of the client library, above every export, so the process cannot
|
|
330
|
+
reach a request handler in a state the assertion would have rejected:
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// src/lib/heleket.ts — first executable line
|
|
334
|
+
import { assertHeleketEnv } from "./heleket-env";
|
|
335
|
+
|
|
336
|
+
export const HELEKET_ENV = assertHeleketEnv();
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Calling it from the checkout handler instead is a control that only fires once a user is
|
|
340
|
+
already trying to pay, and never at all in the run that merely holds the key.
|
|
341
|
+
|
|
342
|
+
### Residual exposure
|
|
343
|
+
|
|
344
|
+
**Named because it does not go away, and an unwritten risk reads as an absent one.**
|
|
345
|
+
|
|
346
|
+
Heleket issues **one credential per merchant** and it is also the webhook signing secret.
|
|
347
|
+
There is no test key, no sandbox host and no read-only scope. "Test mode" is a toggle in
|
|
348
|
+
merchant settings (Section 15), which means the *same* key creates $1 test invoices before
|
|
349
|
+
the toggle moves and real ones after it — and it moves in a dashboard, by anyone with
|
|
350
|
+
dashboard access, with no deploy and no signal to the machines holding that key.
|
|
351
|
+
|
|
352
|
+
Three consequences, stated rather than implied:
|
|
353
|
+
|
|
354
|
+
1. **Unless you hold a second merchant account, a developer following Section 15 Option B
|
|
355
|
+
holds a production credential.** The assertion above narrows that window — it refuses
|
|
356
|
+
the run that declares the wrong environment — but it cannot make the key harmless. It
|
|
357
|
+
is a check on the declaration, not a limit on the credential.
|
|
358
|
+
2. **The key cannot be scoped down.** Because it doubles as the signing secret, you cannot
|
|
359
|
+
issue a verify-only credential for a service that merely validates callbacks; every
|
|
360
|
+
holder of the verifier can also create invoices.
|
|
361
|
+
3. **Rotation is the only real revocation.** There is no per-environment key to revoke, so
|
|
362
|
+
a leak from a dev machine is a production incident: rotate in merchant settings, and
|
|
363
|
+
re-pin `HELEKET_LIVE_KEY_FINGERPRINT` in the same change or the assertion starts
|
|
364
|
+
answering about a key that no longer exists.
|
|
365
|
+
|
|
366
|
+
If the compliance posture in the banner at the top of this document matters to you, this
|
|
367
|
+
credential model is part of the same assessment.
|
|
368
|
+
|
|
147
369
|
---
|
|
148
370
|
|
|
149
371
|
## 4. Database schema (Prisma)
|
|
@@ -1131,7 +1353,9 @@ balance updated, never touching Heleket. Perfect for E2E tests, demos, and featu
|
|
|
1131
1353
|
|
|
1132
1354
|
### Option B — Real Heleket + ngrok / cloudflared
|
|
1133
1355
|
|
|
1134
|
-
For end-to-end testing against the real provider
|
|
1356
|
+
For end-to-end testing against the real provider. **Use a sandbox merchant account if your
|
|
1357
|
+
Heleket account permits one** — that is the control that survives context loss; everything
|
|
1358
|
+
below is what to do because the provider gives you nothing stronger.
|
|
1135
1359
|
|
|
1136
1360
|
```bash
|
|
1137
1361
|
# Terminal 1
|
|
@@ -1143,15 +1367,30 @@ cloudflared tunnel --url http://localhost:3000
|
|
|
1143
1367
|
|
|
1144
1368
|
# Set in .env.local:
|
|
1145
1369
|
NEXTAUTH_URL=https://random-name.trycloudflare.com
|
|
1146
|
-
|
|
1147
|
-
|
|
1370
|
+
# Declared first, and never "production" on a developer machine.
|
|
1371
|
+
HELEKET_ENV=test
|
|
1372
|
+
# The SANDBOX merchant's key and UUID. Not production's.
|
|
1373
|
+
HELEKET_API_KEY=<sandbox merchant key>
|
|
1374
|
+
HELEKET_MERCHANT_ID=<sandbox merchant uuid>
|
|
1375
|
+
# The production merchant, pinned. This is what makes the line above checkable:
|
|
1376
|
+
# paste the live UUID here and `assertHeleketEnv()` refuses to boot if the two
|
|
1377
|
+
# credentials above ever turn out to be it.
|
|
1378
|
+
HELEKET_LIVE_MERCHANT_ID=<production merchant uuid>
|
|
1148
1379
|
HELEKET_WEBHOOK_IP=31.133.220.8
|
|
1149
1380
|
|
|
1150
1381
|
# Restart Next.js so it picks up the public URL
|
|
1151
1382
|
```
|
|
1152
1383
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1384
|
+
Heleket's "Test mode" (toggle in merchant settings) gives free $1 invoices, payable with
|
|
1385
|
+
any spare USDT. **It is a property of the merchant account, not of the key** — the same
|
|
1386
|
+
credential creates real invoices the moment somebody moves that toggle, so it is not a
|
|
1387
|
+
substitute for a separate merchant and it is not what makes this safe. What makes it
|
|
1388
|
+
checkable is the pin above plus
|
|
1389
|
+
[the boot assertion](#the-boot-assertion); what remains after both is
|
|
1390
|
+
[Residual exposure](#residual-exposure).
|
|
1391
|
+
|
|
1392
|
+
If your account has only one merchant, prefer **Option A** for everything except the one
|
|
1393
|
+
thing that genuinely needs the wire: a real callback arriving from Heleket's IP.
|
|
1155
1394
|
|
|
1156
1395
|
---
|
|
1157
1396
|
|
|
@@ -1251,8 +1490,87 @@ expect(res2.status).toBe(200);
|
|
|
1251
1490
|
// User's balance must NOT have increased again.
|
|
1252
1491
|
```
|
|
1253
1492
|
|
|
1493
|
+
### Credential-boundary tests (`heleket-env.ts`)
|
|
1494
|
+
|
|
1495
|
+
Both directions, and a defect planted against each. A boundary nobody has watched refuse
|
|
1496
|
+
is not a boundary — and the direction people skip is the second one.
|
|
1497
|
+
|
|
1498
|
+
```ts
|
|
1499
|
+
import { describe, it, expect } from "vitest";
|
|
1500
|
+
import { assertHeleketEnv, keyFingerprint, HeleketEnvError } from "./heleket-env";
|
|
1501
|
+
|
|
1502
|
+
const LIVE_MERCHANT = "11111111-1111-1111-1111-111111111111";
|
|
1503
|
+
const SANDBOX_MERCHANT = "22222222-2222-2222-2222-222222222222";
|
|
1504
|
+
const LIVE_KEY = "placeholder-live-key-not-a-real-credential";
|
|
1505
|
+
|
|
1506
|
+
const base = {
|
|
1507
|
+
HELEKET_LIVE_MERCHANT_ID: LIVE_MERCHANT,
|
|
1508
|
+
HELEKET_LIVE_KEY_FINGERPRINT: keyFingerprint(LIVE_KEY),
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
function code(env: Record<string, string>): string {
|
|
1512
|
+
try {
|
|
1513
|
+
assertHeleketEnv(env as NodeJS.ProcessEnv);
|
|
1514
|
+
return "PASSED";
|
|
1515
|
+
} catch (e) {
|
|
1516
|
+
return (e as HeleketEnvError).code;
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
describe("assertHeleketEnv", () => {
|
|
1521
|
+
it("refuses a live credential declared test — by merchant uuid", () => {
|
|
1522
|
+
expect(code({ ...base, HELEKET_ENV: "test", HELEKET_MERCHANT_ID: LIVE_MERCHANT }))
|
|
1523
|
+
.toBe("HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL");
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it("refuses a live credential declared test — by key fingerprint alone", () => {
|
|
1527
|
+
// the UUID is the sandbox one, so only the fingerprint can catch this
|
|
1528
|
+
expect(code({ ...base, HELEKET_ENV: "test",
|
|
1529
|
+
HELEKET_MERCHANT_ID: SANDBOX_MERCHANT, HELEKET_API_KEY: LIVE_KEY }))
|
|
1530
|
+
.toBe("HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL");
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it("refuses a test credential declared live", () => {
|
|
1534
|
+
expect(code({ ...base, HELEKET_ENV: "production",
|
|
1535
|
+
HELEKET_MERCHANT_ID: SANDBOX_MERCHANT }))
|
|
1536
|
+
.toBe("HELEKET_ENV_LIVE_HOLDS_TEST_CREDENTIAL");
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
it("refuses an unset environment rather than defaulting", () => {
|
|
1540
|
+
expect(code({ ...base, HELEKET_MERCHANT_ID: SANDBOX_MERCHANT }))
|
|
1541
|
+
.toBe("HELEKET_ENV_MISSING");
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
it("refuses a test run that cannot prove it is not live", () => {
|
|
1545
|
+
expect(code({ HELEKET_ENV: "test", HELEKET_MERCHANT_ID: SANDBOX_MERCHANT }))
|
|
1546
|
+
.toBe("HELEKET_ENV_UNPINNED");
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
it("refuses SKIP_BILLING in production", () => {
|
|
1550
|
+
expect(code({ ...base, HELEKET_ENV: "production",
|
|
1551
|
+
HELEKET_MERCHANT_ID: LIVE_MERCHANT, SKIP_BILLING: "true" }))
|
|
1552
|
+
.toBe("HELEKET_ENV_LIVE_WITH_SKIP_BILLING");
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
it("admits the two configurations that are actually correct", () => {
|
|
1556
|
+
expect(code({ ...base, HELEKET_ENV: "production",
|
|
1557
|
+
HELEKET_MERCHANT_ID: LIVE_MERCHANT })).toBe("PASSED");
|
|
1558
|
+
expect(code({ ...base, HELEKET_ENV: "test",
|
|
1559
|
+
HELEKET_MERCHANT_ID: SANDBOX_MERCHANT })).toBe("PASSED");
|
|
1560
|
+
});
|
|
1561
|
+
});
|
|
1562
|
+
```
|
|
1563
|
+
|
|
1564
|
+
The last case is not padding. A boundary that refuses everything passes every negative
|
|
1565
|
+
test and stops anyone deploying, so it gets switched off within a day — which is the
|
|
1566
|
+
failure mode a refusal with no working configuration always ends in.
|
|
1567
|
+
|
|
1254
1568
|
### Test matrix to cover
|
|
1255
1569
|
|
|
1570
|
+
- `HELEKET_ENV=test` holding the live merchant credential → refused at boot
|
|
1571
|
+
- `HELEKET_ENV=production` holding a non-live credential → refused at boot
|
|
1572
|
+
- `HELEKET_ENV` unset → refused, not defaulted
|
|
1573
|
+
- `HELEKET_ENV=production` with `SKIP_BILLING=true` → refused at boot
|
|
1256
1574
|
- Unauthorized checkout → 401
|
|
1257
1575
|
- Amount below `MIN_AMOUNT` → 400
|
|
1258
1576
|
- Amount above `MAX_AMOUNT` → 400
|
|
@@ -1275,6 +1593,20 @@ Before shipping to production, verify each item:
|
|
|
1275
1593
|
|
|
1276
1594
|
- [ ] `HELEKET_API_KEY` and `HELEKET_MERCHANT_ID` stored in a secret manager, not in
|
|
1277
1595
|
`.env` committed to git.
|
|
1596
|
+
- [ ] A **separate sandbox merchant account** exists, or the reason it does not is
|
|
1597
|
+
written down where the next person will read it — the credential a dev machine
|
|
1598
|
+
holds is the whole boundary, and `assertHeleketEnv()` only checks the declaration.
|
|
1599
|
+
- [ ] `HELEKET_ENV` is set explicitly in every environment, and `HELEKET_LIVE_MERCHANT_ID`
|
|
1600
|
+
is pinned so a test run can be refused.
|
|
1601
|
+
- [ ] `assertHeleketEnv()` runs at **module load** of the client library, not inside the
|
|
1602
|
+
checkout handler — a run that merely holds the key must fail too.
|
|
1603
|
+
- [ ] Both refusal directions have been watched failing:
|
|
1604
|
+
`HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL` and
|
|
1605
|
+
`HELEKET_ENV_LIVE_HOLDS_TEST_CREDENTIAL`.
|
|
1606
|
+
- [ ] `SKIP_BILLING` is refused when `HELEKET_ENV=production`, by the assertion and not
|
|
1607
|
+
by a comment.
|
|
1608
|
+
- [ ] The key-rotation runbook re-pins `HELEKET_LIVE_KEY_FINGERPRINT` in the same change,
|
|
1609
|
+
or the assertion begins answering about a key that no longer exists.
|
|
1278
1610
|
- [ ] Webhook route is in the **CSRF exempt list** of your middleware.
|
|
1279
1611
|
- [ ] Webhook IP allowlist active (`isAllowedWebhookIp`), proxy-aware
|
|
1280
1612
|
(`do-connecting-ip` / `cf-connecting-ip` / last hop of `x-forwarded-for`).
|
|
@@ -1302,6 +1634,10 @@ Before shipping to production, verify each item:
|
|
|
1302
1634
|
|
|
1303
1635
|
| Symptom | Likely cause | Fix |
|
|
1304
1636
|
|---------|-------------|-----|
|
|
1637
|
+
| App refuses to boot with `HELEKET_ENV_TEST_HOLDS_LIVE_CREDENTIAL` | The dev machine is holding the production merchant's key — working as designed | Switch to the sandbox merchant. Do **not** widen the assertion; it caught the thing it exists for. |
|
|
1638
|
+
| App refuses to boot with `HELEKET_ENV_LIVE_HOLDS_TEST_CREDENTIAL` in production | A staging key reached the production environment | Fix the secret, not the check: this is the direction that loses money silently. |
|
|
1639
|
+
| App refuses to boot with `HELEKET_ENV_UNPINNED` | `HELEKET_LIVE_MERCHANT_ID` is not set, so a test run cannot prove it is not live | Pin the production merchant UUID. It is not a secret. |
|
|
1640
|
+
| A real payment arrived but no invoice exists in the dashboard you are looking at | Two merchant accounts; you are reading the sandbox one | Check `HELEKET_MERCHANT_ID` against `HELEKET_LIVE_MERCHANT_ID`. |
|
|
1305
1641
|
| Every webhook fails signature | Forgot the PHP-escape form | Use the dual `verifyWebhookSignature`. |
|
|
1306
1642
|
| Webhook returns 403 in production but works locally | Behind a CDN (Cloudflare / DO / Vercel), real IP is in `cf-connecting-ip` / `do-connecting-ip` / `x-forwarded-for` | Implement proxy-aware `extractIp`. |
|
|
1307
1643
|
| User pays $30, balance shows $29.97 | Crediting `paymentAmountUsd` without buffer | Add 1% buffer in checkout, credit `tokenAmount` (or use the waterfall `paymentAmountUsd ?? tokenAmount ?? amountUsd`). |
|
|
@@ -1324,8 +1660,11 @@ When the user says "add crypto payments to this project":
|
|
|
1324
1660
|
framework, an existing user model with a balance field of some kind, an authenticated
|
|
1325
1661
|
session helper.
|
|
1326
1662
|
2. **Add env vars** to `.env.example` and the secret manager:
|
|
1327
|
-
`HELEKET_API_KEY`, `HELEKET_MERCHANT_ID`, `
|
|
1328
|
-
`SKIP_BILLING=false`.
|
|
1663
|
+
`HELEKET_ENV`, `HELEKET_API_KEY`, `HELEKET_MERCHANT_ID`, `HELEKET_LIVE_MERCHANT_ID`,
|
|
1664
|
+
`HELEKET_WEBHOOK_IP=31.133.220.8`, `SKIP_BILLING=false`. Then create
|
|
1665
|
+
`src/lib/heleket-env.ts` (Section 3, copy verbatim) and call `assertHeleketEnv()` at
|
|
1666
|
+
the top of the client library — **before** step 4, so the first run of anything is
|
|
1667
|
+
already inside the boundary rather than being retrofitted into it later.
|
|
1329
1668
|
3. **Add Prisma model + enum** (Section 4) and run `npx prisma migrate dev --name
|
|
1330
1669
|
add_crypto_payments`.
|
|
1331
1670
|
4. **Create the client library** at `src/lib/heleket.ts` (Section 7, copy verbatim).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Testing and local development
|
|
2
|
+
|
|
3
|
+
Split out of `SKILL.md` on 2026-08-20 for the same reason as
|
|
4
|
+
[`callback-route-hardening.md`](callback-route-hardening.md): the body was ~4894 tokens
|
|
5
|
+
against a 4750 working limit, and the house answer at that point is a split rather than a
|
|
6
|
+
trim. This is the operational half — how you run the thing on a laptop, and what you have
|
|
7
|
+
to cover before believing a green suite.
|
|
8
|
+
|
|
9
|
+
`stripe-billing` ships the same pair of subjects at
|
|
10
|
+
[`../../stripe-billing/references/testing-and-local-dev.md`](../../stripe-billing/references/testing-and-local-dev.md),
|
|
11
|
+
and its *Mutation testing* section is the argument this matrix assumes: plant a defect
|
|
12
|
+
against every row before believing it.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Local development
|
|
17
|
+
|
|
18
|
+
Two paths, and you want both:
|
|
19
|
+
|
|
20
|
+
**Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
|
|
21
|
+
logs loudly. Everyone building a feature that merely *touches* checkout should
|
|
22
|
+
use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
|
|
23
|
+
at the branch, not just in config.
|
|
24
|
+
|
|
25
|
+
**Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cloudflared tunnel --url http://localhost:3000
|
|
29
|
+
# → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then keep a **signed mock callback** in your test tooling — a script that builds
|
|
33
|
+
a real payload, signs it with the dev key and POSTs it. This is what makes the
|
|
34
|
+
sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
|
|
35
|
+
ask a gateway to underpay you on demand.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## Test matrix
|
|
40
|
+
|
|
41
|
+
Cover these, and plant a defect against each before believing the green:
|
|
42
|
+
|
|
43
|
+
| Case | What it catches |
|
|
44
|
+
|---|---|
|
|
45
|
+
| valid signature | the happy path |
|
|
46
|
+
| tampered signature | constant-time compare, 403 not 500 |
|
|
47
|
+
| signature over a payload containing a URL | the escaping trap |
|
|
48
|
+
| duplicate delivery of a settling status | idempotency guard |
|
|
49
|
+
| `paid_over` | crediting the received amount, not the invoiced |
|
|
50
|
+
| `wrong_amount` | no full credit on a partial payment |
|
|
51
|
+
| out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
|
|
52
|
+
| callback from a non-allowlisted IP | proxy-aware extraction |
|
|
53
|
+
| `refund_process` then `refund_fail` | refund is a state machine |
|
|
54
|
+
| `locked` | no credit, no auto-cancel |
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
Back to [`SKILL.md`](../SKILL.md). The provider-specific half — one credential, no sandbox
|
|
59
|
+
host, and what "test mode" actually toggles — is
|
|
60
|
+
[`heleket-provider.md`](heleket-provider.md), *The test/live boundary*.
|
|
@@ -42,7 +42,7 @@ Deep material, loaded on demand:
|
|
|
42
42
|
| [`references/webhook-events.md`](references/webhook-events.md) | writing or reviewing the handler — event catalogue, payload shapes, ordering, failure semantics |
|
|
43
43
|
| [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md) | implementing checkout, verify, renewal, seats, plan change, trials, cancellation, clawback |
|
|
44
44
|
| [`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
|
-
| [`references/testing-and-local-dev.md`](references/testing-and-local-dev.md) | local webhooks
|
|
45
|
+
| [`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
46
|
| [`references/provider-concentration.md`](references/provider-concentration.md) | growing revenue, opening a second market, separating involuntary churn, or asking what happens if the payment account is limited |
|
|
47
47
|
|
|
48
48
|
---
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Money fixtures and the assertion pack
|
|
2
|
+
|
|
3
|
+
**Copy this whole directory into your repository.** It is the executable half of what
|
|
4
|
+
`SKILL.md` and `references/webhook-events.md` state in prose: nine real Stripe event
|
|
5
|
+
bodies, twelve invariants a correct webhook handler must hold — 45 assertions between
|
|
6
|
+
them — and a self-test that breaks the handler one rule at a time so you can watch each
|
|
7
|
+
assertion fail on its own.
|
|
8
|
+
|
|
9
|
+
Two commands, no dependencies, no network, no key:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node assert-money-invariants.mjs # 12 invariants / 45 assertions
|
|
13
|
+
node assert-money-invariants.mjs --self-test # break one rule at a time; EACH ASSERTION must go red
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Point it at **your** handler by replacing the two imports at the top of
|
|
17
|
+
`assert-money-invariants.mjs`. The assertions do not change — that is the point of shipping
|
|
18
|
+
them rather than describing them.
|
|
19
|
+
|
|
20
|
+
## Contents
|
|
21
|
+
|
|
22
|
+
- [The fixtures](#the-fixtures)
|
|
23
|
+
- [The assertions](#the-assertions)
|
|
24
|
+
- [What each mutant deletes](#what-each-mutant-deletes)
|
|
25
|
+
- [What masks what](#what-masks-what)
|
|
26
|
+
- [Placeholders](#placeholders)
|
|
27
|
+
|
|
28
|
+
## The fixtures
|
|
29
|
+
|
|
30
|
+
Every file is a webhook event body shaped the way Stripe sends it on
|
|
31
|
+
`2026-07-29.dahlia` — the period on the invoice **line**, the subscription id under
|
|
32
|
+
`parent.subscription_details`, `amount_refunded` cumulative, minor units throughout.
|
|
33
|
+
|
|
34
|
+
| File | What it is | Why it exists |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `invoice-paid-subscription-cycle.json` | the January renewal, `billing_reason: subscription_cycle` | the one `invoice.paid` that **must** grant |
|
|
37
|
+
| `invoice-paid-subscription-cycle-redelivery.json` | the same `evt_` again, `pending_webhooks` 0 | Stripe re-delivers the same body; only the id matters |
|
|
38
|
+
| `invoice-paid-subscription-update-proration.json` | a mid-cycle seat change, `billing_reason: subscription_update`, two proration lines | its line period starts at the **change date**, later than the granted cycle — so a per-period marker lets it through and only `billing_reason` stops it |
|
|
39
|
+
| `invoice-paid-subscription-cycle-next-period.json` | the February renewal | delivered *before* January, it is the out-of-order pair |
|
|
40
|
+
| `charge-refunded-partial.json` | $40 refunded of a $90 charge, `amount_refunded: 4000` | step one of the cumulative refund |
|
|
41
|
+
| `charge-refunded-remainder.json` | the other $50, `amount_refunded: 9000` | read as an increment this claws back $130 against a $90 charge |
|
|
42
|
+
| `checkout-session-completed-async-unpaid.json` | a bank-debit session, `payment_status: "unpaid"` | the session completed and the money has not moved |
|
|
43
|
+
| `checkout-session-async-payment-failed.json` | days later: it never cleared | the redirect proved a browser and nothing else |
|
|
44
|
+
| `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 |
|
|
45
|
+
|
|
46
|
+
The refund pair resolves to a **purchase row**, not a Stripe object: a $90 credit pack with
|
|
47
|
+
`refundedTotal: 0`, seeded by the assertion pack because it is your database's, not
|
|
48
|
+
Stripe's.
|
|
49
|
+
|
|
50
|
+
## The assertions
|
|
51
|
+
|
|
52
|
+
Run `node assert-money-invariants.mjs` and each line names the invariant it holds:
|
|
53
|
+
|
|
54
|
+
| Assertion | Fixtures | What a wrong handler does |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `renewal-grants-exactly-once` | cycle | grants nothing, and a paying customer has no access |
|
|
57
|
+
| `proration-invoice-grants-nothing` | cycle + proration | gives a month of product for a $40 proration invoice |
|
|
58
|
+
| `sequential-redelivery-grants-once` | cycle + redelivery | answers `received` twice and sends the renewal notice and the server-side conversion twice |
|
|
59
|
+
| `concurrent-redelivery-grants-once` | cycle + redelivery, in flight together | both deliveries pass the marker's `SELECT` and both credit |
|
|
60
|
+
| `sequential-redelivery-grants-once-by-count-alone` | cycle + redelivery | *nothing* — kept because that is the finding; see below |
|
|
61
|
+
| `reconciliation-does-not-regrant` | cycle | the nightly repair grants a period the webhook already granted |
|
|
62
|
+
| `out-of-order-pair-does-not-rewind-state` | February then January | stores the older period, so the renewal date points at a period that ended |
|
|
63
|
+
| `refund-total-is-cumulative` | both refunds | claws back $130 against a $90 charge |
|
|
64
|
+
| `duplicate-refund-claws-back-once` | one refund, twice | *nothing* — the second masked pair; see below |
|
|
65
|
+
| `unpaid-session-grants-nothing` | unpaid + failed | grants the product and reports a purchase for a charge that failed |
|
|
66
|
+
| `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
|
+
| `conversion-id-survives-the-session` | cycle | generates its own conversion id, and the browser event never deduplicates against it |
|
|
68
|
+
|
|
69
|
+
## What each mutant deletes
|
|
70
|
+
|
|
71
|
+
`--self-test` removes exactly one rule at a time from `reference-handler.mjs`. Each one is
|
|
72
|
+
a line a generated handler routinely omits:
|
|
73
|
+
|
|
74
|
+
| Rule | The code it removes | The generated handler that ships without it |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `claim` | `if (!store.claimEvent(event.id)) return duplicate` | reads the processed table, then writes it — two deliveries 40 ms apart both pass the read |
|
|
77
|
+
| `billing-reason` | `if (invoice.billing_reason !== 'subscription_cycle') return` | handles `invoice.paid` as "a payment arrived" |
|
|
78
|
+
| `grant-marker` | `if (granted.has(period.start)) return` | grants whenever an event says paid, including from the reconciliation job |
|
|
79
|
+
| `ordering` | `if (period.start > mirror.periodStart)` around the mirror write | writes whatever arrived last |
|
|
80
|
+
| `cumulative-refund` | the stored total, the increment and the compare-and-swap | claws back `amount_refunded` |
|
|
81
|
+
| `async-gate` | `if (session.payment_status === 'unpaid') return` | grants on `checkout.session.completed`, full stop |
|
|
82
|
+
| `conversion-id-from-metadata` | reading `conversionEventId` out of the subscription metadata | generates an id at emission time |
|
|
83
|
+
| `grant-on-renewal` | the grant itself | treats `invoice.paid` as information |
|
|
84
|
+
| `grant-on-checkout` | the first grant | answers 200 to `checkout.session.completed` and writes nothing |
|
|
85
|
+
|
|
86
|
+
## What masks what
|
|
87
|
+
|
|
88
|
+
The whole reason `--self-test` prints a matrix: **when two rules can both explain a passing
|
|
89
|
+
fixture, neither is tested.** Two pairs in this pack do exactly that, and the fixtures that
|
|
90
|
+
separate them are here because of it rather than by design.
|
|
91
|
+
|
|
92
|
+
- **the event claim and the per-period grant marker.** A redelivery judged by the grant
|
|
93
|
+
count alone is refused by either one. `sequential-redelivery-grants-once-by-count-alone`
|
|
94
|
+
is the fixture that measures it: no *single* removed rule turns it red, only
|
|
95
|
+
`claim`+`grant-marker` together. The claim's own discriminating fixture is therefore the
|
|
96
|
+
**concurrent** delivery — the marker reads before it writes, and a read is a round trip —
|
|
97
|
+
and the marker's is the **reconciliation** entry point, which has no event id at all.
|
|
98
|
+
- **the event claim and the refund compare-and-swap.** A duplicate `charge.refunded` is
|
|
99
|
+
refused by the claim, and by `increment <= 0` without it.
|
|
100
|
+
`duplicate-refund-claws-back-once` records that, and the cumulative pair with two
|
|
101
|
+
*distinct* event ids is what actually tests the arithmetic.
|
|
102
|
+
|
|
103
|
+
`--self-test` fails if any rule has no fixture that isolates it, so this stays true.
|
|
104
|
+
|
|
105
|
+
## Placeholders
|
|
106
|
+
|
|
107
|
+
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
|
|
109
|
+
in this directory — signature verification happens before any of this and is
|
|
110
|
+
`SKILL.md`'s subject, not these fixtures'. The email is `placeholder@example.invalid` and
|
|
111
|
+
the one 64-hex string is its SHA-256.
|