183 lines
10 KiB
Markdown
183 lines
10 KiB
Markdown
# 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=<method>`).
|
||
- 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: <string>, nominal: <number>, mercant_id: <string>, customer_name: <string> }`
|
||
- `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": "<hex sha512>"
|
||
}
|
||
```
|
||
- 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="<dari ERP>"`
|
||
- 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. |