From a940fda6b2b1f333fce2eca2a7d0dcd9ce088fb9 Mon Sep 17 00:00:00 2001 From: CIFO Dev Date: Mon, 10 Nov 2025 16:39:10 +0700 Subject: [PATCH] payment link change --- docs/qa/payment-link.postman_collection.json | 75 +++++++ docs/sprint-change-proposal.md | 146 ++++++++++++++ server/index.cjs | 116 +++++++++++ src/app/router.tsx | 2 + src/pages/PayPage.tsx | 201 +++++++++++++++++++ src/services/api.ts | 34 ++++ 6 files changed, 574 insertions(+) create mode 100644 docs/qa/payment-link.postman_collection.json create mode 100644 docs/sprint-change-proposal.md create mode 100644 src/pages/PayPage.tsx 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..e496ff7 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -5,6 +5,7 @@ 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/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