@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,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.
|