@ssheleg/sheleg-dev 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +172 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/SECURITY.md +63 -0
- package/bin/sheleg-dev.js +99 -0
- package/package.json +45 -0
- package/plugins/sheleg-dev/.claude-plugin/plugin.json +28 -0
- package/plugins/sheleg-dev/skills/ad-tracking/SKILL.md +442 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/consent-mode.md +257 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/event-tracking.md +201 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/gtag-api.md +389 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/meta-linkedin.md +103 -0
- package/plugins/sheleg-dev/skills/ad-tracking/references/performance-security.md +288 -0
- package/plugins/sheleg-dev/skills/crypto-payments/SKILL.md +384 -0
- package/plugins/sheleg-dev/skills/crypto-payments/references/heleket-provider.md +1357 -0
- package/plugins/sheleg-dev/skills/frontend-performance/SKILL.md +147 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/accessibility-perf.md +172 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/css-performance.md +180 -0
- package/plugins/sheleg-dev/skills/frontend-performance/references/nextjs.md +271 -0
- package/plugins/sheleg-dev/skills/google-auth/SKILL.md +275 -0
- package/plugins/sheleg-dev/skills/google-auth/references/adc-and-service-accounts.md +464 -0
- package/plugins/sheleg-dev/skills/google-auth/references/oauth2-web-server.md +615 -0
- package/plugins/sheleg-dev/skills/google-auth/references/sign-in-with-google.md +593 -0
- package/plugins/sheleg-dev/skills/google-auth/references/workload-identity.md +376 -0
- package/plugins/sheleg-dev/skills/google-signin/SKILL.md +121 -0
- package/plugins/sheleg-dev/skills/google-signin/references/full-guide.md +421 -0
- package/plugins/sheleg-dev/skills/stripe-billing/SKILL.md +458 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/price-integrity.md +201 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/stripe-agent-toolchain.md +173 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/subscription-lifecycle.md +294 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/testing-and-local-dev.md +214 -0
- package/plugins/sheleg-dev/skills/stripe-billing/references/webhook-events.md +211 -0
|
@@ -0,0 +1,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 |
|