Compare commits
3 Commits
b0ded99d34
...
11ca3f5117
| Author | SHA1 | Date |
|---|---|---|
|
|
11ca3f5117 | |
|
|
4d10f0e121 | |
|
|
a940fda6b2 |
|
|
@ -0,0 +1,161 @@
|
||||||
|
# Panduan QA End-to-End: Payment Link
|
||||||
|
|
||||||
|
Dokumen ini menjelaskan alur end-to-end Payment Link yang tersedia di proyek, mencakup konfigurasi, endpoint backend, format token, langkah QA dengan contoh request, hingga troubleshooting dan integrasi frontend.
|
||||||
|
|
||||||
|
## Ringkasan Alur
|
||||||
|
- ERP/eksternal membuat Payment Link via `POST /createtransaksi` dan menerima `url` serta `token`.
|
||||||
|
- Frontend membuka halaman `Pay` di route `pay/:token`, lalu me-resolve token via `GET /api/payment-links/:token`.
|
||||||
|
- Pengguna memilih metode pembayaran (sesuai `allowed_methods` dan toggle runtime), kemudian frontend memanggil `POST /api/payments/charge`.
|
||||||
|
- Status pembayaran dapat dicek via `GET /api/payments/:orderId/status` dan disinkronkan via webhook `POST /api/payments/webhook`.
|
||||||
|
|
||||||
|
## Prasyarat & Konfigurasi
|
||||||
|
- Backend: jalankan `node server/index.cjs` (default port `8000`).
|
||||||
|
- Frontend: jalankan `npm run dev` (contoh dev port: `5175`).
|
||||||
|
- Penyesuaian environment penting:
|
||||||
|
- `EXTERNAL_API_KEY`: API Key luar untuk `POST /createtransaksi`. Jika tidak diset, di dev akan diizinkan tanpa key.
|
||||||
|
- `PAYMENT_LINK_SECRET`: secret untuk penandatanganan token Payment Link (HMAC SHA-256). Default dev: `dev-secret`.
|
||||||
|
- `PAYMENT_LINK_TTL_MINUTES`: waktu kedaluwarsa token (default: `30`).
|
||||||
|
- `PAYMENT_LINK_BASE`: base URL untuk halaman `Pay` (default: `http://localhost:5174/pay`). Sesuaikan ke port frontend yang aktif (misal `http://localhost:5175/pay`).
|
||||||
|
- `PORT`: port backend (default: `8000`).
|
||||||
|
- `ERP_NOTIFICATION_URL`, `ERP_CLIENT_ID`, `ERP_MERCANT_ID`, `ERP_ENABLE_NOTIF`: konfigurasi notifikasi ERP saat settlement.
|
||||||
|
- Midtrans keys: Server Key dan Client Key harus tersedia untuk charge/status.
|
||||||
|
- Frontend env: `VITE_API_BASE_URL` (contoh: `http://localhost:8000/api`), `VITE_MIDTRANS_CLIENT_KEY`.
|
||||||
|
|
||||||
|
## Endpoint Backend
|
||||||
|
- `POST /createtransaksi`
|
||||||
|
- Header: `X-API-KEY` (opsional di dev jika `EXTERNAL_API_KEY` tidak diset).
|
||||||
|
- Body: `{ item_id | order_id, nominal, customer?, allowed_methods? }`
|
||||||
|
- Respon: `{ url, token, order_id, nominal, expire_at }`
|
||||||
|
- Error: `UNAUTHORIZED`, `BAD_REQUEST`, `ORDER_COMPLETED`, `ORDER_ACTIVE`, `CREATE_ERROR`.
|
||||||
|
|
||||||
|
- `GET /api/payment-links/:token`
|
||||||
|
- Respon: `{ order_id, nominal, customer?, expire_at?, allowed_methods? }`
|
||||||
|
- Error: `410 TOKEN_EXPIRED`, `400 INVALID_*` (di dev ada fallback payload jika token invalid).
|
||||||
|
|
||||||
|
- `POST /api/payments/charge`
|
||||||
|
- Body: payload Midtrans (contoh di bawah). Diblokir jika method dimatikan oleh runtime toggles.
|
||||||
|
- Error: `PAYMENT_TYPE_DISABLED`, `CHARGE_ERROR`.
|
||||||
|
|
||||||
|
- `GET /api/payments/:orderId/status`
|
||||||
|
- Respon: pass-through dari Midtrans (transaction status, VA, dll.).
|
||||||
|
|
||||||
|
- `POST /api/payments/webhook`
|
||||||
|
- Verifikasi signature: `sha512(orderId + statusCode + grossAmount + serverKey)`.
|
||||||
|
- Pada sukses (settlement atau capture+accept untuk kartu), backend kirim notifikasi ke ERP (jika diaktifkan) dan menandai order sebagai completed.
|
||||||
|
|
||||||
|
- `GET /api/health`, `GET/POST /api/config`
|
||||||
|
- Health: cek ketersediaan key dan environment.
|
||||||
|
- Config: baca/ubah toggles (dev-only untuk `POST`).
|
||||||
|
|
||||||
|
## Format Token Payment Link
|
||||||
|
- Token adalah `base64url(JSON)` dengan fields minimal: `{ v, order_id, nominal, expire_at, sig, customer?, allowed_methods? }`.
|
||||||
|
- `sig` adalah HMAC SHA-256 dari string kanonik: `"order_id|nominal|expire_at"` menggunakan `PAYMENT_LINK_SECRET`.
|
||||||
|
|
||||||
|
## Langkah QA (Contoh)
|
||||||
|
|
||||||
|
1) Buat Payment Link
|
||||||
|
|
||||||
|
PowerShell (Windows):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$body = @{
|
||||||
|
item_id = 'INV-PL-001';
|
||||||
|
nominal = 150000;
|
||||||
|
customer = @{ name='QA Tester'; phone='081234567890'; email='qa@example.com' };
|
||||||
|
allowed_methods = @('bank_transfer','cstore','gopay','credit_card')
|
||||||
|
} | ConvertTo-Json -Depth 5;
|
||||||
|
|
||||||
|
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/createtransaksi' -ContentType 'application/json' -Body $body | ConvertTo-Json -Depth 5
|
||||||
|
```
|
||||||
|
|
||||||
|
curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/createtransaksi \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'X-API-KEY: <jika-diperlukan>' \
|
||||||
|
-d '{
|
||||||
|
"item_id":"INV-PL-001",
|
||||||
|
"nominal":150000,
|
||||||
|
"customer": {"name":"QA Tester","phone":"081234567890","email":"qa@example.com"},
|
||||||
|
"allowed_methods":["bank_transfer","cstore","gopay","credit_card"]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
2) Resolve Token
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod -Method GET -Uri "http://localhost:8000/api/payment-links/<token>" | ConvertTo-Json -Depth 5
|
||||||
|
```
|
||||||
|
|
||||||
|
3) Buka Halaman Pay
|
||||||
|
|
||||||
|
- Buka `http://localhost:5175/pay/<token>` (sesuaikan `PAYMENT_LINK_BASE` dengan port frontend).
|
||||||
|
- Periksa daftar metode, panel, serta batasan dari `allowed_methods` dan runtime toggles.
|
||||||
|
|
||||||
|
4) Charge Bank Transfer (BCA)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$bt = @{
|
||||||
|
payment_type = 'bank_transfer';
|
||||||
|
transaction_details = @{ order_id = 'INV-PL-001'; gross_amount = 150000 };
|
||||||
|
bank_transfer = @{ bank = 'bca' }
|
||||||
|
} | ConvertTo-Json -Depth 5;
|
||||||
|
|
||||||
|
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/api/payments/charge' -ContentType 'application/json' -Body $bt | ConvertTo-Json -Depth 5
|
||||||
|
```
|
||||||
|
|
||||||
|
5) Charge GoPay/QR (opsional)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$qr = @{
|
||||||
|
payment_type = 'gopay';
|
||||||
|
transaction_details = @{ order_id = 'INV-PL-001'; gross_amount = 150000 };
|
||||||
|
gopay = @{ enable_qr = $true }
|
||||||
|
} | ConvertTo-Json -Depth 5;
|
||||||
|
|
||||||
|
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/api/payments/charge' -ContentType 'application/json' -Body $qr | ConvertTo-Json -Depth 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Catatan: Bila `400 Bad Request`, cek konfigurasi akun sandbox dan parameter GoPay/QRIS (lihat bagian troubleshooting).
|
||||||
|
|
||||||
|
6) Status Check
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod -Method GET -Uri 'http://localhost:8000/api/payments/INV-PL-001/status' | ConvertTo-Json -Depth 5
|
||||||
|
```
|
||||||
|
|
||||||
|
7) Webhook (uji manual)
|
||||||
|
|
||||||
|
Untuk uji manual, kirim payload menyerupai notifikasi Midtrans dengan `signature_key` yang valid. Signature dihitung:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Node.js contoh perhitungan signature
|
||||||
|
const crypto = require('crypto')
|
||||||
|
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||||
|
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
|
||||||
|
return crypto.createHash('sha512').update(raw).digest('hex')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Kemudian `POST` ke `http://localhost:8000/api/payments/webhook` dengan body berisi fields Midtrans (order_id, status_code, gross_amount, signature_key, dll.).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Frontend tidak sesuai `PAYMENT_LINK_BASE` (5174 vs 5175): set `PAYMENT_LINK_BASE=http://localhost:5175/pay` di backend agar URL link mengarah ke port yang benar.
|
||||||
|
- `400 Bad Request` untuk GoPay/QR:
|
||||||
|
- Pastikan `gopay` payload memenuhi kebutuhan sandbox (mis. `enable_qr`, kadang perlu `qr_black_white`, atau `callback_url`).
|
||||||
|
- Periksa toggles runtime (`/api/config`) dan ketersediaan Midtrans keys.
|
||||||
|
- Beberapa merchant sandbox memiliki batasan; rujuk dokumentasi Midtrans untuk parameter terbaru.
|
||||||
|
- `UNAUTHORIZED` saat `createtransaksi`: set header `X-API-KEY` sesuai `EXTERNAL_API_KEY` jika dikonfigurasi.
|
||||||
|
- `ORDER_ACTIVE` atau `ORDER_COMPLETED`: backend menjaga `activeOrders` dan `notifiedOrders` untuk mencegah duplikasi; tunggu TTL atau gunakan order baru.
|
||||||
|
|
||||||
|
## Integrasi Frontend
|
||||||
|
- Route: `pay/:token` (lihat `src/app/router.tsx`).
|
||||||
|
- Resolver: `getPaymentLinkPayload(token)` (lihat `src/services/api.ts`).
|
||||||
|
- Toggle & Allowed Methods: `PayPage` menggabungkan `runtimeCfg.paymentToggles` dengan `allowed_methods`. Kunci metode: `bank_transfer`, `credit_card`, `gopay`, `cstore`, `cpay`.
|
||||||
|
|
||||||
|
## Notifikasi ERP
|
||||||
|
- Di settlement sukses, backend menghitung signature ERP (`sha512`) dan mengirim payload ke `ERP_NOTIFICATION_URL` jika `ERP_ENABLE_NOTIF=true` dan konfigurasi lengkap.
|
||||||
|
|
||||||
|
## Postman Collection
|
||||||
|
- Anda dapat mengimpor koleksi: `docs/qa/payment-link.postman_collection.json` untuk mencoba endpoint di atas.
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"name": "Payment Link QA",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"variable": [
|
||||||
|
{ "key": "baseUrl", "value": "http://localhost:8000" },
|
||||||
|
{ "key": "paymentLinkBase", "value": "http://localhost:5174/pay" },
|
||||||
|
{ "key": "externalApiKey", "value": "" },
|
||||||
|
{ "key": "token", "value": "" },
|
||||||
|
{ "key": "order_id", "value": "" }
|
||||||
|
],
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "1) Create Transaction",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{ "key": "Content-Type", "value": "application/json" },
|
||||||
|
{ "key": "X-API-KEY", "value": "{{externalApiKey}}" }
|
||||||
|
],
|
||||||
|
"url": { "raw": "{{baseUrl}}/createtransaksi", "host": ["{{baseUrl}}"], "path": ["createtransaksi"] },
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"item_id\": \"order-demo-1\",\n \"nominal\": 150000,\n \"customer\": { \"name\": \"Demo User\", \"email\": \"demo@example.com\" },\n \"allowed_methods\": [\"bank_transfer\", \"credit_card\", \"gopay\", \"cstore\"]\n}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "2) Resolve Token",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": { "raw": "{{baseUrl}}/api/payment-links/{{token}}", "host": ["{{baseUrl}}"], "path": ["api","payment-links","{{token}}"] }
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3) Charge - Bank Transfer (BCA)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||||
|
"url": { "raw": "{{baseUrl}}/api/payments/charge", "host": ["{{baseUrl}}"], "path": ["api","payments","charge"] },
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"payment_type\": \"bank_transfer\",\n \"transaction_details\": { \"order_id\": \"{{order_id}}\", \"gross_amount\": 150000 },\n \"bank_transfer\": { \"bank\": \"bca\" }\n}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3) Charge - CStore (Indomaret)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||||
|
"url": { "raw": "{{baseUrl}}/api/payments/charge", "host": ["{{baseUrl}}"], "path": ["api","payments","charge"] },
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"payment_type\": \"cstore\",\n \"transaction_details\": { \"order_id\": \"{{order_id}}\", \"gross_amount\": 150000 },\n \"cstore\": { \"store\": \"indomaret\" }\n}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "4) Payment Status",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": { "raw": "{{baseUrl}}/api/payments/{{order_id}}/status", "host": ["{{baseUrl}}"], "path": ["api","payments","{{order_id}}","status"] }
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
# Sprint Change Proposal — ERP → Create Transaction → Payment Link Flow
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- Trigger: Checkout tidak lagi dimulai dari tombol “Buy Now” di frontend. Sistem eksternal (ERP/billing script) mengirim data transaksi ke server dan menerima tautan pembayaran terenkripsi untuk dibagikan ke pengguna.
|
||||||
|
- Outcome: Backend menjadi titik inisiasi transaksi. Pengguna membuka Payment Page via link (`/pay/:token`), memilih metode (VA/GoPay/Cstore/Kartu), lalu UI mengeksekusi charge seperti biasa. Webhook Midtrans dan ERP callback tetap berjalan.
|
||||||
|
|
||||||
|
## Objectives (Sprint)
|
||||||
|
- Menyediakan endpoint backend `POST /createtransaksi` yang mengeluarkan payment link bertanda-tangan (HMAC‑SHA256) dengan TTL dan anti‑replay.
|
||||||
|
- Menambahkan rute frontend `"/pay/:token"` (Payment Page) untuk memvalidasi token, menampilkan metode, dan melakukan charge menggunakan `order_id` dari token.
|
||||||
|
- Menjaga kompatibilitas endpoint yang ada: `POST /api/payments/charge`, `GET /api/payments/:orderId/status`, `POST /api/payments/webhook`.
|
||||||
|
- Menyelaraskan PRD, Arsitektur UI, dan Story E2E dengan alur baru.
|
||||||
|
|
||||||
|
## Scope & Impact
|
||||||
|
- PRD (`docs/prd.md`): Tambah FR “External Create Transaction & Payment Link”, ubah sumber `order_id` (berasal dari `item_id`), tambahkan ketentuan TTL, signature, dan anti‑replay.
|
||||||
|
- Arsitektur UI (`docs/ui-architecture.md`): Tambah rute `"/pay/:token"`, alur token‑driven. `CheckoutPage` tetap sebagai demo/QA.
|
||||||
|
- Story E2E (`docs/stories/midtrans-e2e-checkout-to-webhook.md`): Mulai dari Payment Page via token, bukan dari CheckoutPage langsung.
|
||||||
|
- Backend (`server/index.cjs`): Tambah endpoint `POST /createtransaksi` dan resolver token (API untuk FE). Env baru: `EXTERNAL_API_KEY`, `PAYMENT_LINK_SECRET`, `PAYMENT_LINK_TTL_MINUTES`.
|
||||||
|
- Frontend: Tambah halaman `PayPage` (`/pay/:token`), service untuk resolve token. `postCharge/getPaymentStatus` tetap.
|
||||||
|
|
||||||
|
## Proposed Design
|
||||||
|
|
||||||
|
### 1) Backend — Create Transaction API
|
||||||
|
- Endpoint: `POST /createtransaksi`
|
||||||
|
- Auth: Header `X-API-KEY: <EXTERNAL_API_KEY>` (validasi exact match; opsi IP whitelist & rate limit di reverse proxy).
|
||||||
|
- Body (contoh minimal):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"merchant_id": "TKG-250520029803",
|
||||||
|
"deskripsi": "Pembelian item",
|
||||||
|
"nominal": 200000,
|
||||||
|
"nama": "Dwiki Kurnia Sandi",
|
||||||
|
"no_telepon": "081234567890",
|
||||||
|
"email": "demo@example.com",
|
||||||
|
"item": [
|
||||||
|
{ "item_id": "ITEM-12345", "qty": 1, "price": 200000, "name": "Produk A" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Mapping `order_id`: gunakan `item[0].item_id` sebagai `order_id` untuk Midtrans (validasi format/unik per transaksi aktif).
|
||||||
|
- Idempotensi: jika `order_id` + `nominal` sama dan status transaksi masih `pending`, kembalikan payment link sebelumnya; jika `nominal` berbeda, kembalikan `422 AMOUNT_MISMATCH`.
|
||||||
|
- Response (sukses):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status_code": "200",
|
||||||
|
"status_message": "OK",
|
||||||
|
"url": "https://cifopayment.id/pay/<token>?sig=<signature>",
|
||||||
|
"exp": 1730000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `token`: opaque base64url berisi klaim minimal (mis. `order_id`, `nominal`, `exp`) yang disimpan server.
|
||||||
|
- `signature`: `hex(HMAC_SHA256(token, PAYMENT_LINK_SECRET))`.
|
||||||
|
- `exp`: UNIX epoch seconds (TTL default 30 menit; dapat dikonfigurasi).
|
||||||
|
|
||||||
|
### 2) Backend — Payment Link Resolve API
|
||||||
|
- Endpoint: `GET /api/payment-links/:token` (digunakan frontend untuk bootstrap Payment Page).
|
||||||
|
- Query: otomatis memverifikasi signature (`sig` di query atau header), TTL/anti‑replay, dan mengembalikan payload:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": "ITEM-12345",
|
||||||
|
"nominal": 200000,
|
||||||
|
"customer": { "name": "Dwiki", "phone": "081234567890", "email": "demo@example.com" },
|
||||||
|
"expire_at": 1730000000,
|
||||||
|
"allowed_methods": ["bank_transfer", "gopay", "cstore", "credit_card"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Error: `401 INVALID_SIGNATURE`, `410 LINK_EXPIRED`, `409 LINK_USED` (opsional jika anti‑replay menandai sekali pakai).
|
||||||
|
|
||||||
|
### 3) Frontend — Payment Page (`/pay/:token`)
|
||||||
|
- Flow:
|
||||||
|
- Ambil `token` dari URL → panggil `GET /api/payment-links/:token`.
|
||||||
|
- Set lokal `orderId`, `amount`, `expireAt`, dan info pelanggan.
|
||||||
|
- Render komponen metode (VA/GoPay/Cstore/Kartu) seperti di Checkout, tetapi `orderId` berasal dari token.
|
||||||
|
- Setelah charge, navigasi ke `"/payments/:orderId/status"` (polling status tetap).
|
||||||
|
- Catatan: `CheckoutPage` tetap ada untuk demo/QA; jalur produksi menggunakan Payment Page via link.
|
||||||
|
|
||||||
|
### 4) Webhook & ERP Callback
|
||||||
|
- Tetap: `POST /api/payments/webhook` memverifikasi signature Midtrans dan memperbarui status.
|
||||||
|
- ERP Callback: kirim `POST` ke `https://apibackend.erpskrip.id/paymentnotification/` pada status sukses, signature `sha512(mercant_id + status_code + nominal + client_id)` seperti implementasi saat ini.
|
||||||
|
|
||||||
|
### 5) Security & Config
|
||||||
|
- Secrets:
|
||||||
|
- `EXTERNAL_API_KEY`: memvalidasi `X-API-KEY` dari ERP.
|
||||||
|
- `PAYMENT_LINK_SECRET`: kunci HMAC untuk signature token.
|
||||||
|
- `PAYMENT_LINK_TTL_MINUTES`: default 30.
|
||||||
|
- Praktik:
|
||||||
|
- Rate‑limit `POST /createtransaksi` dan audit log.
|
||||||
|
- Anti‑replay: tandai token sebagai “used” setelah berhasil charge (opsional; atau izinkan reuse sampai status final).
|
||||||
|
- Rotasi secret terjadwal; invalidasi token lama secara bertahap bila diperlukan.
|
||||||
|
|
||||||
|
## Acceptance Criteria (Sprint)
|
||||||
|
- Backend mengeluarkan payment link dengan `token` + `signature`, valid hingga TTL.
|
||||||
|
- Resolve API mengembalikan `order_id` dan `nominal` yang konsisten; invalid jika signature/TTL gagal.
|
||||||
|
- Frontend Payment Page (`/pay/:token`) dapat:
|
||||||
|
- Memvalidasi token dan menampilkan metode pembayaran.
|
||||||
|
- Melakukan charge via endpoint yang ada menggunakan `order_id` dari token.
|
||||||
|
- Menavigasi ke halaman status dan menampilkan detail (VA/QR/payment code/kartu) sesuai metode.
|
||||||
|
- Webhook menerima notifikasi Midtrans dan ERP callback tetap terkirim saat sukses.
|
||||||
|
- Dokumentasi PRD, Arsitektur UI, dan Story E2E diperbarui mencerminkan alur baru.
|
||||||
|
|
||||||
|
## Non‑Functional Requirements
|
||||||
|
- Observability: logging request ID, event penting (`link.create`, `link.resolve`, `charge.start/success`, `webhook.receive`).
|
||||||
|
- Idempotensi: kembalikan link lama untuk transaksi `pending` dengan `order_id` + `nominal` yang sama.
|
||||||
|
- Keamanan: tidak mengekspos server key; signature Midtrans diverifikasi; token link ditandatangani HMAC‑SHA256.
|
||||||
|
- Kinerja: endpoint create/resolver respon <200ms p95 (lokal dev).
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
- Replay/penyalahgunaan link: enforce TTL, anti‑replay flag, rate‑limit.
|
||||||
|
- Konflik `order_id`: validasi unik; strategy untuk recurring (suffix waktu/sequence bila diperlukan).
|
||||||
|
- Ketidaksesuaian nominal: `422 AMOUNT_MISMATCH` untuk `order_id` sama namun nominal beda.
|
||||||
|
- Gangguan ERP: retry callback dengan backoff, feature flag `ERP_ENABLE_NOTIF` untuk mematikan sementara.
|
||||||
|
|
||||||
|
## Rollback Plan
|
||||||
|
- Nonaktifkan konsumsi resolver token (feature flag) dan kembalikan ke alur Checkout demo.
|
||||||
|
- Pertahankan endpoint status & webhook agar UI tetap dapat polling status.
|
||||||
|
|
||||||
|
## Deliverables (Sprint)
|
||||||
|
- Backend: `POST /createtransaksi`, `GET /api/payment-links/:token` (validator signature/TTL), konfigurasi env baru.
|
||||||
|
- Frontend: halaman `PayPage` (`/pay/:token`) dengan integrasi panel metode yang ada.
|
||||||
|
- Docs: update PRD, Arsitektur UI, dan Story E2E sesuai token‑driven flow.
|
||||||
|
|
||||||
|
## Timeline & Tasking (5 hari)
|
||||||
|
- Day 1: Skeleton endpoint `createtransaksi` + HMAC signing + env wiring.
|
||||||
|
- Day 2: Resolver API + idempotensi + anti‑replay dasar.
|
||||||
|
- Day 3: FE `PayPage` + service `getPaymentLinkPayload(token)` + navigasi.
|
||||||
|
- Day 4: QA manual (sandbox) + webhook/ERP callback sanity.
|
||||||
|
- Day 5: Dokumentasi & polish (error codes, logging, runbook).
|
||||||
|
|
||||||
|
## Decisions Confirmed
|
||||||
|
- TTL payment link: 30 menit (konfigurabel; default 30) — disetujui.
|
||||||
|
- Token: HMAC‑SHA256 signature (tanpa enkripsi payload; confidentiality tidak diwajibkan saat ini).
|
||||||
|
- Recurring transaksi: tidak didukung. Kebijakan:
|
||||||
|
- Satu transaksi aktif per `item_id` pada satu waktu.
|
||||||
|
- Jika status sukses (`settlement` atau `capture + fraud_status=accept`), permintaan baru untuk `item_id` yang sama ditolak (`409 ORDER_COMPLETED`).
|
||||||
|
- Re-attempt diperbolehkan hanya jika transaksi sebelumnya tidak sukses dan sudah berakhir (`expire/cancel/deny`), menggunakan kebijakan unik `order_id` sesuai kebutuhan implementasi Midtrans.
|
||||||
|
|
||||||
|
## Appendix — Example
|
||||||
|
- Request `POST /createtransaksi` (ringkas): lihat contoh pada bagian Backend di atas.
|
||||||
|
- Response sukses:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status_code": "200",
|
||||||
|
"status_message": "OK",
|
||||||
|
"url": "https://cifopayment.id/pay/eyJvcmRlcl9pZCI6IklURU0tMTIzNDUiLCJub21pbmFsIjoyMDAwMDAsImV4cCI6MTczMDAwMDAwMH0?sig=4c7f...",
|
||||||
|
"exp": 1730000000
|
||||||
|
}
|
||||||
|
```
|
||||||
116
server/index.cjs
116
server/index.cjs
|
|
@ -98,6 +98,53 @@ const ENABLE = {
|
||||||
cstore: parseEnable(process.env.ENABLE_CSTORE),
|
cstore: parseEnable(process.env.ENABLE_CSTORE),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Payment Link Config
|
||||||
|
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''
|
||||||
|
const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || ''
|
||||||
|
const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '30', 10)
|
||||||
|
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
|
||||||
|
const activeOrders = new Map() // order_id -> expire_at
|
||||||
|
|
||||||
|
function isDevEnv() { return (process.env.NODE_ENV || '').toLowerCase() !== 'production' }
|
||||||
|
function verifyExternalKey(req) {
|
||||||
|
const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString()
|
||||||
|
if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY
|
||||||
|
// Allow if not configured only in dev for easier local testing
|
||||||
|
return isDevEnv()
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64UrlEncode(buf) {
|
||||||
|
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||||
|
}
|
||||||
|
function base64UrlDecode(str) {
|
||||||
|
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
||||||
|
const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad
|
||||||
|
return Buffer.from(s, 'base64').toString('utf8')
|
||||||
|
}
|
||||||
|
function computeTokenSignature(orderId, nominal, expireAt) {
|
||||||
|
const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}`
|
||||||
|
return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex')
|
||||||
|
}
|
||||||
|
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
|
||||||
|
const v = 1
|
||||||
|
const sig = computeTokenSignature(order_id, nominal, expire_at)
|
||||||
|
const payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods }
|
||||||
|
return base64UrlEncode(JSON.stringify(payload))
|
||||||
|
}
|
||||||
|
function resolvePaymentLinkToken(token) {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(base64UrlDecode(token))
|
||||||
|
const { order_id, nominal, expire_at, sig } = json || {}
|
||||||
|
if (!order_id || !nominal || !expire_at || !sig) return { error: 'INVALID_TOKEN' }
|
||||||
|
const expected = computeTokenSignature(order_id, nominal, expire_at)
|
||||||
|
if (String(sig) !== String(expected)) return { error: 'INVALID_SIGNATURE' }
|
||||||
|
if (Date.now() > Number(expire_at)) return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
|
||||||
|
return { payload: json }
|
||||||
|
} catch (e) {
|
||||||
|
return { error: 'TOKEN_PARSE_ERROR', message: e?.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
||||||
|
|
@ -131,6 +178,29 @@ app.post('/api/config', (req, res) => {
|
||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Payment Link Resolver: GET /api/payment-links/:token
|
||||||
|
app.get('/api/payment-links/:token', (req, res) => {
|
||||||
|
const { token } = req.params
|
||||||
|
const result = resolvePaymentLinkToken(token)
|
||||||
|
if (result.error === 'TOKEN_EXPIRED') {
|
||||||
|
logWarn('paymentlink.expired', { order_id: result.payload?.order_id })
|
||||||
|
return res.status(410).json({ error: result.error, ...result.payload })
|
||||||
|
}
|
||||||
|
if (result.error) {
|
||||||
|
logWarn('paymentlink.invalid', { error: result.error })
|
||||||
|
if (isDevEnv()) {
|
||||||
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 30
|
||||||
|
const fallback = { order_id: token, nominal: 150000, expire_at: Date.now() + ttlMin * 60 * 1000 }
|
||||||
|
logInfo('paymentlink.dev.fallback', { order_id: fallback.order_id })
|
||||||
|
return res.json(fallback)
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: result.error })
|
||||||
|
}
|
||||||
|
const p = result.payload
|
||||||
|
logInfo('paymentlink.resolve.success', { order_id: p.order_id, expire_at: p.expire_at })
|
||||||
|
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
|
||||||
|
})
|
||||||
|
|
||||||
// Charge endpoint (pass-through to Midtrans Core API)
|
// Charge endpoint (pass-through to Midtrans Core API)
|
||||||
app.post('/api/payments/charge', async (req, res) => {
|
app.post('/api/payments/charge', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -180,6 +250,50 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// External ERP Create Transaction → issue payment link
|
||||||
|
app.post('/createtransaksi', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!verifyExternalKey(req)) {
|
||||||
|
logWarn('createtransaksi.unauthorized', { id: req.id })
|
||||||
|
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
||||||
|
}
|
||||||
|
const itemId = req?.body?.item_id || req?.body?.order_id
|
||||||
|
const nominalRaw = req?.body?.nominal
|
||||||
|
const customer = req?.body?.customer
|
||||||
|
const allowed_methods = req?.body?.allowed_methods
|
||||||
|
if (!itemId || typeof nominalRaw === 'undefined') {
|
||||||
|
logWarn('createtransaksi.bad_request', { id: req.id })
|
||||||
|
return res.status(400).json({ error: 'BAD_REQUEST', message: 'item_id and nominal are required' })
|
||||||
|
}
|
||||||
|
const order_id = String(itemId)
|
||||||
|
const nominal = Number(nominalRaw)
|
||||||
|
const now = Date.now()
|
||||||
|
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 30
|
||||||
|
const expire_at = now + ttlMin * 60 * 1000
|
||||||
|
|
||||||
|
// Block if already completed
|
||||||
|
if (notifiedOrders.has(order_id)) {
|
||||||
|
logWarn('createtransaksi.completed', { order_id })
|
||||||
|
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
||||||
|
}
|
||||||
|
// Block if active link exists and not expired yet
|
||||||
|
const existing = activeOrders.get(order_id)
|
||||||
|
if (existing && existing > now) {
|
||||||
|
logWarn('createtransaksi.active_exists', { order_id })
|
||||||
|
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods })
|
||||||
|
const url = `${PAYMENT_LINK_BASE}/${token}`
|
||||||
|
activeOrders.set(order_id, expire_at)
|
||||||
|
logInfo('createtransaksi.issued', { order_id, expire_at })
|
||||||
|
res.json({ url, token, order_id, nominal, expire_at })
|
||||||
|
} catch (e) {
|
||||||
|
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
||||||
|
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// --- Helpers: Midtrans signature verification & ERP notify
|
// --- Helpers: Midtrans signature verification & ERP notify
|
||||||
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -294,6 +408,8 @@ app.post('/api/payments/webhook', async (req, res) => {
|
||||||
const nominal = String(grossAmount)
|
const nominal = String(grossAmount)
|
||||||
if (!notifiedOrders.has(orderId)) {
|
if (!notifiedOrders.has(orderId)) {
|
||||||
notifiedOrders.add(orderId)
|
notifiedOrders.add(orderId)
|
||||||
|
// Mark order inactive upon completion
|
||||||
|
activeOrders.delete(orderId)
|
||||||
await notifyERP({ orderId, nominal })
|
await notifyERP({ orderId, nominal })
|
||||||
} else {
|
} else {
|
||||||
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
|
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||||
import { AppLayout } from './AppLayout'
|
import { AppLayout } from './AppLayout'
|
||||||
import { CheckoutPage } from '../pages/CheckoutPage'
|
import { CheckoutPage } from '../pages/CheckoutPage'
|
||||||
import { PaymentStatusPage } from '../pages/PaymentStatusPage'
|
import { PaymentStatusPage } from '../pages/PaymentStatusPage'
|
||||||
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
|
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
|
||||||
import { NotFoundPage } from '../pages/NotFoundPage'
|
import { NotFoundPage } from '../pages/NotFoundPage'
|
||||||
import { DemoStorePage } from '../pages/DemoStorePage'
|
import { DemoStorePage } from '../pages/DemoStorePage'
|
||||||
|
import { PayPage } from '../pages/PayPage'
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
|
|
@ -14,6 +15,7 @@ const router = createBrowserRouter([
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <DemoStorePage /> },
|
{ index: true, element: <DemoStorePage /> },
|
||||||
{ path: 'checkout', element: <CheckoutPage /> },
|
{ path: 'checkout', element: <CheckoutPage /> },
|
||||||
|
{ path: 'pay/:token', element: <PayPage /> },
|
||||||
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
|
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
|
||||||
{ path: 'history', element: <PaymentHistoryPage /> },
|
{ path: 'history', element: <PaymentHistoryPage /> },
|
||||||
{ path: '*', element: <NotFoundPage /> },
|
{ path: '*', element: <NotFoundPage /> },
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ const chargeTasks = new Map<string, Promise<any>>()
|
||||||
|
|
||||||
export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) {
|
export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) {
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
const [selected, setSelected] = React.useState<BankKey | null>(defaultBank ?? null)
|
const [selected] = React.useState<BankKey | null>(defaultBank ?? null)
|
||||||
const [showGuide, setShowGuide] = React.useState(false)
|
const [showGuide, setShowGuide] = React.useState(false)
|
||||||
const [busy, setBusy] = React.useState(false)
|
const [busy, setBusy] = React.useState(false)
|
||||||
const [vaCode, setVaCode] = React.useState('')
|
const [vaCode, setVaCode] = React.useState('')
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const cstoreTasks = new Map<string, Promise<any>>()
|
||||||
|
|
||||||
export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) {
|
export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) {
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
const [selected, setSelected] = React.useState<StoreKey | null>(defaultStore ?? null)
|
const [selected] = React.useState<StoreKey | null>(defaultStore ?? null)
|
||||||
const [showGuide, setShowGuide] = React.useState(false)
|
const [showGuide, setShowGuide] = React.useState(false)
|
||||||
const [busy, setBusy] = React.useState(false)
|
const [busy, setBusy] = React.useState(false)
|
||||||
const [paymentCode, setPaymentCode] = React.useState('')
|
const [paymentCode, setPaymentCode] = React.useState('')
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,7 @@ function pickQrImageUrl(res: any, acts: Array<{ name?: string; method?: string;
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function QRPlaceholder() {
|
//
|
||||||
return (
|
|
||||||
<div className="aspect-square w-full max-w-[220px] mx-auto bg-white dark:bg-black grid grid-cols-9 grid-rows-9">
|
|
||||||
{Array.from({ length: 81 }).map((_, i) => (
|
|
||||||
<div key={i} className={(i + Math.floor(i / 9)) % 2 === 0 ? 'bg-black' : 'bg-white'} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react'
|
|
||||||
import { Button } from '../../../components/ui/button'
|
import { Button } from '../../../components/ui/button'
|
||||||
import { usePaymentNavigation } from '../lib/navigation'
|
import { usePaymentNavigation } from '../lib/navigation'
|
||||||
import { usePaymentStatus } from '../lib/usePaymentStatus'
|
import { usePaymentStatus } from '../lib/usePaymentStatus'
|
||||||
|
|
@ -15,7 +14,6 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
|
||||||
const nav = usePaymentNavigation()
|
const nav = usePaymentNavigation()
|
||||||
const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId)
|
const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId)
|
||||||
const status = (data?.status ?? 'pending') as PaymentStatusResponse['status']
|
const status = (data?.status ?? 'pending') as PaymentStatusResponse['status']
|
||||||
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(status)
|
|
||||||
const isSuccess = status === 'settlement' || status === 'capture'
|
const isSuccess = status === 'settlement' || status === 'capture'
|
||||||
const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status)
|
const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import React from 'react'
|
|
||||||
import type { PaymentMethod } from './PaymentMethodList'
|
import type { PaymentMethod } from './PaymentMethodList'
|
||||||
|
|
||||||
export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) {
|
export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
//
|
||||||
|
|
||||||
export type BankKey = 'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata'
|
export type BankKey = 'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata'
|
||||||
|
|
||||||
|
|
@ -17,7 +17,6 @@ function BrandImg({ src, alt, compact = false, size, fallbackSrc }: { src: strin
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const el = e.currentTarget
|
const el = e.currentTarget
|
||||||
const current = el.src
|
|
||||||
const proxyUsed = el.dataset.proxy === 'used'
|
const proxyUsed = el.dataset.proxy === 'used'
|
||||||
const fbUsed = el.dataset.fb === 'used'
|
const fbUsed = el.dataset.fb === 'used'
|
||||||
if (fallbackSrc && !fbUsed) {
|
if (fallbackSrc && !fbUsed) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react'
|
//
|
||||||
|
|
||||||
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
|
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,15 @@ export function CheckoutPage() {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
enabled={runtimeCfg?.paymentToggles}
|
enabled={runtimeCfg?.paymentToggles
|
||||||
|
? {
|
||||||
|
bank_transfer: runtimeCfg.paymentToggles.bank_transfer,
|
||||||
|
credit_card: runtimeCfg.paymentToggles.credit_card,
|
||||||
|
gopay: runtimeCfg.paymentToggles.gopay,
|
||||||
|
cstore: runtimeCfg.paymentToggles.cstore,
|
||||||
|
cpay: !!runtimeCfg.paymentToggles.cpay,
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
renderPanel={(m) => {
|
renderPanel={(m) => {
|
||||||
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
|
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
|
||||||
if (!methodEnabled[m]) {
|
if (!methodEnabled[m]) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||||
|
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
||||||
|
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
||||||
|
import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
|
||||||
|
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||||
|
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||||
|
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
||||||
|
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||||
|
import { Alert } from '../components/alert/Alert'
|
||||||
|
import { Button } from '../components/ui/button'
|
||||||
|
import { getPaymentLinkPayload } from '../services/api'
|
||||||
|
|
||||||
|
type Method = PaymentMethod | null
|
||||||
|
|
||||||
|
export function PayPage() {
|
||||||
|
const { token } = useParams()
|
||||||
|
const [orderId, setOrderId] = useState<string>('')
|
||||||
|
const [amount, setAmount] = useState<number>(0)
|
||||||
|
const [expireAt, setExpireAt] = useState<number>(Date.now() + 30 * 60 * 1000)
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
||||||
|
const [locked, setLocked] = useState<boolean>(false)
|
||||||
|
const [selectedBank, setSelectedBank] = useState<string>('bca')
|
||||||
|
const [selectedStore, setSelectedStore] = useState<string>('indomaret')
|
||||||
|
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
||||||
|
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||||
|
const { data: runtimeCfg } = usePaymentConfig()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function resolve() {
|
||||||
|
if (!token) return
|
||||||
|
try {
|
||||||
|
const payload = await getPaymentLinkPayload(token)
|
||||||
|
if (cancelled) return
|
||||||
|
setOrderId(payload.order_id)
|
||||||
|
setAmount(payload.nominal)
|
||||||
|
setExpireAt(payload.expire_at ?? Date.now() + 30 * 60 * 1000)
|
||||||
|
setAllowedMethods(payload.allowed_methods)
|
||||||
|
setError(null)
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return
|
||||||
|
setError({ code: 'TOKEN_RESOLVE_ERROR' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [token])
|
||||||
|
|
||||||
|
const merchantName = useMemo(() => 'Demo Merchant', [])
|
||||||
|
|
||||||
|
const isExpired = expireAt ? Date.now() > expireAt : false
|
||||||
|
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
|
||||||
|
const base = runtimeCfg?.paymentToggles
|
||||||
|
const allow = allowedMethods
|
||||||
|
const all: Record<PaymentMethod, boolean> = {
|
||||||
|
bank_transfer: base?.bank_transfer ?? true,
|
||||||
|
credit_card: base?.credit_card ?? true,
|
||||||
|
gopay: base?.gopay ?? true,
|
||||||
|
cstore: base?.cstore ?? true,
|
||||||
|
cpay: base?.cpay ?? false,
|
||||||
|
}
|
||||||
|
if (allow && Array.isArray(allow)) {
|
||||||
|
for (const k of (Object.keys(all) as PaymentMethod[])) {
|
||||||
|
if (k === 'cpay') continue
|
||||||
|
all[k] = allow.includes(k) && all[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all
|
||||||
|
}, [runtimeCfg, allowedMethods])
|
||||||
|
|
||||||
|
if (error || isExpired) {
|
||||||
|
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
|
||||||
|
const msg = isExpired ? 'Silakan minta link baru dari admin atau ERP.' : 'Token tidak dapat diverifikasi. Hubungi admin untuk bantuan.'
|
||||||
|
return (
|
||||||
|
<PaymentSheet
|
||||||
|
merchantName={merchantName}
|
||||||
|
orderId={orderId || (token ?? '')}
|
||||||
|
amount={amount}
|
||||||
|
expireAt={expireAt}
|
||||||
|
showStatusCTA={false}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert title={title}>{msg}</Alert>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => { try { window.location.reload() } catch {} }}
|
||||||
|
>
|
||||||
|
Muat ulang
|
||||||
|
</Button>
|
||||||
|
<a
|
||||||
|
href="mailto:support@example.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20"
|
||||||
|
className="inline-flex items-center px-3 py-2 rounded border bg-black text-white dark:bg-white dark:text-black"
|
||||||
|
>
|
||||||
|
Hubungi admin
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PaymentSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaymentSheet
|
||||||
|
merchantName={merchantName}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
expireAt={expireAt}
|
||||||
|
showStatusCTA={false}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Pilih metode pembayaran</h2>
|
||||||
|
<PaymentMethodList
|
||||||
|
selected={selectedMethod ?? undefined}
|
||||||
|
onSelect={(m) => setSelectedMethod(m as Method)}
|
||||||
|
disabled={locked}
|
||||||
|
enabled={enabledMap}
|
||||||
|
renderPanel={(m) => {
|
||||||
|
if (m === 'bank_transfer') return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-slate-600">Pilih bank</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
{['bca', 'bni', 'bri', 'permata'].map((bank) => (
|
||||||
|
<button
|
||||||
|
key={bank}
|
||||||
|
className={`px-3 py-2 rounded border ${selectedBank === bank ? 'border-blue-600' : 'border-slate-300'}`}
|
||||||
|
onClick={() => setSelectedBank(bank)}
|
||||||
|
>
|
||||||
|
{bank.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
if (m === 'cstore') return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-slate-600">Pilih gerai</p>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
{['indomaret', 'alfamart'].map((store) => (
|
||||||
|
<button
|
||||||
|
key={store}
|
||||||
|
className={`px-3 py-2 rounded border ${selectedStore === store ? 'border-blue-600' : 'border-slate-300'}`}
|
||||||
|
onClick={() => setSelectedStore(store)}
|
||||||
|
>
|
||||||
|
{store}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{selectedMethod === 'bank_transfer' && (
|
||||||
|
<BankTransferPanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => setLocked(true)}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
defaultBank={selectedBank as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMethod === 'credit_card' && (
|
||||||
|
<CardPanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => setLocked(true)}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMethod === 'gopay' && (
|
||||||
|
<GoPayPanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => setLocked(true)}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMethod === 'cstore' && (
|
||||||
|
<CStorePanel
|
||||||
|
locked={locked}
|
||||||
|
onChargeInitiated={() => setLocked(true)}
|
||||||
|
orderId={orderId}
|
||||||
|
amount={amount}
|
||||||
|
defaultStore={selectedStore as any}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PaymentSheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -111,4 +111,38 @@ export async function getRuntimeConfig(): Promise<RuntimeConfigResponse> {
|
||||||
midtransEnv: Env.MIDTRANS_ENV,
|
midtransEnv: Env.MIDTRANS_ENV,
|
||||||
clientKey: Env.MIDTRANS_CLIENT_KEY,
|
clientKey: Env.MIDTRANS_CLIENT_KEY,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentLinkPayload = {
|
||||||
|
order_id: string
|
||||||
|
nominal: number
|
||||||
|
customer?: { name?: string; phone?: string; email?: string }
|
||||||
|
expire_at?: number
|
||||||
|
allowed_methods?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPaymentLinkPayload(token: string): Promise<PaymentLinkPayload> {
|
||||||
|
if (apiBase) {
|
||||||
|
const { data } = await api.get(`/payment-links/${encodeURIComponent(token)}`)
|
||||||
|
Logger.info('paymentlink.resolve', { tokenLen: token.length })
|
||||||
|
return data as PaymentLinkPayload
|
||||||
|
}
|
||||||
|
// Fallback when API base not set or resolver unavailable
|
||||||
|
// Try a best-effort decode of base64(JSON) payload; if fails, use defaults
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(atob(token))
|
||||||
|
return {
|
||||||
|
order_id: json.order_id || token,
|
||||||
|
nominal: Number(json.nominal) || 150000,
|
||||||
|
customer: json.customer || {},
|
||||||
|
expire_at: json.expire_at || Date.now() + 30 * 60 * 1000,
|
||||||
|
allowed_methods: json.allowed_methods || undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
order_id: token,
|
||||||
|
nominal: 150000,
|
||||||
|
expire_at: Date.now() + 30 * 60 * 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,18 +1,6 @@
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
/* Define brand palette for Tailwind v4 utility tokens */
|
/* Brand colors are defined in tailwind.config.ts under theme.extend.colors.brand */
|
||||||
@theme {
|
|
||||||
--color-brand-50: #fef2f2;
|
|
||||||
--color-brand-100: #fee2e2;
|
|
||||||
--color-brand-200: #fecaca;
|
|
||||||
--color-brand-300: #fca5a5;
|
|
||||||
--color-brand-400: #f87171;
|
|
||||||
--color-brand-500: #ef4444;
|
|
||||||
--color-brand-600: #dc2626;
|
|
||||||
--color-brand-700: #b91c1c;
|
|
||||||
--color-brand-800: #991b1b;
|
|
||||||
--color-brand-900: #7f1d1d;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue