diff --git a/docs/integration-midtrans.md b/docs/integration-midtrans.md index 555d869..b0029be 100644 --- a/docs/integration-midtrans.md +++ b/docs/integration-midtrans.md @@ -65,4 +65,28 @@ Pastikan `VITE_API_BASE_URL` menunjuk ke `http://localhost:8000/api` agar fronte ## Referensi - Midtrans Docs: Credit Card with 3DS (Core API). -- Contoh: `coreApiSimpleExample.js` untuk pola panggilan Core API. \ No newline at end of file +- Contoh: `coreApiSimpleExample.js` untuk pola panggilan Core API. + +## Webhook & ERP Callback + +- Endpoint webhook Midtrans: `POST /api/payments/webhook` (server Express) + - Verifikasi `signature_key = sha512(order_id + status_code + gross_amount + MIDTRANS_SERVER_KEY)`. + - Menerima notifikasi status dan menandai sukses untuk `settlement` (umum) atau `capture` dengan `fraud_status=accept` (kartu). +- ERP Notification (opsional, via env feature flag): + - Konfigurasi `.env` backend: + - `ERP_NOTIFICATION_URL="https://apibackend.erpskrip.id/paymentnotification/"` + - `ERP_CLIENT_ID=""` + - `ERP_MERCANT_ID=""` + - `ERP_ENABLE_NOTIF=true` + - Payload dikirim saat sukses: + ```json + { + "data": { + "mercant_id": "", + "status_code": "200", + "nominal": "", + "client_id": "" + }, + "signature": "sha512(mercant_id + status_code + nominal + client_id)" + } + ``` \ No newline at end of file diff --git a/docs/stories/midtrans-e2e-checkout-to-webhook.md b/docs/stories/midtrans-e2e-checkout-to-webhook.md new file mode 100644 index 0000000..9af3895 --- /dev/null +++ b/docs/stories/midtrans-e2e-checkout-to-webhook.md @@ -0,0 +1,183 @@ +# Story: End-to-End Midtrans — Checkout → Webhook (Payment Sukses) + +## User Story +Sebagai pembeli, +Saya ingin menyelesaikan pembayaran melalui Midtrans dan melihat status pembayaran otomatis diperbarui melalui webhook, +Sehingga saya mendapatkan konfirmasi yang jelas dan pesanan saya segera diproses. + +## Story Context +**Integrates with:** +- Frontend: `src/pages/CheckoutPage.tsx`, `src/features/payments/components/*` (BankTransferPanel, CardPanel, GoPayPanel, CStorePanel), `src/pages/PaymentStatusPage.tsx`, hooks `usePaymentStatus`, navigasi `usePaymentNavigation` +- Backend: `server/index.cjs` — endpoint `POST /api/payments/charge`, `GET /api/payments/:orderId/status`, penambahan `POST /api/payments/webhook` +- Services: `src/services/api.ts` (`postCharge`, `getPaymentStatus`) + +**Technology:** React + Vite, TanStack Query (polling status), Express, `midtrans-client` Core API, Midtrans 3DS (kartu). + +**Follows pattern:** Semua panggilan ke Midtrans dilakukan via backend; UI menavigasi ke halaman status (`/payments/:orderId/status`) dan melakukan polling hingga status final; webhook memperbarui status di backend sebagai sumber kebenaran utama. + +## Flow Overview +1) Checkout +- Pengguna memilih metode: `bank_transfer` (VA/echannel), `gopay` (QRIS/GoPay), `cstore` (Alfamart/Indomaret), `credit_card` (tokenisasi + 3DS). +- UI menampilkan panel sesuai metode dan mengumpulkan input yang diperlukan. + +2) Charge (Backend) +- Frontend memanggil `POST /api/payments/charge` via `postCharge(payload)`. +- Backend meneruskan ke Midtrans `core.charge(payload)` dan mengembalikan respons (termasuk `order_id`, `transaction_status`, serta `redirect_url` untuk kartu jika 3DS diperlukan). + +3) 3DS (Kartu) +- Bila respons berisi `redirect_url`, UI memanggil `authenticate3ds(redirect_url)` untuk challenge 3DS. +- Setelah 3DS selesai, status akan menjadi `capture` (berhasil) atau status lain sesuai penilaian fraud. + +4) Status Page + Polling +- UI menavigasi ke `/payments/:orderId/status` (opsional: `?m=`). +- Halaman melakukan polling `GET /api/payments/:orderId/status` setiap 3 detik sampai status final: `settlement`, `capture`, `expire`, `cancel`, `deny`, `refund`, `chargeback`. + +5) Webhook (Notifikasi Midtrans) +- Backend menyediakan `POST /api/payments/webhook` untuk menerima notifikasi transaksi. +- Backend memverifikasi `signature_key` dan memperbarui status transaksi (DB/in-memory) sebagai sumber kebenaran. +- UI tetap polling hingga menangkap status final (atau dapat diinformasikan melalui SSE jika ditambahkan kemudian). + +6) Success Outcome +- Untuk `bank_transfer/gopay/cstore`: status `settlement` dianggap sukses. +- Untuk `credit_card`: status `capture` dengan `fraud_status=accept` dianggap sukses. +- UI menampilkan badge hijau “Pembayaran berhasil” dan detail metode (VA, QR links, masked card, dsb.). + +7) ERP Notification (External Callback) +- Ketika pembayaran sukses, backend mengirim callback ke ERP pada URL `https://apibackend.erpskrip.id/paymentnotification/`. +- Metode: `POST`, Body JSON berisi: + - `data`: `{ channel, nominal, mercant_id, customer_name }` + - `status_code`: selalu `"200"` untuk pembayaran sukses + - `signature`: `sha512(mercant_id + status_code + nominal + client_id)` dalam hex lowercase +- `client_id` disediakan oleh konfigurasi backend (contoh env `ERP_CLIENT_ID`). + +## Acceptance Criteria +- Checkout + - Pengguna dapat memilih metode Midtrans dan melihat panel input/instruksi yang sesuai. + - Tombol “Bayar” mengirim charge ke backend dan menampilkan feedback loading/error yang ramah. +- Charge & 3DS + - `credit_card`: tokenisasi via 3DS SDK; jika wajib 3DS, UI mengarahkan ke challenge lalu kembali ke halaman status. + - `bank_transfer/gopay/cstore`: charge mengembalikan detail VA/QR/payment code sesuai; UI menampilkan instruksi yang relevan. +- Status Page + - Halaman status menampilkan `order_id`, metode, dan badge status: `pending` (kuning), `settlement/capture` (hijau), `deny/cancel/expire/refund/chargeback` (merah). + - Polling berhenti otomatis ketika status final. +- Webhook Backend + - Endpoint `POST /api/payments/webhook` tersedia dan tervalidasi signature Midtrans. + - Status transaksi diperbarui idempoten (repeat notification tidak merusak data), menyimpan metadata minimal: `source=webhook`, `occurred_at`. + - Logging mencatat event webhook, hasil verifikasi, dan perubahan status. +- Success Case + - Setelah pembayaran sukses (settlement/capture), UI menampilkan konfirmasi “Pembayaran berhasil” dan menyediakan navigasi “Lihat Riwayat” dan “Kembali ke Checkout”. +- ERP Notification + - Saat status sukses (VA/GoPay/QRIS/Cstore = `settlement`, Kartu = `capture` + `fraud_status=accept`), backend melakukan `POST` ke `https://apibackend.erpskrip.id/paymentnotification/` dengan body: + - `data`: `{ channel: , nominal: , mercant_id: , customer_name: }` + - `status_code`: `"200"` + - `signature`: hasil `sha512(mercant_id + status_code + nominal + client_id)` + - Signature tervalidasi di sisi ERP (nilai harus cocok dengan rumus). + - Callback idempoten (jika dipanggil ulang karena retry, tidak menggandakan efek di ERP). +- Keamanan & Ketahanan + - Server Key tidak terekspos ke frontend. + - Verifikasi signature sesuai rumus Midtrans; input sensitif (token_id, card data) tidak tercatat di log. + - Fallback polling tetap bekerja jika webhook terlambat. + +## Technical Notes +- Endpoint backend saat ini: + - `POST /api/payments/charge` — sudah ada (pass-through ke Midtrans Core API) + - `GET /api/payments/:orderId/status` — sudah ada (memanggil `core.transaction.status(orderId)`). + - Tambahkan: `POST /api/payments/webhook` — menerima notifikasi Midtrans. + +- Verifikasi Signature Midtrans (HTTP Notification) + - `signature_key = sha512(order_id + status_code + gross_amount + serverKey)` (hex lowercase) + - Contoh Node.js: + ```js + const crypto = require('crypto') + const signature = crypto + .createHash('sha512') + .update(orderId + statusCode + grossAmount + serverKey) + .digest('hex') + const isValid = signature === req.body.signature_key + ``` + +- Status Mapping (UI) + - Final: `settlement`, `capture`, `expire`, `cancel`, `deny`, `refund`, `chargeback`. + - Sukses: `settlement` (VA/QR/Cstore), `capture` + `fraud_status=accept` (Card). + - Normalisasi di `src/features/payments/lib/midtrans.ts` melalui `normalizeMidtransStatus`. + +- ERP External Notification (Backend → ERP) + - Kirim callback hanya ketika status sukses: + - VA/GoPay/QRIS/Cstore: `transaction_status === 'settlement'` + - Kartu: `transaction_status === 'capture'` dan `fraud_status === 'accept'` + - Payload contoh: + ```json + { + "data": { + "channel": "DANA", + "nominal": 200000, + "mercant_id": "TKG-250520029803", + "customer_name": "Dwiki Kurnia Sandi" + }, + "status_code": "200", + "signature": "" + } + ``` + - Perhitungan signature (Node.js): + ```js + const crypto = require('crypto') + const mercantId = data.mercant_id + const nominal = String(data.nominal) + const statusCode = '200' + const clientId = process.env.ERP_CLIENT_ID || '' + const raw = `${mercantId}${statusCode}${nominal}${clientId}` + const signature = crypto.createHash('sha512').update(raw).digest('hex') + ``` + - Pengiriman (contoh menggunakan fetch): + ```js + const res = await fetch('https://apibackend.erpskrip.id/paymentnotification/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data, status_code: '200', signature }) + }) + if (!res.ok) throw new Error(`ERP notify failed: ${res.status}`) + ``` + - Idempotensi: gunakan kunci unik (mis. `order_id`) untuk memastikan sekali proses pada ERP; lakukan retry dengan backoff jika gagal. + +## Test Cases (Sandbox) +- Credit Card (3DS) + - Input kartu sandbox → tokenisasi → challenge 3DS → kembali ke status → `capture` + `fraud_status=accept` → UI sukses. +- Bank Transfer (VA Permata/Mandiri E-Channel) + - Charge menghasilkan VA → UI menampilkan VA dan bank → simulasi pembayaran → webhook atau polling → `settlement` → UI sukses. +- GoPay/QRIS + - Charge menghasilkan `actions` (deeplink/QR) → buka tautan → `settlement` → UI sukses. + - Backend mengirim ERP callback dengan `status_code="200"` dan signature valid. +- Negative/Edge + - Tidak bayar dalam waktu batas → `expire` → UI merah, polling berhenti. + - `deny/cancel` → UI merah; riwayat mencatat status akhir. + - ERP callback tidak dikirim untuk status non-sukses. + +## Dependencies & Config +- Env Frontend: `VITE_API_BASE_URL`, `VITE_MIDTRANS_CLIENT_KEY`, `VITE_MIDTRANS_ENV` (sandbox/production). +- Env Backend: `MIDTRANS_SERVER_KEY`, `MIDTRANS_CLIENT_KEY` (opsional untuk log), `MIDTRANS_IS_PRODUCTION`. +- Toggle fitur: `GET/POST /api/config` (bank_transfer, credit_card, gopay, cstore). Pastikan metode yang digunakan aktif. +- ERP Notifikasi: + - `ERP_NOTIFICATION_URL="https://apibackend.erpskrip.id/paymentnotification/"` + - `ERP_CLIENT_ID=""` + - Opsional: `ERP_ENABLE_NOTIF=true` (feature flag untuk mengaktifkan/nonaktifkan callback) + +## Validation Checklist +- [ ] Perubahan dapat selesai dalam satu sesi (dokumen + endpoint webhook kecil) +- [ ] Integrasi mengikuti pola yang ada (`postCharge`, `getPaymentStatus`, polling) +- [ ] Tidak ada kebutuhan arsitektur baru besar +- [ ] Acceptance criteria dapat diuji di sandbox +- [ ] Rollback sederhana: nonaktifkan webhook, andalkan polling sementara +- [ ] ERP callback dikirim pada status sukses dengan `status_code="200"` dan signature sesuai + +## Rollback & Risk +- Rollback: nonaktifkan konsumsi webhook (feature flag), gunakan polling status sementara. +- Risiko: signature salah, keterlambatan webhook, idempotensi tidak benar. Mitigasi: verifikasi ketat, logging, dan retry aman. +- Risiko ERP: endpoint tidak tersedia atau timeouts. Mitigasi: retry dengan backoff, dead-letter queue (opsional), observabilitas. + +## Out of Scope +- Metode eksternal non‑Midtrans (contoh: cPay/CIFO Token) tidak termasuk dalam skenario sukses Midtrans ini. +- Refund/chargeback flow admin; hanya ditampilkan sebagai status jika terjadi. + +## Notes +- Rujuk `docs/integration-midtrans.md` untuk detail kartu/3DS. +- Tambahkan dokumentasi runbook QA untuk simulasi webhook dan verifikasi status manual. \ No newline at end of file diff --git a/public/logos/ALFAMART_LOGO_BARU.png b/public/logos/ALFAMART_LOGO_BARU.png new file mode 100644 index 0000000..e5cf0d2 Binary files /dev/null and b/public/logos/ALFAMART_LOGO_BARU.png differ diff --git a/public/logos/Cifo_cpay.png b/public/logos/Cifo_cpay.png new file mode 100644 index 0000000..f032040 Binary files /dev/null and b/public/logos/Cifo_cpay.png differ diff --git a/public/logos/Gopay_logo.svg b/public/logos/Gopay_logo.svg new file mode 100644 index 0000000..c33e4f0 --- /dev/null +++ b/public/logos/Gopay_logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/Logo_Indomaret.png b/public/logos/Logo_Indomaret.png new file mode 100644 index 0000000..d44590d Binary files /dev/null and b/public/logos/Logo_Indomaret.png differ diff --git a/public/logos/Logo_QRIS.svg b/public/logos/Logo_QRIS.svg new file mode 100644 index 0000000..27c8279 --- /dev/null +++ b/public/logos/Logo_QRIS.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/gopay.svg b/public/logos/gopay.svg deleted file mode 100644 index 81ab62e..0000000 --- a/public/logos/gopay.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - GoPay - \ No newline at end of file diff --git a/public/logos/qris.svg b/public/logos/qris.svg deleted file mode 100644 index 4197460..0000000 --- a/public/logos/qris.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - QRIS - \ No newline at end of file diff --git a/server/index.cjs b/server/index.cjs index fab40ce..f923bbb 100644 --- a/server/index.cjs +++ b/server/index.cjs @@ -2,6 +2,8 @@ const express = require('express') const cors = require('cors') const dotenv = require('dotenv') const midtransClient = require('midtrans-client') +const crypto = require('crypto') +const https = require('https') dotenv.config() @@ -23,6 +25,22 @@ const core = new midtransClient.CoreApi({ clientKey, }) +// --- ERP Notification Config +function parseEnable(v) { + if (typeof v === 'string') { + const s = v.trim().toLowerCase() + return s === 'true' || s === '1' || s === 'yes' || s === 'on' + } + if (typeof v === 'boolean') return v + if (typeof v === 'number') return v === 1 + return true +} +const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || '' +const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF) +const ERP_CLIENT_ID = process.env.ERP_CLIENT_ID || '' +const ERP_MERCANT_ID = process.env.ERP_MERCANT_ID || process.env.ERP_MERCHANT_ID || '' +const notifiedOrders = new Set() + // --- Logger utilities const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase() const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 } @@ -73,16 +91,6 @@ app.use((req, res, next) => { next() }) -function parseEnable(v) { - if (typeof v === 'string') { - const s = v.trim().toLowerCase() - return s === 'true' || s === '1' || s === 'yes' || s === 'on' - } - if (typeof v === 'boolean') return v - if (typeof v === 'number') return v === 1 - return true -} - const ENABLE = { bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER), credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD), @@ -172,6 +180,133 @@ app.get('/api/payments/:orderId/status', async (req, res) => { } }) +// --- Helpers: Midtrans signature verification & ERP notify +function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) { + try { + const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey) + return crypto.createHash('sha512').update(raw).digest('hex') + } catch { + return '' + } +} + +function isSuccessfulMidtransStatus(body) { + const s = (body?.transaction_status || '').toLowerCase() + const fraud = (body?.fraud_status || '').toLowerCase() + // Success for most methods: settlement; Card: capture with fraud_status=accept also success + if (s === 'settlement') return true + if (s === 'capture' && fraud === 'accept') return true + return false +} + +function postJson(url, data) { + return new Promise((resolve, reject) => { + try { + const u = new URL(url) + const body = JSON.stringify(data) + const opts = { + method: 'POST', + hostname: u.hostname, + path: u.pathname + (u.search || ''), + port: u.port || (u.protocol === 'https:' ? 443 : 80), + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + } + const req = (u.protocol === 'https:' ? https : require('http')).request(opts, (res) => { + let chunks = '' + res.on('data', (d) => { chunks += d.toString() }) + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) resolve({ status: res.statusCode, body: chunks }) + else reject(new Error(`HTTP ${res.statusCode}: ${chunks}`)) + }) + }) + req.on('error', reject) + req.write(body) + req.end() + } catch (e) { + reject(e) + } + }) +} + +function computeErpSignature(mercantId, statusCode, nominal, clientId) { + try { + const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId) + return crypto.createHash('sha512').update(raw).digest('hex') + } catch { + return '' + } +} + +async function notifyERP({ orderId, nominal }) { + if (!ERP_ENABLE_NOTIF) { + logInfo('erp.notify.skip', { reason: 'disabled' }) + return + } + if (!ERP_NOTIFICATION_URL || !ERP_CLIENT_ID || !ERP_MERCANT_ID) { + logWarn('erp.notify.missing_config', { hasUrl: !!ERP_NOTIFICATION_URL, hasClientId: !!ERP_CLIENT_ID, hasMercantId: !!ERP_MERCANT_ID }) + return + } + const statusCode = '200' + const signature = computeErpSignature(ERP_MERCANT_ID, statusCode, nominal, ERP_CLIENT_ID) + const payload = { + data: { + mercant_id: ERP_MERCANT_ID, + status_code: statusCode, + nominal: nominal, + client_id: ERP_CLIENT_ID, + }, + signature, + } + logInfo('erp.notify.start', { orderId, url: ERP_NOTIFICATION_URL }) + try { + const res = await postJson(ERP_NOTIFICATION_URL, payload) + logInfo('erp.notify.success', { orderId, status: res.status }) + } catch (e) { + logError('erp.notify.error', { orderId, message: e?.message }) + } +} + +// Webhook endpoint for Midtrans notifications +app.post('/api/payments/webhook', async (req, res) => { + try { + const body = req.body || {} + const orderId = body?.order_id + const statusCode = body?.status_code + const grossAmount = body?.gross_amount + const signatureKey = (body?.signature_key || '').toLowerCase() + logInfo('webhook.receive', { order_id: orderId, transaction_status: body?.transaction_status }) + + // Verify signature + const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey) + if (!expectedSig || signatureKey !== expectedSig) { + logWarn('webhook.signature.invalid', { order_id: orderId }) + return res.status(401).json({ error: 'INVALID_SIGNATURE' }) + } + + // Acknowledge quickly + res.json({ ok: true }) + + // Process success callbacks asynchronously + if (isSuccessfulMidtransStatus(body)) { + const nominal = String(grossAmount) + if (!notifiedOrders.has(orderId)) { + notifiedOrders.add(orderId) + await notifyERP({ orderId, nominal }) + } else { + logInfo('erp.notify.skip', { orderId, reason: 'already_notified' }) + } + } else { + logInfo('webhook.non_success', { order_id: orderId, transaction_status: body?.transaction_status }) + } + } catch (e) { + logError('webhook.error', { message: e?.message }) + try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {} + } +}) + const port = process.env.PORT || 8000 app.listen(port, () => { console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`) diff --git a/src/lib/env.ts b/src/lib/env.ts index d0b1baa..d71823a 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -18,4 +18,5 @@ export const Env = { ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD), ENABLE_GOPAY: parseEnable(import.meta.env.VITE_ENABLE_GOPAY), ENABLE_CSTORE: parseEnable(import.meta.env.VITE_ENABLE_CSTORE), + ENABLE_CPAY: parseEnable(import.meta.env.VITE_ENABLE_CPAY), } \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index 05fcd22..ae99a41 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -85,6 +85,7 @@ export type RuntimeConfigResponse = { credit_card: boolean gopay: boolean cstore: boolean + cpay?: boolean } midtransEnv?: 'production' | 'sandbox' clientKey?: string @@ -105,6 +106,7 @@ export async function getRuntimeConfig(): Promise { credit_card: Env.ENABLE_CREDIT_CARD, gopay: Env.ENABLE_GOPAY, cstore: Env.ENABLE_CSTORE, + cpay: Env.ENABLE_CPAY, }, midtransEnv: Env.MIDTRANS_ENV, clientKey: Env.MIDTRANS_CLIENT_KEY,