feat/payment-link-flow #2
|
|
@ -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.
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
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 [busy, setBusy] = React.useState(false)
|
||||
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 }) {
|
||||
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 [busy, setBusy] = React.useState(false)
|
||||
const [paymentCode, setPaymentCode] = React.useState('')
|
||||
|
|
|
|||
|
|
@ -26,15 +26,7 @@ function pickQrImageUrl(res: any, acts: Array<{ name?: string; method?: string;
|
|||
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 }) {
|
||||
const nav = usePaymentNavigation()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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[] }) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
//
|
||||
|
||||
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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]) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue