From 4d10f0e121f8f675c39a9e31369fecb53d05dac7 Mon Sep 17 00:00:00 2001 From: CIFO Dev Date: Mon, 10 Nov 2025 16:59:31 +0700 Subject: [PATCH] perbaikan flow --- docs/payment-link-e2e.md | 161 ++++++++++++++++++ src/app/router.tsx | 2 +- .../payments/components/BankTransferPanel.tsx | 2 +- .../payments/components/CStorePanel.tsx | 2 +- .../payments/components/GoPayPanel.tsx | 10 +- .../components/InlinePaymentStatus.tsx | 2 - .../components/PaymentInstructions.tsx | 1 - .../payments/components/PaymentLogos.tsx | 3 +- .../payments/components/TrustStrip.tsx | 2 +- src/pages/CheckoutPage.tsx | 10 +- src/styles/globals.css | 14 +- 11 files changed, 177 insertions(+), 32 deletions(-) create mode 100644 docs/payment-link-e2e.md 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/src/app/router.tsx b/src/app/router.tsx index e496ff7..4dd5a2a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -1,4 +1,4 @@ -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' 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/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;