# pay.growwithai.id: panduan integrasi

Untuk agen AI atau developer yang menghubungkan sebuah situs (GrowWithAI, kelasgratis.com, ilingo.id, dll.) ke hub pembayaran pay.growwithai.id. Baca sampai habis sebelum menulis kode.

## Apa ini

pay.growwithai.id adalah satu-satunya jembatan ke payment gateway **Tripay** untuk semua situs Irfan. Situs tidak pernah memanggil Tripay langsung dan tidak menyimpan kredensial Tripay.

```
Situs  ── Bearer token ──▶  pay.growwithai.id  ──▶  Tripay      (bikin transaksi, cek status)
Situs  ◀── webhook + X-Hub-Signature ──  pay.growwithai.id  ◀──  Tripay callback
```

Alasannya: Tripay mewajibkan IP whitelist, sedangkan Cloudflare Workers tidak punya IP keluar yang tetap. Hub berjalan di VPS ber-IP tetap `43.173.40.141`.

## Yang dibutuhkan sebelum mulai

Minta ke Irfan (jangan dikarang, jangan di-commit ke git):

| Nilai | Keterangan | Simpan sebagai |
|---|---|---|
| `PAY_HUB_TOKEN` | token API khusus situs ini | secret (mis. `wrangler secret put PAY_HUB_TOKEN`) |
| `PAY_HUB_WEBHOOK_SECRET` | kunci untuk memverifikasi webhook dari hub | secret |
| prefix `merchant_ref` | mis. `GWA`, `ILG`, `KG`. Situs **hanya** boleh memakai prefix miliknya | konstanta di kode |

Beri tahu Irfan URL webhook situsmu (mis. `https://api.situsmu.com/webhooks/payhub`), karena URL itu didaftarkan di konfigurasi hub.

Base URL: `https://pay.growwithai.id`. Semua endpoint `/v1/*` wajib header `Authorization: Bearer <PAY_HUB_TOKEN>`.

## Pilih mode integrasi

**Mode A: Checkout hosted (disarankan).** Situs cukup membuat sesi checkout lalu me-redirect pembeli. Hub menampilkan halaman pilih metode, QR/VA/e-wallet, instruksi, cek status otomatis, lalu mengembalikan pembeli ke `return_url`. Tidak perlu membangun UI pembayaran sendiri.

**Mode B: API langsung.** Situs membangun UI sendiri (pilih metode, tampilkan QR/VA). Pakai ini hanya kalau situs sudah punya checkout custom yang ingin dipertahankan.

Kedua mode memakai webhook yang sama untuk konfirmasi pembayaran.

## Mode A: checkout hosted

### 1. Buat sesi checkout (server-side)

```http
POST /v1/checkout
Authorization: Bearer <PAY_HUB_TOKEN>
Content-Type: application/json

{
  "merchant_ref": "KG-20261106-8f3a",
  "amount": 99000,
  "customer_name": "Budi Santoso",
  "customer_email": "budi@example.com",
  "customer_phone": "081234567890",
  "order_items": [{ "name": "Kelas Prompting Dasar", "price": 99000, "quantity": 1 }],
  "return_url": "https://kelasgratis.com/order/KG-20261106-8f3a",
  "methods": ["QRIS", "BRIVA", "BNIVA"],
  "expires_in": 86400
}
```

Respons:

```json
{ "success": true, "data": { "checkout_url": "https://pay.growwithai.id/c/eyJ...", "expires_at": 1793990000 } }
```

Redirect pembeli ke `checkout_url`.

Aturan validasi (hub membalas `400` kalau dilanggar):
- `merchant_ref` diawali prefix situs + `-`, unik per pesanan, maksimal ~50 karakter. Contoh: `KG-<timestamp>-<acak>`.
- `amount` integer rupiah > 0, **harus sama** dengan Σ `price × quantity` di `order_items`.
- `customer_name` dan `customer_email` wajib. `customer_phone` wajib untuk sebagian metode (OVO, retail), jadi kirim kalau ada.
- `return_url` wajib `https://`.
- `methods` opsional: daftar kode channel Tripay yang boleh dipilih. Kosongkan untuk menampilkan semua metode aktif.
- `expires_in` opsional, dalam detik, maksimal 86400 (24 jam).

