feat/payments-ux-instructions-va #1

Merged
root merged 4 commits from feat/payments-ux-instructions-va into main 2025-11-10 08:50:52 +00:00
12 changed files with 518 additions and 19 deletions
Showing only changes of commit 85d0c9a58b - Show all commits

View File

@ -65,4 +65,28 @@ Pastikan `VITE_API_BASE_URL` menunjuk ke `http://localhost:8000/api` agar fronte
## Referensi ## Referensi
- 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)"
}
```

View File

@ -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 nonMidtrans (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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
public/logos/Cifo_cpay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 987 KiB

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

150
public/logos/Logo_QRIS.svg Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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})`)

View File

@ -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),
} }

View File

@ -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,