@ssheleg/sheleg-dev 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/SECURITY.md +63 -0
  5. package/bin/sheleg-dev.js +99 -0
  6. package/package.json +45 -0
  7. package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
  8. package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
  9. package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
  10. package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
  11. package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
  12. package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
  13. package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
  14. package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
  15. package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
  16. package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
  17. package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
  18. package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
  19. package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
  20. package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
  21. package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
  22. package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
  23. package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
  24. package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
  25. package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
  26. package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
  27. package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
  28. package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
  29. package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
  30. package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
  31. package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
  32. package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +211 -0
@@ -0,0 +1,288 @@
1
+ # GA4 Performance & Security — Reference
2
+
3
+
4
+ ## Contents
5
+
6
+ - [Table of Contents](#table-of-contents)
7
+ - [Script Loading Strategies](#script-loading-strategies)
8
+ - [Content Security Policy](#content-security-policy)
9
+ - [Next.js / React Integration](#nextjs--react-integration)
10
+ - [SPA (Single Page Application) Handling](#spa-single-page-application-handling)
11
+ - [Debug & Testing](#debug--testing)
12
+ - [Common Mistakes](#common-mistakes)
13
+ - [Multi-Page Consistency](#multi-page-consistency)
14
+
15
+ ## Table of Contents
16
+
17
+ - [Script Loading Strategies](#script-loading-strategies)
18
+ - [Content Security Policy](#content-security-policy)
19
+ - [SPA (Single Page Application) Handling](#spa-single-page-application-handling)
20
+ - [Debug & Testing](#debug--testing)
21
+ - [Common Mistakes](#common-mistakes)
22
+ - [Multi-Page Consistency](#multi-page-consistency)
23
+
24
+ ## Script Loading Strategies
25
+
26
+ ### Standard Async (default)
27
+
28
+ ```html
29
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
30
+ <script>
31
+ gtag('js', new Date());
32
+ gtag('config', 'G-XXXXXXXXXX');
33
+ </script>
34
+ ```
35
+
36
+ - Downloads in parallel, executes as soon as available
37
+ - Best for: static pages, marketing sites, pages where analytics are important
38
+
39
+ ### Deferred (post-load)
40
+
41
+ ```html
42
+ <script>
43
+ window.addEventListener('load', function() {
44
+ var s = document.createElement('script');
45
+ s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
46
+ s.async = true;
47
+ s.onload = function() {
48
+ gtag('js', new Date());
49
+ gtag('config', 'G-XXXXXXXXXX');
50
+ };
51
+ document.head.appendChild(s);
52
+ });
53
+ </script>
54
+ ```
55
+
56
+ - Loads after all critical resources (HTML, CSS, fonts, images)
57
+ - Best for: SPA main shells, app pages where UX is priority
58
+ - Events queued in `dataLayer` before load fire once the script arrives
59
+
60
+ ### Preconnect Hint
61
+
62
+ Add in `<head>` to reduce DNS/TLS latency:
63
+
64
+ ```html
65
+ <link rel="preconnect" href="https://www.googletagmanager.com" crossorigin>
66
+ <link rel="preconnect" href="https://www.google-analytics.com" crossorigin>
67
+ ```
68
+
69
+ Use with deferred loading for best of both worlds.
70
+
71
+ ### Performance Impact
72
+
73
+ | Strategy | LCP impact | TBT impact | Data completeness |
74
+ |----------|-----------|-----------|-------------------|
75
+ | Standard async | Small | Minimal | Full |
76
+ | Deferred (post-load) | None | None | Slight first-hit delay |
77
+ | No preconnect | +100-200ms cold | — | Full |
78
+
79
+ ## Content Security Policy
80
+
81
+ ### Minimal CSP Directives for GA4
82
+
83
+ ```
84
+ script-src 'self' https://www.googletagmanager.com;
85
+ img-src https://www.googletagmanager.com;
86
+ connect-src https://www.google-analytics.com https://analytics.google.com;
87
+ ```
88
+
89
+ ### With Nonce (Recommended for Inline Scripts)
90
+
91
+ Server generates a unique nonce per request:
92
+
93
+ ```html
94
+ <script nonce="RANDOM_NONCE_VALUE">
95
+ window.dataLayer = window.dataLayer || [];
96
+ function gtag(){dataLayer.push(arguments);}
97
+ // ... consent defaults ...
98
+ </script>
99
+ ```
100
+
101
+ CSP header:
102
+
103
+ ```
104
+ script-src 'nonce-RANDOM_NONCE_VALUE' https://www.googletagmanager.com;
105
+ ```
106
+
107
+ ### EU Region-Specific Endpoints
108
+
109
+ For EU users, Google may route to regional endpoints. Add:
110
+
111
+ ```
112
+ connect-src https://www.google-analytics.com
113
+ https://analytics.google.com
114
+ https://region1.google-analytics.com;
115
+ ```
116
+
117
+ ### Avoid `'unsafe-inline'`
118
+
119
+ Using nonces or hashes is strongly preferred. Only fall back to `'unsafe-inline'` if your
120
+ server cannot generate nonces (e.g. static hosting with no server-side rendering).
121
+
122
+ For static sites: move all inline GA code to an external file (e.g. `/js/analytics.js`)
123
+ and use `'self'` in `script-src`.
124
+
125
+ ## Next.js / React Integration
126
+
127
+ ### Script Loading with next/script
128
+
129
+ ```tsx
130
+ import Script from 'next/script';
131
+
132
+ // beforeInteractive — runs in <head> before hydration
133
+ <Script id="gtag-consent" strategy="beforeInteractive"
134
+ dangerouslySetInnerHTML={{ __html: `...consent defaults...` }} />
135
+
136
+ // afterInteractive — runs after page becomes interactive (default)
137
+ <Script src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXX"
138
+ strategy="afterInteractive" />
139
+
140
+ // lazyOnload — runs during idle time (lowest priority)
141
+ <Script src="..." strategy="lazyOnload" />
142
+ ```
143
+
144
+ | Strategy | Runs when | Use for |
145
+ |----------|-----------|---------|
146
+ | `beforeInteractive` | In `<head>`, before any JS | Consent defaults, dataLayer stub |
147
+ | `afterInteractive` | After hydration | gtag.js load + config |
148
+ | `lazyOnload` | Browser idle | Non-critical third-party scripts |
149
+
150
+ ### Docker Build-Time Variables
151
+
152
+ `NEXT_PUBLIC_*` variables are statically inlined during `npm run build`.
153
+ If building in Docker, declare them as build args:
154
+
155
+ ```dockerfile
156
+ # In the builder stage, BEFORE npm run build
157
+ ARG NEXT_PUBLIC_GA_MEASUREMENT_ID
158
+ ENV NEXT_PUBLIC_GA_MEASUREMENT_ID=$NEXT_PUBLIC_GA_MEASUREMENT_ID
159
+ RUN npm run build
160
+ ```
161
+
162
+ Without this, the variable resolves to `undefined` in the production bundle.
163
+
164
+ ### Preconnect in Next.js
165
+
166
+ Add preconnect hints in the root layout metadata:
167
+
168
+ ```tsx
169
+ export const metadata: Metadata = {
170
+ other: {
171
+ 'link': [
172
+ { rel: 'preconnect', href: 'https://www.googletagmanager.com', crossOrigin: 'anonymous' },
173
+ { rel: 'preconnect', href: 'https://www.google-analytics.com', crossOrigin: 'anonymous' },
174
+ ]
175
+ }
176
+ };
177
+ ```
178
+
179
+ Or use `<link>` directly in the layout's `<head>` (if using a custom `<head>`).
180
+
181
+ ## SPA (Single Page Application) Handling
182
+
183
+ GA4 with gtag.js tracks `page_view` automatically via the browser History API.
184
+ If your SPA uses hash routing or custom navigation, send manual page views:
185
+
186
+ ```javascript
187
+ // On route change
188
+ gtag('event', 'page_view', {
189
+ page_title: document.title,
190
+ page_location: window.location.href,
191
+ page_path: window.location.pathname
192
+ });
193
+ ```
194
+
195
+ ### When Manual page_view Is Needed
196
+
197
+ | Routing type | Auto page_view works? | Manual needed? |
198
+ |-------------|----------------------|----------------|
199
+ | Full page reload | Yes | No |
200
+ | History API (`pushState`) | Yes (enhanced measurement) | Usually no |
201
+ | Hash routing (`#/path`) | No | Yes |
202
+ | Custom SPA router | Depends | Test with DebugView |
203
+
204
+ ### Consent on SPA Route Changes
205
+
206
+ Consent state persists across SPA route changes — no need to re-initialize.
207
+ Only re-initialize consent defaults on full page reloads.
208
+
209
+ ## Debug & Testing
210
+
211
+ ### GA4 DebugView
212
+
213
+ Enable for the current session:
214
+
215
+ ```javascript
216
+ gtag('config', 'G-XXXXXXXXXX', { 'debug_mode': true });
217
+ ```
218
+
219
+ Or via URL parameter: `?debug_mode=true` (requires allowlisting in GA4 Admin).
220
+
221
+ View in: GA4 > Admin > DebugView.
222
+
223
+ ### Tag Assistant
224
+
225
+ 1. Visit [tagassistant.google.com](https://tagassistant.google.com)
226
+ 2. Connect your site
227
+ 3. Check:
228
+ - Tags firing correctly
229
+ - Consent state per tag
230
+ - Event parameters
231
+
232
+ ### Browser DevTools Checks
233
+
234
+ ```javascript
235
+ // Verify dataLayer contents
236
+ console.table(dataLayer);
237
+
238
+ // Check consent state
239
+ dataLayer.filter(item => item[0] === 'consent');
240
+
241
+ // Verify cookies (should be absent when denied)
242
+ document.cookie.split(';').filter(c => c.trim().startsWith('_ga'));
243
+
244
+ // Check localStorage
245
+ localStorage.getItem('app_cookie_consent');
246
+ ```
247
+
248
+ ### Network Tab Verification
249
+
250
+ Filter by `google-analytics.com` or `googletagmanager.com`:
251
+ - `collect` requests = measurement pings
252
+ - Check payload for `_gcs` parameter (consent state encoding)
253
+ - `gcs=G111` = all granted; `gcs=G100` = only analytics granted
254
+
255
+ ## Common Mistakes
256
+
257
+ | Mistake | Impact | Fix |
258
+ |---------|--------|-----|
259
+ | Consent defaults after gtag.js loads | First pageview ignores consent | Move defaults before `<script async src="...">` |
260
+ | Missing `wait_for_update` | CMP loads too late, data lost | Add `wait_for_update: 500` |
261
+ | No `gtag()` stub before consent call | `gtag is not defined` error | Define `function gtag(){dataLayer.push(arguments)}` first |
262
+ | Hardcoded measurement ID in many files | Painful to change | Use a config file, env var, or template variable |
263
+ | Not calling `gtag('consent','update')` on decline | Consent state stays in limbo | Always update on both accept and decline |
264
+ | Banner shows on every page load | Broken localStorage check | Verify `getItem` returns the correct key |
265
+ | No way to re-open consent banner | GDPR non-compliance | Expose `window.appCookieSettings()` or footer link |
266
+ | Tracking events on localhost | Polluted production data | Guard with hostname check in `trackEvent` |
267
+ | Custom event names in camelCase | Inconsistent with GA4 conventions | Use `snake_case` |
268
+ | Forgetting custom dimension setup | Custom params invisible in reports | Create dimension in GA4 Admin for each custom param |
269
+
270
+ ## Multi-Page Consistency
271
+
272
+ When GA4 is implemented across multiple HTML pages (not a SPA):
273
+
274
+ 1. **Use identical consent default blocks** in every page's `<head>`
275
+ 2. **Share the same localStorage key** for consent across all pages
276
+ 3. **Use the same measurement ID** everywhere
277
+ 4. **Load `consent.js` on every page** (or its minified version)
278
+ 5. **Consider a shared snippet file** (or server-side include) to avoid drift
279
+
280
+ ### Minification
281
+
282
+ Include `consent.js` in build/minification pipeline:
283
+
284
+ ```bash
285
+ terser js/consent.js -o js/consent.min.js -c -m
286
+ ```
287
+
288
+ Use the minified version in production, source version in development.
@@ -0,0 +1,384 @@
1
+ ---
2
+ name: crypto-payments
3
+ description: >-
4
+ Use when adding or auditing crypto checkout, crypto top-up, or payment webhooks — where a
5
+ payer sends the wrong amount, a webhook arrives more than once, and the rate moves between
6
+ quote and transfer. Covers invoice lifecycle and status mapping, webhook signature
7
+ verification and the JSON-escaping trap, idempotent processing, IP allowlisting behind a
8
+ proxy, CSRF exemption for callback routes, the conversion buffer, reconciliation fields,
9
+ credit waterfalls, refund and AML-hold states, local development with a tunnel and signed mock
10
+ callbacks, a test matrix and a security checklist. Triggers - "crypto payment", "crypto
11
+ checkout", "pay with crypto", "USDT payment", "TRC20", "payment webhook", "IPN", "webhook
12
+ signature", "underpayment", "Heleket", "NOWPayments", "приём криптоплатежей", "оплата
13
+ криптой", "вебхук платежа", "недоплата". Not for card billing — use stripe-billing.
14
+ ---
15
+
16
+ # Crypto payments
17
+
18
+ A card charge either succeeds or fails, and the amount is the amount. A crypto
19
+ payment is a **request that someone may partially satisfy, over-satisfy, satisfy
20
+ late, or satisfy after the price moved**. Almost every defect in a crypto
21
+ checkout comes from writing card-shaped code and meeting one of those cases in
22
+ production.
23
+
24
+ This skill is provider-neutral. Concrete request/response shapes for one gateway
25
+ live in [`references/heleket-provider.md`](references/heleket-provider.md);
26
+ the invariants below hold for Coinbase Commerce, NOWPayments, BTCPay, Heleket
27
+ and anything else that issues an invoice and calls you back.
28
+
29
+ > **Choosing a provider is a business and compliance decision, not a technical
30
+ > one.** Crypto payment processors differ sharply in regulatory standing —
31
+ > licensing, AML programme, sanctions exposure — and that standing changes.
32
+ > Check the current position of any processor before you route customer money
33
+ > through it, and re-check it periodically. This skill tells you how to
34
+ > integrate one correctly; it does not tell you which one to trust.
35
+
36
+ ## Contents
37
+
38
+ - [The lifecycle, and where money goes missing](#the-lifecycle-and-where-money-goes-missing)
39
+ - [Status mapping — and finality](#status-mapping--and-finality)
40
+ - [Webhook signature verification](#webhook-signature-verification)
41
+ - [Idempotent webhook processing](#idempotent-webhook-processing)
42
+ - [IP allowlisting behind a proxy](#ip-allowlisting-behind-a-proxy)
43
+ - [CSRF exemption for callback routes](#csrf-exemption-for-callback-routes)
44
+ - [The conversion buffer](#the-conversion-buffer)
45
+ - [Reconciliation fields](#reconciliation-fields)
46
+ - [Crediting: the amount waterfall](#crediting-the-amount-waterfall)
47
+ - [Refunds and AML holds](#refunds-and-aml-holds)
48
+ - [Local development](#local-development)
49
+ - [Test matrix](#test-matrix)
50
+ - [Security checklist](#security-checklist)
51
+ - [Common pitfalls](#common-pitfalls)
52
+
53
+ ---
54
+
55
+ ## The lifecycle, and where money goes missing
56
+
57
+ ```
58
+ your app gateway blockchain
59
+ │ create invoice ─────►│
60
+ │◄──── invoice id, address, amount, expiry
61
+ │ │
62
+ │ (user pays) ────────┼──────────────────────►│
63
+ │ │◄── confirmations ─────│
64
+ │◄──── webhook: status change (MAY REPEAT)
65
+ │ credit the user │
66
+ │◄──── webhook: status change (again, later)
67
+ ```
68
+
69
+ Three facts that shape every design decision:
70
+
71
+ 1. **The callback is at-least-once.** Providers retry until you 200. A handler
72
+ that credits on every delivery credits three times.
73
+ 2. **The paid amount is not the invoiced amount.** Underpayment, overpayment and
74
+ "paid a different currency than quoted" are normal states, not errors.
75
+ 3. **Status is not monotonic in the way you expect.** A payment can go
76
+ `pending → paid → refund_process → refund_paid`, or sit in an AML hold for
77
+ days. Model the terminal set explicitly.
78
+
79
+ **Never derive entitlement from the redirect back to your site.** The user's
80
+ browser returning to `/success` proves the user has a browser. Credit on the
81
+ webhook, or on a server-side status poll — never on a client-side landing.
82
+
83
+ ---
84
+
85
+ ## Status mapping — and finality
86
+
87
+ Every gateway has its own vocabulary. Map it to **your** states once, in one
88
+ function, and define the terminal set explicitly:
89
+
90
+ ```ts
91
+ const FINAL_STATUSES = ['PAID', 'FAILED', 'REFUNDED', 'EXPIRED'] as const;
92
+
93
+ function mapStatus(providerStatus: string): PaymentStatus {
94
+ switch (providerStatus) {
95
+ case 'paid':
96
+ case 'paid_over': return 'PAID'; // over-payment still pays
97
+ case 'wrong_amount': return 'UNDERPAID'; // partial — DO NOT credit in full
98
+ case 'confirm_check': return 'CONFIRMING'; // seen, not yet confirmed
99
+ case 'cancel':
100
+ case 'fail':
101
+ case 'system_fail': return 'FAILED';
102
+ case 'refund_process': return 'REFUNDING';
103
+ case 'refund_paid': return 'REFUNDED';
104
+ case 'locked': return 'AML_HOLD'; // compliance review, may take days
105
+ default: return 'PENDING';
106
+ }
107
+ }
108
+ ```
109
+
110
+ `paid_over` is the one people get wrong in both directions: treating it as a
111
+ failure loses a paying customer, treating it as exactly-paid quietly gives away
112
+ the excess. Credit the **received** amount, not the invoiced one — see the
113
+ waterfall below.
114
+
115
+ ---
116
+
117
+ ## Webhook signature verification
118
+
119
+ The callback is an unauthenticated public endpoint until you prove otherwise.
120
+
121
+ ```ts
122
+ import { createHash, timingSafeEqual } from 'node:crypto';
123
+
124
+ function sign(payload: unknown, apiKey: string): string {
125
+ const json = JSON.stringify(payload);
126
+ return createHash('md5')
127
+ .update(Buffer.from(json).toString('base64') + apiKey)
128
+ .digest('hex');
129
+ }
130
+
131
+ function verify(body: Record<string, unknown>, apiKey: string): boolean {
132
+ const { sign: given, ...rest } = body; // signature is never part of the signed payload
133
+ if (typeof given !== 'string') return false;
134
+ const expected = sign(rest, apiKey);
135
+ const a = Buffer.from(expected, 'utf8');
136
+ const b = Buffer.from(given, 'utf8');
137
+ return a.length === b.length && timingSafeEqual(a, b); // length check FIRST — timingSafeEqual throws on mismatch
138
+ }
139
+ ```
140
+
141
+ Three rules, each of which has burned a real integration:
142
+
143
+ - **Compare in constant time.** `===` on a signature leaks it a byte at a time.
144
+ And check lengths first: `timingSafeEqual` *throws* on unequal buffers, so a
145
+ naive call turns a forged signature into a 500 instead of a 403.
146
+ - **Sign exactly what the provider signed.** Re-serializing the parsed body can
147
+ change it. Keep the raw body if the framework lets you.
148
+ - **Mind the escaping.** Some gateways (PHP-based ones especially) sign JSON
149
+ produced by `json_encode`, which escapes forward slashes as `\/` and
150
+ non-ASCII as `\uXXXX`. `JSON.stringify` does neither. If your signature is
151
+ correct for payloads with no URL and wrong for payloads containing one, this
152
+ is why. **Try both encodings and accept either** — the alternative is
153
+ rejecting real callbacks:
154
+
155
+ ```ts
156
+ const candidates = [json, json.replace(/\//g, '\\/')];
157
+ return candidates.some((c) => timingSafeCompare(md5(b64(c) + key), given));
158
+ ```
159
+
160
+ Verify the signature **before** parsing anything into your domain, and return
161
+ 403 without detail on failure. A verbose error is an oracle.
162
+
163
+ ---
164
+
165
+ ## Idempotent webhook processing
166
+
167
+ Do not read-then-write. Make the database refuse the second delivery:
168
+
169
+ ```ts
170
+ const { count } = await db.payment.updateMany({
171
+ where: {
172
+ invoiceId,
173
+ status: { notIn: FINAL_STATUSES }, // ← the guard: a settled payment cannot re-settle
174
+ },
175
+ data: { status: mapped, paidAmount, txid, network, settledAt: new Date() },
176
+ });
177
+
178
+ if (count === 0) {
179
+ // already final: a retry, or a late duplicate. Acknowledge and do nothing.
180
+ return res.status(200).json({ ok: true, duplicate: true });
181
+ }
182
+
183
+ await creditUser(...); // only reachable once per invoice
184
+ ```
185
+
186
+ `updateMany` + a status guard is a compare-and-swap. `findFirst` followed by
187
+ `update` is a race, and the two deliveries that arrive 40ms apart will both pass
188
+ the read.
189
+
190
+ **Always return 200 for a duplicate.** A 409 makes the provider retry forever.
191
+
192
+ ---
193
+
194
+ ## IP allowlisting behind a proxy
195
+
196
+ Signature verification is the real gate; the allowlist is defence in depth and
197
+ cheap. It is also the single most common cause of "webhooks stopped working
198
+ after we moved to a load balancer".
199
+
200
+ ```ts
201
+ function callerIp(req): string | null {
202
+ // Trust ONLY the hop your own infrastructure appends.
203
+ const xff = req.headers['x-forwarded-for'];
204
+ if (typeof xff === 'string') {
205
+ const hops = xff.split(',').map((s) => s.trim());
206
+ return hops[hops.length - TRUSTED_PROXY_COUNT] ?? null;
207
+ }
208
+ return req.socket.remoteAddress ?? null;
209
+ }
210
+ ```
211
+
212
+ Taking `xff[0]` trusts a header the client controls — anyone can claim to be the
213
+ gateway. Count from the right by the number of proxies you actually run, and
214
+ keep that number in configuration, because it changes when infrastructure does.
215
+
216
+ Allowlist the provider's published callback IPs, log a rejection with the
217
+ observed IP, and **never** fall back to "allow if the header is missing".
218
+
219
+ ---
220
+
221
+ ## CSRF exemption for callback routes
222
+
223
+ A gateway cannot present your CSRF token. Exempt the callback path explicitly
224
+ and narrowly:
225
+
226
+ ```ts
227
+ // middleware.ts (Next.js)
228
+ export const config = { matcher: ['/((?!api/payments/webhook).*)'] };
229
+ ```
230
+
231
+ Exempt the **one** path, by exact match, and make it the only route in your app
232
+ that skips CSRF. A prefix match on `/api/payments` exempts the checkout endpoint
233
+ too, which is where the money is.
234
+
235
+ ---
236
+
237
+ ## The conversion buffer
238
+
239
+ You quote in USD. The user pays in a coin whose rate moves between your quote
240
+ and their transfer. Without a margin, a rate move of a fraction of a percent
241
+ turns a full payment into `wrong_amount`, and now you are handling a partial
242
+ payment for no reason.
243
+
244
+ ```ts
245
+ const BUFFER = 0.01; // 1%
246
+ const invoiceAmount = round(amountUsd * (1 + BUFFER), 2);
247
+ ```
248
+
249
+ Two rules:
250
+
251
+ - **Buffer the invoice, credit the intent.** Charge the buffered amount, credit
252
+ the user for what they meant to buy. The buffer covers drift; it is not
253
+ revenue and should not appear in what the user is told they bought.
254
+ - **Store both numbers.** `amountUsd` (intent) and `invoicedAmount` (what you
255
+ asked for) are different, and support questions are unanswerable without both.
256
+
257
+ Pick the buffer from observed rate volatility for the coins you accept, not from
258
+ this document.
259
+
260
+ ---
261
+
262
+ ## Reconciliation fields
263
+
264
+ Six months later someone asks "did this payment actually arrive, and how much
265
+ did we net". Store enough to answer it **at webhook time** — the gateway's
266
+ retention is not your retention:
267
+
268
+ | Field | Why |
269
+ |---|---|
270
+ | `merchantAmount` | what you net after the gateway's commission — never equals the invoice |
271
+ | `paidAmount` + `payerCurrency` | what actually arrived, in what coin |
272
+ | `paidAmountUsd` | the gateway's own USD valuation at settlement |
273
+ | `commission` | so net vs gross is arithmetic, not archaeology |
274
+ | `txid` | the only link to the chain; without it a dispute is unresolvable |
275
+ | `network` | TRC20 vs ERC20 vs BEP20 — same coin, different chain, different fee |
276
+ | `from` | payer address, for AML questions you will eventually be asked |
277
+
278
+ Write them on the settling webhook, in the same update that flips the status.
279
+
280
+ ---
281
+
282
+ ## Crediting: the amount waterfall
283
+
284
+ What do you credit when the numbers disagree? Prefer the most authoritative,
285
+ fall back in a fixed order, and log which one won:
286
+
287
+ ```ts
288
+ const credit = payment.paidAmountUsd // gateway's valuation of what arrived
289
+ ?? payment.tokenAmount // what the plan says this purchase grants
290
+ ?? payment.amountUsd; // the original intent — last resort
291
+ ```
292
+
293
+ The order matters: valuing an over-payment at the intent silently keeps the
294
+ excess, and valuing an under-payment at the intent gives away product. Write an
295
+ audit row naming the source, or the first disputed balance is unprovable.
296
+
297
+ ---
298
+
299
+ ## Refunds and AML holds
300
+
301
+ Crypto has no chargeback, which people mistake for "no reversals".
302
+
303
+ - **Refunds are a multi-step state**, not an event: `refund_process` →
304
+ `refund_paid` | `refund_fail`. A user is not refunded when you asked for it.
305
+ - **AML holds are open-ended.** A `locked` payment may resolve in hours or
306
+ never. Surface it honestly ("under review by the payment provider"), do not
307
+ credit, do not auto-cancel, and do not retry the invoice — a second invoice
308
+ during a hold is how you end up with two payments and one product.
309
+ - **Never auto-refund from the webhook.** Route holds and refunds to a queue a
310
+ human can see.
311
+
312
+ ---
313
+
314
+ ## Local development
315
+
316
+ Two paths, and you want both:
317
+
318
+ **Skip the gateway.** A `SKIP_BILLING=true` branch that credits immediately and
319
+ logs loudly. Everyone building a feature that merely *touches* checkout should
320
+ use this. Make it impossible in production: assert `NODE_ENV !== 'production'`
321
+ at the branch, not just in config.
322
+
323
+ **Real gateway, tunnelled callback.** Callbacks cannot reach `localhost`:
324
+
325
+ ```bash
326
+ cloudflared tunnel --url http://localhost:3000
327
+ # → https://random-name.trycloudflare.com → set as the callback base URL, restart the app
328
+ ```
329
+
330
+ Then keep a **signed mock callback** in your test tooling — a script that builds
331
+ a real payload, signs it with the dev key and POSTs it. This is what makes the
332
+ sad paths (`wrong_amount`, `refund_fail`, `locked`) testable at all; you cannot
333
+ ask a gateway to underpay you on demand.
334
+
335
+ ---
336
+
337
+ ## Test matrix
338
+
339
+ Cover these, and plant a defect against each before believing the green:
340
+
341
+ | Case | What it catches |
342
+ |---|---|
343
+ | valid signature | the happy path |
344
+ | tampered signature | constant-time compare, 403 not 500 |
345
+ | signature over a payload containing a URL | the escaping trap |
346
+ | duplicate delivery of a settling status | idempotency guard |
347
+ | `paid_over` | crediting the received amount, not the invoiced |
348
+ | `wrong_amount` | no full credit on a partial payment |
349
+ | out-of-order delivery (final, then earlier) | the `notIn` guard, not timestamps |
350
+ | callback from a non-allowlisted IP | proxy-aware extraction |
351
+ | `refund_process` then `refund_fail` | refund is a state machine |
352
+ | `locked` | no credit, no auto-cancel |
353
+
354
+ ---
355
+
356
+ ## Security checklist
357
+
358
+ - [ ] Signature verified before any domain parsing; constant-time compare; lengths checked first
359
+ - [ ] Both JSON escapings accepted, or the raw body signed
360
+ - [ ] Webhook idempotent via a compare-and-swap on a non-final status
361
+ - [ ] Duplicates answered 200, never 409
362
+ - [ ] IP allowlist counts proxy hops from the right; no allow-on-missing-header
363
+ - [ ] CSRF exempted for exactly one path, by exact match
364
+ - [ ] Entitlement never granted from a client-side redirect
365
+ - [ ] API key and merchant id from environment, never in the repository
366
+ - [ ] Callback URL is HTTPS in every environment that is not a local tunnel
367
+ - [ ] `SKIP_BILLING` asserted impossible in production
368
+ - [ ] Reconciliation fields written at settlement time
369
+ - [ ] Refunds and AML holds routed to a human, never automated from the webhook
370
+
371
+ ---
372
+
373
+ ## Common pitfalls
374
+
375
+ | Symptom | Cause |
376
+ |---|---|
377
+ | Signature valid for some payloads, invalid for others | forward-slash / unicode escaping difference |
378
+ | 500s on forged callbacks | `timingSafeEqual` on unequal buffers throws |
379
+ | User credited two or three times | read-then-write instead of compare-and-swap |
380
+ | Provider retries forever | duplicate answered with 409 |
381
+ | Webhooks died after an infra change | `x-forwarded-for` hop counting |
382
+ | "Paid" users with no product | credited from the browser redirect |
383
+ | Half the payments land as `wrong_amount` | no conversion buffer |
384
+ | Cannot answer "how much did we net" | commission and `merchantAmount` never stored |