Sesi checkout bertanda tangan dan tidak bisa diubah pembeli. Link yang sama boleh dibuka ulang: halaman mengingat transaksi yang sedang berjalan.

### 2. Halaman `return_url`

Setelah lunas, pembeli diarahkan ke `return_url`. **Jangan memberi akses berdasarkan kunjungan ke `return_url`.** Halaman itu hanya menampilkan status pesanan dari database situs sendiri, yang diisi oleh webhook (langkah berikut). Kalau webhook belum masuk, tampilkan "Pembayaran sedang dikonfirmasi" dan polling database situs tiap beberapa detik.

## Mode B: API langsung

| Endpoint | Fungsi |
|---|---|
| `GET /v1/channels` | Daftar metode Tripay (`code`, `name`, `group`, `fee_customer`, `total_fee`, `icon_url`, `active`). Di-cache 10 menit. |
| `POST /v1/transactions` | Buat transaksi. Body sama dengan checkout, plus `method` (kode channel, mis. `QRIS`, `BRIVA`), minus `methods`/`expires_in`. Boleh `expired_time` (unix detik). |
| `GET /v1/transactions/<reference>` | Detail transaksi dari Tripay. Hanya untuk transaksi berprefix milik situs ini. |

Respons `POST /v1/transactions` adalah respons Tripay apa adanya: `{ success, message, data }`. Field penting di `data`:
- `reference`: ID transaksi Tripay. **Simpan** di database situs.
- `pay_code`: nomor VA atau kode bayar.
- `qr_string` / `qr_url`: untuk QRIS.
- `pay_url`: untuk e-wallet redirect.
- `amount`, `fee_customer`, `total_fee`, `expired_time`, `instructions[]`, `status` (`UNPAID`).

`callback_url` selalu diisi hub sendiri. Jangan dikirim, karena akan ditimpa.

## Webhook: wajib untuk kedua mode

Hub meneruskan callback Tripay ke `webhook_url` situs:

```http
POST <webhook_url>
Content-Type: application/json
X-Hub-Signature: <hex HMAC-SHA256(raw_body, PAY_HUB_WEBHOOK_SECRET)>
X-Callback-Event: payment_status

{"reference":"T1234...","merchant_ref":"KG-20261106-8f3a","payment_method":"QRIS","payment_method_code":"QRIS",
 "total_amount":99750,"fee_merchant":0,"fee_customer":750,"total_fee":750,"amount_received":99000,
 "is_closed_payment":1,"status":"PAID","paid_at":1793900000,"note":null}
```

Langkah handler (urutan ini wajib):

1. Baca **raw body** sebagai string sebelum di-parse. Verifikasi `X-Hub-Signature` = HMAC-SHA256 hex dari raw body dengan `PAY_HUB_WEBHOOK_SECRET`, pakai perbandingan constant-time. Kalau salah, balas `401`.
2. Parse JSON. Cari pesanan berdasarkan `merchant_ref`. Kalau tidak ada, balas `200` (jangan memicu retry).
3. **Idempoten:** kalau pesanan sudah `paid`, balas `200` tanpa memproses ulang. Pembeli bisa membayar dua kali (mis. ganti metode lalu membayar keduanya), dan Tripay bisa mengirim ulang callback.
4. Untuk `status = "PAID"`: cocokkan `total_amount - fee_customer` dengan nominal pesanan. Kalau berbeda, catat dan jangan beri akses. Lalu tandai lunas, simpan `reference`, dan berikan akses/produk.
5. `EXPIRED` / `FAILED`: tandai pesanan sesuai status. `REFUND`: cabut akses bila perlu.
6. Balas HTTP `2xx` secepatnya. Non-2xx atau timeout lebih dari 15 detik membuat Tripay mengulang (3 kali, tiap 2 menit). Setelah itu callback hilang, jadi sediakan juga verifikasi cadangan (lihat di bawah).

