From baed44ecd710cb472e71e302030b90080e918420 Mon Sep 17 00:00:00 2001 From: CIFO Dev Date: Tue, 11 Nov 2025 10:19:50 +0700 Subject: [PATCH] Align PayPage flow and update /createtransaksi schema --- docs/payment-link-e2e.md | 151 ++++++++++++- docs/qa/payment-link.postman_collection.json | 67 +++++- server/index.cjs | 38 +++- src/main.tsx | 15 ++ src/pages/CheckoutPage.tsx | 7 - src/pages/PayPage.tsx | 213 +++++++++++-------- src/services/api.ts | 4 +- src/styles/globals.css | 1 + 8 files changed, 392 insertions(+), 104 deletions(-) diff --git a/docs/payment-link-e2e.md b/docs/payment-link-e2e.md index f748386..a607714 100644 --- a/docs/payment-link-e2e.md +++ b/docs/payment-link-e2e.md @@ -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. \ No newline at end of file +- 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. \ No newline at end of file diff --git a/docs/qa/payment-link.postman_collection.json b/docs/qa/payment-link.postman_collection.json index d6614f3..ad712e6 100644 --- a/docs/qa/payment-link.postman_collection.json +++ b/docs/qa/payment-link.postman_collection.json @@ -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": [] } ] diff --git a/server/index.cjs b/server/index.cjs index de4876f..b014f6f 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -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' }) diff --git a/src/main.tsx b/src/main.tsx index 1b20c97..1226f31 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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' diff --git a/src/pages/CheckoutPage.tsx b/src/pages/CheckoutPage.tsx index 299f876..bb10e2a 100644 --- a/src/pages/CheckoutPage.tsx +++ b/src/pages/CheckoutPage.tsx @@ -178,13 +178,6 @@ export function CheckoutPage() { if (m === 'bank_transfer') { return (
-
- Logo semua bank yang didukung -
Pilih bank untuk membuat Virtual Account
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => ( diff --git a/src/pages/PayPage.tsx b/src/pages/PayPage.tsx index defd4db..2387b13 100644 --- a/src/pages/PayPage.tsx +++ b/src/pages/PayPage.tsx @@ -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(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 [selectedBank, setSelectedBank] = useState(null) + const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null) const [allowedMethods, setAllowedMethods] = useState(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} >
-

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} + {currentStep === 2 && ( +
+ { + 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 ( +
+ Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan. +
+ ) + } + if (m === 'bank_transfer') { + return ( +
+
Pilih bank untuk membuat Virtual Account
+
+ {(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => ( + + ))} +
+ {isBusy && ( +
+ + Menyiapkan VA… +
+ )} +
+ ) + } + if (m === 'cstore') { + return ( +
+
Pilih toko untuk membuat kode pembayaran
+
+ {(['alfamart','indomaret'] as const).map((st) => ( + + ))} +
+
+ ) + } + return null + }} /> - )} +
+ )} - {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} - /> - )} -
+ {currentStep === 3 && ( +
+ {selectedMethod === 'bank_transfer' && ( + setLocked(true)} + orderId={orderId} + amount={amount} + defaultBank={(selectedBank ?? 'bca')} + /> + )} + {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 ?? undefined} + /> + )} +
+ )}
) diff --git a/src/services/api.ts b/src/services/api.ts index 11c6cd1..dca842e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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 } ) diff --git a/src/styles/globals.css b/src/styles/globals.css index 691834a..689ed25 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; } -- 2.40.1