Align PayPage flow and update /createtransaksi schema #3

Merged
root merged 1 commits from feat/payment-link-flow into main 2025-11-11 06:09:04 +00:00
8 changed files with 392 additions and 104 deletions

View File

@ -158,4 +158,153 @@ Kemudian `POST` ke `http://localhost:8000/api/payments/webhook` dengan body beri
- 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.
- Anda dapat mengimpor koleksi: `docs/qa/payment-link.postman_collection.json` untuk mencoba endpoint di atas.
## Pengujian via Postman (Langkah Lengkap)
### 1) Import & Setup Environment
- Import koleksi: `docs/qa/payment-link.postman_collection.json`.
- Buat Environment (mis. `Midtrans-CIFO-Local`) dengan variabel:
- `baseUrl`: `http://localhost:8000`
- `paymentLinkBase`: `http://localhost:5175/pay` (sesuaikan port frontend)
- `externalApiKey`: kosong atau set sesuai `EXTERNAL_API_KEY` (jika diaktifkan)
- `token`: kosong (akan diisi dari respon create)
- `order_id`: kosong (akan diisi dari respon create)
Catatan: Koleksi memakai variabel ini pada URL/body. Pastikan environment terpilih saat menjalankan request.
### 2) Jalankan Koleksi (Urutan Dasar)
1. `1) Create Transaction`
- Body default: `item_id`, `nominal`, `customer`, `allowed_methods`.
- Jika `EXTERNAL_API_KEY` aktif, pastikan header `X-API-KEY: {{externalApiKey}}` terisi.
2. `2) Resolve Token`
- Memakai `{{token}}` dari langkah 1.
3. `3) Charge - Bank Transfer (BCA)` atau `3) Charge - CStore (Indomaret)`
- Memakai `{{order_id}}` dari langkah 1.
4. `4) Payment Status`
- Memakai `{{order_id}}` untuk cek status.
### 3) Skrip Test untuk Otomatis Mengisi Variabel
Tambahkan skrip berikut pada tab `Tests` di request `1) Create Transaction` agar `token`, `order_id`, dan `paymentLinkUrl` otomatis tersimpan ke variabel koleksi:
```javascript
// Tests: 1) Create Transaction
pm.test('Create returns token and url', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('token');
pm.expect(json).to.have.property('url');
pm.expect(json).to.have.property('order_id');
});
const res = pm.response.json();
pm.collectionVariables.set('token', res.token);
pm.collectionVariables.set('order_id', res.order_id);
pm.collectionVariables.set('paymentLinkUrl', res.url);
```
Tambahkan skrip sederhana di `2) Resolve Token` untuk validasi payload:
```javascript
pm.test('Resolve returns payload fields', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('order_id');
pm.expect(json).to.have.property('nominal');
});
```
Di `3) Charge - Bank Transfer (BCA)`, tambahkan assert dasar:
```javascript
pm.test('Charge is created (pending)', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('status_code');
pm.expect(json.status_code).to.eql('201');
pm.expect(json.transaction_status).to.eql('pending');
});
```
Di `4) Payment Status`, periksa status:
```javascript
pm.test('Status returns transaction info', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('order_id');
pm.expect(json).to.have.property('transaction_status');
});
```
### 4) Menambah Request GoPay/QR (Opsional)
Jika Anda ingin menguji GoPay/QR, tambahkan request baru di koleksi:
- Method: `POST`
- URL: `{{baseUrl}}/api/payments/charge`
- Body (raw JSON):
```json
{
"payment_type": "gopay",
"transaction_details": { "order_id": "{{order_id}}", "gross_amount": 150000 },
"gopay": { "enable_qr": true }
}
```
Catatan: Bila menghasilkan `400 Bad Request`, cek kembali konfigurasi sandbox merchant dan parameter tambahan yang dibutuhkan (misal `qr_black_white`, `callback_url`).
### 5) Membuka Halaman Pay dari Postman
Setelah `1) Create Transaction`, variabel `paymentLinkUrl` tersimpan. Anda bisa klik tombol `Open in Browser` (ikon tautan) pada Postman untuk membuka `{{paymentLinkUrl}}` langsung di browser.
### 6) Uji Webhook dengan Pre-request Script
Tambahkan request baru `Webhook (Manual)` dengan URL `{{baseUrl}}/api/payments/webhook`. Isi body dengan fields Midtrans. Untuk menghitung `signature_key` otomatis, gunakan Pre-request Script berikut (CryptoJS tersedia di sandbox Postman):
```javascript
// Pre-request: Webhook signature
const orderId = pm.collectionVariables.get('order_id');
const statusCode = pm.collectionVariables.get('status_code') || '200';
const grossAmount = pm.collectionVariables.get('gross_amount') || '150000';
const serverKey = pm.collectionVariables.get('server_key'); // set di environment
if (!serverKey) {
console.warn('server_key is not set in environment');
}
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(serverKey);
const sig = CryptoJS.SHA512(raw).toString(CryptoJS.enc.Hex);
pm.collectionVariables.set('signature_key', sig);
```
Body contoh (raw JSON):
```json
{
"order_id": "{{order_id}}",
"status_code": "{{status_code}}",
"gross_amount": "{{gross_amount}}",
"signature_key": "{{signature_key}}",
"transaction_status": "settlement"
}
```
Tambahkan test sederhana untuk memverifikasi respons webhook:
```javascript
pm.test('Webhook acknowledged', function () {
pm.response.to.have.status(200);
const json = pm.response.json();
pm.expect(json).to.have.property('ok');
pm.expect(json.ok).to.eql(true);
});
```
### 7) Postman Runner (Otomasi)
- Gunakan Runner untuk menjalankan berurutan: `1) Create Transaction``2) Resolve Token``3) Charge``4) Status`.
- Pastikan environment dipilih. Anda dapat menambah delay antar request jika diperlukan.
### 8) Tips & Variabel Tambahan
- Sesuaikan `paymentLinkBase` ke port frontend aktif (mis. `5175`).
- Jika `EXTERNAL_API_KEY` aktif, isi `externalApiKey` di environment.
- Simpan `server_key` (Midtrans Server Key) di environment untuk uji webhook.
- Tambahkan `gross_amount`, `status_code` variabel supaya mudah dikustom saat uji webhook/status.

View File

@ -4,8 +4,8 @@
"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": "baseUrl", "value": "https://be-midtrans-cifo.winteraccess.id" },
{ "key": "paymentLinkBase", "value": "https://midtrans-cifo.winteraccess.id/pay" },
{ "key": "externalApiKey", "value": "" },
{ "key": "token", "value": "" },
{ "key": "order_id", "value": "" }
@ -25,6 +25,31 @@
"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}"
}
},
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"let res = {};",
"try { res = pm.response.json(); } catch(e) { res = {}; }",
"if (res && res.token) {",
" pm.collectionVariables.set('token', res.token);",
"}",
"if (res && res.order_id) {",
" pm.collectionVariables.set('order_id', res.order_id);",
"}",
"if (res && res.url) {",
" pm.collectionVariables.set('paymentLinkUrl', res.url);",
"}",
"pm.test('Create Transaction returns token and order_id', function () {",
" pm.expect(res.token, 'token exists').to.be.a('string');",
" pm.expect(res.order_id, 'order_id exists').to.be.a('string');",
"});"
]
}
}
],
"response": []
},
{
@ -34,6 +59,32 @@
"header": [],
"url": { "raw": "{{baseUrl}}/api/payment-links/{{token}}", "host": ["{{baseUrl}}"], "path": ["api","payment-links","{{token}}"] }
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const t = pm.variables.get('token');",
"if (!t) { throw new Error(\"Missing token. Run '1) Create Transaction' first.\"); }"
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"let res = {};",
"try { res = pm.response.json(); } catch(e) { res = {}; }",
"pm.test('Resolve Token payload has order_id and nominal', function () {",
" pm.expect(res.order_id, 'order_id').to.be.a('string');",
" pm.expect(res.nominal, 'nominal').to.exist;",
"});"
]
}
}
],
"response": []
},
{
@ -69,6 +120,18 @@
"header": [],
"url": { "raw": "{{baseUrl}}/api/payments/{{order_id}}/status", "host": ["{{baseUrl}}"], "path": ["api","payments","{{order_id}}","status"] }
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
"const oid = pm.variables.get('order_id');",
"if (!oid) { throw new Error(\"Missing order_id. Run '1) Create Transaction' first.\"); }"
]
}
}
],
"response": []
}
]