Contoh (Cloudflare Worker / Hono):

```ts
app.post('/webhooks/payhub', async (c) => {
  const raw = await c.req.text();
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(c.env.PAY_HUB_WEBHOOK_SECRET),
    { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
  const sig = c.req.header('X-Hub-Signature') || '';
  const sigBytes = new Uint8Array((sig.match(/../g) || []).map((h) => parseInt(h, 16)));
  const ok = sig.length === 64 && await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(raw));
  if (!ok) return c.json({ success: false }, 401);

  const ev = JSON.parse(raw);
  const order = await findOrderByRef(c.env, ev.merchant_ref);
  if (!order || order.status === 'paid') return c.json({ success: true });
  if (ev.status === 'PAID') {
    if (ev.total_amount - ev.fee_customer !== order.amount) { console.error('AMOUNT_MISMATCH', ev); return c.json({ success: true }); }
    await markPaidAndGrantAccess(c.env, order, ev.reference);
  } else if (ev.status === 'EXPIRED' || ev.status === 'FAILED') {
    await markStatus(c.env, order, ev.status.toLowerCase());
  }
  return c.json({ success: true });
});
```

`crypto.subtle.verify` sudah constant-time. Di Node, pakai `crypto.timingSafeEqual`.

## Verifikasi cadangan (disarankan)

Kalau webhook terlewat, cek status langsung ke hub: `GET /v1/transactions/<reference>`, lalu proses `data.status` dengan aturan yang sama seperti webhook. Ini butuh `reference`:
- Mode B: `reference` sudah disimpan saat membuat transaksi.
- Mode A: `reference` baru diketahui dari webhook. Cadangan untuk mode A adalah meminta Irfan mengecek dashboard Tripay.

Pasang cron (mis. tiap 10 menit) untuk pesanan `pending` yang sudah punya `reference` dan berumur lebih dari 5 menit.

## Uji coba

- Hub berjalan di mode **sandbox** sampai akun Tripay production disetujui. Cek dengan `GET https://pay.growwithai.id/health` → `{"ok":true,"mode":"sandbox"|"production","tripay":true}`.
- Di sandbox, pembayaran disimulasikan dari dashboard Tripay sandbox (menu Transaksi → Simulasi pembayaran). Minta Irfan melakukannya, atau minta aksesnya.
- Checklist sebelum go-live:
  - [ ] `PAY_HUB_TOKEN` & `PAY_HUB_WEBHOOK_SECRET` tersimpan sebagai secret, tidak ada di repo.
  - [ ] Signature webhook palsu ditolak `401`.
  - [ ] Callback `PAID` yang sama dua kali hanya memberi akses sekali.
  - [ ] Nominal yang tidak cocok tidak memberi akses.
  - [ ] Akses tidak diberikan dari `return_url`.

## Error dari hub

| Status | Arti |
|---|---|
| `400` | Validasi gagal. Baca `message`. |
| `401` | Token salah atau tidak ada. |
| `403` | `merchant_ref` memakai prefix situs lain. |
| `404` | Transaksi tidak ditemukan atau bukan milik situs ini. |
| `410` | Link checkout kedaluwarsa atau rusak. Buat sesi baru. |
| `503` | Kredensial Tripay belum dipasang di hub. |
| lainnya | Diteruskan dari Tripay, lihat `message`. |

## Menambah situs baru (khusus Irfan / agen yang punya akses VPS)

Konfigurasi situs ada di `/etc/pay-hub.env` pada VPS (`ssh -i ~/.ssh/pay-hub_ed25519 ubuntu@43.173.40.141`), variabel `SITES_JSON`:

```json
{"kelasgratis":{"label":"KelasGratis","token":"<openssl rand -hex 32>","prefixes":["KG"],
  "webhook_url":"https://api.kelasgratis.com/webhooks/payhub","webhook_secret":"<openssl rand -hex 32>"}}
```

Prefix tidak boleh dipakai dua situs. Setelah mengubah file: `sudo systemctl restart pay-hub`.
