diff --git a/docs/payment-link-e2e.md b/docs/payment-link-e2e.md new file mode 100644 index 0000000..f748386 --- /dev/null +++ b/docs/payment-link-e2e.md @@ -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: ' \ + -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/" | ConvertTo-Json -Depth 5 +``` + +3) Buka Halaman Pay + +- Buka `http://localhost:5175/pay/` (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. \ No newline at end of file diff --git a/docs/qa/payment-link.postman_collection.json b/docs/qa/payment-link.postman_collection.json new file mode 100644 index 0000000..d6614f3 --- /dev/null +++ b/docs/qa/payment-link.postman_collection.json @@ -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": [] + } + ] +} \ No newline at end of file diff --git a/docs/sprint-change-proposal.md b/docs/sprint-change-proposal.md new file mode 100644 index 0000000..d3073a1 --- /dev/null +++ b/docs/sprint-change-proposal.md @@ -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: ` (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/?sig=", + "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 + } + ``` \ No newline at end of file diff --git a/server/index.cjs b/server/index.cjs index f923bbb..de4876f 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -98,6 +98,53 @@ const ENABLE = { 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 app.get('/api/health', (_req, res) => { logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey }) @@ -131,6 +178,29 @@ app.post('/api/config', (req, res) => { 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) app.post('/api/payments/charge', async (req, res) => { 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 function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) { try { @@ -294,6 +408,8 @@ app.post('/api/payments/webhook', async (req, res) => { const nominal = String(grossAmount) if (!notifiedOrders.has(orderId)) { notifiedOrders.add(orderId) + // Mark order inactive upon completion + activeOrders.delete(orderId) await notifyERP({ orderId, nominal }) } else { logInfo('erp.notify.skip', { orderId, reason: 'already_notified' }) diff --git a/src/app/router.tsx b/src/app/router.tsx index 098dcac..4dd5a2a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,10 +1,11 @@ -import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { AppLayout } from './AppLayout' import { CheckoutPage } from '../pages/CheckoutPage' import { PaymentStatusPage } from '../pages/PaymentStatusPage' import { PaymentHistoryPage } from '../pages/PaymentHistoryPage' import { NotFoundPage } from '../pages/NotFoundPage' import { DemoStorePage } from '../pages/DemoStorePage' +import { PayPage } from '../pages/PayPage' const router = createBrowserRouter([ { @@ -14,6 +15,7 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'checkout', element: }, + { path: 'pay/:token', element: }, { path: 'payments/:orderId/status', element: }, { path: 'history', element: }, { path: '*', element: }, diff --git a/src/features/payments/components/BankTransferPanel.tsx b/src/features/payments/components/BankTransferPanel.tsx index 61cc295..853cbee 100644 --- a/src/features/payments/components/BankTransferPanel.tsx +++ b/src/features/payments/components/BankTransferPanel.tsx @@ -16,7 +16,7 @@ const chargeTasks = new Map>() export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) { const nav = usePaymentNavigation() - const [selected, setSelected] = React.useState(defaultBank ?? null) + const [selected] = React.useState(defaultBank ?? null) const [showGuide, setShowGuide] = React.useState(false) const [busy, setBusy] = React.useState(false) const [vaCode, setVaCode] = React.useState('') diff --git a/src/features/payments/components/CStorePanel.tsx b/src/features/payments/components/CStorePanel.tsx index 8c524c2..6c8724a 100644 --- a/src/features/payments/components/CStorePanel.tsx +++ b/src/features/payments/components/CStorePanel.tsx @@ -14,7 +14,7 @@ const cstoreTasks = new Map>() export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) { const nav = usePaymentNavigation() - const [selected, setSelected] = React.useState(defaultStore ?? null) + const [selected] = React.useState(defaultStore ?? null) const [showGuide, setShowGuide] = React.useState(false) const [busy, setBusy] = React.useState(false) const [paymentCode, setPaymentCode] = React.useState('') diff --git a/src/features/payments/components/GoPayPanel.tsx b/src/features/payments/components/GoPayPanel.tsx index bdc6b6e..3d87008 100644 --- a/src/features/payments/components/GoPayPanel.tsx +++ b/src/features/payments/components/GoPayPanel.tsx @@ -26,15 +26,7 @@ function pickQrImageUrl(res: any, acts: Array<{ name?: string; method?: string; return '' } -function QRPlaceholder() { - return ( -
- {Array.from({ length: 81 }).map((_, i) => ( -
- ))} -
- ) -} +// export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) { const nav = usePaymentNavigation() diff --git a/src/features/payments/components/InlinePaymentStatus.tsx b/src/features/payments/components/InlinePaymentStatus.tsx index d70ff4c..2236725 100644 --- a/src/features/payments/components/InlinePaymentStatus.tsx +++ b/src/features/payments/components/InlinePaymentStatus.tsx @@ -1,4 +1,3 @@ -import React from 'react' import { Button } from '../../../components/ui/button' import { usePaymentNavigation } from '../lib/navigation' import { usePaymentStatus } from '../lib/usePaymentStatus' @@ -15,7 +14,6 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str const nav = usePaymentNavigation() const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId) 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 isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status) diff --git a/src/features/payments/components/PaymentInstructions.tsx b/src/features/payments/components/PaymentInstructions.tsx index cb432c7..36be88c 100644 --- a/src/features/payments/components/PaymentInstructions.tsx +++ b/src/features/payments/components/PaymentInstructions.tsx @@ -1,4 +1,3 @@ -import React from 'react' import type { PaymentMethod } from './PaymentMethodList' export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) { diff --git a/src/features/payments/components/PaymentLogos.tsx b/src/features/payments/components/PaymentLogos.tsx index f3a5c79..ec88161 100644 --- a/src/features/payments/components/PaymentLogos.tsx +++ b/src/features/payments/components/PaymentLogos.tsx @@ -1,4 +1,4 @@ -import React from 'react' +// 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" onError={(e) => { const el = e.currentTarget - const current = el.src const proxyUsed = el.dataset.proxy === 'used' const fbUsed = el.dataset.fb === 'used' if (fallbackSrc && !fbUsed) { diff --git a/src/features/payments/components/TrustStrip.tsx b/src/features/payments/components/TrustStrip.tsx index a9ff819..e7e7398 100644 --- a/src/features/payments/components/TrustStrip.tsx +++ b/src/features/payments/components/TrustStrip.tsx @@ -1,4 +1,4 @@ -import React from 'react' +// export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) { return ( diff --git a/src/pages/CheckoutPage.tsx b/src/pages/CheckoutPage.tsx index 00bf98b..299f876 100644 --- a/src/pages/CheckoutPage.tsx +++ b/src/pages/CheckoutPage.tsx @@ -157,7 +157,15 @@ export function CheckoutPage() { } }} 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) => { const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled() if (!methodEnabled[m]) { diff --git a/src/pages/PayPage.tsx b/src/pages/PayPage.tsx new file mode 100644 index 0000000..defd4db --- /dev/null +++ b/src/pages/PayPage.tsx @@ -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('') + const [amount, setAmount] = useState(0) + const [expireAt, setExpireAt] = useState(Date.now() + 30 * 60 * 1000) + const [selectedMethod, setSelectedMethod] = useState(null) + const [locked, setLocked] = useState(false) + const [selectedBank, setSelectedBank] = useState('bca') + const [selectedStore, setSelectedStore] = useState('indomaret') + const [allowedMethods, setAllowedMethods] = useState(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 = useMemo(() => { + const base = runtimeCfg?.paymentToggles + const allow = allowedMethods + const all: Record = { + 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 ( + +
+ {msg} +
+ + + Hubungi admin + +
+
+
+ ) + } + + return ( + +
+

Pilih metode pembayaran

+ setSelectedMethod(m as Method)} + disabled={locked} + enabled={enabledMap} + renderPanel={(m) => { + if (m === 'bank_transfer') return ( +
+

Pilih bank

+
+ {['bca', 'bni', 'bri', 'permata'].map((bank) => ( + + ))} +
+
+ ) + if (m === 'cstore') return ( +
+

Pilih gerai

+
+ {['indomaret', 'alfamart'].map((store) => ( + + ))} +
+
+ ) + return null + }} + /> + +
+ {selectedMethod === 'bank_transfer' && ( + setLocked(true)} + orderId={orderId} + amount={amount} + defaultBank={selectedBank as any} + /> + )} + + {selectedMethod === 'credit_card' && ( + setLocked(true)} + orderId={orderId} + amount={amount} + /> + )} + + {selectedMethod === 'gopay' && ( + setLocked(true)} + orderId={orderId} + amount={amount} + /> + )} + + {selectedMethod === 'cstore' && ( + setLocked(true)} + orderId={orderId} + amount={amount} + defaultStore={selectedStore as any} + /> + )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index ae99a41..11c6cd1 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -111,4 +111,38 @@ export async function getRuntimeConfig(): Promise { midtransEnv: Env.MIDTRANS_ENV, 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 { + 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, + } + } } \ No newline at end of file diff --git a/src/styles/globals.css b/src/styles/globals.css index 7660dd9..691834a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,18 +1,6 @@ @import 'tailwindcss'; -/* Define brand palette for Tailwind v4 utility tokens */ -@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; -} +/* Brand colors are defined in tailwind.config.ts under theme.extend.colors.brand */ :root { --radius: 8px;