@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/README.md +115 -1
  3. package/SECURITY.md +199 -52
  4. package/bin/sheleg-dev.js +18 -0
  5. package/package.json +2 -2
  6. package/plugins/sheleg-dev/.claude-plugin/plugin.json +1 -1
  7. package/plugins/sheleg-dev/hooks/hooks.json +29 -0
  8. package/plugins/sheleg-dev/hooks/lib/moneygate.js +587 -0
  9. package/plugins/sheleg-dev/hooks/money-gate.js +68 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +7 -3
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +77 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +397 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/manifest.json +75 -0
  14. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-from-thank-you-page.json +32 -0
  15. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-capi-server.json +26 -0
  16. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/purchase-pixel-browser.json +12 -0
  17. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/reference-emitter.mjs +129 -0
  18. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +17 -1
  19. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +82 -77
  20. package/plugins/sheleg-dev/skills/crypto-payments/references/callback-route-hardening.md +55 -0
  21. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  22. package/plugins/sheleg-dev/skills/crypto-payments/references/testing-and-local-dev.md +60 -0
  23. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +1 -1
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +111 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +564 -0
  26. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-partial.json +52 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/charge-refunded-remainder.json +63 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-async-payment-failed.json +43 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-async-unpaid.json +43 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/checkout-session-completed-paid.json +42 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-next-period.json +70 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle-redelivery.json +70 -0
  33. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-cycle.json +61 -0
  34. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/invoice-paid-subscription-update-proration.json +90 -0
  35. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/manifest.json +172 -0
  36. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/reference-handler.mjs +315 -0
  37. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +39 -2
  38. 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
- | `HELEKET_API_KEY` | yes | — | Merchant settings → Payment API. Trim whitespace on read. **Never log this value**, it is also the webhook signing secret. |
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, 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
- HELEKET_API_KEY=...
1147
- HELEKET_MERCHANT_ID=...
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
- Use Heleket's "Test mode" (toggle in merchant settings) for free $1 invoices. Pay them
1154
- with any spare USDT.
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`, `HELEKET_WEBHOOK_IP=31.133.220.8`,
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, fixtures, mocks, and a suite that would actually catch a money defect |
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.