@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,1357 @@
1
+ # Heleket — concrete request and response shapes
2
+
3
+ One gateway, worked end to end. Read this **after**
4
+ [`../SKILL.md`](../SKILL.md): the invariants there are what matter, this is what
5
+ one provider's wire format looks like when you apply them.
6
+
7
+ ## Contents
8
+
9
+ - [1. What is Heleket and when to use it](#1-what-is-heleket-and-when-to-use-it)
10
+ - [2. Architecture overview](#2-architecture-overview)
11
+ - [3. Environment variables](#3-environment-variables)
12
+ - [4. Database schema (Prisma)](#4-database-schema-prisma)
13
+ - [5. Heleket REST API reference](#5-heleket-rest-api-reference)
14
+ - [6. Webhook (IPN) payload reference](#6-webhook-ipn-payload-reference)
15
+ - [7. Implementation — client library (`src/lib/heleket.ts`)](#7-implementation--client-library-srclibheleketts)
16
+ - [8. Implementation — checkout endpoint](#8-implementation--checkout-endpoint)
17
+ - [9. Implementation — webhook endpoint](#9-implementation--webhook-endpoint)
18
+ - [10. Status endpoint (for client polling UX)](#10-status-endpoint-for-client-polling-ux)
19
+ - [11. The 1% buffer pattern (read this section before adapting)](#11-the-1-buffer-pattern-read-this-section-before-adapting)
20
+ - [12. Reconciliation fields (always store)](#12-reconciliation-fields-always-store)
21
+ - [13. Refund handling](#13-refund-handling)
22
+ - [14. Integration with subscriptions / credits](#14-integration-with-subscriptions--credits)
23
+ - [15. Local development & SKIP_BILLING](#15-local-development--skip_billing)
24
+ - [16. Testing strategy](#16-testing-strategy)
25
+ - [17. Security checklist](#17-security-checklist)
26
+ - [18. Common pitfalls & troubleshooting](#18-common-pitfalls--troubleshooting)
27
+ - [19. Step-by-step playbook for a NEW project](#19-step-by-step-playbook-for-a-new-project)
28
+
29
+
30
+ > **Provider standing is not a technical property and it changes.** Heleket is
31
+ > the successor/parallel service of Cryptomus, which was fined by Canada's
32
+ > FINTRAC in October 2025 over anti-money-laundering and terrorist-financing
33
+ > compliance (reported by TRM Labs and the International Journalism Foundation,
34
+ > checked 2026-08-06). That is a compliance and reputational question for
35
+ > whoever routes customer money, and it is not answered by this document.
36
+ > Verify the current position of any processor before integrating it, and treat
37
+ > the sections below as a worked example of the patterns rather than a
38
+ > recommendation.
39
+
40
+ ---
41
+
42
+ Battle-tested integration playbook based on the Sasai production implementation. Heleket
43
+ (`api.heleket.com`) is the modern rebrand of **Cryptomus** — the API surface is identical,
44
+ so this guide also applies if you see legacy `cryptomus.com` documentation.
45
+
46
+ This skill gives the agent everything needed to add crypto payments to a fresh Next.js +
47
+ Prisma project end-to-end, or to debug an existing integration.
48
+
49
+ ---
50
+
51
+ ## 1. What is Heleket and when to use it
52
+
53
+ **Heleket** is a non-custodial crypto payment processor. You create a USD-denominated
54
+ invoice via REST API; the user pays in any supported coin/network; Heleket converts at
55
+ spot rate and settles to your merchant wallet in your chosen target coin (typically
56
+ **USDT**). You receive a webhook (callback) when the payment status changes.
57
+
58
+ | Property | Value |
59
+ |----------|-------|
60
+ | API base | `https://api.heleket.com/v1` |
61
+ | Default webhook IP | `31.133.220.8` (single static IP) |
62
+ | Auth | Per-merchant API key + `merchant` UUID header |
63
+ | Signature | MD5 over base64 of JSON body, suffixed with API key |
64
+ | Dashboard | https://heleket.com → Merchant settings |
65
+ | Settles to | USDT, USDC, BTC, ETH, BNB, TRX, etc. (configurable per invoice) |
66
+ | Pricing currency | Any fiat ISO code (USD recommended) |
67
+ | Webhook auth | Signature in body + caller-IP allowlist |
68
+
69
+ **Use Heleket when** you need a hosted crypto checkout (Heleket renders the QR /
70
+ address page) without running your own node, validator, or BTCPay server. **Avoid it**
71
+ if you need self-custodied on-chain settlement, support for memo-required coins like
72
+ XRP without UI hand-holding, or an audit-grade refund path — refunds in Heleket are
73
+ manual and partial.
74
+
75
+ **Prerequisites**
76
+
77
+ - Heleket merchant account (KYC for fiat off-ramp, no KYC for crypto-only settlement).
78
+ - Public HTTPS callback URL (use ngrok / cloudflared in dev).
79
+ - A Postgres database (this guide uses Prisma; SQLite works too, drop the `enum` block).
80
+
81
+ ---
82
+
83
+ ## 2. Architecture overview
84
+
85
+ ```mermaid
86
+ sequenceDiagram
87
+ autonumber
88
+ participant U as User (browser)
89
+ participant FE as Frontend (Pay with crypto button)
90
+ participant API as POST /api/billing/crypto/checkout
91
+ participant H as Heleket /v1/payment
92
+ participant DB as Postgres
93
+ participant W as POST /api/billing/crypto/webhook
94
+ participant Wallet as User crypto wallet
95
+
96
+ U->>FE: Click "Pay with crypto" ($30)
97
+ FE->>API: { amountUsd: 30, context: "topup" }
98
+ API->>API: Auth + rate-limit + validate
99
+ API->>API: Add 1% buffer -> $30.30
100
+ API->>H: createInvoice(USD 30.30 -> USDT)
101
+ H-->>API: { uuid, url, status: "process" }
102
+ API->>DB: INSERT crypto_payments(status=pending)
103
+ API-->>FE: { paymentUrl }
104
+ FE->>U: Redirect to Heleket checkout
105
+ U->>Wallet: Send 30.30 USDT to Heleket address
106
+ Wallet->>H: On-chain transfer
107
+ H->>W: POST { type:"payment", status:"paid", uuid, sign, ... }
108
+ W->>W: Verify caller IP + MD5 signature
109
+ W->>W: Type guard, idempotency check
110
+ W->>DB: $transaction:<br/> updateMany(status notIn final)<br/> creditAccountPurchasedTokens<br/> logTokenAudit(action:"crypto_topup")<br/> optional createBalanceSubscription / seat upgrade
111
+ W-->>H: 200 { status: "ok" }
112
+ H-->>U: Redirect to url_success
113
+ U->>FE: Polls /api/billing/crypto/status?orderId=...
114
+ FE-->>U: status=paid, balance updated
115
+ ```
116
+
117
+ The whole lifecycle is **server-driven**. The frontend never holds secrets. The webhook
118
+ is the only authoritative source for status changes; client polling is for UX only.
119
+
120
+ ---
121
+
122
+ ## 3. Environment variables
123
+
124
+ | Variable | Required | Default | Source / Notes |
125
+ |----------|----------|---------|----------------|
126
+ | `HELEKET_API_KEY` | yes | — | Merchant settings → Payment API. Trim whitespace on read. **Never log this value**, it is also the webhook signing secret. |
127
+ | `HELEKET_MERCHANT_ID` | yes | — | Merchant settings → Merchant UUID. Sent as `merchant` header on every API call. |
128
+ | `HELEKET_WEBHOOK_IP` | no | `31.133.220.8` | Override only if Heleket changes their callback IP. Localhost (`127.0.0.1` / `::1`) is always allowed for tests. |
129
+ | `SKIP_BILLING` | no | `false` | When `true` the checkout endpoint short-circuits, marks the payment paid immediately, and credits the balance — for local dev / staging without hitting Heleket. |
130
+ | `NEXTAUTH_URL` or `NEXT_PUBLIC_APP_URL` | yes | — | Public origin used to build `url_callback`, `url_success`, `url_return`. Must be HTTPS in production. |
131
+
132
+ `.env.example` snippet:
133
+
134
+ ```bash
135
+ # ── Heleket (crypto payments) ─────────────────────────────────
136
+ # Payment API key and merchant UUID from Heleket merchant settings (https://heleket.com).
137
+ HELEKET_API_KEY=""
138
+ HELEKET_MERCHANT_ID=""
139
+ # Whitelist IP for webhook verification (Heleket callback origin).
140
+ HELEKET_WEBHOOK_IP="31.133.220.8"
141
+ ```
142
+
143
+ **Security**: rotate the API key quarterly, store in a secret manager (Doppler, Doppler,
144
+ AWS Secrets Manager, Cloudflare Workers Secret, K8s Secret), never commit `.env`,
145
+ never expose to the client bundle (no `NEXT_PUBLIC_` prefix).
146
+
147
+ ---
148
+
149
+ ## 4. Database schema (Prisma)
150
+
151
+ Drop these blocks into `prisma/schema.prisma` and run `npx prisma migrate dev --name
152
+ add_crypto_payments`.
153
+
154
+ ```prisma
155
+ enum CryptoPaymentStatus {
156
+ pending // process, check
157
+ confirming // confirm_check
158
+ paid // paid
159
+ paid_over // paid_over (overpayment)
160
+ wrong_amount // wrong_amount, wrong_amount_waiting (partial)
161
+ cancelled // cancel
162
+ failed // fail, system_fail, refund_fail
163
+ refund_process // refund_process
164
+ refund_paid // refund_paid
165
+ locked // AML hold
166
+ }
167
+
168
+ model CryptoPayment {
169
+ id String @id @default(cuid())
170
+ userId String @map("user_id")
171
+ heleketuuid String @unique @map("heleket_uuid")
172
+ orderId String @unique @map("order_id")
173
+ amountUsd Float @map("amount_usd") // base amount user requested
174
+ tokenAmount Float @map("token_amount") // base + buffer = amount sent to Heleket
175
+ status CryptoPaymentStatus @default(pending)
176
+ paymentUrl String? @map("payment_url")
177
+ currency String? // pricing currency, "USD"
178
+ network String? // settlement network reported by Heleket
179
+ payerCurrency String? @map("payer_currency") // coin the user actually paid in
180
+ paymentAmountUsd Float? @map("payment_amount_usd") // USD value Heleket received
181
+ merchantAmount String? @map("merchant_amount") // amount credited to merchant wallet
182
+ fromAddress String? @map("from_address") // payer wallet address
183
+ commissionAmount String? @map("commission_amount") // Heleket fee
184
+ txid String? // on-chain tx hash
185
+ metadata Json? // arbitrary context (planProductId, seatUpgrade, etc.)
186
+ createdAt DateTime @default(now()) @map("created_at")
187
+ updatedAt DateTime @updatedAt @map("updated_at")
188
+
189
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
190
+ // Optional: link to subscriptions funded by this payment
191
+ // subscriptions Subscription[]
192
+
193
+ @@index([userId, createdAt])
194
+ @@map("crypto_payments")
195
+ }
196
+ ```
197
+
198
+ Optional **funding-source linkage** on your `Subscription` model (drop if you do not have
199
+ balance-funded subs):
200
+
201
+ ```prisma
202
+ model Subscription {
203
+ // ...
204
+ paymentSource String? @map("payment_source") // "stripe" | "balance"
205
+ fundingSource String? @map("funding_source") // "topup" | "crypto" | null
206
+ cryptoPaymentId String? @map("crypto_payment_id")
207
+ cryptoPayment CryptoPayment? @relation(fields: [cryptoPaymentId], references: [id])
208
+ }
209
+ ```
210
+
211
+ The composite index `@@index([userId, createdAt])` is critical — it powers the user-facing
212
+ billing history page.
213
+
214
+ ---
215
+
216
+ ## 5. Heleket REST API reference
217
+
218
+ All API calls are `POST` with JSON body, two custom headers, and a unified envelope.
219
+
220
+ | Header | Value |
221
+ |--------|-------|
222
+ | `merchant` | `HELEKET_MERCHANT_ID` (raw UUID string) |
223
+ | `sign` | `MD5(base64(JSON.stringify(body)) + apiKey)` |
224
+ | `Content-Type` | `application/json` |
225
+
226
+ Success envelope:
227
+
228
+ ```json
229
+ { "state": 0, "result": { ... } }
230
+ ```
231
+
232
+ Error envelope:
233
+
234
+ ```json
235
+ { "state": 1, "message": "Reason..." }
236
+ ```
237
+
238
+ Treat `state !== 0` as an error and surface `message` (or stringified `errors`) to the
239
+ caller.
240
+
241
+ ### POST `/v1/payment` — create invoice
242
+
243
+ Request fields (only the ones you need; snake_case in the body):
244
+
245
+ | Field | Type | Notes |
246
+ |-------|------|-------|
247
+ | `amount` | string | Decimal, **2 dp** for USD (`"30.30"`). |
248
+ | `currency` | string | Pricing currency, ISO code (`"USD"`). |
249
+ | `order_id` | string | Your idempotency key, must be globally unique per merchant. |
250
+ | `network` | string? | Force a specific network (e.g. `"tron"`, `"ethereum"`). Omit to let user pick. |
251
+ | `to_currency` | string? | Settlement coin (`"USDT"`, `"USDC"`, etc.). |
252
+ | `url_callback` | string? | Public HTTPS endpoint for IPN. |
253
+ | `url_success` | string? | Where Heleket redirects after the user pays. |
254
+ | `url_return` | string? | Where Heleket redirects on cancel/back. |
255
+ | `is_payment_multiple` | bool? | `false` to disable multiple payments to the same address. |
256
+ | `lifetime` | int? | Seconds before invoice expires. Use `3600` (1h). |
257
+ | `additional_data` | string? | Arbitrary string, **≤ 255 chars**, echoed in the webhook. Use it to carry your own metadata JSON (truncate if needed). |
258
+ | `subtract` | int? | Take fees from payer (`0`) or merchant (`100`). |
259
+ | `accuracy_payment_percent` | number? | Tolerance for under/overpayment. |
260
+
261
+ Response (`result`) — useful fields:
262
+
263
+ ```jsonc
264
+ {
265
+ "uuid": "abc123-...", // Heleket internal ID
266
+ "order_id": "crypto_xyz_...", // echo of your order_id
267
+ "url": "https://heleket.com/pay/...", // hosted checkout URL — redirect user here
268
+ "amount": "30.30",
269
+ "currency": "USD",
270
+ "payment_amount": null,
271
+ "payment_amount_usd": "0",
272
+ "payer_amount": "30.30",
273
+ "payer_currency": "USDT",
274
+ "merchant_amount": "0",
275
+ "network": "tron",
276
+ "address": "TXyz...", // settlement address
277
+ "from": null,
278
+ "txid": null,
279
+ "payment_status": "process",
280
+ "expired_at": 1737000000,
281
+ "status": "process",
282
+ "is_final": false,
283
+ "additional_data": "{\"context\":\"topup\"}",
284
+ "created_at": "...",
285
+ "updated_at": "...",
286
+ "commission": "0.30",
287
+ "address_qr_code": "data:image/png;base64,..."
288
+ }
289
+ ```
290
+
291
+ ### POST `/v1/payment/info` — refresh status
292
+
293
+ Body: `{ "uuid": "..." }` **or** `{ "order_id": "..." }`. Response shape identical to
294
+ create. Use this for manual reconciliation (e.g. user asks "where's my payment", or a
295
+ cron sweep over `pending` rows older than the invoice lifetime).
296
+
297
+ ### POST `/v1/payment/services` — list networks/coins
298
+
299
+ Body: `{}`. Returns an array of `{ network, currency, is_available, is_enabled, limit:
300
+ { min_amount, max_amount }, commission: { fee_amount, percent } }` so you can render a
301
+ selector or block coins below their min.
302
+
303
+ ### MD5 signing — worked example
304
+
305
+ ```ts
306
+ import { createHash } from "crypto";
307
+
308
+ const body = { amount: "30.30", currency: "USD", order_id: "crypto_abc" };
309
+ const apiKey = "your-api-key";
310
+
311
+ const json = JSON.stringify(body); // {"amount":"30.30","currency":"USD","order_id":"crypto_abc"}
312
+ const base64 = Buffer.from(json).toString("base64"); // eyJhbW91bnQiOi...
313
+ const sign = createHash("md5").update(base64 + apiKey).digest("hex");
314
+ // → 32-char lowercase hex
315
+ ```
316
+
317
+ **Critical**: sign the **exact bytes you send over the wire**. If you serialize the body
318
+ once for signing and again for `fetch`, you risk a key-order or whitespace mismatch.
319
+
320
+ ---
321
+
322
+ ## 6. Webhook (IPN) payload reference
323
+
324
+ Heleket POSTs JSON to `url_callback` whenever a payment changes status.
325
+
326
+ | Field | Type | Notes |
327
+ |-------|------|-------|
328
+ | `type` | string | Always `"payment"` for invoice events. Other values exist for other product lines — **filter on `type === "payment"`**. |
329
+ | `uuid` | string | Heleket invoice ID. |
330
+ | `order_id` | string | Your `order_id`. Use as the primary lookup key. |
331
+ | `status` | string | One of the 14 lifecycle values below. |
332
+ | `payment_amount_usd` | string | USD value of what Heleket actually received. **Use this as the credit amount when present.** |
333
+ | `payer_amount` | string | Amount in payer's coin. |
334
+ | `payer_currency` | string | Coin the user paid in (`"USDT"`). |
335
+ | `network` | string | Network used (`"tron"`). |
336
+ | `merchant_amount` | string | What landed in the merchant wallet (after Heleket fee). |
337
+ | `from` | string | Payer wallet address. **Store for AML / refund.** |
338
+ | `commission` | string | Heleket's fee. |
339
+ | `txid` | string | On-chain tx hash. |
340
+ | `additional_data` | string? | Echo of your invoice's `additional_data`. |
341
+ | `sign` | string | MD5 of the body **excluding `sign`**. |
342
+
343
+ ### The 14 statuses
344
+
345
+ | Heleket status | Meaning | Internal enum |
346
+ |----------------|---------|---------------|
347
+ | `process` | Invoice created, awaiting payment | `pending` |
348
+ | `check` | Payment seen in mempool, not yet confirmed | `pending` |
349
+ | `confirm_check` | Confirming on blockchain | `confirming` |
350
+ | `paid` | Fully paid and confirmed | `paid` ← **CREDIT** |
351
+ | `paid_over` | Overpayment confirmed | `paid_over` ← **CREDIT** |
352
+ | `wrong_amount` | Underpayment, accepted as-is | `wrong_amount` |
353
+ | `wrong_amount_waiting` | Underpayment, waiting for top-up | `wrong_amount` |
354
+ | `cancel` | Payer cancelled | `cancelled` |
355
+ | `fail` | Payment failed | `failed` |
356
+ | `system_fail` | Heleket internal error | `failed` |
357
+ | `refund_process` | Merchant-initiated refund pending | `refund_process` |
358
+ | `refund_paid` | Refund completed | `refund_paid` |
359
+ | `refund_fail` | Refund failed | `failed` |
360
+ | `locked` | AML hold (compliance review) | `locked` |
361
+
362
+ **Final statuses** (do not transition further): `paid`, `paid_over`, `cancel`, `fail`,
363
+ `system_fail`, `refund_paid`, `locked`.
364
+
365
+ ---
366
+
367
+ ## 7. Implementation — client library (`src/lib/heleket.ts`)
368
+
369
+ Drop this verbatim into your project. Zero dependencies beyond the Node `crypto` module
370
+ and your logger.
371
+
372
+ ```ts
373
+ import { createHash, timingSafeEqual } from "crypto";
374
+ import { logger } from "@/lib/logger";
375
+
376
+ const HELEKET_API_BASE = "https://api.heleket.com/v1";
377
+
378
+ function getApiKey(): string {
379
+ const key = process.env.HELEKET_API_KEY?.trim();
380
+ if (!key) throw new Error("HELEKET_API_KEY is not set");
381
+ return key;
382
+ }
383
+
384
+ function getMerchantId(): string {
385
+ const id = process.env.HELEKET_MERCHANT_ID?.trim();
386
+ if (!id) throw new Error("HELEKET_MERCHANT_ID is not set");
387
+ return id;
388
+ }
389
+
390
+ /**
391
+ * MD5 sign for outgoing API requests.
392
+ * Algorithm: MD5(base64(JSON.stringify(body)) + apiKey).
393
+ * Signs the EXACT bytes we send over the wire.
394
+ */
395
+ export function computeSign(body: Record<string, unknown>, apiKey: string): string {
396
+ const jsonStr = JSON.stringify(body);
397
+ const base64 = Buffer.from(jsonStr).toString("base64");
398
+ return createHash("md5").update(base64 + apiKey).digest("hex");
399
+ }
400
+
401
+ /**
402
+ * MD5 sign with PHP-style slash escaping (/ → \/).
403
+ * PHP's json_encode() escapes forward slashes by default, so Heleket's PHP backend
404
+ * signs the webhook body using this escaped form. We must verify against BOTH variants.
405
+ */
406
+ export function computeSignPhp(body: Record<string, unknown>, apiKey: string): string {
407
+ const jsonStr = JSON.stringify(body).replace(/\//g, "\\/");
408
+ const base64 = Buffer.from(jsonStr).toString("base64");
409
+ return createHash("md5").update(base64 + apiKey).digest("hex");
410
+ }
411
+
412
+ /**
413
+ * Verify webhook signature. Strip the `sign` field before passing the body in.
414
+ * Tries PHP-escaped first (Heleket's actual format), then plain — defends against
415
+ * future server changes. Uses timingSafeEqual to prevent timing attacks.
416
+ */
417
+ export function verifyWebhookSignature(
418
+ bodyWithoutSign: Record<string, unknown>,
419
+ receivedSign: string,
420
+ apiKey: string,
421
+ ): boolean {
422
+ if (receivedSign.length !== 32) return false;
423
+
424
+ const phpSign = computeSignPhp(bodyWithoutSign, apiKey);
425
+ if (timingSafeEqual(Buffer.from(phpSign), Buffer.from(receivedSign))) return true;
426
+
427
+ const plainSign = computeSign(bodyWithoutSign, apiKey);
428
+ if (timingSafeEqual(Buffer.from(plainSign), Buffer.from(receivedSign))) return true;
429
+
430
+ return false;
431
+ }
432
+
433
+ async function heleletRequest<T>(
434
+ endpoint: string,
435
+ body: Record<string, unknown>,
436
+ ): Promise<T> {
437
+ const apiKey = getApiKey();
438
+ const merchantId = getMerchantId();
439
+ const sign = computeSign(body, apiKey);
440
+
441
+ const url = `${HELEKET_API_BASE}${endpoint}`;
442
+ logger.debug("Heleket API request", { url, body });
443
+
444
+ const response = await fetch(url, {
445
+ method: "POST",
446
+ headers: {
447
+ merchant: merchantId,
448
+ sign,
449
+ "Content-Type": "application/json",
450
+ },
451
+ body: JSON.stringify(body),
452
+ });
453
+
454
+ const data = await response.json();
455
+
456
+ if (data.state !== 0) {
457
+ const errMsg = data.message || data.errors || "Unknown Heleket error";
458
+ logger.error("Heleket API error", { url, state: data.state, error: errMsg });
459
+ throw new Error(typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg));
460
+ }
461
+
462
+ return data.result as T;
463
+ }
464
+
465
+ // ── Create Invoice ────────────────────────────────────────────────────────
466
+
467
+ export interface CreateInvoiceParams {
468
+ amount: string; // 2-dp decimal string
469
+ currency: string; // pricing currency, e.g. "USD"
470
+ orderId: string; // your idempotency key
471
+ network?: string;
472
+ toCurrency?: string; // settlement coin, e.g. "USDT"
473
+ urlCallback?: string;
474
+ urlSuccess?: string;
475
+ urlReturn?: string;
476
+ isPaymentMultiple?: boolean;
477
+ lifetime?: number;
478
+ additionalData?: string; // ≤ 255 chars
479
+ subtract?: number;
480
+ accuracyPaymentPercent?: number;
481
+ }
482
+
483
+ export interface InvoiceResult {
484
+ uuid: string;
485
+ order_id: string;
486
+ amount: string;
487
+ payment_amount: string | null;
488
+ payment_amount_usd: string;
489
+ payer_amount: string;
490
+ payer_currency: string;
491
+ currency: string;
492
+ merchant_amount: string;
493
+ network: string;
494
+ address: string | null;
495
+ from: string | null;
496
+ txid: string | null;
497
+ payment_status: string;
498
+ url: string;
499
+ expired_at: number;
500
+ status: string;
501
+ is_final: boolean;
502
+ additional_data: string | null;
503
+ created_at: string;
504
+ updated_at: string;
505
+ commission: string;
506
+ address_qr_code?: string;
507
+ }
508
+
509
+ export async function createInvoice(params: CreateInvoiceParams): Promise<InvoiceResult> {
510
+ const body: Record<string, unknown> = {
511
+ amount: params.amount,
512
+ currency: params.currency,
513
+ order_id: params.orderId,
514
+ };
515
+ if (params.network) body.network = params.network;
516
+ if (params.toCurrency) body.to_currency = params.toCurrency;
517
+ if (params.urlCallback) body.url_callback = params.urlCallback;
518
+ if (params.urlSuccess) body.url_success = params.urlSuccess;
519
+ if (params.urlReturn) body.url_return = params.urlReturn;
520
+ if (params.isPaymentMultiple !== undefined) body.is_payment_multiple = params.isPaymentMultiple;
521
+ if (params.lifetime) body.lifetime = params.lifetime;
522
+ if (params.additionalData) body.additional_data = params.additionalData;
523
+ if (params.subtract !== undefined) body.subtract = params.subtract;
524
+ if (params.accuracyPaymentPercent !== undefined) {
525
+ body.accuracy_payment_percent = params.accuracyPaymentPercent;
526
+ }
527
+ return heleletRequest<InvoiceResult>("/payment", body);
528
+ }
529
+
530
+ // ── Payment Info ──────────────────────────────────────────────────────────
531
+
532
+ export async function getPaymentInfo(
533
+ identifier: { uuid: string } | { orderId: string },
534
+ ): Promise<InvoiceResult> {
535
+ const body: Record<string, unknown> = {};
536
+ if ("uuid" in identifier) body.uuid = identifier.uuid;
537
+ else body.order_id = identifier.orderId;
538
+ return heleletRequest<InvoiceResult>("/payment/info", body);
539
+ }
540
+
541
+ // ── Webhook IP Allowlist ──────────────────────────────────────────────────
542
+
543
+ const DEFAULT_WEBHOOK_IP = "31.133.220.8";
544
+
545
+ export function isAllowedWebhookIp(ip: string): boolean {
546
+ const allowed = process.env.HELEKET_WEBHOOK_IP || DEFAULT_WEBHOOK_IP;
547
+ return ip === allowed || ip === "127.0.0.1" || ip === "::1";
548
+ }
549
+
550
+ // ── Status Helpers ────────────────────────────────────────────────────────
551
+
552
+ export type HeleletWebhookStatus =
553
+ | "process" | "check" | "confirm_check"
554
+ | "paid" | "paid_over"
555
+ | "wrong_amount" | "wrong_amount_waiting"
556
+ | "cancel" | "fail" | "system_fail"
557
+ | "refund_process" | "refund_fail" | "refund_paid"
558
+ | "locked";
559
+
560
+ export function isFinalStatus(status: string): boolean {
561
+ return ["paid", "paid_over", "cancel", "fail", "system_fail", "refund_paid", "locked"]
562
+ .includes(status);
563
+ }
564
+
565
+ export function isPaidStatus(status: string): boolean {
566
+ return status === "paid" || status === "paid_over";
567
+ }
568
+ ```
569
+
570
+ ### The PHP-escape gotcha (read this twice)
571
+
572
+ Heleket's backend is PHP. PHP's `json_encode()` escapes forward slashes (`/` → `\/`) by
573
+ default, and that escaped form is what gets MD5-signed. Node's `JSON.stringify` does not.
574
+
575
+ If you only verify against the plain form, **every webhook containing a URL or path will
576
+ fail signature verification** (e.g. `url_callback`, `from` address never has slashes but
577
+ your `additional_data` JSON might). Always try `computeSignPhp` first, then fall back to
578
+ `computeSign`. The dual verifier above is mandatory.
579
+
580
+ ---
581
+
582
+ ## 8. Implementation — checkout endpoint
583
+
584
+ Authenticated, rate-limited, validates input, applies the 1% buffer, creates the Heleket
585
+ invoice, persists the row, returns the redirect URL.
586
+
587
+ ```ts
588
+ // src/app/api/billing/crypto/checkout/route.ts
589
+ import { NextResponse } from "next/server";
590
+ import { auth } from "@/lib/auth";
591
+ import { db } from "@/lib/db";
592
+ import { createInvoice } from "@/lib/heleket";
593
+ import { creditAccountPurchasedTokens } from "@/lib/account-balance";
594
+ import { logTokenAudit } from "@/lib/token-audit";
595
+ import { randomUUID } from "crypto";
596
+ import { logger } from "@/lib/logger";
597
+ import { rateLimit } from "@/lib/rate-limit";
598
+
599
+ export const dynamic = "force-dynamic";
600
+
601
+ const SKIP_BILLING = process.env.SKIP_BILLING === "true";
602
+ const MIN_AMOUNT = 10;
603
+ const MAX_AMOUNT = 10000;
604
+ const CRYPTO_BUFFER_RATE = 0.01; // 1% buffer for slippage
605
+ const INVOICE_LIFETIME_SECONDS = 3600; // 1 hour
606
+
607
+ export async function POST(request: Request) {
608
+ // 1. Auth
609
+ const session = await auth();
610
+ if (!session?.user?.id) {
611
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
612
+ }
613
+ const userId = session.user.id;
614
+
615
+ // 2. Rate limit (5/min, 12/hour) — prevents invoice spam
616
+ if (!rateLimit(`crypto-checkout:${userId}`, 5, 60_000).allowed) {
617
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
618
+ }
619
+ if (!rateLimit(`crypto-checkout-hour:${userId}`, 12, 3_600_000).allowed) {
620
+ return NextResponse.json(
621
+ { error: "Too many crypto invoices created. Wait up to an hour or finish an open one." },
622
+ { status: 429 },
623
+ );
624
+ }
625
+
626
+ // 3. Parse + validate body
627
+ let body: Record<string, unknown>;
628
+ try {
629
+ body = await request.json();
630
+ } catch {
631
+ return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
632
+ }
633
+
634
+ const {
635
+ amountUsd,
636
+ context = "topup", // "topup" | "subscription" | "seat_upgrade"
637
+ successUrl: customSuccessUrl,
638
+ cancelUrl: customCancelUrl,
639
+ ...extra // planProductId, subscriptionId, targetQuantity, etc.
640
+ } = body as { amountUsd?: number; context?: string; successUrl?: string; cancelUrl?: string };
641
+
642
+ if (!amountUsd || typeof amountUsd !== "number" ||
643
+ amountUsd < MIN_AMOUNT || amountUsd > MAX_AMOUNT) {
644
+ return NextResponse.json(
645
+ { error: `Amount must be between $${MIN_AMOUNT} and $${MAX_AMOUNT}` },
646
+ { status: 400 },
647
+ );
648
+ }
649
+
650
+ // 4. Compute amounts (1% buffer for crypto slippage)
651
+ const orderId = `crypto_${userId.slice(0, 8)}_${Date.now()}_${randomUUID().slice(0, 8)}`;
652
+ const bufferAmount = Math.ceil(amountUsd * CRYPTO_BUFFER_RATE * 100) / 100;
653
+ const invoiceAmount = Math.round((amountUsd + bufferAmount) * 100) / 100;
654
+ const tokenAmount = invoiceAmount; // user gets the buffer back as balance
655
+
656
+ // 5. Build callback / success / return URLs (same-origin only)
657
+ const appUrl = process.env.NEXTAUTH_URL || process.env.NEXT_PUBLIC_APP_URL || "";
658
+ const isSameOrigin = (url?: string) => {
659
+ if (!url || !appUrl) return false;
660
+ try { return new URL(url).origin === new URL(appUrl).origin; } catch { return false; }
661
+ };
662
+ const successUrl = isSameOrigin(customSuccessUrl)
663
+ ? `${customSuccessUrl}${customSuccessUrl!.includes("?") ? "&" : "?"}crypto_success=true&orderId=${orderId}`
664
+ : `${appUrl}/billing?crypto_success=true&orderId=${orderId}`;
665
+ const returnUrl = isSameOrigin(customCancelUrl) ? customCancelUrl! : `${appUrl}/billing`;
666
+ const callbackUrl = `${appUrl}/api/billing/crypto/webhook`;
667
+
668
+ // 6. Build metadata (echoed back via additional_data, max 255 chars)
669
+ const metadata = { userId, context, ...extra,
670
+ bufferRate: CRYPTO_BUFFER_RATE, bufferAmount,
671
+ baseAmountUsd: amountUsd, invoiceAmountUsd: invoiceAmount };
672
+ const additionalDataStr = JSON.stringify(metadata);
673
+ const additionalData = additionalDataStr.length <= 255 ? additionalDataStr : undefined;
674
+
675
+ // 7. SKIP_BILLING — local dev / staging short-circuit
676
+ if (SKIP_BILLING) {
677
+ const mockUuid = `mock_${randomUUID()}`;
678
+ const record = await db.$transaction(async (tx) => {
679
+ const created = await tx.cryptoPayment.create({
680
+ data: {
681
+ userId, heleketuuid: mockUuid, orderId,
682
+ amountUsd, tokenAmount,
683
+ status: "paid", paymentUrl: successUrl,
684
+ paymentAmountUsd: invoiceAmount, currency: "USD",
685
+ metadata: metadata as any,
686
+ },
687
+ });
688
+ const balanceResult = await creditAccountPurchasedTokens(tx, userId, invoiceAmount);
689
+ await logTokenAudit({
690
+ userId, action: "crypto_topup", amount: invoiceAmount, source: "heleket_mock",
691
+ balanceBefore: { /* fetch before */ }, balanceAfter: { /* result */ },
692
+ metadata: { orderId, mock: true, bufferAmount }, tx,
693
+ });
694
+ return created;
695
+ });
696
+ return NextResponse.json({ paymentUrl: successUrl, orderId: record.orderId,
697
+ heleketuuid: record.heleketuuid, mock: true });
698
+ }
699
+
700
+ // 8. Real Heleket call
701
+ let invoice;
702
+ try {
703
+ invoice = await createInvoice({
704
+ amount: invoiceAmount.toFixed(2),
705
+ currency: "USD",
706
+ orderId,
707
+ toCurrency: "USDT",
708
+ urlCallback: callbackUrl,
709
+ urlSuccess: successUrl,
710
+ urlReturn: returnUrl,
711
+ isPaymentMultiple: false,
712
+ lifetime: INVOICE_LIFETIME_SECONDS,
713
+ additionalData,
714
+ });
715
+ } catch (err) {
716
+ logger.error("Failed to create crypto invoice", {
717
+ error: err instanceof Error ? err.message : String(err), userId, amountUsd });
718
+ return NextResponse.json({ error: "Failed to create payment. Please try again." },
719
+ { status: 500 });
720
+ }
721
+
722
+ // 9. Persist DB row. If this fails we have an orphan invoice at Heleket — log it loudly.
723
+ try {
724
+ await db.cryptoPayment.create({
725
+ data: {
726
+ userId,
727
+ heleketuuid: invoice.uuid,
728
+ orderId,
729
+ amountUsd,
730
+ tokenAmount,
731
+ status: "pending",
732
+ paymentUrl: invoice.url,
733
+ currency: invoice.currency,
734
+ network: invoice.network || null,
735
+ metadata: metadata as any,
736
+ },
737
+ });
738
+ } catch (dbErr) {
739
+ logger.error("ORPHAN INVOICE — Heleket invoice created but DB insert failed", {
740
+ error: dbErr instanceof Error ? dbErr.message : String(dbErr),
741
+ userId, orderId, heleketuuid: invoice.uuid,
742
+ });
743
+ return NextResponse.json({ error: "Failed to create payment. Please try again." },
744
+ { status: 500 });
745
+ }
746
+
747
+ return NextResponse.json({ paymentUrl: invoice.url, orderId, heleketuuid: invoice.uuid });
748
+ }
749
+ ```
750
+
751
+ **Key invariants the agent must preserve when adapting:**
752
+
753
+ - `orderId` is **globally unique** and **prefixed** so it is recognizable in logs.
754
+ - `additional_data` is **truncated to 255 chars or omitted** — Heleket silently drops
755
+ oversized values, and you cannot rely on them in the webhook.
756
+ - Custom redirect URLs are **same-origin-validated** to prevent open-redirect abuse.
757
+ - `lifetime: 3600` is a sensible default — long enough for slow networks, short enough
758
+ to free up the slot quickly.
759
+ - The orphan-invoice path **MUST log loudly** (Sentry, Telegram alert) — those are
760
+ reconcilable manually via `getPaymentInfo`.
761
+
762
+ ---
763
+
764
+ ## 9. Implementation — webhook endpoint
765
+
766
+ The webhook is the single source of truth for status changes. It must be:
767
+
768
+ 1. **CSRF-exempt** (no browser is the caller).
769
+ 2. **IP-allowlisted** (proxy-aware).
770
+ 3. **Signature-verified** (constant-time).
771
+ 4. **Type-filtered** (`type === "payment"`).
772
+ 5. **Idempotent** (replay-safe via conditional `updateMany`).
773
+ 6. **Transactional** (status update + credit + audit log = one DB transaction).
774
+
775
+ ```ts
776
+ // src/app/api/billing/crypto/webhook/route.ts
777
+ import { NextResponse } from "next/server";
778
+ import { db } from "@/lib/db";
779
+ import { verifyWebhookSignature, isAllowedWebhookIp, isPaidStatus } from "@/lib/heleket";
780
+ import { creditAccountPurchasedTokens } from "@/lib/account-balance";
781
+ import { logTokenAudit } from "@/lib/token-audit";
782
+ import { notify } from "@/lib/notifications";
783
+ import { logger } from "@/lib/logger";
784
+ import type { CryptoPaymentStatus } from "@prisma/client";
785
+
786
+ export const dynamic = "force-dynamic";
787
+
788
+ function mapHeleletStatus(status: string): CryptoPaymentStatus {
789
+ switch (status) {
790
+ case "process": case "check": return "pending";
791
+ case "confirm_check": return "confirming";
792
+ case "paid": return "paid";
793
+ case "paid_over": return "paid_over";
794
+ case "wrong_amount": case "wrong_amount_waiting": return "wrong_amount";
795
+ case "cancel": return "cancelled";
796
+ case "fail": case "system_fail": case "refund_fail": return "failed";
797
+ case "refund_process": return "refund_process";
798
+ case "refund_paid": return "refund_paid";
799
+ case "locked": return "locked";
800
+ default: return "pending";
801
+ }
802
+ }
803
+
804
+ // Proxy-aware IP extraction. Order matters: trust DO/Cloudflare/your CDN headers
805
+ // before falling back to the LAST hop of x-forwarded-for (the closest hop = your edge).
806
+ function extractIp(request: Request): string {
807
+ const doConnecting = request.headers.get("do-connecting-ip"); // DigitalOcean
808
+ if (doConnecting) return doConnecting.trim();
809
+ const cf = request.headers.get("cf-connecting-ip"); // Cloudflare
810
+ if (cf) return cf.trim();
811
+ const forwarded = request.headers.get("x-forwarded-for");
812
+ if (forwarded) {
813
+ const parts = forwarded.split(",").map((s) => s.trim()).filter(Boolean);
814
+ return parts[parts.length - 1]; // last = closest to us
815
+ }
816
+ return request.headers.get("x-real-ip") || "unknown";
817
+ }
818
+
819
+ export async function POST(request: Request) {
820
+ // 1. IP allowlist
821
+ const ip = extractIp(request);
822
+ if (!isAllowedWebhookIp(ip)) {
823
+ logger.warn("Heleket webhook rejected: IP not allowed", { ip });
824
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
825
+ }
826
+
827
+ // 2. Parse body
828
+ let rawBody: Record<string, unknown>;
829
+ try { rawBody = await request.json(); }
830
+ catch { return NextResponse.json({ error: "Invalid body" }, { status: 400 }); }
831
+
832
+ const { sign, ...bodyWithoutSign } = rawBody as Record<string, unknown> & { sign?: string };
833
+ if (!sign) return NextResponse.json({ error: "Missing signature" }, { status: 400 });
834
+
835
+ // 3. Signature
836
+ const apiKey = process.env.HELEKET_API_KEY?.trim();
837
+ if (!apiKey) return NextResponse.json({ error: "Webhook not configured" }, { status: 500 });
838
+ if (!verifyWebhookSignature(bodyWithoutSign, sign, apiKey)) {
839
+ logger.warn("Heleket webhook rejected: invalid signature", { ip });
840
+ return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
841
+ }
842
+
843
+ // 4. Type filter — only invoice events
844
+ const webhookType = bodyWithoutSign.type as string | undefined;
845
+ if (webhookType && webhookType !== "payment") {
846
+ return NextResponse.json({ status: "ok" });
847
+ }
848
+
849
+ // 5. Extract fields
850
+ const orderId = bodyWithoutSign.order_id as string | undefined;
851
+ const heleketuuid = bodyWithoutSign.uuid as string | undefined;
852
+ const webhookStatus = bodyWithoutSign.status as string;
853
+ const rawAmt = bodyWithoutSign.payment_amount_usd ? parseFloat(bodyWithoutSign.payment_amount_usd as string) : null;
854
+ const paymentAmountUsd = rawAmt !== null && Number.isFinite(rawAmt) ? rawAmt : null;
855
+ const txid = bodyWithoutSign.txid as string | undefined;
856
+ const payerCurrency = bodyWithoutSign.payer_currency as string | undefined;
857
+ const network = bodyWithoutSign.network as string | undefined;
858
+ const merchantAmount = bodyWithoutSign.merchant_amount as string | undefined;
859
+ const fromAddress = bodyWithoutSign.from as string | undefined;
860
+ const commissionAmount = bodyWithoutSign.commission as string | undefined;
861
+
862
+ if (!orderId && !heleketuuid) {
863
+ return NextResponse.json({ error: "Missing order_id or uuid" }, { status: 400 });
864
+ }
865
+
866
+ const payment = await db.cryptoPayment.findFirst({
867
+ where: orderId ? { orderId } : { heleketuuid: heleketuuid! },
868
+ });
869
+ if (!payment) {
870
+ logger.error("Heleket webhook: payment row not found", { orderId, heleketuuid });
871
+ // Return 200 to stop Heleket retrying a permanently unknown payment.
872
+ return NextResponse.json({ status: "ok" });
873
+ }
874
+
875
+ const FINAL: CryptoPaymentStatus[] = ["paid", "paid_over"];
876
+ if (FINAL.includes(payment.status)) {
877
+ return NextResponse.json({ status: "ok" }); // already credited, ignore
878
+ }
879
+
880
+ const mappedStatus = mapHeleletStatus(webhookStatus);
881
+ const meta = { paymentAmountUsd: paymentAmountUsd ?? undefined, txid, payerCurrency,
882
+ network, merchantAmount, fromAddress, commissionAmount };
883
+
884
+ if (isPaidStatus(webhookStatus)) {
885
+ // Credit waterfall: trust Heleket's USD figure first, then our buffered total,
886
+ // finally the user's base amount as a last resort.
887
+ const creditAmount = paymentAmountUsd ?? payment.tokenAmount ?? payment.amountUsd;
888
+
889
+ const claimed = await db.$transaction(async (tx) => {
890
+ // Idempotency — only the first paid webhook flips the status.
891
+ const updated = await tx.cryptoPayment.updateMany({
892
+ where: { id: payment.id, status: { notIn: FINAL } },
893
+ data: { status: mappedStatus, ...meta },
894
+ });
895
+ if (updated.count === 0) return false; // someone else got here first
896
+
897
+ const user = await tx.user.findUniqueOrThrow({
898
+ where: { id: payment.userId },
899
+ select: { accountSubscriptionTokens: true, accountPurchasedTokens: true },
900
+ });
901
+ const balanceBefore = { ...user };
902
+ const result = await creditAccountPurchasedTokens(tx, payment.userId, creditAmount);
903
+
904
+ await logTokenAudit({
905
+ userId: payment.userId,
906
+ action: "crypto_topup",
907
+ amount: creditAmount,
908
+ source: "heleket",
909
+ balanceBefore,
910
+ balanceAfter: { accountSubscriptionTokens: result.newSub,
911
+ accountPurchasedTokens: result.newPur },
912
+ metadata: { orderId: payment.orderId, heleketuuid: payment.heleketuuid,
913
+ txid, paymentAmountUsd, originalAmountUsd: payment.amountUsd, webhookStatus },
914
+ tx,
915
+ });
916
+
917
+ // Optional context-driven side effects (subscription, seat upgrade) go here.
918
+ // Wrap each in try/catch and emit a "warning" notification on failure so the
919
+ // user keeps their credited balance even if the secondary action fails.
920
+
921
+ return true;
922
+ });
923
+
924
+ if (!claimed) return NextResponse.json({ status: "ok" });
925
+
926
+ await notify({
927
+ userId: payment.userId, type: "billing",
928
+ title: "Crypto payment received",
929
+ message: `$${creditAmount.toFixed(2)} has been added to your token balance.`,
930
+ i18nKey: "cryptoPaymentSuccess",
931
+ i18nParams: { amount: creditAmount.toFixed(2) },
932
+ });
933
+ return NextResponse.json({ status: "ok" });
934
+ }
935
+
936
+ // Non-paid status: store metadata, notify the user, do NOT credit.
937
+ const claimed = await db.cryptoPayment.updateMany({
938
+ where: { id: payment.id, status: { notIn: FINAL } },
939
+ data: { status: mappedStatus, ...meta },
940
+ });
941
+ if (claimed.count === 0) return NextResponse.json({ status: "ok" });
942
+
943
+ if (webhookStatus === "confirm_check") {
944
+ await notify({ userId: payment.userId, type: "billing",
945
+ title: "Crypto payment confirming",
946
+ message: "Your payment is being confirmed on the blockchain.",
947
+ i18nKey: "cryptoPaymentConfirming" });
948
+ } else if (webhookStatus === "wrong_amount" || webhookStatus === "wrong_amount_waiting") {
949
+ await notify({ userId: payment.userId, type: "warning",
950
+ title: "Partial crypto payment",
951
+ message: "Partial payment received. Please complete the payment.",
952
+ i18nKey: "cryptoPaymentPartial" });
953
+ } else if (["cancel", "fail", "system_fail"].includes(webhookStatus)) {
954
+ await notify({ userId: payment.userId, type: "warning",
955
+ title: "Crypto payment failed",
956
+ message: "Your crypto payment was cancelled or failed.",
957
+ i18nKey: "cryptoPaymentFailed" });
958
+ } else if (webhookStatus === "locked") {
959
+ await notify({ userId: payment.userId, type: "warning",
960
+ title: "Crypto payment locked",
961
+ message: "Your payment funds have been locked for AML review. Contact support.",
962
+ i18nKey: "cryptoPaymentLocked" });
963
+ }
964
+
965
+ return NextResponse.json({ status: "ok" });
966
+ }
967
+ ```
968
+
969
+ ### CSRF exemption (Next.js middleware)
970
+
971
+ If your app uses double-submit-cookie CSRF, add the webhook path to your exempt list:
972
+
973
+ ```ts
974
+ // src/middleware.ts
975
+ const CSRF_EXEMPT_PATHS = [
976
+ "/api/auth/",
977
+ "/api/billing/webhook", // Stripe (if used)
978
+ "/api/billing/crypto/webhook", // Heleket — REQUIRED
979
+ "/api/webhooks/",
980
+ "/api/cron/",
981
+ "/api/health",
982
+ ];
983
+ ```
984
+
985
+ ---
986
+
987
+ ## 10. Status endpoint (for client polling UX)
988
+
989
+ Polling is cosmetic — the webhook does the real work. Keep this read-only and scoped to
990
+ the requesting user.
991
+
992
+ ```ts
993
+ // src/app/api/billing/crypto/status/route.ts
994
+ export async function GET(request: Request) {
995
+ const session = await auth();
996
+ if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
997
+ const orderId = new URL(request.url).searchParams.get("orderId");
998
+ if (!orderId) return NextResponse.json({ error: "orderId required" }, { status: 400 });
999
+
1000
+ const payment = await db.cryptoPayment.findFirst({
1001
+ where: { orderId, userId: session.user.id },
1002
+ select: { id: true, status: true, amountUsd: true, tokenAmount: true,
1003
+ paymentAmountUsd: true, txid: true, metadata: true, createdAt: true },
1004
+ });
1005
+ if (!payment) return NextResponse.json({ error: "Payment not found" }, { status: 404 });
1006
+ return NextResponse.json(payment);
1007
+ }
1008
+ ```
1009
+
1010
+ The frontend should poll on a backoff (1s, 2s, 4s, 8s, 16s) and stop once the status is
1011
+ `paid`, `paid_over`, `cancelled`, `failed`, or `locked`, or after ~2 min.
1012
+
1013
+ ---
1014
+
1015
+ ## 11. The 1% buffer pattern (read this section before adapting)
1016
+
1017
+ Crypto invoices have three sources of micro-loss between the user's wallet and your
1018
+ ledger:
1019
+
1020
+ 1. **Network spread / DEX slippage** — between the payer's coin and the settlement coin.
1021
+ 2. **Stablecoin float** — USDT trades at $0.999–$1.001 even on Tier-1 venues.
1022
+ 3. **Rounding** — Heleket rounds to 4–8 decimals depending on the network.
1023
+
1024
+ Without a buffer, a $30 invoice frequently lands as `payment_amount_usd: 29.97`, and if
1025
+ your balance is debited at $30 to provision a subscription, the user gets stuck on a
1026
+ "insufficient balance" error 30 seconds after paying.
1027
+
1028
+ **The fix**: add 1% on the invoice side, credit the **full invoice amount** (not the
1029
+ base) to the user's balance.
1030
+
1031
+ ```ts
1032
+ const bufferAmount = Math.ceil(amountUsd * 0.01 * 100) / 100; // 0.30 for $30
1033
+ const invoiceAmount = Math.round((amountUsd + bufferAmount) * 100) / 100; // 30.30
1034
+ const tokenAmount = invoiceAmount; // user gets the buffer back as balance
1035
+ ```
1036
+
1037
+ Show the breakdown in the UI:
1038
+
1039
+ ```
1040
+ Subscription: $30.00
1041
+ Crypto buffer: +$0.30 (covers conversion losses, credited to your balance)
1042
+ Invoice total: $30.30
1043
+ ```
1044
+
1045
+ Why credit the full invoice (not just `paymentAmountUsd`)? Because if the user pays
1046
+ exactly $30.30, they should keep $0.30 in balance after the subscription is provisioned.
1047
+ If Heleket reports `payment_amount_usd: 30.27` (a 0.1% loss), use that instead — the
1048
+ credit waterfall `paymentAmountUsd ?? tokenAmount ?? amountUsd` does this automatically.
1049
+
1050
+ ---
1051
+
1052
+ ## 12. Reconciliation fields (always store)
1053
+
1054
+ When the paid webhook arrives, persist **every** of these — finance and support will
1055
+ need them within the first month of going live:
1056
+
1057
+ | Field | Source | Use case |
1058
+ |-------|--------|----------|
1059
+ | `paymentAmountUsd` | `payment_amount_usd` | Authoritative credit amount |
1060
+ | `merchantAmount` | `merchant_amount` | What landed in your wallet (after fee) |
1061
+ | `commissionAmount` | `commission` | Heleket's cut, reconcile against monthly invoice |
1062
+ | `txid` | `txid` | Block-explorer link, refund source-of-truth |
1063
+ | `fromAddress` | `from` | Refund destination, AML investigation |
1064
+ | `payerCurrency` | `payer_currency` | Coin actually paid (rev recognition by coin) |
1065
+ | `network` | `network` | Network used (`tron`, `eth`, `bsc`, ...) |
1066
+
1067
+ A typical support ticket: _"I paid but my balance didn't update."_ With these fields,
1068
+ support copy-pastes the `txid` into a block explorer in 10 seconds and tells the user
1069
+ their tx is still confirming, or finds a `wrong_amount` and guides them to top up.
1070
+
1071
+ ---
1072
+
1073
+ ## 13. Refund handling
1074
+
1075
+ Heleket refunds are **merchant-initiated only** (user cannot self-refund). They require:
1076
+
1077
+ 1. A separate API call (`/v1/payment/refund`, not implemented in this skill — call
1078
+ manually via the dashboard).
1079
+ 2. The user's destination wallet address (you have it as `fromAddress`).
1080
+
1081
+ Webhook handling:
1082
+
1083
+ | Status | Action |
1084
+ |--------|--------|
1085
+ | `refund_process` | Update DB row to `refund_process`, no balance change. |
1086
+ | `refund_paid` | Update DB row to `refund_paid`. **Optionally** debit the user's balance manually if you previously credited them — the reference implementation leaves this to ops to avoid accidental negative balances. |
1087
+ | `refund_fail` | Update DB row to `failed`, alert ops. |
1088
+
1089
+ There is no chargeback mechanism in crypto — once `paid` is on-chain, the only path back
1090
+ is a refund initiated by you. Set up a financial alarm at, e.g., $500/month of refunds.
1091
+
1092
+ ---
1093
+
1094
+ ## 14. Integration with subscriptions / credits
1095
+
1096
+ Use `metadata.context` in the invoice to drive post-payment side effects. The webhook
1097
+ handler reads it from `payment.metadata` (which you stored at checkout) and branches.
1098
+
1099
+ | `context` value | After credit, the webhook should... |
1100
+ |------------------|--------------------------------------|
1101
+ | `"topup"` | Nothing more — credit + audit + notify is the whole flow. |
1102
+ | `"subscription"` | Call `createBalanceSubscription({ userId, productId, quantity, fundingSource: "crypto", cryptoPaymentId: payment.id })` inside the same transaction. The first month's price gets debited from the just-credited balance. |
1103
+ | `"seat_upgrade"` | Look up the existing subscription, debit `proratedCharge` from balance, bump `quantity` and `balanceMonthlyAmount`. |
1104
+
1105
+ Wrap each side effect in its own `try/catch` inside the transaction. **If the side
1106
+ effect fails, the user's balance is still credited** — you must surface a "subscription
1107
+ setup incomplete, please retry from billing page" notification. Do NOT roll back the
1108
+ credit just because the secondary action failed.
1109
+
1110
+ Mark balance-funded subscriptions with a distinct UI badge ("Crypto" vs "Balance") so
1111
+ the user can see how each subscription is funded.
1112
+
1113
+ ---
1114
+
1115
+ ## 15. Local development & SKIP_BILLING
1116
+
1117
+ There are two ways to develop locally without a real Heleket merchant account.
1118
+
1119
+ ### Option A — `SKIP_BILLING=true` (fastest)
1120
+
1121
+ Set `SKIP_BILLING=true` in `.env.local`. The checkout endpoint:
1122
+
1123
+ 1. Generates a `mock_<uuid>` Heleket UUID.
1124
+ 2. Inserts a `crypto_payments` row with `status: "paid"`.
1125
+ 3. Credits the user's balance immediately.
1126
+ 4. Optionally creates the balance subscription / applies seat upgrade.
1127
+ 5. Returns the success URL.
1128
+
1129
+ The user is redirected to `/billing?crypto_success=true&orderId=...` and sees their
1130
+ balance updated, never touching Heleket. Perfect for E2E tests, demos, and feature work.
1131
+
1132
+ ### Option B — Real Heleket + ngrok / cloudflared
1133
+
1134
+ For end-to-end testing against the real provider:
1135
+
1136
+ ```bash
1137
+ # Terminal 1
1138
+ npm run dev # Next.js on :3000
1139
+
1140
+ # Terminal 2
1141
+ cloudflared tunnel --url http://localhost:3000
1142
+ # → https://random-name.trycloudflare.com
1143
+
1144
+ # Set in .env.local:
1145
+ NEXTAUTH_URL=https://random-name.trycloudflare.com
1146
+ HELEKET_API_KEY=...
1147
+ HELEKET_MERCHANT_ID=...
1148
+ HELEKET_WEBHOOK_IP=31.133.220.8
1149
+
1150
+ # Restart Next.js so it picks up the public URL
1151
+ ```
1152
+
1153
+ Use Heleket's "Test mode" (toggle in merchant settings) for free $1 invoices. Pay them
1154
+ with any spare USDT.
1155
+
1156
+ ---
1157
+
1158
+ ## 16. Testing strategy
1159
+
1160
+ ### Unit tests (`heleket.ts`)
1161
+
1162
+ ```ts
1163
+ import { describe, it, expect } from "vitest";
1164
+ import { createHash } from "crypto";
1165
+ import { computeSign, computeSignPhp, verifyWebhookSignature,
1166
+ isFinalStatus, isPaidStatus, isAllowedWebhookIp } from "./heleket";
1167
+
1168
+ describe("computeSign", () => {
1169
+ it("produces a 32-char hex deterministic MD5", () => {
1170
+ const sig = computeSign({ a: 1 }, "key");
1171
+ expect(sig).toMatch(/^[a-f0-9]{32}$/);
1172
+ expect(sig).toBe(computeSign({ a: 1 }, "key"));
1173
+ });
1174
+
1175
+ it("differs from computeSignPhp when body has slashes", () => {
1176
+ const body = { url: "https://example.com/path" };
1177
+ expect(computeSign(body, "k")).not.toBe(computeSignPhp(body, "k"));
1178
+ });
1179
+
1180
+ it("matches computeSignPhp when body has no slashes", () => {
1181
+ const body = { amount: "100" };
1182
+ expect(computeSign(body, "k")).toBe(computeSignPhp(body, "k"));
1183
+ });
1184
+ });
1185
+
1186
+ describe("verifyWebhookSignature", () => {
1187
+ it("rejects signatures of wrong length", () => {
1188
+ expect(verifyWebhookSignature({ a: 1 }, "short", "key")).toBe(false);
1189
+ });
1190
+
1191
+ it("accepts both PHP-escaped and plain MD5", () => {
1192
+ const body = { url: "https://example.com" };
1193
+ const apiKey = "key";
1194
+ expect(verifyWebhookSignature(body, computeSignPhp(body, apiKey), apiKey)).toBe(true);
1195
+ expect(verifyWebhookSignature(body, computeSign(body, apiKey), apiKey)).toBe(true);
1196
+ });
1197
+ });
1198
+
1199
+ describe("status helpers", () => {
1200
+ it("isFinalStatus", () => {
1201
+ expect(isFinalStatus("paid")).toBe(true);
1202
+ expect(isFinalStatus("process")).toBe(false);
1203
+ });
1204
+ it("isPaidStatus", () => {
1205
+ expect(isPaidStatus("paid")).toBe(true);
1206
+ expect(isPaidStatus("paid_over")).toBe(true);
1207
+ expect(isPaidStatus("wrong_amount")).toBe(false);
1208
+ });
1209
+ });
1210
+
1211
+ describe("isAllowedWebhookIp", () => {
1212
+ it("allows the configured IP and localhost", () => {
1213
+ expect(isAllowedWebhookIp("31.133.220.8")).toBe(true);
1214
+ expect(isAllowedWebhookIp("127.0.0.1")).toBe(true);
1215
+ expect(isAllowedWebhookIp("::1")).toBe(true);
1216
+ expect(isAllowedWebhookIp("8.8.8.8")).toBe(false);
1217
+ });
1218
+ });
1219
+ ```
1220
+
1221
+ ### E2E webhook test (Playwright / supertest pattern)
1222
+
1223
+ ```ts
1224
+ // Compute a real signature locally so the webhook accepts the call.
1225
+ const apiKey = process.env.HELEKET_API_KEY!;
1226
+ const body = {
1227
+ type: "payment", uuid: "test-uuid", order_id: testOrderId,
1228
+ status: "paid", payment_amount_usd: "30.30",
1229
+ txid: "0xabc", payer_currency: "USDT", network: "tron",
1230
+ merchant_amount: "30.00", from: "TXyz...", commission: "0.30",
1231
+ };
1232
+ const sign = createHash("md5")
1233
+ .update(Buffer.from(JSON.stringify(body).replace(/\//g, "\\/")).toString("base64") + apiKey)
1234
+ .digest("hex");
1235
+
1236
+ const res = await fetch(`${baseUrl}/api/billing/crypto/webhook`, {
1237
+ method: "POST",
1238
+ headers: { "Content-Type": "application/json", "x-forwarded-for": "31.133.220.8" },
1239
+ body: JSON.stringify({ ...body, sign }),
1240
+ });
1241
+ expect(res.status).toBe(200);
1242
+
1243
+ // Assertions:
1244
+ // - DB row status flipped to "paid"
1245
+ // - User's balance increased by 30.30
1246
+ // - logTokenAudit row exists with action: "crypto_topup"
1247
+
1248
+ // Replay the same webhook to test idempotency:
1249
+ const res2 = await fetch(/* same call */);
1250
+ expect(res2.status).toBe(200);
1251
+ // User's balance must NOT have increased again.
1252
+ ```
1253
+
1254
+ ### Test matrix to cover
1255
+
1256
+ - Unauthorized checkout → 401
1257
+ - Amount below `MIN_AMOUNT` → 400
1258
+ - Amount above `MAX_AMOUNT` → 400
1259
+ - Rate-limited 6th call within a minute → 429
1260
+ - Webhook from disallowed IP → 403
1261
+ - Webhook with wrong signature → 400
1262
+ - Webhook with `type: "non-payment"` → 200, no DB write
1263
+ - Webhook with unknown `order_id` → 200, no DB write (do not retry)
1264
+ - First `paid` webhook → status flips, balance credited, audit logged
1265
+ - Replay `paid` webhook → status unchanged, balance NOT credited again
1266
+ - `confirm_check` then `paid` → two webhooks, one credit
1267
+ - `wrong_amount` webhook → status flips, balance NOT credited, user notified
1268
+ - `locked` webhook → status flips, balance NOT credited, user notified about AML
1269
+
1270
+ ---
1271
+
1272
+ ## 17. Security checklist
1273
+
1274
+ Before shipping to production, verify each item:
1275
+
1276
+ - [ ] `HELEKET_API_KEY` and `HELEKET_MERCHANT_ID` stored in a secret manager, not in
1277
+ `.env` committed to git.
1278
+ - [ ] Webhook route is in the **CSRF exempt list** of your middleware.
1279
+ - [ ] Webhook IP allowlist active (`isAllowedWebhookIp`), proxy-aware
1280
+ (`do-connecting-ip` / `cf-connecting-ip` / last hop of `x-forwarded-for`).
1281
+ - [ ] Signature verification uses `timingSafeEqual` (not `===`), tries both PHP-escaped
1282
+ and plain JSON forms.
1283
+ - [ ] Webhook is **idempotent**: conditional `updateMany(status notIn FINAL_STATUSES)`,
1284
+ not "find then update".
1285
+ - [ ] Credit + audit + side-effect run inside a single `db.$transaction`.
1286
+ - [ ] Audit log written for every credit (action: `crypto_topup`, includes `txid`,
1287
+ `paymentAmountUsd`, `webhookStatus`).
1288
+ - [ ] Checkout has rate limiting (per-minute and per-hour ceilings).
1289
+ - [ ] Amount bounds (`MIN_AMOUNT`, `MAX_AMOUNT`) enforced in the checkout.
1290
+ - [ ] Custom `successUrl` / `cancelUrl` validated as **same-origin** (no open redirect).
1291
+ - [ ] Orphan-invoice path (Heleket created but DB insert failed) emits a high-priority
1292
+ log + alert.
1293
+ - [ ] Webhook responds **200 OK** for "permanent" failures (unknown order, unsupported
1294
+ type) so Heleket doesn't retry forever.
1295
+ - [ ] Webhook responds **4xx/5xx** only for transient or actually-malicious cases.
1296
+ - [ ] No sensitive values in logs (`HELEKET_API_KEY`, full webhook bodies in production).
1297
+ - [ ] User balance **never goes negative** as a result of a webhook.
1298
+
1299
+ ---
1300
+
1301
+ ## 18. Common pitfalls & troubleshooting
1302
+
1303
+ | Symptom | Likely cause | Fix |
1304
+ |---------|-------------|-----|
1305
+ | Every webhook fails signature | Forgot the PHP-escape form | Use the dual `verifyWebhookSignature`. |
1306
+ | Webhook returns 403 in production but works locally | Behind a CDN (Cloudflare / DO / Vercel), real IP is in `cf-connecting-ip` / `do-connecting-ip` / `x-forwarded-for` | Implement proxy-aware `extractIp`. |
1307
+ | User pays $30, balance shows $29.97 | Crediting `paymentAmountUsd` without buffer | Add 1% buffer in checkout, credit `tokenAmount` (or use the waterfall `paymentAmountUsd ?? tokenAmount ?? amountUsd`). |
1308
+ | Subscription auto-creation fails after credit | Side-effect threw inside transaction | Wrap each side effect in `try/catch`, never roll back the credit; emit a "setup incomplete" notification. |
1309
+ | User gets credited twice on retry | Race between two webhook deliveries | Use conditional `updateMany(... status notIn FINAL_STATUSES)`; check `updated.count === 0` and bail. |
1310
+ | `additional_data` is `null` in webhook | Exceeded 255 chars | Stringify your metadata first, omit if too long, store the full version in DB only. |
1311
+ | Heleket creates invoice but no DB row exists | DB write failed after API call (orphan invoice) | Log to Sentry + ops Telegram, reconcile via `getPaymentInfo`. |
1312
+ | Webhook arrives but the `payment` lookup returns null | Wrong `order_id` field name (`orderId` vs `order_id`) | Use `bodyWithoutSign.order_id` (Heleket sends snake_case). |
1313
+ | Checkout creates invoice in EUR when you wanted USD | Heleket interprets `currency` as the **pricing** currency | Use `currency: "USD"` and `to_currency: "USDT"` for "user pays USDT priced in USD". |
1314
+ | Webhook IP allowlist passes locally but not on Vercel | Vercel doesn't forward `do-connecting-ip` | Trust `x-real-ip` or the **last hop** of `x-forwarded-for`. |
1315
+ | `state: 1, message: "Sign is wrong"` from `/v1/payment` | Body re-serialized between sign and send (key reorder, whitespace) | Stringify once, sign and send the same `string`. |
1316
+
1317
+ ---
1318
+
1319
+ ## 19. Step-by-step playbook for a NEW project
1320
+
1321
+ When the user says "add crypto payments to this project":
1322
+
1323
+ 1. **Confirm prerequisites**: Postgres + Prisma, Next.js (App Router) or any Node HTTP
1324
+ framework, an existing user model with a balance field of some kind, an authenticated
1325
+ session helper.
1326
+ 2. **Add env vars** to `.env.example` and the secret manager:
1327
+ `HELEKET_API_KEY`, `HELEKET_MERCHANT_ID`, `HELEKET_WEBHOOK_IP=31.133.220.8`,
1328
+ `SKIP_BILLING=false`.
1329
+ 3. **Add Prisma model + enum** (Section 4) and run `npx prisma migrate dev --name
1330
+ add_crypto_payments`.
1331
+ 4. **Create the client library** at `src/lib/heleket.ts` (Section 7, copy verbatim).
1332
+ 5. **Create the checkout route** at `src/app/api/billing/crypto/checkout/route.ts`
1333
+ (Section 8). Wire `auth`, `rateLimit`, and your project's balance-credit helper.
1334
+ 6. **Create the webhook route** at `src/app/api/billing/crypto/webhook/route.ts`
1335
+ (Section 9). Wire your `notify` helper and audit log.
1336
+ 7. **Add CSRF exemption** for `/api/billing/crypto/webhook` in middleware.
1337
+ 8. **Create the status route** at `src/app/api/billing/crypto/status/route.ts`
1338
+ (Section 10) for client polling.
1339
+ 9. **Add the UI**: a "Pay with crypto" button that POSTs to the checkout, then
1340
+ `window.location = data.paymentUrl`. On the success page, poll the status endpoint
1341
+ until paid.
1342
+ 10. **Add notifications** for `cryptoPaymentSuccess`, `cryptoPaymentConfirming`,
1343
+ `cryptoPaymentPartial`, `cryptoPaymentFailed`, `cryptoPaymentLocked` in your i18n
1344
+ files.
1345
+ 11. **Write tests** (Section 16): unit tests for signing + status helpers, e2e test for
1346
+ a signed mock webhook + idempotency check.
1347
+ 12. **Smoke-test with `SKIP_BILLING=true`**: full checkout → balance updated, no Heleket
1348
+ contact.
1349
+ 13. **Smoke-test with real Heleket** behind ngrok/cloudflared, $1 test-mode invoice,
1350
+ pay with USDT, verify webhook arrives and balance updates.
1351
+ 14. **Set up monitoring**: alert on orphan invoices, rejected signatures (> 5/hour
1352
+ suggests an attack), `locked` (AML) statuses, refund volume, webhook 5xx rate.
1353
+ 15. **Document** the integration in your project README (env vars, how to test, where
1354
+ refunds are issued).
1355
+
1356
+ When done the user should be able to fund their account with USDT in under 90 seconds
1357
+ end-to-end, and you should be able to reconcile every payment via `txid` in the DB.