@ssheleg/sheleg-dev 0.5.2 → 0.7.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 (39) hide show
  1. package/CHANGELOG.md +347 -0
  2. package/README.md +101 -1
  3. package/SECURITY.md +190 -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 +24 -75
  11. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/README.md +76 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/fixtures/assert-dedup-contract.mjs +249 -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/event-tracking.md +42 -0
  19. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +159 -2
  20. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +68 -0
  21. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +349 -10
  22. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +31 -13
  23. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +48 -104
  24. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/README.md +110 -0
  25. package/plugins/sheleg-dev/skills/stripe-billing/fixtures/assert-money-invariants.mjs +404 -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/subscription-lifecycle.md +27 -0
  38. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +38 -2
  39. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +43 -1
@@ -0,0 +1,129 @@
1
+ // A purchase emitter that satisfies the deduplication contract this skill states, and the
2
+ // same emitter with one rule removed at a time.
3
+ //
4
+ // Two call sites, and which one may report a purchase is the whole invariant:
5
+ //
6
+ // onChargeCleared(purchase) <- the webhook handler, the only place that knows the money
7
+ // moved. Sends the Conversions API event.
8
+ // onThankYouPage(purchase) <- the browser. Fires the pixel with the id the server will
9
+ // reuse, and reports nothing on its own authority.
10
+ //
11
+ // `without: ['shared-event-id']` and friends delete the lines a generated integration
12
+ // routinely omits; `fixtures/README.md` shows the code each flag removes. Nothing here
13
+ // reaches the network — the sink collects what WOULD have been sent, which is what the
14
+ // assertions read. No access token appears in this directory at all.
15
+
16
+ export const RULES = Object.freeze([
17
+ 'shared-event-id',
18
+ 'exact-event-name',
19
+ 'webhook-sourced',
20
+ 'keep-browser-event',
21
+ 'hashed-identifiers',
22
+ ]);
23
+
24
+ /** Meta compares `event_name` as well as the id, and it is the half that gets missed. */
25
+ export const EVENT_NAME = 'Purchase';
26
+
27
+ export function createSink() {
28
+ return { pixel: [], capi: [] };
29
+ }
30
+
31
+ function customData(purchase) {
32
+ return {
33
+ value: purchase.value,
34
+ currency: purchase.currency,
35
+ content_ids: purchase.contentIds,
36
+ content_type: 'product',
37
+ num_items: purchase.numItems,
38
+ };
39
+ }
40
+
41
+ export function createEmitter(sink, options = {}) {
42
+ const without = new Set(options.without || []);
43
+ for (const rule of without) {
44
+ if (!RULES.includes(rule)) throw new Error(`unknown rule: ${rule}`);
45
+ }
46
+ const has = (rule) => !without.has(rule);
47
+ let generated = 0;
48
+
49
+ /** One id, generated once at session creation, carried by both sides. */
50
+ function eventIdFor(purchase) {
51
+ if (has('shared-event-id')) return purchase.conversionEventId;
52
+ generated += 1;
53
+ return `evtid_generated_at_emission_${generated}`;
54
+ }
55
+
56
+ function eventNameFor(side) {
57
+ if (has('exact-event-name')) return EVENT_NAME;
58
+ // The trap: an id that matches and a name that does not deduplicates nothing.
59
+ return side === 'server' ? EVENT_NAME.toLowerCase() : EVENT_NAME;
60
+ }
61
+
62
+ function userData(purchase) {
63
+ const identifiers = has('hashed-identifiers')
64
+ ? { em: [purchase.emailSha256], external_id: [purchase.externalId] }
65
+ : { em: [purchase.email], external_id: [purchase.externalId] };
66
+ return {
67
+ ...identifiers,
68
+ client_ip_address: purchase.clientIpAddress,
69
+ client_user_agent: purchase.clientUserAgent,
70
+ fbp: purchase.fbp,
71
+ fbc: purchase.fbc,
72
+ };
73
+ }
74
+
75
+ function capiBody(purchase) {
76
+ return {
77
+ data: [{
78
+ event_name: eventNameFor('server'),
79
+ event_time: purchase.eventTime,
80
+ event_id: eventIdFor(purchase),
81
+ event_source_url: purchase.eventSourceUrl,
82
+ action_source: 'website',
83
+ user_data: userData(purchase),
84
+ custom_data: customData(purchase),
85
+ }],
86
+ };
87
+ }
88
+
89
+ return {
90
+ /** The webhook handler. `cleared` is the provider's word, not the browser's. */
91
+ onChargeCleared(purchase) {
92
+ if (purchase.cleared !== true) {
93
+ // Reached only by a caller that guessed. The gate below is the one that matters.
94
+ return { sent: false, why: 'the charge has not cleared' };
95
+ }
96
+ sink.capi.push(capiBody(purchase));
97
+ return { sent: true };
98
+ },
99
+
100
+ /**
101
+ * The thank-you page. It fires the pixel and NOTHING ELSE: a browser cannot know
102
+ * whether the charge cleared, and for an async method it usually has not.
103
+ */
104
+ onThankYouPage(purchase) {
105
+ if (!has('webhook-sourced')) {
106
+ // The defect: reporting the purchase from the one place with no idea whether the
107
+ // money moved. `purchase-capi-from-thank-you-page.json` is what this sends.
108
+ sink.capi.push(capiBody(purchase));
109
+ }
110
+ if (has('webhook-sourced') && purchase.cleared !== true) {
111
+ // Not even the pixel: a Purchase fired here exists for every session that reached
112
+ // the page, including the ones that never paid.
113
+ return { fired: false, why: 'the charge has not cleared' };
114
+ }
115
+ if (!has('keep-browser-event')) {
116
+ // The other direction: dropping the browser event loses the click ids, the consent
117
+ // state and the session that only the browser carries.
118
+ return { fired: false, why: 'browser event removed' };
119
+ }
120
+ sink.pixel.push([
121
+ 'track',
122
+ eventNameFor('browser'),
123
+ customData(purchase),
124
+ { eventID: eventIdFor(purchase) },
125
+ ]);
126
+ return { fired: true };
127
+ },
128
+ };
129
+ }
@@ -201,3 +201,45 @@ function trackEvent(name, params) {
201
201
  gtag('event', name, params);
202
202
  }
