improvisasi
|
|
@ -66,3 +66,27 @@ Pastikan `VITE_API_BASE_URL` menunjuk ke `http://localhost:8000/api` agar fronte
|
||||||
|
|
||||||
- Midtrans Docs: Credit Card with 3DS (Core API).
|
- Midtrans Docs: Credit Card with 3DS (Core API).
|
||||||
- Contoh: `coreApiSimpleExample.js` untuk pola panggilan Core API.
|
- 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="<dari ERP>"`
|
||||||
|
- `ERP_MERCANT_ID="<dari ERP>"`
|
||||||
|
- `ERP_ENABLE_NOTIF=true`
|
||||||
|
- Payload dikirim saat sukses:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"mercant_id": "<id>",
|
||||||
|
"status_code": "200",
|
||||||
|
"nominal": "<gross_amount>",
|
||||||
|
"client_id": "<id>"
|
||||||
|
},
|
||||||
|
"signature": "sha512(mercant_id + status_code + nominal + client_id)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -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=<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.
|
||||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 987 KiB |
|
|
@ -0,0 +1,12 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="63" height="16" viewBox="0 0 63 16">
|
||||||
|
<g fill="none" fill-rule="evenodd">
|
||||||
|
<path fill="#FFF" fill-opacity=".01" d="M0 0h63v16H0z"/>
|
||||||
|
<g transform="translate(0 1.143)">
|
||||||
|
<ellipse cx="6.811" cy="6.857" fill="#00AED6" fill-rule="nonzero" rx="6.811" ry="6.857"/>
|
||||||
|
<path fill="#FFF" d="M10.778 6.644a1.587 1.587 0 0 0-1.652-1.5H4.824a.285.285 0 0 1-.284-.286c0-.158.127-.286.284-.286h4.359a1.362 1.362 0 0 0-.993-1.26 10.97 10.97 0 0 0-3.84 0 1.82 1.82 0 0 0-1.362 1.526 13.711 13.711 0 0 0 0 4.06 1.92 1.92 0 0 0 1.552 1.526 19.13 19.13 0 0 0 4.748 0 1.669 1.669 0 0 0 1.317-1.44c.14-.772.199-1.556.173-2.34zm-1.413.96v.254a.285.285 0 0 1-.284.286.285.285 0 0 1-.284-.286v-.254a.427.427 0 0 1 .284-.746.427.427 0 0 1 .284.746z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#000" fill-rule="nonzero">
|
||||||
|
<path d="M18.937 11.414a2.921 2.921 0 0 0 2.545 1.252c1.187 0 2.059-.763 2.059-1.8v-.547h-.029c-.65.64-1.537.974-2.444.922a3.955 3.955 0 0 1-3.513-1.94 4.012 4.012 0 0 1-.037-4.033 3.956 3.956 0 0 1 3.478-2.002 3.39 3.39 0 0 1 2.516.892h.029V3.41h2.03v7.428c0 2.159-1.7 3.656-4.089 3.656a4.87 4.87 0 0 1-4.06-1.814l1.515-1.266zm4.519-4.622c0-.863-.973-1.655-2.059-1.655-1.373 0-2.288.835-2.288 2.087-.04.594.18 1.175.605 1.588a1.995 1.995 0 0 0 1.597.557c1.187 0 2.145-.748 2.145-1.684v-.893zM30.916 3.194c2.474 0 4.276 1.77 4.276 4.03 0 2.26-1.802 4.031-4.276 4.031a4.005 4.005 0 0 1-3.692-1.935 4.063 4.063 0 0 1 0-4.191 4.005 4.005 0 0 1 3.692-1.935zm0 1.87a2.152 2.152 0 0 0-2.13 2.17 2.152 2.152 0 0 0 2.15 2.15 2.152 2.152 0 0 0 2.14-2.16 2.075 2.075 0 0 0-.605-1.562 2.045 2.045 0 0 0-1.555-.597zM36.29 3.41h2.03v.676h.03a3.359 3.359 0 0 1 2.444-.892c2.18.04 3.928 1.828 3.932 4.023.004 2.196-1.738 3.99-3.918 4.038-.86.02-1.7-.265-2.373-.806h-.029v3.829H36.29V3.41zm4.176 1.67c-1.116 0-2.06.791-2.06 1.655v.964c0 .922.916 1.684 2.073 1.684a2.145 2.145 0 0 0 2.131-2.158 2.145 2.145 0 0 0-2.144-2.146zM48.803 6.49c1.387-.187 1.802-.388 1.802-.777 0-.504-.53-.806-1.344-.806a1.79 1.79 0 0 0-1.888 1.367l-2.002-.417c.286-1.555 1.874-2.663 3.832-2.663 2.216 0 3.59 1.137 3.59 2.993v4.852H50.89v-.835h-.03a3.117 3.117 0 0 1-2.559 1.051c-1.673 0-2.83-.921-2.83-2.275 0-1.425.943-2.159 3.331-2.49zm1.973.806h-.028c-.187.274-.587.432-1.616.62-1.244.23-1.687.474-1.687.92 0 .461.372.663 1.172.663 1.216 0 2.16-.562 2.16-1.296v-.907zM56.82 10.622L53.317 3.41h2.331l2.302 4.98h.028l2.274-4.98h2.345L57.35 14.278h-2.331z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
|
@ -0,0 +1,150 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="45.938mm"
|
||||||
|
height="17.413mm"
|
||||||
|
viewBox="0 0 45.938003 17.413"
|
||||||
|
version="1.1"
|
||||||
|
id="svg845"
|
||||||
|
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||||
|
sodipodi:docname="Logo QRIS.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<defs
|
||||||
|
id="defs839">
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath96">
|
||||||
|
<path
|
||||||
|
d="M -1.2207e-4,2.4414e-4 H 959.99988 V 540.00024 H -1.2207e-4 Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
id="path94" />
|
||||||
|
</clipPath>
|
||||||
|
<clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath106">
|
||||||
|
<path
|
||||||
|
d="M 0,0 H 130.46 V 23.577 H 0 Z"
|
||||||
|
id="path104" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="4"
|
||||||
|
inkscape:cx="81.75"
|
||||||
|
inkscape:cy="51.875"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
inkscape:document-rotation="0"
|
||||||
|
showgrid="false"
|
||||||
|
fit-margin-top="0"
|
||||||
|
fit-margin-left="0"
|
||||||
|
fit-margin-right="0"
|
||||||
|
fit-margin-bottom="0"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="991"
|
||||||
|
inkscape:window-x="-9"
|
||||||
|
inkscape:window-y="-9"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1" />
|
||||||
|
<metadata
|
||||||
|
id="metadata842">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-31.991243,-118.40059)">
|
||||||
|
<g
|
||||||
|
id="g90"
|
||||||
|
transform="matrix(0.7313755,0,0,-0.7313755,-550.54458,504.98281)"
|
||||||
|
style="fill:#000000;stroke-width:0.999487;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g92"
|
||||||
|
clip-path="url(#clipPath96)"
|
||||||
|
style="fill:#000000;stroke-width:0.999487;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g98"
|
||||||
|
transform="matrix(2.1033,-7.54e-7,-7.54e-7,0.375,795.24,502.8)"
|
||||||
|
style="fill:#000000;stroke-width:1.12541;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g100"
|
||||||
|
transform="scale(0.55189,3.0538)"
|
||||||
|
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g102"
|
||||||
|
clip-path="url(#clipPath106)"
|
||||||
|
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g108"
|
||||||
|
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g110"
|
||||||
|
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none">
|
||||||
|
<g
|
||||||
|
id="g112"
|
||||||
|
style="fill:#000000;stroke-width:0.86689;stroke-dasharray:none" />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d="M 18.813,19.108 H 32.767 V 10.622 H 26.913 L 32.767,5.1912 H 27.794 L 22.053,10.622 V 5.1912 h -3.24 v 8.6268 h 10.231 v 2.065 H 18.813 Z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path114" />
|
||||||
|
<path
|
||||||
|
d="m 34.216,5.1912 h 3.4103 v 13.945 H 34.216 Z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path116" />
|
||||||
|
<path
|
||||||
|
d="M 6.9058,8.5572 V 19.108 H 4.2344 c -0.4831,0 -0.8526,-0.368 -0.8526,-0.82 0,-2.49 -0.0284,-9.8157 -0.0284,-12.2482 0,-0.4526 0.3695,-0.8486 0.8242,-0.8486 1.8472,0 7.1614,0 7.9284,0 v 3.366 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path118" />
|
||||||
|
<path
|
||||||
|
d="m 13.868,1.712 c 0.313,0 2.7,0 3.496,0 0,0 0,6.8735 0,6.9866 -1.137,0 -2.33,0 -3.496,0 0,-2.3194 0,-4.6388 0,-6.9866 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path120" />
|
||||||
|
<path
|
||||||
|
d="m 8.6109,19.108 c 0,-0.057 0,-3.479 0,-3.479 1.5631,0 3.5521,0 5.2571,0 0,-2.405 0,-5.205 0,-5.205 0.796,0 3.183,0 3.496,0 v 7.835 c 0,0.453 -0.37,0.821 -0.824,0.849 -1.705,0 -5.741,0 -7.9291,0 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path122" />
|
||||||
|
<path
|
||||||
|
d="m 8.6109,13.932 c 0,-1.16 0,-2.32 0,-3.536 0.2842,0 0.5968,0 0.881,0 0.7961,0 1.7901,0 2.5861,0 0,0 0,3.479 0,3.536 -1.137,0 -2.3019,0 -3.4671,0 z m 2.4441,-2.461 v 0 c -0.341,0 -0.739,0 -1.052,0 -0.1132,0 -0.2269,0 -0.369,0 0,0.481 0,0.933 0,1.414 0.483,0 0.938,0 1.421,0 0,-0.028 0,-1.414 0,-1.414 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path124" />
|
||||||
|
<path
|
||||||
|
d="M 54.365,8.6986 V 3.494 c 0,-0.4525 -0.398,-0.8203 -0.852,-0.8203 H 48.284 V 1.8252 h 6.081 0.029 c 0.426,0 0.795,0.3677 0.795,0.8203 v 0.0282 6.0249 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path126" />
|
||||||
|
<path
|
||||||
|
d="m 1.9325,15.657 v 5.205 c 0,0.452 0.341,0.792 0.7957,0.792 h 5.2291 c 0.0284,0 0.0284,0.028 0.0284,0.028 v 0.792 c 0,0 0,0.028 -0.0284,0.028 H 1.9325 c -0.4831,0 -0.8526,-0.368 -0.8526,-0.848 v -5.997 c 0,-0.028 0.0284,-0.028 0.0284,-0.028 h 0.7958 c 0,0 0.0284,0 0.0284,0.028 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path128" />
|
||||||
|
<path
|
||||||
|
d="m 52.887,15.657 v 3.451 H 39.104 v -5.176 -3.48 h 9.18 V 8.7269 h -9.18 V 5.2478 h 13.783 v 8.6552 h -9.179 v 1.754 z"
|
||||||
|
style="fill:#000000;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.86689;stroke-dasharray:none"
|
||||||
|
id="path130" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.8 KiB |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="24" viewBox="0 0 72 24">
|
|
||||||
<rect width="72" height="24" rx="4" fill="#00AED6"/>
|
|
||||||
<text x="36" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">GoPay</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 274 B |
|
|
@ -1,4 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="72" height="24" viewBox="0 0 72 24">
|
|
||||||
<rect width="72" height="24" rx="4" fill="#000000"/>
|
|
||||||
<text x="36" y="16" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#ffffff">QRIS</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 273 B |
155
server/index.cjs
|
|
@ -2,6 +2,8 @@ const express = require('express')
|
||||||
const cors = require('cors')
|
const cors = require('cors')
|
||||||
const dotenv = require('dotenv')
|
const dotenv = require('dotenv')
|
||||||
const midtransClient = require('midtrans-client')
|
const midtransClient = require('midtrans-client')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const https = require('https')
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
|
|
@ -23,6 +25,22 @@ const core = new midtransClient.CoreApi({
|
||||||
clientKey,
|
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
|
// --- Logger utilities
|
||||||
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
||||||
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||||
|
|
@ -73,16 +91,6 @@ app.use((req, res, next) => {
|
||||||
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 = {
|
const ENABLE = {
|
||||||
bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER),
|
bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER),
|
||||||
credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD),
|
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
|
const port = process.env.PORT || 8000
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,5 @@ export const Env = {
|
||||||
ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD),
|
ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD),
|
||||||
ENABLE_GOPAY: parseEnable(import.meta.env.VITE_ENABLE_GOPAY),
|
ENABLE_GOPAY: parseEnable(import.meta.env.VITE_ENABLE_GOPAY),
|
||||||
ENABLE_CSTORE: parseEnable(import.meta.env.VITE_ENABLE_CSTORE),
|
ENABLE_CSTORE: parseEnable(import.meta.env.VITE_ENABLE_CSTORE),
|
||||||
|
ENABLE_CPAY: parseEnable(import.meta.env.VITE_ENABLE_CPAY),
|
||||||
}
|
}
|
||||||
|
|
@ -85,6 +85,7 @@ export type RuntimeConfigResponse = {
|
||||||
credit_card: boolean
|
credit_card: boolean
|
||||||
gopay: boolean
|
gopay: boolean
|
||||||
cstore: boolean
|
cstore: boolean
|
||||||
|
cpay?: boolean
|
||||||
}
|
}
|
||||||
midtransEnv?: 'production' | 'sandbox'
|
midtransEnv?: 'production' | 'sandbox'
|
||||||
clientKey?: string
|
clientKey?: string
|
||||||
|
|
@ -105,6 +106,7 @@ export async function getRuntimeConfig(): Promise<RuntimeConfigResponse> {
|
||||||
credit_card: Env.ENABLE_CREDIT_CARD,
|
credit_card: Env.ENABLE_CREDIT_CARD,
|
||||||
gopay: Env.ENABLE_GOPAY,
|
gopay: Env.ENABLE_GOPAY,
|
||||||
cstore: Env.ENABLE_CSTORE,
|
cstore: Env.ENABLE_CSTORE,
|
||||||
|
cpay: Env.ENABLE_CPAY,
|
||||||
},
|
},
|
||||||
midtransEnv: Env.MIDTRANS_ENV,
|
midtransEnv: Env.MIDTRANS_ENV,
|
||||||
clientKey: Env.MIDTRANS_CLIENT_KEY,
|
clientKey: Env.MIDTRANS_CLIENT_KEY,
|
||||||
|
|
|
||||||