View File

@ -257,26 +257,42 @@ app.post('/createtransaksi', (req, res) => {
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
// Skema baru:
// {
// mercant_id, timestamp, deskripsi, nominal,
// nama, no_telepon, email,
// item: [{ item_id, nama, harga, qty }, ...]
// }
const mercantId = req?.body?.mercant_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 items = Array.isArray(req?.body?.item) ? req.body.item : []
const primaryItemId = items?.[0]?.item_id
const order_id = String(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
// Bentuk customer dari field nama/no_telepon/email
const customer = {
name: req?.body?.nama,
phone: req?.body?.no_telepon,
email: req?.body?.email,
}
const allowed_methods = req?.body?.allowed_methods
if (!order_id || typeof nominalRaw === 'undefined') {
logWarn('createtransaksi.bad_request', { id: req.id, hasOrderId: !!order_id, hasNominal: typeof nominalRaw !== 'undefined' })
return res.status(400).json({ error: 'BAD_REQUEST', message: 'order_id (mercant_id atau item[0].item_id) dan nominal wajib ada' })
}
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
// Block jika sudah selesai
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
// Block jika ada link aktif belum expired
const existing = activeOrders.get(order_id)
if (existing && existing > now) {
logWarn('createtransaksi.active_exists', { order_id })
@ -287,7 +303,9 @@ app.post('/createtransaksi', (req, res) => {
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 })
// Respons mengikuti format yang diminta
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
} catch (e) {
logError('createtransaksi.error', { id: req.id, message: e?.message })
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })

View File

@ -1,6 +1,21 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/globals.css'
// Force light theme in case hosting injects a global 'dark' class
(() => {
const html = document.documentElement
try {
if (html.classList.contains('dark')) {
html.classList.remove('dark')
}
document.body.classList.remove('dark')
// Hint browsers to prefer light color scheme
html.style.colorScheme = 'light'
html.setAttribute('data-theme', 'light')
} catch {
// noop
}
})()
import { AppRouter } from './app/router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

View File

@ -178,13 +178,6 @@ export function CheckoutPage() {
if (m === 'bank_transfer') {
return (
<div className="space-y-2" aria-live="polite">
<div className="flex justify-center">
<img
src="/logos/logo-semua-bank.PNG"
alt="Logo semua bank yang didukung"
className="max-h-20 sm:max-h-24 object-contain"
/>
</div>
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (

View File

@ -7,6 +7,7 @@ import { BankTransferPanel } from '../features/payments/components/BankTransferP
import { CardPanel } from '../features/payments/components/CardPanel'
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
import { CStorePanel } from '../features/payments/components/CStorePanel'
import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button'
@ -21,11 +22,13 @@ export function PayPage() {
const [expireAt, setExpireAt] = useState<number>(Date.now() + 30 * 60 * 1000)
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
const [locked, setLocked] = useState<boolean>(false)
const [selectedBank, setSelectedBank] = useState<string>('bca')
const [selectedStore, setSelectedStore] = useState<string>('indomaret')
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null)
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
const { data: runtimeCfg } = usePaymentConfig()
const [currentStep, setCurrentStep] = useState<2 | 3>(2)
const [isBusy, setIsBusy] = useState(false)
useEffect(() => {
let cancelled = false
@ -110,91 +113,135 @@ export function PayPage() {
orderId={orderId}
amount={amount}
expireAt={expireAt}
showStatusCTA={false}
showStatusCTA={currentStep === 3}
>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Pilih metode pembayaran</h2>
<PaymentMethodList
selected={selectedMethod ?? undefined}
onSelect={(m) => setSelectedMethod(m as Method)}
disabled={locked}
enabled={enabledMap}
renderPanel={(m) => {
if (m === 'bank_transfer') return (
<div className="mt-2">
<p className="text-sm text-slate-600">Pilih bank</p>
<div className="mt-2 flex gap-2">
{['bca', 'bni', 'bri', 'permata'].map((bank) => (
<button
key={bank}
className={`px-3 py-2 rounded border ${selectedBank === bank ? 'border-blue-600' : 'border-slate-300'}`}
onClick={() => setSelectedBank(bank)}
>
{bank.toUpperCase()}
</button>
))}
</div>
</div>
)
if (m === 'cstore') return (
<div className="mt-2">
<p className="text-sm text-slate-600">Pilih gerai</p>
<div className="mt-2 flex gap-2">
{['indomaret', 'alfamart'].map((store) => (
<button
key={store}
className={`px-3 py-2 rounded border ${selectedStore === store ? 'border-blue-600' : 'border-slate-300'}`}
onClick={() => setSelectedStore(store)}
>
{store}
</button>
))}
</div>
</div>
)
return null
}}
/>
<div className="mt-6">
{selectedMethod === 'bank_transfer' && (
<BankTransferPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
defaultBank={selectedBank as any}
{currentStep === 2 && (
<div className="space-y-3">
<PaymentMethodList
selected={selectedMethod ?? undefined}
onSelect={(m) => {
setSelectedMethod(m as Method)
if (m === 'bank_transfer' || m === 'cstore') {
// Panel pemilihan bank/toko akan muncul di bawah item, dan lanjut ke step 3 setelah memilih
} else if (m === 'cpay') {
try {
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
} catch {}
} else {
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}
}}
disabled={locked}
enabled={enabledMap}
renderPanel={(m) => {
const enabled = enabledMap[m]
if (!enabled) {
return (
<div className="p-2">
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
</div>
)
}
if (m === 'bank_transfer') {
return (
<div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
<button
key={bk}
type="button"
onClick={() => {
setSelectedBank(bk)
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
aria-label={`Pilih bank ${bk.toUpperCase()}`}
>
<BankLogo bank={bk} />
</button>
))}
</div>
{isBusy && (
<div className="text-xs text-black/60 dark:text-white/60 inline-flex items-center gap-2">
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
Menyiapkan VA
</div>
)}
</div>
)
}
if (m === 'cstore') {
return (
<div className="space-y-2" aria-live="polite">
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko untuk membuat kode pembayaran</div>
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
{(['alfamart','indomaret'] as const).map((st) => (
<button
key={st}
type="button"
onClick={() => {
setSelectedStore(st)
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
}}
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
aria-label={`Pilih toko ${st.toUpperCase()}`}
>
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
</button>
))}
</div>
</div>
)
}
return null
}}
/>
)}
</div>
)}
{selectedMethod === 'credit_card' && (
<CardPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
/>
)}
{selectedMethod === 'gopay' && (
<GoPayPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
/>
)}
{selectedMethod === 'cstore' && (
<CStorePanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
defaultStore={selectedStore as any}
/>
)}
</div>
{currentStep === 3 && (
<div className="space-y-3" aria-live="polite">
{selectedMethod === 'bank_transfer' && (
<BankTransferPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
defaultBank={(selectedBank ?? 'bca')}
/>
)}
{selectedMethod === 'credit_card' && (
<CardPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
/>
)}
{selectedMethod === 'gopay' && (
<GoPayPanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
/>
)}
{selectedMethod === 'cstore' && (
<CStorePanel
locked={locked}
onChargeInitiated={() => setLocked(true)}
orderId={orderId}
amount={amount}
defaultStore={selectedStore ?? undefined}
/>
)}
</div>
)}
</div>
</PaymentSheet>
)

View File

@ -49,9 +49,11 @@ api.interceptors.response.use(
return response
},
(error) => {
const baseURL = error.config?.baseURL || ''
const url = error.config?.url || ''
const status = error.response?.status
Logger.error('api.error', { url, status, message: error.message })
const fullUrl = `${baseURL}${url}`
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
throw error
}
)

View File

@ -13,6 +13,7 @@ body {
background-color: #ffffff;
color: #000000;
}
.light, html[data-theme="light"] body { background-color: #ffffff; color: #000000; }
.dark body { background-color: #000000; color: #ffffff; }
a { color: #dc2626; }
a:hover { color: #b91c1c; }