203
203
  ```
204
+
205
+ ---
206
+
207
+ ## User identification
208
+
209
+ ### Cross-Platform Identification Strategy
210
+
211
+ | Platform | Method | When | What it enables |
212
+ |---|---|---|---|
213
+ | GA4 | `user_id` via `gtag('config', TAG_ID, { user_id })` | After login/onboarding | Cross-device tracking, audience building |
214
+ | GA4 | Enhanced Conversions via `gtag('set', 'user_data', { email })` | After login/onboarding | Better conversion attribution |
215
+ | Meta Pixel | Advanced Matching via `fbq('init', PIXEL_ID, { em, fn })` | After login/onboarding | Better conversion attribution, larger custom audiences |
216
+ | Mixpanel | `alias()` + `identify()` | After login/onboarding | Merge anonymous → identified user profiles |
217
+
218
+ **All identification calls should be placed in a single identification component** that runs in the dashboard/authenticated layout. This ensures:
219
+ - User data is sent once per session
220
+ - All platforms receive identification data at the same time
221
+ - Pre-login anonymous events merge into the identified profile
222
+
223
+ ### Alias vs Identify (Mixpanel)
224
+
225
+ - `alias(userId, anonymousId)` — creates a permanent link between the anonymous and real user ID. Must be called **exactly once** per user, **before** `identify()`. Use a localStorage flag to prevent duplicate calls.
226
+ - `identify(userId)` — sets the user ID for all subsequent events.
227
+
228
+ ### Timing
229
+
230
+ Fire identification in a `useEffect` in the authenticated layout:
231
+
232
+ ```typescript
233
+ useEffect(() => {
234
+ // 1. Mixpanel alias + identify
235
+ // 2. GA4 user_id + Enhanced Conversions
236
+ // 3. Meta Advanced Matching
237
+ }, [userId, email, name]);
238
+ ```
239
+
240
+ ---
241
+
242
+ Moved out of `SKILL.md` on 2026-08-16: the body was 5273 tokens against a
243
+ < 5000 budget, and identity is the other half of the question this file already
244
+ owns — an event name says what happened, identity says who it happened to.
245
+
@@ -9,9 +9,13 @@ setup snippet and the traps.
9
9
  ## Contents
10
10
 
11
11
  - [Parameter object properties](#parameter-object-properties) — what Meta accepts per event
12
+ - [Standard events](#standard-events) — the seventeen, and the four that carry a funnel
12
13
  - [Firing events](#firing-events) — the wrapper and its consent gate
13
- - [Advanced matching](#advanced-matching) — hashed identifiers, and what must never be sent
14
- - [Everything below](#) — LinkedIn conversion detail, deduplication with CAPI
14
+ - [Advanced matching](#advanced-matching) — hashed identifiers
15
+ - [What must never be sent](#what-must-never-be-sent) — the categories that are a terms breach, not a tuning choice
16
+ - [Deduplication with the Conversions API](#deduplication-with-the-conversions-api) — the contract that stops one purchase counting twice
17
+ - [LinkedIn conversion tracking](#linkedin-conversion-tracking) — Insight Tag, Campaign Manager rules, `lintrk`
18
+ - [Meta Events Manager verification](#meta-events-manager-verification) — reading it back
15
19
 
16
20
  ### Parameter Object Properties
17
21
 
@@ -28,6 +32,22 @@ setup snippet and the traps.
28
32
  | `predicted_ltv` | float | Predicted lifetime value (for Subscribe) |
29
33
  | `status` | boolean | Registration status (for CompleteRegistration) |
30
34
 
35
+ ### Standard Events
36
+
37
+ Meta defines 17 standard events. The most important for SaaS conversion funnels:
38
+
39
+ | Event | When | Parameters | Notes |
40
+ |---|---|---|---|
41
+ | `PageView` | Every page load | (automatic from base code) | Fires from the init script |
42
+ | `CompleteRegistration` | User signs up | `content_name` (method), `status: true` | Separate from GA4 `sign_up` |
43
+ | `InitiateCheckout` | Checkout started | `value`, `currency`, `content_name`, `content_category`, `num_items` | |
44
+ | `Purchase` | Purchase completed | `value` (required), `currency` (required), `content_name`, `content_category`, `content_type`, `contents[]` | `value` + `currency` are **mandatory** for optimisation |
45
+ | `Subscribe` | Recurring subscription | `value`, `currency`, `predicted_ltv` | Distinct from one-time Purchase |
46
+ | `Lead` | Lead form submitted | (optional params) | |
47
+ | `ViewContent` | Key content viewed | `content_name`, `content_category` | |
48
+ | `AddToCart` | Item added to cart | `content_name`, `value`, `currency` | |
49
+ | `Search` | Search performed | `search_string` | |
50
+
31
51
  ### Firing Events
32
52
 
33
53
  ```javascript
@@ -75,6 +95,143 @@ export function setFbAdvancedMatching(data: { em?: string; fn?: string }) {
75
95
  }
76
96
  ```
77
97
 
98
+ ### What must never be sent
99
+
100
+ Advanced Matching is the one place a tracking integration can breach Meta's terms
101
+ by accident, because the wrapper accepts whatever you hand it and hashes it
102
+ without judgement. SHA-256 does **not** make a value safe to send: hashing is a
103
+ matching mechanism, not a permission. That the wrapper receives a hash at all is checkable:
104
+ `identifiers-reach-the-server-hashed` in `fixtures/assert-dedup-contract.mjs` requires every
105
+ `user_data.em` entry to be 64 hex characters and refuses anything with an `@` in it. What may
106
+ be hashed in the first place is the table below, and no assertion decides it.
107
+
108
+ Meta's Business Tools Terms prohibit sharing data that "includes or is based on"
109
+ any of these, and the prohibition covers the Pixel, the Conversions API, the
110
+ Facebook SDK for App Events, Offline Conversions and the App Events API alike:
111
+
112
+ | Never send | Why it is not a judgement call |
113
+ |---|---|
114
+ | Health information | Named in the terms. Meta also runs a signals-filtering mechanism that drops data it categorises as potentially health-related before it reaches ads ranking — so the data is both a breach and useless |
115
+ | Financial information | Named in the terms |
116
+ | Consumer report information | Named in the terms |
117
+ | Social security numbers, credit card numbers | Named as identifiers Meta does not permit |
118
+ | Data from or about children under 13 | Named in the terms |
119
+ | Anything sensitive under applicable law or industry guidance | The terms defer to local definitions, so the list above is a floor and not a ceiling |
120
+
121
+ **The event NAME is covered too**, and this is the half integrations miss: "the
122
+ names you choose and criteria you establish for your events, conversions, and any
123
+ custom audiences you create must not reflect, imply or be based on any category of
124
+ sensitive information." A `trackCustom('DiabetesPlanPurchase')` is a breach with
125
+ an empty parameter object.
126
+
127
+ **The practical rule:** the eleven Advanced Matching keys — `em`, `fn`, `ln`,
128
+ `ph`, `external_id`, `ge`, `db`, `ct`, `st`, `zp`, `country` — are the whole
129
+ allowed surface. Anything you were about to add beside them is the thing to stop
130
+ and check.
131
+
132
+ Source: Meta Business Tools Terms and the Sensitive Health Information help
133
+ centre, read 2026-08-16. Re-read before quoting: the categories move.
134
+
135
+ ### Deduplication with the Conversions API
136
+
137
+ **Send both, and let Meta discard one.** Browser and server events are not an
138
+ either/or — the pixel is blocked often enough that server-side is the reliable
139
+ source, and the browser event carries signals the server cannot see. The whole
140
+ job is making sure the pair collapses into one conversion instead of two.
141
+
142
+ **Two fields must match, not one.** Meta compares:
143
+
144
+ | Pixel | Conversions API |
145
+ |---|---|
146
+ | `eventID` | `event_id` |
147
+ | `event` (the event name) | `event_name` |
148
+
149
+ Both. An integration that generates a shared `event_id` and lets the two sides
150
+ disagree on the name — `Purchase` in the browser, `purchase` on the server — has
151
+ two events that will never deduplicate, and the revenue is counted twice. This is
152
+ the single most common way this goes wrong, because the `event_id` half is
153
+ obvious and the `event_name` half is not.
154
+
155
+ **The window is 48 hours**, and it runs from when Meta receives the *first* event
156
+ carrying a given `event_id` — not from when the purchase happened. A server event
157
+ retried out of a dead-letter queue two days later is a second conversion.
158
+
159
+ ```javascript
160
+ // One id, generated once, used by both sides.
161
+ const eventId = crypto.randomUUID();
162
+
163
+ // Browser
164
+ fbq('track', 'Purchase', { value: 30.00, currency: 'USD' }, { eventID: eventId });
165
+
166
+ // Server — the SAME event_name string, not a variant
167
+ await sendCapi({
168
+ event_name: 'Purchase', // must equal the pixel's 'Purchase' exactly
169
+ event_id: eventId,
170
+ event_time: Math.floor(Date.now() / 1000),
171
+ action_source: 'website',
172
+ user_data: { /* hashed */ },
173
+ custom_data: { value: 30.00, currency: 'USD' },
174
+ });
175
+ ```
176
+
177
+ **The alternative method, and its trap.** Where you cannot thread an id through,
178
+ Meta will also deduplicate on `event_name` plus `fbp` and/or `external_id`, held
179
+ consistent across both sources. It is weaker in a specific direction: *"server
180
+ events will not be discarded if a browser event has not been received in the past
181
+ 48 hours, even if an identical browser event arrives after the server event."*
182
+ So the browser event must arrive **first**. For a purchase confirmed by a webhook
183
+ — which is the pattern this skill teaches, because the webhook is the payment —
184
+ the server event usually arrives first, and this method then does nothing. Use
185
+ `event_id`.
186
+
187
+ Neither method deduplicates within a single source: two pixel fires of the same
188
+ purchase are two conversions no matter what.
189
+
190
+ **Proved, not asserted.** `fixtures/purchase-pixel-browser.json` and
191
+ `fixtures/purchase-capi-server.json` are the two payloads one purchase produces, and
192
+ `fixtures/assert-dedup-contract.mjs` compares them: `pixel-and-capi-carry-one-event-name`
193
+ fails an emitter that sends `Purchase` from the browser and a lowercased variant from the
194
+ server, which is the half of this contract that gets missed. The shape itself is a ratchet —
195
+ `the-shipped-capi-fixture-is-what-a-correct-emitter-sends` requires the emitted body to equal
196
+ the fixture byte for byte, so **the fixtures are the reference output rather than an
197
+ illustration**.
198
+
199
+ Source: [Deduplicate pixel and Conversions API
200
+ events](https://developers.facebook.com/docs/marketing-api/conversions-api/deduplicate-pixel-and-server-events),
201
+ read 2026-08-16.
202
+
203
+ ### LinkedIn conversion tracking
204
+
205
+ **Env var:** `NEXT_PUBLIC_LINKEDIN_PARTNER_ID` (numeric string).
206
+
207
+ The Insight Tag is consent-gated on the same wrapper as the Meta Pixel — it loads
208
+ `insight.min.js` and ships a `noscript` pixel fallback.
209
+
210
+ ```javascript
211
+ _linkedin_partner_id = "PARTNER_ID";
212
+ window._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];
213
+ window._linkedin_data_partner_ids.push(_linkedin_partner_id);
214
+ ```
215
+
216
+ **Conversions are configured in Campaign Manager, not in code.** The Insight Tag
217
+ tracks page views by itself, and URL rules match those page loads to conversions:
218
+
219
+ 1. Campaign Manager → Analyze → Conversion tracking → Create conversion
220
+ 2. Define the URL rules (`/thank-you`, `/onboarding`)
221
+ 3. The tag matches page loads against them
222
+
223
+ For an event-based conversion rather than a URL one:
224
+ `window.lintrk('track', { conversion_id: 123456 })`.
225
+
226
+ **LinkedIn has no equivalent of the `event_id` contract above.** There is no
227
+ browser/server pair to deduplicate here, which is why a URL rule that matches a
228
+ page a user can refresh counts every refresh — put the rule on a page reached once,
229
+ or use `lintrk` from the code path that actually completed the action.
230
+
231
+ **Docs:**
232
+ - [LinkedIn Insight Tag setup](https://www.linkedin.com/help/lms/answer/a418880)
233
+ - [LinkedIn conversion tracking](https://www.linkedin.com/help/lms/answer/a423304)
234
+
78
235
  ### Meta Events Manager Verification
79
236
 
80
237
  1. Install the **Meta Pixel Helper** Chrome extension
@@ -45,6 +45,7 @@ and anything else that issues an invoice and calls you back.
45
45
  - [Reconciliation fields](#reconciliation-fields)
46
46
  - [Crediting: the amount waterfall](#crediting-the-amount-waterfall)
47
47
  - [Refunds and AML holds](#refunds-and-aml-holds)
48
+ - [The test/live credential boundary](#the-testlive-credential-boundary)
48
49
  - [Local development](#local-development)
49
50
  - [Test matrix](#test-matrix)
50
51
  - [Security checklist](#security-checklist)
@@ -309,6 +310,68 @@ Crypto has no chargeback, which people mistake for "no reversals".
309
310
  - **Never auto-refund from the webhook.** Route holds and refunds to a queue a
310
311
  human can see.
311
312
 
313
+ **And make it a precondition, not a paragraph.** The three lines above were advice for
314
+ four releases: an agent that never read them, and a shell nobody read, were unaffected by
315
+ all three. This pack now ships a `PreToolUse` gate —
316
+ `plugins/sheleg-dev/hooks/money-gate.js` — that refuses a refund, a payout, closing a
317
+ dispute, an export of `HELEKET_API_KEY` in a run declaring `test`, a live `sk_live_…` key
318
+ and `SKIP_BILLING=true` in production, until the authorised person has signed that
319
+ category off for the session. The pack's `README.md` carries the categories and how one is
320
+ authorised. In your application the same rule is a queue and a human; in the agent's
321
+ shell it is the hook.
322
+
323
+ ---
324
+
325
+ ## The test/live credential boundary
326
+
327
+ **Establish what the provider actually offers before designing around it.** Card
328
+ processors hand you two parallel worlds — Stripe gives a whole second account and stamps
329
+ the environment into the key itself (`sk_test_` / `sk_live_`). Most crypto gateways do
330
+ not. Answer these three, from the provider's documentation, and write the answers down:
331
+
332
+ 1. **Is there a separate test credential?** Not "a test mode" — a *different secret*.
333
+ 2. **Can the environment be read from the key?** A prefix, a scope, anything.
334
+ 3. **Does the key also sign webhooks?** If yes, it cannot be scoped down: everything that
335
+ verifies a callback can also create a charge.
336
+
337
+ The answers decide the design, and the worst combination is common: one key, no marker,
338
+ "test mode" as a **toggle on the merchant account**. Then the same secret creates play
339
+ invoices before somebody moves a dashboard switch and real ones after — with no deploy and
340
+ no signal to any machine holding it.
341
+
342
+ **Build the strongest control the provider allows, in this order:**
343
+
344
+ 1. **A separate sandbox account**, so the credential on a dev machine *cannot* reach
345
+ production. This is the only control that still works after everyone has forgotten why
346
+ it is there — which is the whole argument for preferring it to a warning in a README.
347
+ 2. **A declared environment plus a boot assertion**, when there is no second credential.
348
+ Keep the declaration in a variable **separate from the secret** (`<PROVIDER>_ENV=test|
349
+ production`): one variable that both carries the key and names its environment cannot
350
+ be checked against itself, two can. Pin the production identity with something
351
+ non-secret — the merchant id, or a truncated hash of the live key — and refuse
352
+ **both** mismatches at **startup**:
353
+
354
+ - a live credential declared test — the obvious loss;
355
+ - a test credential declared live — the quiet one, where invoices settle to a merchant
356
+ nobody reconciles and nothing errors until revenue is missing.
357
+
358
+ Assert at module load, not in the payment handler: a run that merely *holds* the key
359
+ must fail too, and it is agent and CI runs that hold keys without ever charging
360
+ anything. And when the check cannot tell — nothing pinned — refuse on the test side.
361
+ "Could not prove it was safe" must never read as "it was safe".
362
+ 3. **Write down what is left.** Where the provider offers no test credential, a developer
363
+ holds a production one and the assertion narrows that window without closing it. An
364
+ unavoidable exposure that is named is a different object from one that is silent: the
365
+ first gets rotated, monitored and revisited, the second gets rediscovered by an
366
+ incident.
367
+
368
+ The same rule applies to the mock path below: `SKIP_BILLING=true` in production is not a
369
+ shortcut, it is a free-money path, and the assertion is what refuses it.
370
+
371
+ Worked end to end for one gateway with no test credential at all —
372
+ [`references/heleket-provider.md`](references/heleket-provider.md), *The test/live
373
+ boundary* and *Residual exposure*.
374
+
312
375
  ---
313
376
 
314
377
  ## Local development
@@ -363,6 +426,9 @@ Cover these, and plant a defect against each before believing the green:
363
426
  - [ ] CSRF exempted for exactly one path, by exact match
364
427
  - [ ] Entitlement never granted from a client-side redirect
365
428
  - [ ] API key and merchant id from environment, never in the repository
429
+ - [ ] A sandbox account is used where the provider offers one, or the reason it cannot be is written down
430
+ - [ ] The declared environment is a variable separate from the secret, and a boot assertion refuses **both** mismatches
431
+ - [ ] The assertion runs at module load, so a run that only holds the key fails too
366
432
  - [ ] Callback URL is HTTPS in every environment that is not a local tunnel
367
433
  - [ ] `SKIP_BILLING` asserted impossible in production
368
434
  - [ ] Reconciliation fields written at settlement time
@@ -382,3 +448,5 @@ Cover these, and plant a defect against each before believing the green:
382
448
  | "Paid" users with no product | credited from the browser redirect |
383
449
  | Half the payments land as `wrong_amount` | no conversion buffer |
384
450
  | Cannot answer "how much did we net" | commission and `merchantAmount` never stored |
451
+ | A dev machine, a CI job or an agent run is holding the production key | no separate credential, and no assertion binding the key to a declared environment |
452
+ | Real invoices in a merchant dashboard nobody watches | a staging credential declared live — the mismatch nothing refused |