Compare commits
30 Commits
feat/payme
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
c5995f7b39 | |
|
|
b98c50cea8 | |
|
|
5034ecf1e4 | |
|
|
18513b5968 | |
|
|
c6b17bfab6 | |
|
|
ec96b71161 | |
|
|
2494f3cedd | |
|
|
96c4cd3aba | |
|
|
9a4e14575b | |
|
|
8c42768ec3 | |
|
|
11197bf3c7 | |
|
|
e1dbe911c9 | |
|
|
33df1b326c | |
|
|
b234556f92 | |
|
|
e4c81dce78 | |
|
|
f4e0ca4741 | |
|
|
e1f989447b | |
|
|
9edcd6191a | |
|
|
80fb683dcc | |
|
|
d430e82d3f | |
|
|
6472e95310 | |
|
|
9b8a62f1d8 | |
|
|
4b43c61365 | |
|
|
92ec715632 | |
|
|
ef4dcb8795 | |
|
|
baed44ecd7 | |
|
|
11ca3f5117 | |
|
|
4d10f0e121 | |
|
|
a940fda6b2 | |
|
|
b0ded99d34 |
|
|
@ -0,0 +1,310 @@
|
|||
# Panduan QA End-to-End: Payment Link
|
||||
|
||||
Dokumen ini menjelaskan alur end-to-end Payment Link yang tersedia di proyek, mencakup konfigurasi, endpoint backend, format token, langkah QA dengan contoh request, hingga troubleshooting dan integrasi frontend.
|
||||
|
||||
## Ringkasan Alur
|
||||
- ERP/eksternal membuat Payment Link via `POST /createtransaksi` dan menerima `url` serta `token`.
|
||||
- Frontend membuka halaman `Pay` di route `pay/:token`, lalu me-resolve token via `GET /api/payment-links/:token`.
|
||||
- Pengguna memilih metode pembayaran (sesuai `allowed_methods` dan toggle runtime), kemudian frontend memanggil `POST /api/payments/charge`.
|
||||
- Status pembayaran dapat dicek via `GET /api/payments/:orderId/status` dan disinkronkan via webhook `POST /api/payments/webhook`.
|
||||
|
||||
## Prasyarat & Konfigurasi
|
||||
- Backend: jalankan `node server/index.cjs` (default port `8000`).
|
||||
- Frontend: jalankan `npm run dev` (contoh dev port: `5175`).
|
||||
- Penyesuaian environment penting:
|
||||
- `EXTERNAL_API_KEY`: API Key luar untuk `POST /createtransaksi`. Jika tidak diset, di dev akan diizinkan tanpa key.
|
||||
- `PAYMENT_LINK_SECRET`: secret untuk penandatanganan token Payment Link (HMAC SHA-256). Default dev: `dev-secret`.
|
||||
- `PAYMENT_LINK_TTL_MINUTES`: waktu kedaluwarsa token (default: `30`).
|
||||
- `PAYMENT_LINK_BASE`: base URL untuk halaman `Pay` (default: `http://localhost:5174/pay`). Sesuaikan ke port frontend yang aktif (misal `http://localhost:5175/pay`).
|
||||
- `PORT`: port backend (default: `8000`).
|
||||
- `ERP_NOTIFICATION_URL`, `ERP_CLIENT_ID`, `ERP_MERCANT_ID`, `ERP_ENABLE_NOTIF`: konfigurasi notifikasi ERP saat settlement.
|
||||
- Midtrans keys: Server Key dan Client Key harus tersedia untuk charge/status.
|
||||
- Frontend env: `VITE_API_BASE_URL` (contoh: `http://localhost:8000/api`), `VITE_MIDTRANS_CLIENT_KEY`.
|
||||
|
||||
## Endpoint Backend
|
||||
- `POST /createtransaksi`
|
||||
- Header: `X-API-KEY` (opsional di dev jika `EXTERNAL_API_KEY` tidak diset).
|
||||
- Body: `{ item_id | order_id, nominal, customer?, allowed_methods? }`
|
||||
- Respon: `{ url, token, order_id, nominal, expire_at }`
|
||||
- Error: `UNAUTHORIZED`, `BAD_REQUEST`, `ORDER_COMPLETED`, `ORDER_ACTIVE`, `CREATE_ERROR`.
|
||||
|
||||
- `GET /api/payment-links/:token`
|
||||
- Respon: `{ order_id, nominal, customer?, expire_at?, allowed_methods? }`
|
||||
- Error: `410 TOKEN_EXPIRED`, `400 INVALID_*` (di dev ada fallback payload jika token invalid).
|
||||
|
||||
- `POST /api/payments/charge`
|
||||
- Body: payload Midtrans (contoh di bawah). Diblokir jika method dimatikan oleh runtime toggles.
|
||||
- Error: `PAYMENT_TYPE_DISABLED`, `CHARGE_ERROR`.
|
||||
|
||||
- `GET /api/payments/:orderId/status`
|
||||
- Respon: pass-through dari Midtrans (transaction status, VA, dll.).
|
||||
|
||||
- `POST /api/payments/webhook`
|
||||
- Verifikasi signature: `sha512(orderId + statusCode + grossAmount + serverKey)`.
|
||||
- Pada sukses (settlement atau capture+accept untuk kartu), backend kirim notifikasi ke ERP (jika diaktifkan) dan menandai order sebagai completed.
|
||||
|
||||
- `GET /api/health`, `GET/POST /api/config`
|
||||
- Health: cek ketersediaan key dan environment.
|
||||
- Config: baca/ubah toggles (dev-only untuk `POST`).
|
||||
|
||||
## Format Token Payment Link
|
||||
- Token adalah `base64url(JSON)` dengan fields minimal: `{ v, order_id, nominal, expire_at, sig, customer?, allowed_methods? }`.
|
||||
- `sig` adalah HMAC SHA-256 dari string kanonik: `"order_id|nominal|expire_at"` menggunakan `PAYMENT_LINK_SECRET`.
|
||||
|
||||
## Langkah QA (Contoh)
|
||||
|
||||
1) Buat Payment Link
|
||||
|
||||
PowerShell (Windows):
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
item_id = 'INV-PL-001';
|
||||
nominal = 150000;
|
||||
customer = @{ name='QA Tester'; phone='081234567890'; email='qa@example.com' };
|
||||
allowed_methods = @('bank_transfer','cstore','gopay','credit_card')
|
||||
} | ConvertTo-Json -Depth 5;
|
||||
|
||||
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/createtransaksi' -ContentType 'application/json' -Body $body | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
curl:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/createtransaksi \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-API-KEY: <jika-diperlukan>' \
|
||||
-d '{
|
||||
"item_id":"INV-PL-001",
|
||||
"nominal":150000,
|
||||
"customer": {"name":"QA Tester","phone":"081234567890","email":"qa@example.com"},
|
||||
"allowed_methods":["bank_transfer","cstore","gopay","credit_card"]
|
||||
}'
|
||||
```
|
||||
|
||||
2) Resolve Token
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method GET -Uri "http://localhost:8000/api/payment-links/<token>" | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
3) Buka Halaman Pay
|
||||
|
||||
- Buka `http://localhost:5175/pay/<token>` (sesuaikan `PAYMENT_LINK_BASE` dengan port frontend).
|
||||
- Periksa daftar metode, panel, serta batasan dari `allowed_methods` dan runtime toggles.
|
||||
|
||||
4) Charge Bank Transfer (BCA)
|
||||
|
||||
```powershell
|
||||
$bt = @{
|
||||
payment_type = 'bank_transfer';
|
||||
transaction_details = @{ order_id = 'INV-PL-001'; gross_amount = 150000 };
|
||||
bank_transfer = @{ bank = 'bca' }
|
||||
} | ConvertTo-Json -Depth 5;
|
||||
|
||||
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/api/payments/charge' -ContentType 'application/json' -Body $bt | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
5) Charge GoPay/QR (opsional)
|
||||
|
||||
```powershell
|
||||
$qr = @{
|
||||
payment_type = 'gopay';
|
||||
transaction_details = @{ order_id = 'INV-PL-001'; gross_amount = 150000 };
|
||||
gopay = @{ enable_qr = $true }
|
||||
} | ConvertTo-Json -Depth 5;
|
||||
|
||||
Invoke-RestMethod -Method POST -Uri 'http://localhost:8000/api/payments/charge' -ContentType 'application/json' -Body $qr | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
Catatan: Bila `400 Bad Request`, cek konfigurasi akun sandbox dan parameter GoPay/QRIS (lihat bagian troubleshooting).
|
||||
|
||||
6) Status Check
|
||||
|
||||
```powershell
|
||||
Invoke-RestMethod -Method GET -Uri 'http://localhost:8000/api/payments/INV-PL-001/status' | ConvertTo-Json -Depth 5
|
||||
```
|
||||
|
||||
7) Webhook (uji manual)
|
||||
|
||||
Untuk uji manual, kirim payload menyerupai notifikasi Midtrans dengan `signature_key` yang valid. Signature dihitung:
|
||||
|
||||
```js
|
||||
// Node.js contoh perhitungan signature
|
||||
const crypto = require('crypto')
|
||||
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
|
||||
return crypto.createHash('sha512').update(raw).digest('hex')
|
||||
}
|
||||
```
|
||||
|
||||
Kemudian `POST` ke `http://localhost:8000/api/payments/webhook` dengan body berisi fields Midtrans (order_id, status_code, gross_amount, signature_key, dll.).
|
||||
|
||||
## Troubleshooting
|
||||
- Frontend tidak sesuai `PAYMENT_LINK_BASE` (5174 vs 5175): set `PAYMENT_LINK_BASE=http://localhost:5175/pay` di backend agar URL link mengarah ke port yang benar.
|
||||
- `400 Bad Request` untuk GoPay/QR:
|
||||
- Pastikan `gopay` payload memenuhi kebutuhan sandbox (mis. `enable_qr`, kadang perlu `qr_black_white`, atau `callback_url`).
|
||||
- Periksa toggles runtime (`/api/config`) dan ketersediaan Midtrans keys.
|
||||
- Beberapa merchant sandbox memiliki batasan; rujuk dokumentasi Midtrans untuk parameter terbaru.
|
||||
- `UNAUTHORIZED` saat `createtransaksi`: set header `X-API-KEY` sesuai `EXTERNAL_API_KEY` jika dikonfigurasi.
|
||||
- `ORDER_ACTIVE` atau `ORDER_COMPLETED`: backend menjaga `activeOrders` dan `notifiedOrders` untuk mencegah duplikasi; tunggu TTL atau gunakan order baru.
|
||||
|
||||
## Integrasi Frontend
|
||||
- Route: `pay/:token` (lihat `src/app/router.tsx`).
|
||||
- Resolver: `getPaymentLinkPayload(token)` (lihat `src/services/api.ts`).
|
||||
- Toggle & Allowed Methods: `PayPage` menggabungkan `runtimeCfg.paymentToggles` dengan `allowed_methods`. Kunci metode: `bank_transfer`, `credit_card`, `gopay`, `cstore`, `cpay`.
|
||||
|
||||
## Notifikasi ERP
|
||||
- Di settlement sukses, backend menghitung signature ERP (`sha512`) dan mengirim payload ke `ERP_NOTIFICATION_URL` jika `ERP_ENABLE_NOTIF=true` dan konfigurasi lengkap.
|
||||
|
||||
## Postman Collection
|
||||
- Anda dapat mengimpor koleksi: `docs/qa/payment-link.postman_collection.json` untuk mencoba endpoint di atas.
|
||||
|
||||
## Pengujian via Postman (Langkah Lengkap)
|
||||
|
||||
### 1) Import & Setup Environment
|
||||
- Import koleksi: `docs/qa/payment-link.postman_collection.json`.
|
||||
- Buat Environment (mis. `Midtrans-CIFO-Local`) dengan variabel:
|
||||
- `baseUrl`: `http://localhost:8000`
|
||||
- `paymentLinkBase`: `http://localhost:5175/pay` (sesuaikan port frontend)
|
||||
- `externalApiKey`: kosong atau set sesuai `EXTERNAL_API_KEY` (jika diaktifkan)
|
||||
- `token`: kosong (akan diisi dari respon create)
|
||||
- `order_id`: kosong (akan diisi dari respon create)
|
||||
|
||||
Catatan: Koleksi memakai variabel ini pada URL/body. Pastikan environment terpilih saat menjalankan request.
|
||||
|
||||
### 2) Jalankan Koleksi (Urutan Dasar)
|
||||
1. `1) Create Transaction`
|
||||
- Body default: `item_id`, `nominal`, `customer`, `allowed_methods`.
|
||||
- Jika `EXTERNAL_API_KEY` aktif, pastikan header `X-API-KEY: {{externalApiKey}}` terisi.
|
||||
2. `2) Resolve Token`
|
||||
- Memakai `{{token}}` dari langkah 1.
|
||||
3. `3) Charge - Bank Transfer (BCA)` atau `3) Charge - CStore (Indomaret)`
|
||||
- Memakai `{{order_id}}` dari langkah 1.
|
||||
4. `4) Payment Status`
|
||||
- Memakai `{{order_id}}` untuk cek status.
|
||||
|
||||
### 3) Skrip Test untuk Otomatis Mengisi Variabel
|
||||
Tambahkan skrip berikut pada tab `Tests` di request `1) Create Transaction` agar `token`, `order_id`, dan `paymentLinkUrl` otomatis tersimpan ke variabel koleksi:
|
||||
|
||||
```javascript
|
||||
// Tests: 1) Create Transaction
|
||||
pm.test('Create returns token and url', function () {
|
||||
pm.response.to.have.status(200);
|
||||
const json = pm.response.json();
|
||||
pm.expect(json).to.have.property('token');
|
||||
pm.expect(json).to.have.property('url');
|
||||
pm.expect(json).to.have.property('order_id');
|
||||
});
|
||||
|
||||
const res = pm.response.json();
|
||||
pm.collectionVariables.set('token', res.token);
|
||||
pm.collectionVariables.set('order_id', res.order_id);
|
||||
pm.collectionVariables.set('paymentLinkUrl', res.url);
|
||||
```
|
||||
|
||||
Tambahkan skrip sederhana di `2) Resolve Token` untuk validasi payload:
|
||||
|
||||
```javascript
|
||||
pm.test('Resolve returns payload fields', function () {
|
||||
pm.response.to.have.status(200);
|
||||
const json = pm.response.json();
|
||||
pm.expect(json).to.have.property('order_id');
|
||||
pm.expect(json).to.have.property('nominal');
|
||||
});
|
||||
```
|
||||
|
||||
Di `3) Charge - Bank Transfer (BCA)`, tambahkan assert dasar:
|
||||
|
||||
```javascript
|
||||
pm.test('Charge is created (pending)', function () {
|
||||
pm.response.to.have.status(200);
|
||||
const json = pm.response.json();
|
||||
pm.expect(json).to.have.property('status_code');
|
||||
pm.expect(json.status_code).to.eql('201');
|
||||
pm.expect(json.transaction_status).to.eql('pending');
|
||||
});
|
||||
```
|
||||
|
||||
Di `4) Payment Status`, periksa status:
|
||||
|
||||
```javascript
|
||||
pm.test('Status returns transaction info', function () {
|
||||
pm.response.to.have.status(200);
|
||||
const json = pm.response.json();
|
||||
pm.expect(json).to.have.property('order_id');
|
||||
pm.expect(json).to.have.property('transaction_status');
|
||||
});
|
||||
```
|
||||
|
||||
### 4) Menambah Request GoPay/QR (Opsional)
|
||||
Jika Anda ingin menguji GoPay/QR, tambahkan request baru di koleksi:
|
||||
|
||||
- Method: `POST`
|
||||
- URL: `{{baseUrl}}/api/payments/charge`
|
||||
- Body (raw JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"payment_type": "gopay",
|
||||
"transaction_details": { "order_id": "{{order_id}}", "gross_amount": 150000 },
|
||||
"gopay": { "enable_qr": true }
|
||||
}
|
||||
```
|
||||
|
||||
Catatan: Bila menghasilkan `400 Bad Request`, cek kembali konfigurasi sandbox merchant dan parameter tambahan yang dibutuhkan (misal `qr_black_white`, `callback_url`).
|
||||
|
||||
### 5) Membuka Halaman Pay dari Postman
|
||||
Setelah `1) Create Transaction`, variabel `paymentLinkUrl` tersimpan. Anda bisa klik tombol `Open in Browser` (ikon tautan) pada Postman untuk membuka `{{paymentLinkUrl}}` langsung di browser.
|
||||
|
||||
### 6) Uji Webhook dengan Pre-request Script
|
||||
Tambahkan request baru `Webhook (Manual)` dengan URL `{{baseUrl}}/api/payments/webhook`. Isi body dengan fields Midtrans. Untuk menghitung `signature_key` otomatis, gunakan Pre-request Script berikut (CryptoJS tersedia di sandbox Postman):
|
||||
|
||||
```javascript
|
||||
// Pre-request: Webhook signature
|
||||
const orderId = pm.collectionVariables.get('order_id');
|
||||
const statusCode = pm.collectionVariables.get('status_code') || '200';
|
||||
const grossAmount = pm.collectionVariables.get('gross_amount') || '150000';
|
||||
const serverKey = pm.collectionVariables.get('server_key'); // set di environment
|
||||
|
||||
if (!serverKey) {
|
||||
console.warn('server_key is not set in environment');
|
||||
}
|
||||
|
||||
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(serverKey);
|
||||
const sig = CryptoJS.SHA512(raw).toString(CryptoJS.enc.Hex);
|
||||
pm.collectionVariables.set('signature_key', sig);
|
||||
```
|
||||
|
||||
Body contoh (raw JSON):
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "{{order_id}}",
|
||||
"status_code": "{{status_code}}",
|
||||
"gross_amount": "{{gross_amount}}",
|
||||
"signature_key": "{{signature_key}}",
|
||||
"transaction_status": "settlement"
|
||||
}
|
||||
```
|
||||
|
||||
Tambahkan test sederhana untuk memverifikasi respons webhook:
|
||||
|
||||
```javascript
|
||||
pm.test('Webhook acknowledged', function () {
|
||||
pm.response.to.have.status(200);
|
||||
const json = pm.response.json();
|
||||
pm.expect(json).to.have.property('ok');
|
||||
pm.expect(json.ok).to.eql(true);
|
||||
});
|
||||
```
|
||||
|
||||
### 7) Postman Runner (Otomasi)
|
||||
- Gunakan Runner untuk menjalankan berurutan: `1) Create Transaction` → `2) Resolve Token` → `3) Charge` → `4) Status`.
|
||||
- Pastikan environment dipilih. Anda dapat menambah delay antar request jika diperlukan.
|
||||
|
||||
### 8) Tips & Variabel Tambahan
|
||||
- Sesuaikan `paymentLinkBase` ke port frontend aktif (mis. `5175`).
|
||||
- Jika `EXTERNAL_API_KEY` aktif, isi `externalApiKey` di environment.
|
||||
- Simpan `server_key` (Midtrans Server Key) di environment untuk uji webhook.
|
||||
- Tambahkan `gross_amount`, `status_code` variabel supaya mudah dikustom saat uji webhook/status.
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
{
|
||||
"info": {
|
||||
"name": "Payment Link QA",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{ "key": "baseUrl", "value": "https://be-midtrans-cifo.winteraccess.id" },
|
||||
{ "key": "paymentLinkBase", "value": "https://midtrans-cifo.winteraccess.id/pay" },
|
||||
{ "key": "externalApiKey", "value": "" },
|
||||
{ "key": "token", "value": "" },
|
||||
{ "key": "order_id", "value": "" }
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "1) Create Transaction",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{ "key": "Content-Type", "value": "application/json" },
|
||||
{ "key": "X-API-KEY", "value": "{{externalApiKey}}" }
|
||||
],
|
||||
"url": { "raw": "{{baseUrl}}/createtransaksi", "host": ["{{baseUrl}}"], "path": ["createtransaksi"] },
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"mercant_id\": \"REFNO-001\",\n \"timestamp\": 1731300000000,\n \"deskripsi\": \"Bayar Internet\",\n \"nominal\": 200000,\n \"nama\": \"Demo User\",\n \"no_telepon\": \"081234567890\",\n \"email\": \"demo@example.com\",\n \"item\": [\n { \"item_id\": \"TKG-2511101\", \"nama\": \"Internet\", \"harga\": 200000, \"qty\": 1 }\n ]\n}"
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"let res = {};",
|
||||
"try { res = pm.response.json(); } catch(e) { res = {}; }",
|
||||
"const url = (res && res.data && res.data.url) ? res.data.url : (res && res.url ? res.url : '');",
|
||||
"if (url) {",
|
||||
" pm.collectionVariables.set('paymentLinkUrl', url);",
|
||||
" const trimmed = url.replace(/\\\/$/, '');",
|
||||
" const parts = trimmed.split('/');",
|
||||
" const tok = parts[parts.length - 1];",
|
||||
" pm.collectionVariables.set('token', tok);",
|
||||
"}",
|
||||
"pm.test('Create Transaction returns data.url and token', function () {",
|
||||
" pm.expect(url, 'data.url exists').to.be.a('string');",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "2) Resolve Token",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": { "raw": "{{baseUrl}}/api/payment-links/{{token}}", "host": ["{{baseUrl}}"], "path": ["api","payment-links","{{token}}"] }
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"const t = pm.variables.get('token');",
|
||||
"if (!t) { throw new Error(\"Missing token. Run '1) Create Transaction' first.\"); }"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"let res = {};",
|
||||
"try { res = pm.response.json(); } catch(e) { res = {}; }",
|
||||
"if (res && res.order_id) { pm.collectionVariables.set('order_id', res.order_id); }",
|
||||
"pm.test('Resolve Token payload has order_id and nominal', function () {",
|
||||
" pm.expect(res.order_id, 'order_id').to.be.a('string');",
|
||||
" pm.expect(res.nominal, 'nominal').to.exist;",
|
||||
"});"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "3) Charge - Bank Transfer (BCA)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"url": { "raw": "{{baseUrl}}/api/payments/charge", "host": ["{{baseUrl}}"], "path": ["api","payments","charge"] },
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"payment_type\": \"bank_transfer\",\n \"transaction_details\": { \"order_id\": \"{{order_id}}\", \"gross_amount\": 150000 },\n \"bank_transfer\": { \"bank\": \"bca\" }\n}"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "3) Charge - CStore (Indomaret)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "Content-Type", "value": "application/json" } ],
|
||||
"url": { "raw": "{{baseUrl}}/api/payments/charge", "host": ["{{baseUrl}}"], "path": ["api","payments","charge"] },
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"payment_type\": \"cstore\",\n \"transaction_details\": { \"order_id\": \"{{order_id}}\", \"gross_amount\": 150000 },\n \"cstore\": { \"store\": \"indomaret\" }\n}"
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "4) Payment Status",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": { "raw": "{{baseUrl}}/api/payments/{{order_id}}/status", "host": ["{{baseUrl}}"], "path": ["api","payments","{{order_id}}","status"] }
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"const oid = pm.variables.get('order_id');",
|
||||
"if (!oid) { throw new Error(\"Missing order_id. Run '1) Create Transaction' first.\"); }"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# Sprint Change Proposal — ERP → Create Transaction → Payment Link Flow
|
||||
|
||||
## Summary
|
||||
- Trigger: Checkout tidak lagi dimulai dari tombol “Buy Now” di frontend. Sistem eksternal (ERP/billing script) mengirim data transaksi ke server dan menerima tautan pembayaran terenkripsi untuk dibagikan ke pengguna.
|
||||
- Outcome: Backend menjadi titik inisiasi transaksi. Pengguna membuka Payment Page via link (`/pay/:token`), memilih metode (VA/GoPay/Cstore/Kartu), lalu UI mengeksekusi charge seperti biasa. Webhook Midtrans dan ERP callback tetap berjalan.
|
||||
|
||||
## Objectives (Sprint)
|
||||
- Menyediakan endpoint backend `POST /createtransaksi` yang mengeluarkan payment link bertanda-tangan (HMAC‑SHA256) dengan TTL dan anti‑replay.
|
||||
- Menambahkan rute frontend `"/pay/:token"` (Payment Page) untuk memvalidasi token, menampilkan metode, dan melakukan charge menggunakan `order_id` dari token.
|
||||
- Menjaga kompatibilitas endpoint yang ada: `POST /api/payments/charge`, `GET /api/payments/:orderId/status`, `POST /api/payments/webhook`.
|
||||
- Menyelaraskan PRD, Arsitektur UI, dan Story E2E dengan alur baru.
|
||||
|
||||
## Scope & Impact
|
||||
- PRD (`docs/prd.md`): Tambah FR “External Create Transaction & Payment Link”, ubah sumber `order_id` (berasal dari `item_id`), tambahkan ketentuan TTL, signature, dan anti‑replay.
|
||||
- Arsitektur UI (`docs/ui-architecture.md`): Tambah rute `"/pay/:token"`, alur token‑driven. `CheckoutPage` tetap sebagai demo/QA.
|
||||
- Story E2E (`docs/stories/midtrans-e2e-checkout-to-webhook.md`): Mulai dari Payment Page via token, bukan dari CheckoutPage langsung.
|
||||
- Backend (`server/index.cjs`): Tambah endpoint `POST /createtransaksi` dan resolver token (API untuk FE). Env baru: `EXTERNAL_API_KEY`, `PAYMENT_LINK_SECRET`, `PAYMENT_LINK_TTL_MINUTES`.
|
||||
- Frontend: Tambah halaman `PayPage` (`/pay/:token`), service untuk resolve token. `postCharge/getPaymentStatus` tetap.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
### 1) Backend — Create Transaction API
|
||||
- Endpoint: `POST /createtransaksi`
|
||||
- Auth: Header `X-API-KEY: <EXTERNAL_API_KEY>` (validasi exact match; opsi IP whitelist & rate limit di reverse proxy).
|
||||
- Body (contoh minimal):
|
||||
```json
|
||||
{
|
||||
"merchant_id": "TKG-250520029803",
|
||||
"deskripsi": "Pembelian item",
|
||||
"nominal": 200000,
|
||||
"nama": "Dwiki Kurnia Sandi",
|
||||
"no_telepon": "081234567890",
|
||||
"email": "demo@example.com",
|
||||
"item": [
|
||||
{ "item_id": "ITEM-12345", "qty": 1, "price": 200000, "name": "Produk A" }
|
||||
]
|
||||
}
|
||||
```
|
||||
- Mapping `order_id`: gunakan `"mercant_id:item_id"` bila keduanya tersedia (contoh: `MERC-001:ITEM-12345`). Jika salah satu tidak ada, fallback ke `item[0].item_id` atau `mercant_id`. Tujuan: item yang sama pada merchant berbeda tetap menghasilkan order unik sehingga link bisa diterbitkan.
|
||||
- Idempotensi: jika `order_id` + `nominal` sama dan status transaksi masih `pending`, kembalikan payment link sebelumnya; jika `nominal` berbeda, kembalikan `422 AMOUNT_MISMATCH`.
|
||||
- Response (sukses):
|
||||
```json
|
||||
{
|
||||
"status_code": "200",
|
||||
"status_message": "OK",
|
||||
"url": "https://cifopayment.id/pay/<token>?sig=<signature>",
|
||||
"exp": 1730000000
|
||||
}
|
||||
```
|
||||
- `token`: opaque base64url berisi klaim minimal (mis. `order_id`, `nominal`, `exp`) yang disimpan server.
|
||||
- `signature`: `hex(HMAC_SHA256(token, PAYMENT_LINK_SECRET))`.
|
||||
- `exp`: UNIX epoch seconds (TTL default 30 menit; dapat dikonfigurasi).
|
||||
|
||||
### 2) Backend — Payment Link Resolve API
|
||||
- Endpoint: `GET /api/payment-links/:token` (digunakan frontend untuk bootstrap Payment Page).
|
||||
- Query: otomatis memverifikasi signature (`sig` di query atau header), TTL/anti‑replay, dan mengembalikan payload:
|
||||
```json
|
||||
{
|
||||
"order_id": "ITEM-12345",
|
||||
"nominal": 200000,
|
||||
"customer": { "name": "Dwiki", "phone": "081234567890", "email": "demo@example.com" },
|
||||
"expire_at": 1730000000,
|
||||
"allowed_methods": ["bank_transfer", "gopay", "cstore", "credit_card"]
|
||||
}
|
||||
```
|
||||
- Error: `401 INVALID_SIGNATURE`, `410 LINK_EXPIRED`, `409 LINK_USED` (opsional jika anti‑replay menandai sekali pakai).
|
||||
|
||||
### 3) Frontend — Payment Page (`/pay/:token`)
|
||||
- Flow:
|
||||
- Ambil `token` dari URL → panggil `GET /api/payment-links/:token`.
|
||||
- Set lokal `orderId`, `amount`, `expireAt`, dan info pelanggan.
|
||||
- Render komponen metode (VA/GoPay/Cstore/Kartu) seperti di Checkout, tetapi `orderId` berasal dari token.
|
||||
- Setelah charge, navigasi ke `"/payments/:orderId/status"` (polling status tetap).
|
||||
- Catatan: `CheckoutPage` tetap ada untuk demo/QA; jalur produksi menggunakan Payment Page via link.
|
||||
|
||||
### 4) Webhook & ERP Callback
|
||||
- Tetap: `POST /api/payments/webhook` memverifikasi signature Midtrans dan memperbarui status.
|
||||
- ERP Callback: kirim `POST` ke `https://apibackend.erpskrip.id/paymentnotification/` pada status sukses, signature `sha512(mercant_id + status_code + nominal + client_id)` seperti implementasi saat ini.
|
||||
|
||||
### 5) Security & Config
|
||||
- Secrets:
|
||||
- `EXTERNAL_API_KEY`: memvalidasi `X-API-KEY` dari ERP.
|
||||
- `PAYMENT_LINK_SECRET`: kunci HMAC untuk signature token.
|
||||
- `PAYMENT_LINK_TTL_MINUTES`: default 30.
|
||||
- Praktik:
|
||||
- Rate‑limit `POST /createtransaksi` dan audit log.
|
||||
- Anti‑replay: tandai token sebagai “used” setelah berhasil charge (opsional; atau izinkan reuse sampai status final).
|
||||
- Rotasi secret terjadwal; invalidasi token lama secara bertahap bila diperlukan.
|
||||
|
||||
## Acceptance Criteria (Sprint)
|
||||
- Backend mengeluarkan payment link dengan `token` + `signature`, valid hingga TTL.
|
||||
- Resolve API mengembalikan `order_id` dan `nominal` yang konsisten; invalid jika signature/TTL gagal.
|
||||
- Frontend Payment Page (`/pay/:token`) dapat:
|
||||
- Memvalidasi token dan menampilkan metode pembayaran.
|
||||
- Melakukan charge via endpoint yang ada menggunakan `order_id` dari token.
|
||||
- Menavigasi ke halaman status dan menampilkan detail (VA/QR/payment code/kartu) sesuai metode.
|
||||
- Webhook menerima notifikasi Midtrans dan ERP callback tetap terkirim saat sukses.
|
||||
- Dokumentasi PRD, Arsitektur UI, dan Story E2E diperbarui mencerminkan alur baru.
|
||||
|
||||
## Non‑Functional Requirements
|
||||
- Observability: logging request ID, event penting (`link.create`, `link.resolve`, `charge.start/success`, `webhook.receive`).
|
||||
- Idempotensi: kembalikan link lama untuk transaksi `pending` dengan `order_id` + `nominal` yang sama.
|
||||
- Keamanan: tidak mengekspos server key; signature Midtrans diverifikasi; token link ditandatangani HMAC‑SHA256.
|
||||
- Kinerja: endpoint create/resolver respon <200ms p95 (lokal dev).
|
||||
|
||||
## Risks & Mitigations
|
||||
- Replay/penyalahgunaan link: enforce TTL, anti‑replay flag, rate‑limit.
|
||||
- Konflik `order_id`: validasi unik; strategy untuk recurring (suffix waktu/sequence bila diperlukan).
|
||||
- Ketidaksesuaian nominal: `422 AMOUNT_MISMATCH` untuk `order_id` sama namun nominal beda.
|
||||
- Gangguan ERP: retry callback dengan backoff, feature flag `ERP_ENABLE_NOTIF` untuk mematikan sementara.
|
||||
|
||||
## Rollback Plan
|
||||
- Nonaktifkan konsumsi resolver token (feature flag) dan kembalikan ke alur Checkout demo.
|
||||
- Pertahankan endpoint status & webhook agar UI tetap dapat polling status.
|
||||
|
||||
## Deliverables (Sprint)
|
||||
- Backend: `POST /createtransaksi`, `GET /api/payment-links/:token` (validator signature/TTL), konfigurasi env baru.
|
||||
- Frontend: halaman `PayPage` (`/pay/:token`) dengan integrasi panel metode yang ada.
|
||||
- Docs: update PRD, Arsitektur UI, dan Story E2E sesuai token‑driven flow.
|
||||
|
||||
## Timeline & Tasking (5 hari)
|
||||
- Day 1: Skeleton endpoint `createtransaksi` + HMAC signing + env wiring.
|
||||
- Day 2: Resolver API + idempotensi + anti‑replay dasar.
|
||||
- Day 3: FE `PayPage` + service `getPaymentLinkPayload(token)` + navigasi.
|
||||
- Day 4: QA manual (sandbox) + webhook/ERP callback sanity.
|
||||
- Day 5: Dokumentasi & polish (error codes, logging, runbook).
|
||||
|
||||
## Decisions Confirmed
|
||||
- TTL payment link: 30 menit (konfigurabel; default 30) — disetujui.
|
||||
- Token: HMAC‑SHA256 signature (tanpa enkripsi payload; confidentiality tidak diwajibkan saat ini).
|
||||
- Recurring transaksi: tidak didukung. Kebijakan:
|
||||
- Satu transaksi aktif per `item_id` pada satu waktu.
|
||||
- Jika status sukses (`settlement` atau `capture + fraud_status=accept`), permintaan baru untuk `item_id` yang sama ditolak (`409 ORDER_COMPLETED`).
|
||||
- Re-attempt diperbolehkan hanya jika transaksi sebelumnya tidak sukses dan sudah berakhir (`expire/cancel/deny`), menggunakan kebijakan unik `order_id` sesuai kebutuhan implementasi Midtrans.
|
||||
|
||||
## Appendix — Example
|
||||
- Request `POST /createtransaksi` (ringkas): lihat contoh pada bagian Backend di atas.
|
||||
- Response sukses:
|
||||
```json
|
||||
{
|
||||
"status_code": "200",
|
||||
"status_message": "OK",
|
||||
"url": "https://cifopayment.id/pay/eyJvcmRlcl9pZCI6IklURU0tMTIzNDUiLCJub21pbmFsIjoyMDAwMDAsImV4cCI6MTczMDAwMDAwMH0?sig=4c7f...",
|
||||
"exp": 1730000000
|
||||
}
|
||||
```
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/simaya.png"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>core-midtrans-cifo</title>
|
||||
<title>Simaya Midtrans | Retail Payment</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
447
server/index.cjs
447
server/index.cjs
|
|
@ -37,13 +37,31 @@ function parseEnable(v) {
|
|||
}
|
||||
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 || ''
|
||||
// Gunakan secret untuk signature; fallback ke CLIENT_ID bila SECRET belum ada
|
||||
const ERP_CLIENT_SECRET = process.env.ERP_CLIENT_SECRET || process.env.ERP_CLIENT_ID || ''
|
||||
const notifiedOrders = new Set()
|
||||
|
||||
// Mendukung banyak endpoint ERP (comma-separated) via env ERP_NOTIFICATION_URLS
|
||||
function parseList(value) {
|
||||
if (!value) return []
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const ERP_NOTIFICATION_URLS = (() => {
|
||||
const multi = parseList(process.env.ERP_NOTIFICATION_URLS)
|
||||
if (multi.length > 0) return multi
|
||||
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
|
||||
})()
|
||||
|
||||
// --- Logger utilities
|
||||
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
||||
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
|
||||
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
|
||||
const recentLogs = []
|
||||
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
|
||||
function ts() { return new Date().toISOString() }
|
||||
function sanitize(obj) {
|
||||
|
|
@ -66,6 +84,11 @@ function maskPayload(obj) {
|
|||
function log(level, msg, meta) {
|
||||
if (!shouldLog(level)) return
|
||||
const line = `[${ts()}] [${level}] ${msg}`
|
||||
try {
|
||||
const entry = { ts: ts(), level, msg, meta: sanitize(meta) }
|
||||
recentLogs.push(entry)
|
||||
if (recentLogs.length > LOG_BUFFER_SIZE) recentLogs.shift()
|
||||
} catch {}
|
||||
if (meta) {
|
||||
const data = typeof meta === 'string' ? meta : JSON.stringify(meta)
|
||||
console.log(line, data)
|
||||
|
|
@ -98,6 +121,55 @@ const ENABLE = {
|
|||
cstore: parseEnable(process.env.ENABLE_CSTORE),
|
||||
}
|
||||
|
||||
// --- Payment Link Config
|
||||
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''
|
||||
const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || ''
|
||||
const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10)
|
||||
const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay'
|
||||
const activeOrders = new Map() // order_id -> expire_at
|
||||
// Map untuk menyimpan mercant_id per order_id agar notifikasi ERP bisa dinamis
|
||||
const orderMerchantId = new Map() // order_id -> mercant_id
|
||||
|
||||
function isDevEnv() { return (process.env.NODE_ENV || '').toLowerCase() !== 'production' }
|
||||
function verifyExternalKey(req) {
|
||||
const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString()
|
||||
if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY
|
||||
// Allow if not configured only in dev for easier local testing
|
||||
return isDevEnv()
|
||||
}
|
||||
|
||||
function base64UrlEncode(buf) {
|
||||
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||
}
|
||||
function base64UrlDecode(str) {
|
||||
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
||||
const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad
|
||||
return Buffer.from(s, 'base64').toString('utf8')
|
||||
}
|
||||
function computeTokenSignature(orderId, nominal, expireAt) {
|
||||
const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}`
|
||||
return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex')
|
||||
}
|
||||
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
|
||||
const v = 1
|
||||
const sig = computeTokenSignature(order_id, nominal, expire_at)
|
||||
const payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods }
|
||||
return base64UrlEncode(JSON.stringify(payload))
|
||||
}
|
||||
function resolvePaymentLinkToken(token) {
|
||||
try {
|
||||
const json = JSON.parse(base64UrlDecode(token))
|
||||
const { order_id, nominal, expire_at, sig } = json || {}
|
||||
if (!order_id || !nominal || !expire_at || !sig) return { error: 'INVALID_TOKEN' }
|
||||
const expected = computeTokenSignature(order_id, nominal, expire_at)
|
||||
if (String(sig) !== String(expected)) return { error: 'INVALID_SIGNATURE' }
|
||||
if (Date.now() > Number(expire_at)) return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
|
||||
return { payload: json }
|
||||
} catch (e) {
|
||||
return { error: 'TOKEN_PARSE_ERROR', message: e?.message }
|
||||
}
|
||||
}
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
||||
|
|
@ -115,6 +187,26 @@ app.get('/api/config', (_req, res) => {
|
|||
res.json(payload)
|
||||
})
|
||||
|
||||
// Logs endpoint (dev/debug): GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
|
||||
app.get('/api/logs', (req, res) => {
|
||||
if (!LOG_EXPOSE_API) {
|
||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
||||
}
|
||||
const limit = Math.max(1, Math.min(1000, parseInt(String(req.query.limit || '100'), 10)))
|
||||
const level = String(req.query.level || '').toLowerCase()
|
||||
const q = String(req.query.q || '').toLowerCase()
|
||||
let items = recentLogs
|
||||
if (level) items = items.filter((e) => e.level === level)
|
||||
if (q) items = items.filter((e) => {
|
||||
try {
|
||||
const m = e.meta ? JSON.stringify(e.meta).toLowerCase() : ''
|
||||
return e.msg.toLowerCase().includes(q) || m.includes(q)
|
||||
} catch { return false }
|
||||
})
|
||||
const sliced = items.slice(Math.max(0, items.length - limit))
|
||||
res.json({ count: sliced.length, items: sliced })
|
||||
})
|
||||
|
||||
// Dev-only: allow updating toggles at runtime without restart
|
||||
app.post('/api/config', (req, res) => {
|
||||
const isDev = process.env.NODE_ENV !== 'production'
|
||||
|
|
@ -131,12 +223,59 @@ app.post('/api/config', (req, res) => {
|
|||
res.json(result)
|
||||
})
|
||||
|
||||
// Payment Link Resolver: GET /api/payment-links/:token
|
||||
app.get('/api/payment-links/:token', (req, res) => {
|
||||
const { token } = req.params
|
||||
const result = resolvePaymentLinkToken(token)
|
||||
if (result.error === 'TOKEN_EXPIRED') {
|
||||
logWarn('paymentlink.expired', { order_id: result.payload?.order_id })
|
||||
return res.status(410).json({ error: result.error, ...result.payload })
|
||||
}
|
||||
if (result.error) {
|
||||
logWarn('paymentlink.invalid', { error: result.error })
|
||||
if (isDevEnv()) {
|
||||
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
||||
const fallback = { order_id: token, nominal: 150000, expire_at: Date.now() + ttlMin * 60 * 1000 }
|
||||
logInfo('paymentlink.dev.fallback', { order_id: fallback.order_id })
|
||||
return res.json(fallback)
|
||||
}
|
||||
return res.status(400).json({ error: result.error })
|
||||
}
|
||||
const p = result.payload
|
||||
logInfo('paymentlink.resolve.success', { order_id: p.order_id, expire_at: p.expire_at })
|
||||
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
|
||||
})
|
||||
|
||||
// Charge endpoint (pass-through to Midtrans Core API)
|
||||
app.post('/api/payments/charge', async (req, res) => {
|
||||
try {
|
||||
const pt = req?.body?.payment_type
|
||||
logInfo('charge.request', { id: req.id, payment_type: pt })
|
||||
logDebug('charge.payload', maskPayload(req.body))
|
||||
|
||||
// Idempotency guard: if an order is already pending in Midtrans, block re-charge for the same order_id
|
||||
const orderId = req?.body?.transaction_details?.order_id || req?.body?.order_id || ''
|
||||
if (orderId) {
|
||||
try {
|
||||
const st = await core.transaction.status(orderId)
|
||||
const ts = (st?.transaction_status || '').toLowerCase()
|
||||
if (ts === 'pending') {
|
||||
logWarn('charge.blocked.pending_exists', { id: req.id, order_id: orderId })
|
||||
return res.status(409).json({
|
||||
error: 'ORDER_ACTIVE',
|
||||
message: 'Order sudah memiliki transaksi pending di Midtrans; tidak dapat membuat ulang. Gunakan instruksi pembayaran yang ada atau buat order baru.',
|
||||
status: st,
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = (e?.message || '').toLowerCase()
|
||||
if (msg.includes('not found') || msg.includes('404')) {
|
||||
logDebug('charge.status_not_found', { order_id: orderId })
|
||||
} else {
|
||||
logDebug('charge.status_check_error', { order_id: orderId, message: e?.message })
|
||||
}
|
||||
}
|
||||
}
|
||||
const isBankType = pt === 'bank_transfer' || pt === 'echannel' || pt === 'permata'
|
||||
if (isBankType && !ENABLE.bank_transfer) {
|
||||
logWarn('charge.blocked', { id: req.id, reason: 'bank_transfer disabled' })
|
||||
|
|
@ -172,7 +311,31 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
|||
logInfo('status.request', { id: req.id, orderId })
|
||||
const status = await core.transaction.status(orderId)
|
||||
logInfo('status.success', { id: req.id, orderId, transaction_status: status?.transaction_status })
|
||||
// Respond immediately with status
|
||||
res.json(status)
|
||||
|
||||
// Fallback: selain webhook, jika status di sini sudah sukses (settlement/capture+accept), kirim notifikasi ke ERP
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
if (isSuccessfulMidtransStatus(status)) {
|
||||
const nominal = String(status?.gross_amount || '')
|
||||
if (!notifiedOrders.has(orderId)) {
|
||||
activeOrders.delete(orderId)
|
||||
logInfo('status.notify.erp.trigger', { orderId, transaction_status: status?.transaction_status })
|
||||
const ok = await notifyERP({ orderId, nominal })
|
||||
if (ok) {
|
||||
notifiedOrders.add(orderId)
|
||||
} else {
|
||||
logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' })
|
||||
}
|
||||
} else {
|
||||
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logError('status.notify.error', { orderId, message: e?.message })
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e?.message || 'Status check failed'
|
||||
logError('status.error', { id: req.id, orderId, message: msg })
|
||||
|
|
@ -180,6 +343,99 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
|||
}
|
||||
})
|
||||
|
||||
// External ERP Create Transaction → issue payment link
|
||||
app.post('/createtransaksi', async (req, res) => {
|
||||
try {
|
||||
if (!verifyExternalKey(req)) {
|
||||
logWarn('createtransaksi.unauthorized', { id: req.id })
|
||||
return res.status(401).json({ error: 'UNAUTHORIZED', message: 'X-API-KEY invalid' })
|
||||
}
|
||||
|
||||
// Skema baru:
|
||||
// {
|
||||
// mercant_id, timestamp, deskripsi, nominal,
|
||||
// nama, no_telepon, email,
|
||||
// item: [{ item_id, nama, harga, qty }, ...]
|
||||
// }
|
||||
const mercantId = req?.body?.mercant_id
|
||||
const nominalRaw = req?.body?.nominal
|
||||
const items = Array.isArray(req?.body?.item) ? req.body.item : []
|
||||
const primaryItemId = items?.[0]?.item_id
|
||||
// Mapping order_id: gunakan "mercant_id:item_id" bila keduanya tersedia,
|
||||
// jika tidak, fallback ke item_id atau mercant_id atau field order_id/item_id yang disediakan.
|
||||
const order_id = String(
|
||||
(primaryItemId && mercantId) ? `${mercantId}:${primaryItemId}` :
|
||||
(primaryItemId || mercantId || req?.body?.order_id || req?.body?.item_id || '')
|
||||
)
|
||||
// Simpan mercant_id per order agar dapat digunakan saat notifikasi ERP
|
||||
if (mercantId) {
|
||||
try { orderMerchantId.set(order_id, mercantId) } catch {}
|
||||
}
|
||||
|
||||
// Bentuk customer dari field nama/no_telepon/email
|
||||
const customer = {
|
||||
name: req?.body?.nama,
|
||||
phone: req?.body?.no_telepon,
|
||||
email: req?.body?.email,
|
||||
}
|
||||
const allowed_methods = req?.body?.allowed_methods
|
||||
|
||||
if (!order_id || typeof nominalRaw === 'undefined') {
|
||||
logWarn('createtransaksi.bad_request', { id: req.id, hasOrderId: !!order_id, hasNominal: typeof nominalRaw !== 'undefined' })
|
||||
return res.status(400).json({ error: 'BAD_REQUEST', message: 'order_id (mercant_id atau item[0].item_id) dan nominal wajib ada' })
|
||||
}
|
||||
const nominal = Number(nominalRaw)
|
||||
const now = Date.now()
|
||||
const ttlMin = PAYMENT_LINK_TTL_MINUTES > 0 ? PAYMENT_LINK_TTL_MINUTES : 1440
|
||||
const expire_at = now + ttlMin * 60 * 1000
|
||||
|
||||
// Block jika sudah selesai
|
||||
if (notifiedOrders.has(order_id)) {
|
||||
logWarn('createtransaksi.completed', { order_id })
|
||||
return res.status(409).json({ error: 'ORDER_COMPLETED', message: 'Order already completed' })
|
||||
}
|
||||
// Block jika ada link aktif belum expired
|
||||
const existing = activeOrders.get(order_id)
|
||||
if (existing && existing > now) {
|
||||
logWarn('createtransaksi.active_exists', { order_id })
|
||||
return res.status(409).json({ error: 'ORDER_ACTIVE', message: 'Active payment link exists' })
|
||||
}
|
||||
|
||||
// Guard tambahan: cek ke Midtrans apakah order_id sudah memiliki transaksi pending
|
||||
try {
|
||||
const status = await core.transaction.status(order_id)
|
||||
const s = (status?.transaction_status || '').toLowerCase()
|
||||
if (s === 'pending') {
|
||||
logWarn('createtransaksi.midtrans_pending', { order_id })
|
||||
return res.status(409).json({
|
||||
error: 'ORDER_ACTIVE',
|
||||
message: 'Order sudah memiliki transaksi pending di Midtrans; gunakan instruksi pembayaran yang ada atau buat order baru.',
|
||||
status: { order_id: status?.order_id, status_code: status?.status_code, status_message: status?.status_message, payment_type: status?.payment_type },
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// Jika 404/not found, lanjut membuat payment link; error lain tetap diteruskan
|
||||
const msg = (e?.message || '').toLowerCase()
|
||||
if (msg.includes('not found') || msg.includes('404')) {
|
||||
logDebug('createtransaksi.midtrans_status_not_found', { order_id })
|
||||
} else {
|
||||
logDebug('createtransaksi.midtrans_status_check_error', { order_id, message: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
const token = createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods })
|
||||
const url = `${PAYMENT_LINK_BASE}/${token}`
|
||||
activeOrders.set(order_id, expire_at)
|
||||
logInfo('createtransaksi.issued', { order_id, expire_at })
|
||||
|
||||
// Respons mengikuti format yang diminta
|
||||
res.json({ status: '200', messages: 'SUCCESS', data: { url } })
|
||||
} catch (e) {
|
||||
logError('createtransaksi.error', { id: req.id, message: e?.message })
|
||||
res.status(500).json({ error: 'CREATE_ERROR', message: e?.message || 'Internal error' })
|
||||
}
|
||||
})
|
||||
|
||||
// --- Helpers: Midtrans signature verification & ERP notify
|
||||
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||
try {
|
||||
|
|
@ -199,74 +455,149 @@ function isSuccessfulMidtransStatus(body) {
|
|||
return false
|
||||
}
|
||||
|
||||
function postJson(url, data) {
|
||||
class HttpError extends Error {
|
||||
constructor(statusCode, body) {
|
||||
super(`HTTP ${statusCode}`)
|
||||
this.statusCode = statusCode
|
||||
this.body = body
|
||||
}
|
||||
}
|
||||
|
||||
function postJson(url, data, extraHeaders = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const body = JSON.stringify(data)
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
...extraHeaders,
|
||||
}
|
||||
logDebug('http.post', {
|
||||
url,
|
||||
headers: sanitize(headers),
|
||||
body_length: Buffer.byteLength(body),
|
||||
body_preview: body.slice(0, 256),
|
||||
})
|
||||
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),
|
||||
},
|
||||
headers,
|
||||
}
|
||||
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}`))
|
||||
const info = {
|
||||
url,
|
||||
statusCode: res.statusCode,
|
||||
body_length: chunks.length,
|
||||
body_preview: chunks.slice(0, 256),
|
||||
}
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
logDebug('http.response', info)
|
||||
resolve({ status: res.statusCode, body: chunks })
|
||||
} else {
|
||||
logWarn('http.response.error', info)
|
||||
reject(new HttpError(res.statusCode, chunks))
|
||||
}
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.on('error', (e) => {
|
||||
logError('http.request.error', { url, message: e?.message })
|
||||
reject(e)
|
||||
})
|
||||
req.write(body)
|
||||
req.end()
|
||||
} catch (e) {
|
||||
logError('http.build.error', { url, message: e?.message })
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
||||
async 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')
|
||||
const sign = crypto.createHash('sha512').update(raw).digest('hex')
|
||||
return sign;
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyERP({ orderId, nominal }) {
|
||||
// Resolve mercant_id untuk sebuah order_id:
|
||||
// 1) gunakan map yang tersimpan dari createtransaksi
|
||||
// 2) jika order_id memakai skema "mercant_id:item_id", ambil prefix sebelum ':'
|
||||
// 3) fallback ke ERP_MERCANT_ID dari env (untuk kasus lama)
|
||||
function resolveMercantId(orderId) {
|
||||
try {
|
||||
if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId)
|
||||
if (typeof orderId === 'string' && orderId.includes(':')) {
|
||||
const [m] = orderId.split(':')
|
||||
if (m) return m
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function notifyERP({ orderId, nominal, mercantId }) {
|
||||
if (!ERP_ENABLE_NOTIF) {
|
||||
logInfo('erp.notify.skip', { reason: 'disabled' })
|
||||
return
|
||||
return false
|
||||
}
|
||||
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
|
||||
// Untuk notifikasi dinamis, hanya URL dan client secret yang wajib
|
||||
if (ERP_NOTIFICATION_URLS.length === 0 || !ERP_CLIENT_SECRET) {
|
||||
logWarn('erp.notify.missing_config', { urlsCount: ERP_NOTIFICATION_URLS.length, hasClientSecret: !!ERP_CLIENT_SECRET })
|
||||
return false
|
||||
}
|
||||
const statusCode = '200'
|
||||
const signature = computeErpSignature(ERP_MERCANT_ID, statusCode, nominal, ERP_CLIENT_ID)
|
||||
const mId = mercantId || resolveMercantId(orderId)
|
||||
if (!mId) {
|
||||
logWarn('erp.notify.skip', { orderId, reason: 'missing_mercant_id' })
|
||||
return false
|
||||
}
|
||||
const signature = await computeErpSignature(mId, statusCode, nominal, ERP_CLIENT_SECRET)
|
||||
|
||||
const payload = {
|
||||
data: {
|
||||
mercant_id: ERP_MERCANT_ID,
|
||||
mercant_id: mId,
|
||||
status_code: statusCode,
|
||||
nominal: nominal,
|
||||
client_id: ERP_CLIENT_ID,
|
||||
},
|
||||
signature,
|
||||
signature: signature,
|
||||
}
|
||||
logInfo('erp.notify.start', { orderId, url: ERP_NOTIFICATION_URL })
|
||||
// Tambahan debug: pastikan signature benar-benar ikut terkirim
|
||||
logDebug('erp.notify.payload.outgoing', {
|
||||
mercant_id: mId,
|
||||
status_code: statusCode,
|
||||
nominal: nominal,
|
||||
signature_present: typeof signature !== 'undefined',
|
||||
signature_length: (typeof signature === 'string') ? signature.length : -1,
|
||||
})
|
||||
logInfo('erp.notify.start', { orderId, urls: ERP_NOTIFICATION_URLS })
|
||||
const results = await Promise.allSettled(
|
||||
ERP_NOTIFICATION_URLS.map(async (url) => {
|
||||
try {
|
||||
const res = await postJson(ERP_NOTIFICATION_URL, payload)
|
||||
logInfo('erp.notify.success', { orderId, status: res.status })
|
||||
const res = await postJson(url, payload)
|
||||
logInfo('erp.notify.success', { orderId, url, status: res.status })
|
||||
return true
|
||||
} catch (e) {
|
||||
logError('erp.notify.error', { orderId, message: e?.message })
|
||||
logError('erp.notify.error', {
|
||||
orderId,
|
||||
url,
|
||||
status: e?.statusCode,
|
||||
message: e?.message,
|
||||
body: typeof e?.body === 'string' ? e.body.slice(0, 256) : undefined,
|
||||
})
|
||||
return false
|
||||
}
|
||||
})
|
||||
)
|
||||
const okCount = results.reduce((acc, r) => acc + (r.status === 'fulfilled' && r.value ? 1 : 0), 0)
|
||||
const failCount = ERP_NOTIFICATION_URLS.length - okCount
|
||||
logInfo('erp.notify.summary', { orderId, okCount, failCount, total: ERP_NOTIFICATION_URLS.length })
|
||||
return okCount > 0
|
||||
}
|
||||
|
||||
// Webhook endpoint for Midtrans notifications
|
||||
|
|
@ -291,10 +622,17 @@ app.post('/api/payments/webhook', async (req, res) => {
|
|||
|
||||
// Process success callbacks asynchronously
|
||||
if (isSuccessfulMidtransStatus(body)) {
|
||||
logInfo('webhook.success_status', { order_id: orderId, transaction_status: body?.transaction_status, fraud_status: body?.fraud_status })
|
||||
const nominal = String(grossAmount)
|
||||
if (!notifiedOrders.has(orderId)) {
|
||||
// Mark order inactive upon completion
|
||||
activeOrders.delete(orderId)
|
||||
const ok = await notifyERP({ orderId, nominal })
|
||||
if (ok) {
|
||||
notifiedOrders.add(orderId)
|
||||
await notifyERP({ orderId, nominal })
|
||||
} else {
|
||||
logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' })
|
||||
}
|
||||
} else {
|
||||
logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
|
||||
}
|
||||
|
|
@ -307,6 +645,59 @@ app.post('/api/payments/webhook', async (req, res) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Dev-only helpers: echo endpoint and manual ERP notify trigger
|
||||
if (LOG_EXPOSE_API) {
|
||||
// Echo incoming JSON for local testing (can be used as ERP_NOTIFICATION_URL)
|
||||
app.post('/api/echo', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {}
|
||||
const sig = body?.signature
|
||||
logDebug('erp.mock.receive', {
|
||||
has_signature: typeof sig !== 'undefined',
|
||||
signature_length: (typeof sig === 'string') ? sig.length : -1,
|
||||
body_length: JSON.stringify(body).length,
|
||||
})
|
||||
return res.json({ ok: true, received: body })
|
||||
} catch (e) {
|
||||
logError('erp.mock.error', { message: e?.message })
|
||||
return res.status(500).json({ error: 'ECHO_ERROR', message: e?.message || 'Echo failed' })
|
||||
}
|
||||
})
|
||||
|
||||
// Echo kedua untuk pengujian multi-URL lokal
|
||||
app.post('/api/echo2', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {}
|
||||
const sig = body?.signature
|
||||
logDebug('erp.mock.receive', {
|
||||
endpoint: 'echo2',
|
||||
has_signature: typeof sig !== 'undefined',
|
||||
signature_length: (typeof sig === 'string') ? sig.length : -1,
|
||||
body_length: JSON.stringify(body).length,
|
||||
})
|
||||
return res.json({ ok: true, received: body })
|
||||
} catch (e) {
|
||||
logError('erp.mock.error', { endpoint: 'echo2', message: e?.message })
|
||||
return res.status(500).json({ error: 'ECHO2_ERROR', message: e?.message || 'Echo2 failed' })
|
||||
}
|
||||
})
|
||||
|
||||
// Manually trigger ERP notification for testing signature presence in body
|
||||
app.post('/api/test/notify-erp', async (req, res) => {
|
||||
try {
|
||||
const { orderId, nominal, mercant_id } = req.body || {}
|
||||
if (!orderId && !mercant_id) {
|
||||
return res.status(400).json({ error: 'BAD_REQUEST', message: 'Provide orderId or mercant_id and nominal' })
|
||||
}
|
||||
const ok = await notifyERP({ orderId, nominal: String(nominal || ''), mercantId: mercant_id })
|
||||
return res.json({ ok })
|
||||
} catch (e) {
|
||||
logError('test.notify.error', { message: e?.message })
|
||||
return res.status(500).json({ error: 'TEST_NOTIFY_ERROR', message: e?.message || 'Notify test failed' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const port = process.env.PORT || 8000
|
||||
app.listen(port, () => {
|
||||
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Link, Outlet } from 'react-router-dom'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { ToastHost } from '../components/ui/toast'
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="border-b border-black/10 dark:border-white/20 bg-white dark:bg-black">
|
||||
{/* <header className="border-b border-black/10 dark:border-white/20 bg-white dark:bg-black">
|
||||
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between">
|
||||
<Link to="/checkout" className="font-semibold text-brand-600">Core Midtrans CIFO</Link>
|
||||
<nav className="flex gap-4 text-sm">
|
||||
|
|
@ -11,13 +12,14 @@ export function AppLayout() {
|
|||
<Link to="/history" className="hover:underline">History</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
</header> */}
|
||||
<main className="mx-auto max-w-5xl px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
<footer className="mx-auto max-w-5xl px-4 pb-8 text-xs text-black/60 dark:text-white/60">
|
||||
<ToastHost />
|
||||
{/* <footer className="mx-auto max-w-5xl px-4 pb-8 text-xs text-black/60 dark:text-white/60">
|
||||
Brand: Merah–Hitam–Putih • Sandbox UI skeleton
|
||||
</footer>
|
||||
</footer> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import { AppLayout } from './AppLayout'
|
||||
import { CheckoutPage } from '../pages/CheckoutPage'
|
||||
// import { CheckoutPage } from '../pages/CheckoutPage'
|
||||
import { PaymentStatusPage } from '../pages/PaymentStatusPage'
|
||||
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
|
||||
import { NotFoundPage } from '../pages/NotFoundPage'
|
||||
import { DemoStorePage } from '../pages/DemoStorePage'
|
||||
// import { DemoStorePage } from '../pages/DemoStorePage'
|
||||
import { InitPage } from '../pages/InitialPage'
|
||||
import { PayPage } from '../pages/PayPage'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
|
|
@ -12,8 +14,9 @@ const router = createBrowserRouter([
|
|||
element: <AppLayout />,
|
||||
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
|
||||
children: [
|
||||
{ index: true, element: <DemoStorePage /> },
|
||||
{ path: 'checkout', element: <CheckoutPage /> },
|
||||
{ index: true, element: <InitPage /> },
|
||||
// { path: 'checkout', element: <CheckoutPage /> },
|
||||
{ path: 'pay/:token', element: <PayPage /> },
|
||||
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
|
||||
{ path: 'history', element: <PaymentHistoryPage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export function Alert({ title, children }: { title: string; children?: React.Rea
|
|||
<div className="text-brand-600">⚑</div>
|
||||
<div>
|
||||
<div className="font-semibold">{title}</div>
|
||||
{children ? <div className="text-sm text-black/70 dark:text-white/70">{children}</div> : null}
|
||||
{children ? <div className="text-sm text-black/70">{children}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
|||
import { cn } from '../../lib/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black disabled:opacity-50 disabled:pointer-events-none',
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:opacity-50 disabled:pointer-events-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand-600 text-white hover:bg-brand-700',
|
||||
secondary: 'bg-white text-black hover:bg-white/90 dark:bg-black dark:text-white dark:hover:bg-black/90',
|
||||
outline: 'border border-black text-black hover:bg-black/5 dark:border-white dark:text-white dark:hover:bg-white/10',
|
||||
secondary: 'bg-white text-black border border-black/10 hover:bg-black/5',
|
||||
outline: 'border border-black text-black hover:bg-black/5',
|
||||
},
|
||||
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-11 px-6' },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import React from 'react'
|
||||
|
||||
type ToastKind = 'info' | 'success' | 'error'
|
||||
type ToastItem = { id: number; message: string; kind: ToastKind }
|
||||
|
||||
let nextId = 1
|
||||
const subs = new Set<(t: ToastItem) => void>()
|
||||
|
||||
export const toast = {
|
||||
show(message: string, kind: ToastKind = 'info') {
|
||||
const item: ToastItem = { id: nextId++, message, kind }
|
||||
subs.forEach((fn) => fn(item))
|
||||
},
|
||||
info(message: string) { toast.show(message, 'info') },
|
||||
success(message: string) { toast.show(message, 'success') },
|
||||
error(message: string) { toast.show(message, 'error') },
|
||||
}
|
||||
|
||||
export function ToastHost() {
|
||||
const [items, setItems] = React.useState<ToastItem[]>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
function onPush(t: ToastItem) {
|
||||
setItems((prev) => [...prev, t])
|
||||
// auto-remove after 3.5s
|
||||
setTimeout(() => {
|
||||
setItems((prev) => prev.filter((x) => x.id !== t.id))
|
||||
}, 3500)
|
||||
}
|
||||
subs.add(onPush)
|
||||
return () => { subs.delete(onPush) }
|
||||
}, [])
|
||||
|
||||
if (!items.length) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{items.map((it) => (
|
||||
<div
|
||||
key={it.id}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
`min-w-[240px] max-w-[360px] rounded shadow-lg border p-3 text-sm ` +
|
||||
(it.kind === 'success'
|
||||
? 'bg-green-600 text-white border-green-600'
|
||||
: it.kind === 'error'
|
||||
? 'bg-red-600 text-white border-red-600'
|
||||
: 'bg-black text-white border-black')
|
||||
}
|
||||
>
|
||||
{it.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -3,11 +3,11 @@ import { usePaymentNavigation } from '../lib/navigation'
|
|||
import React from 'react'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { BcaInstructionList } from './BcaInstructionList'
|
||||
import { TrustStrip } from './TrustStrip'
|
||||
import { type BankKey } from './PaymentLogos'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { Alert } from '../../../components/alert/Alert'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
|
||||
// Global guard to prevent duplicate auto-charge across StrictMode double-mounts
|
||||
const attemptedChargeKeys = new Set<string>()
|
||||
|
|
@ -16,7 +16,7 @@ const chargeTasks = new Map<string, Promise<any>>()
|
|||
|
||||
export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated, defaultBank }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultBank?: BankKey }) {
|
||||
const nav = usePaymentNavigation()
|
||||
const [selected, setSelected] = React.useState<BankKey | null>(defaultBank ?? null)
|
||||
const [selected] = React.useState<BankKey | null>(defaultBank ?? null)
|
||||
const [showGuide, setShowGuide] = React.useState(false)
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
const [vaCode, setVaCode] = React.useState('')
|
||||
|
|
@ -29,7 +29,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
|||
function copy(text: string, label: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard?.writeText(text)
|
||||
alert(`${label} disalin: ${text}`)
|
||||
toast.success(`${label} disalin: ${text}`)
|
||||
}
|
||||
// Auto-create VA immediately when a bank is selected (runs once per selection)
|
||||
React.useEffect(() => {
|
||||
|
|
@ -137,43 +137,43 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
|||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">Transfer bank</div>
|
||||
<div className="font-medium">Transfer Bank</div>
|
||||
{selected && (
|
||||
<div className="flex items-center gap-2 text-base">
|
||||
<span className="text-black/60 dark:text-white/60">Bank:</span>
|
||||
<span className="text-black/80 dark:text-white/80 font-semibold">{selected.toUpperCase()}</span>
|
||||
<span className="text-black/60">Bank:</span>
|
||||
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-black/70 dark:text-white/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
||||
<div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
||||
{errorMessage && (
|
||||
<Alert title="Gagal membuat VA">{errorMessage}</Alert>
|
||||
)}
|
||||
{selected && (
|
||||
<div className="pt-1">
|
||||
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20">
|
||||
<div className="rounded-lg p-3 border-2 border-black/30">
|
||||
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
||||
<div className="text-sm text-black/70 dark:text-white/70">
|
||||
<div className="text-sm text-black/70">
|
||||
{vaCode ? (
|
||||
<span>
|
||||
Nomor VA:
|
||||
<span className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black dark:text-white">{vaCode}</span>
|
||||
<span className="block break-all mt-1 font-mono text-xl sm:text-2xl md:text-3xl font-semibold tracking-normal text-black">{vaCode}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
||||
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />}
|
||||
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />}
|
||||
{busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
|
||||
</span>
|
||||
)}
|
||||
{billKey && (
|
||||
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billKey}</span></span>
|
||||
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
|
||||
)}
|
||||
{billerCode && (
|
||||
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black dark:text-white">{billerCode}</span></span>
|
||||
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -187,7 +187,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
|||
{selected === 'bca' ? (
|
||||
<BcaInstructionList />
|
||||
) : (
|
||||
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20">
|
||||
<div className="rounded-lg border-2 border-black/30 p-3 bg-white">
|
||||
<div className="text-sm font-medium mb-2">Instruksi pembayaran</div>
|
||||
<PaymentInstructions method="bank_transfer" />
|
||||
</div>
|
||||
|
|
@ -195,7 +195,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
|||
</div>
|
||||
)}
|
||||
{locked && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div>
|
||||
<div className="text-xs text-black/60">Metode terkunci. Gunakan kode VA/bill key untuk menyelesaikan pembayaran.</div>
|
||||
)}
|
||||
<div className="pt-2 space-y-2">
|
||||
{(!vaCode || errorMessage) && (
|
||||
|
|
@ -280,7 +280,7 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
|||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 dark:border-black/40 border-t-transparent" aria-hidden />
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
||||
Membuat VA…
|
||||
</span>
|
||||
) : 'Buat VA'}
|
||||
|
|
@ -295,7 +295,6 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
|||
Buka halaman status
|
||||
</Button>
|
||||
</div>
|
||||
<TrustStrip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ type TabKey = 'mobile' | 'atm' | 'ib'
|
|||
export function BcaInstructionList() {
|
||||
const [tab, setTab] = React.useState<TabKey>('mobile')
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border-2 border-black/20 dark:border-white/20 bg-white dark:bg-black/20">
|
||||
<div className="mt-2 rounded-lg border-2 border-gray-300 bg-white">
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium mb-2">Instruksi BCA</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Metode pembayaran BCA"
|
||||
className="inline-flex mb-3 rounded-md border border-black/20 dark:border-white/20 overflow-hidden"
|
||||
className="inline-flex mb-3 rounded-md border border-gray-300 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -20,7 +20,7 @@ export function BcaInstructionList() {
|
|||
aria-selected={tab === 'mobile'}
|
||||
aria-controls="panel-bca-mobile"
|
||||
onClick={() => setTab('mobile')}
|
||||
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'mobile' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`}
|
||||
className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'mobile' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
|
||||
>
|
||||
BCA Mobile
|
||||
</button>
|
||||
|
|
@ -31,7 +31,7 @@ export function BcaInstructionList() {
|
|||
aria-selected={tab === 'atm'}
|
||||
aria-controls="panel-bca-atm"
|
||||
onClick={() => setTab('atm')}
|
||||
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'atm' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`}
|
||||
className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'atm' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
|
||||
>
|
||||
ATM BCA
|
||||
</button>
|
||||
|
|
@ -42,7 +42,7 @@ export function BcaInstructionList() {
|
|||
aria-selected={tab === 'ib'}
|
||||
aria-controls="panel-bca-ib"
|
||||
onClick={() => setTab('ib')}
|
||||
className={`px-3 py-2 text-xs font-medium focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${tab === 'ib' ? 'bg-black text-white' : 'bg-transparent hover:bg-black/5 dark:hover:bg-white/10'}`}
|
||||
className={`px-3 py-2 text-xs font-medium focus:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${tab === 'ib' ? 'bg-black text-white' : 'bg-transparent hover:bg-gray-100'}`}
|
||||
>
|
||||
KlikBCA
|
||||
</button>
|
||||
|
|
@ -63,7 +63,7 @@ export function BcaInstructionList() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-black/10 dark:border-white/10 p-3 text-xs text-black/60 dark:text-white/60">
|
||||
<div className="border-t border-gray-200 p-3 text-xs text-gray-600">
|
||||
Catatan: Gunakan label "Nomor VA" secara konsisten di semua metode.
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -111,7 +111,7 @@ function StepsKlikBca() {
|
|||
|
||||
function StepList({ steps }: { steps: string[] }) {
|
||||
return (
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80">
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-800">
|
||||
{steps.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Button } from '../../../components/ui/button'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import React from 'react'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { TrustStrip } from './TrustStrip'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
|
||||
|
|
@ -14,7 +14,7 @@ const cstoreTasks = new Map<string, Promise<any>>()
|
|||
|
||||
export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaultStore }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void; defaultStore?: StoreKey }) {
|
||||
const nav = usePaymentNavigation()
|
||||
const [selected, setSelected] = React.useState<StoreKey | null>(defaultStore ?? null)
|
||||
const [selected] = React.useState<StoreKey | null>(defaultStore ?? null)
|
||||
const [showGuide, setShowGuide] = React.useState(false)
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
const [paymentCode, setPaymentCode] = React.useState('')
|
||||
|
|
@ -36,7 +36,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
if (typeof res?.store === 'string') setStoreFromRes(res.store)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false)
|
||||
cstoreTasks.delete(chargeKey)
|
||||
|
|
@ -60,7 +60,7 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
if (typeof res?.store === 'string') setStoreFromRes(res.store)
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) alert(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(`Gagal membuat kode pembayaran: ${(e as Error).message}`)
|
||||
attemptedCStoreKeys.delete(chargeKey)
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false)
|
||||
|
|
@ -74,14 +74,14 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
function copy(text: string, label: string) {
|
||||
if (!text) return
|
||||
navigator.clipboard?.writeText(text)
|
||||
alert(`${label} disalin: ${text}`)
|
||||
toast.success(`${label} disalin: ${text}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="font-medium">Convenience Store</div>
|
||||
{selected && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Toko dipilih: <span className="font-medium text-black dark:text-white">{selected.toUpperCase()}</span></div>
|
||||
<div className="text-xs text-gray-600">Toko dipilih: <span className="font-medium text-gray-900">{selected.toUpperCase()}</span></div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -93,17 +93,17 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
</button>
|
||||
{showGuide && <PaymentInstructions method="cstore" />}
|
||||
{locked && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
|
||||
<div className="text-xs text-gray-600">Metode terkunci. Gunakan kode pembayaran di kasir {selected?.toUpperCase()}.</div>
|
||||
)}
|
||||
<div className="pt-2 space-y-2">
|
||||
<div className="rounded border border-black/10 dark:border-white/10 p-2 text-sm" aria-live="polite">
|
||||
<div className="rounded border border-gray-300 p-2 text-sm" aria-live="polite">
|
||||
<div className="font-medium">Kode Pembayaran</div>
|
||||
{!selected && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
|
||||
<div className="text-xs text-gray-600">Pilih toko terlebih dahulu di langkah sebelumnya.</div>
|
||||
)}
|
||||
{selected && busy && (
|
||||
<div className="inline-flex items-center gap-2 text-xs text-black/60 dark:text-white/60">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
|
||||
<div className="inline-flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Membuat kode…
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -125,7 +125,6 @@ export function CStorePanel({ orderId, amount, locked, onChargeInitiated, defaul
|
|||
</Button>
|
||||
<InlinePaymentStatus orderId={orderId} method="cstore" />
|
||||
</div>
|
||||
<TrustStrip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,13 +2,13 @@ import { Button } from '../../../components/ui/button'
|
|||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import React from 'react'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { TrustStrip } from './TrustStrip'
|
||||
import { CardLogosRow } from './PaymentLogos'
|
||||
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
|
||||
import { Logger } from '../../../lib/logger'
|
||||
import { Env } from '../../../lib/env'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
|
||||
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||||
const nav = usePaymentNavigation()
|
||||
|
|
@ -41,16 +41,16 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
|||
<CardLogosRow compact />
|
||||
<div className="space-y-2">
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Nomor kartu</div>
|
||||
<div className="text-xs text-gray-600">Nomor kartu</div>
|
||||
<input type="text" inputMode="numeric" maxLength={23} placeholder="0000-0000-0000-0000" className="w-full rounded border px-3 py-2" value={cardNumber} onChange={(e) => setCardNumber(formatCard(e.target.value))} />
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Masa berlaku</div>
|
||||
<div className="text-xs text-gray-600">Masa berlaku</div>
|
||||
<input type="text" inputMode="numeric" maxLength={5} placeholder="MM/YY" className="w-full rounded border px-3 py-2" value={exp} onChange={(e) => setExp(formatExp(e.target.value))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">CVV</div>
|
||||
<div className="text-xs text-gray-600">CVV</div>
|
||||
<input type="password" inputMode="numeric" maxLength={4} placeholder="123" className="w-full rounded border px-3 py-2" value={cvv} onChange={(e) => setCvv(formatCvv(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -69,7 +69,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
|||
</button>
|
||||
{showGuide && <PaymentInstructions method="credit_card" />}
|
||||
{locked && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div>
|
||||
<div className="text-xs text-gray-600">Metode terkunci. Lanjutkan verifikasi 3DS/OTP pada kartu Anda.</div>
|
||||
)}
|
||||
<div className="pt-2 space-y-2">
|
||||
<Button
|
||||
|
|
@ -89,24 +89,24 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
|||
// Minimal validation
|
||||
if (sanitizedCard.length < 13 || sanitizedCard.length > 19) {
|
||||
Logger.warn('card.input.invalid', { reason: 'card_number_length', length: sanitizedCard.length })
|
||||
alert('Nomor kartu tidak valid. Harus 13–19 digit.')
|
||||
toast.error('Nomor kartu tidak valid. Harus 13–19 digit.')
|
||||
return
|
||||
}
|
||||
const mmNum = Number(mm)
|
||||
if (!mm || mm.length !== 2 || mmNum < 1 || mmNum > 12) {
|
||||
Logger.warn('card.input.invalid', { reason: 'exp_month', value: mm })
|
||||
alert('Format bulan kedaluwarsa tidak valid (MM).')
|
||||
toast.error('Format bulan kedaluwarsa tidak valid (MM).')
|
||||
return
|
||||
}
|
||||
if (!yy || yy.length < 2) {
|
||||
Logger.warn('card.input.invalid', { reason: 'exp_year', value: yy })
|
||||
alert('Format tahun kedaluwarsa tidak valid (YY).')
|
||||
toast.error('Format tahun kedaluwarsa tidak valid (YY).')
|
||||
return
|
||||
}
|
||||
const cvvSan = cvv.replace(/[^0-9]/g, '')
|
||||
if (cvvSan.length < 3 || cvvSan.length > 4) {
|
||||
Logger.warn('card.input.invalid', { reason: 'cvv_length', length: cvvSan.length })
|
||||
alert('CVV harus 3–4 digit.')
|
||||
toast.error('CVV harus 3–4 digit.')
|
||||
return
|
||||
}
|
||||
if (Env.LOG_LEVEL === 'debug') {
|
||||
|
|
@ -132,7 +132,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
|||
}
|
||||
} catch (e) {
|
||||
Logger.error('card.process.error', { message: (e as Error)?.message })
|
||||
alert(`Gagal memproses kartu: ${(e as Error).message}`)
|
||||
toast.error(`Gagal memproses kartu: ${(e as Error).message}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
|||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 dark:border-black/40 border-t-transparent" aria-hidden />
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
||||
Memproses pembayaran…
|
||||
</span>
|
||||
) : 'Bayar sekarang'}
|
||||
|
|
@ -156,7 +156,6 @@ export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orde
|
|||
</div>
|
||||
{/* Status inline dengan polling otomatis */}
|
||||
<InlinePaymentStatus orderId={orderId} method="credit_card" compact />
|
||||
<TrustStrip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,10 +2,10 @@ import { Button } from '../../../components/ui/button'
|
|||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import React from 'react'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { TrustStrip } from './TrustStrip'
|
||||
import { GoPayLogosRow } from './PaymentLogos'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
|
||||
// Global guards/tasks to stabilize QR generation across StrictMode remounts
|
||||
const attemptedChargeKeys = new Set<string>()
|
||||
|
|
@ -26,15 +26,7 @@ function pickQrImageUrl(res: any, acts: Array<{ name?: string; method?: string;
|
|||
return ''
|
||||
}
|
||||
|
||||
function QRPlaceholder() {
|
||||
return (
|
||||
<div className="aspect-square w-full max-w-[220px] mx-auto bg-white dark:bg-black grid grid-cols-9 grid-rows-9">
|
||||
{Array.from({ length: 81 }).map((_, i) => (
|
||||
<div key={i} className={(i + Math.floor(i / 9)) % 2 === 0 ? 'bg-black' : 'bg-white'} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
//
|
||||
|
||||
export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||||
const nav = usePaymentNavigation()
|
||||
|
|
@ -56,7 +48,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
|||
a.target = '_blank'
|
||||
a.click()
|
||||
} else {
|
||||
alert('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
|
||||
toast.error('QR belum tersedia. Klik "Buat QR" terlebih dulu.')
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
|
@ -76,13 +68,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
|||
<div className="font-medium">GoPay / QRIS</div>
|
||||
<GoPayLogosRow compact />
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-black/60 dark:text-white/60">Mode:</span>
|
||||
<div className="inline-flex rounded-md border-2 border-black/20 dark:border-white/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
|
||||
<span className="text-black/60">Mode:</span>
|
||||
<div className="inline-flex rounded-md border-2 border-black/20 overflow-hidden" role="group" aria-label="Pilih mode pembayaran">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('gopay')}
|
||||
aria-pressed={mode==='gopay'}
|
||||
className={`px-2 py-1 focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white dark:bg-black text-black dark:text-white hover:bg-black/10 dark:hover:bg-white/10'}`}
|
||||
className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='gopay' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
|
||||
>
|
||||
GoPay
|
||||
</button>
|
||||
|
|
@ -90,25 +82,25 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
|||
type="button"
|
||||
onClick={() => setMode('qris')}
|
||||
aria-pressed={mode==='qris'}
|
||||
className={`px-2 py-1 focus:outline-none ring-[#2563EB] ring-3 ring-offset-2 ring-offset-white dark:ring-offset-black transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white dark:bg-black text-black dark:text-white hover:bg-black/10 dark:hover:bg-white/10'}`}
|
||||
className={`px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#2563EB] focus-visible:ring-offset-2 focus-visible:ring-offset-white transition ${mode==='qris' ? 'bg-black text-white' : 'bg-white text-black hover:bg-black/10'}`}
|
||||
>
|
||||
QRIS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border border-black/10 dark:border-white/10 p-3 flex flex-col items-center gap-2">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
|
||||
<div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 dark:border-white/20 bg-white">
|
||||
<div className="rounded border border-black/10 p-3 flex flex-col items-center gap-2">
|
||||
<div className="text-xs text-black/60">Scan QR berikut menggunakan aplikasi {mode === 'gopay' ? 'GoPay' : 'QRIS'}</div>
|
||||
<div className="relative w-full max-w-[280px] aspect-square grid place-items-center rounded-md border border-black/20 bg-white">
|
||||
{mode === 'qris' && (!qrUrl || busy) ? (
|
||||
<span className="inline-flex items-center justify-center gap-2 text-xs text-black/60 dark:text-white/60" role="status" aria-live="polite">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
|
||||
<span className="inline-flex items-center justify-center gap-2 text-xs text-black/60" role="status" aria-live="polite">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
|
||||
Membuat QR…
|
||||
</span>
|
||||
) : qrUrl ? (
|
||||
<img src={qrUrl} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[10px] text-black/50 dark:text-white/50">Mode: {mode.toUpperCase()}</div>
|
||||
<div className="text-[10px] text-black/50">Mode: {mode.toUpperCase()}</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={downloadQR} disabled={!qrUrl}>Download QR</Button>
|
||||
|
|
@ -133,7 +125,7 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
|||
/>
|
||||
</div>
|
||||
{locked && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
|
||||
<div className="text-xs text-black/60">Metode terkunci. Gunakan QR/deeplink untuk menyelesaikan pembayaran.</div>
|
||||
)}
|
||||
<div className="pt-2">
|
||||
<InlinePaymentStatus orderId={orderId} method={mode} />
|
||||
|
|
@ -147,14 +139,13 @@ export function GoPayPanel({ orderId, amount, locked, onChargeInitiated }: { ord
|
|||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center justify-center gap-2" role="status" aria-live="polite">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
|
||||
Menuju status…
|
||||
</span>
|
||||
) : 'Buka halaman status'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TrustStrip />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -184,7 +175,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
|
|||
onChargeInitiated?.()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setBusy(false)
|
||||
|
|
@ -213,7 +204,7 @@ export function GoPayPanel_AutoEffect({ orderId, amount, locked, mode, setBusy,
|
|||
onChargeInitiated?.()
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) alert(`Gagal membuat QR: ${(e as Error).message}`)
|
||||
if (!cancelled) toast.error(`Gagal membuat QR: ${(e as Error).message}`)
|
||||
attemptedChargeKeys.delete(chargeKey)
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react'
|
||||
import { Button } from '../../../components/ui/button'
|
||||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import { usePaymentStatus } from '../lib/usePaymentStatus'
|
||||
|
|
@ -15,12 +14,11 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
|
|||
const nav = usePaymentNavigation()
|
||||
const { data, isLoading, error, refetch, isRefetching } = usePaymentStatus(orderId)
|
||||
const status = (data?.status ?? 'pending') as PaymentStatusResponse['status']
|
||||
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(status)
|
||||
const isSuccess = status === 'settlement' || status === 'capture'
|
||||
const isFailure = ['deny', 'cancel', 'expire', 'refund', 'chargeback'].includes(status)
|
||||
|
||||
return (
|
||||
<div className={`rounded border ${compact ? 'p-2' : 'p-3'} border-black/10 dark:border-white/10 bg-white dark:bg-black/20`} aria-live="polite">
|
||||
<div className={`rounded border ${compact ? 'p-2' : 'p-3'} border-black/10 bg-white`} aria-live="polite">
|
||||
{/* Header minimal tanpa detail teknis */}
|
||||
<div className="text-sm font-medium">Status pembayaran</div>
|
||||
|
||||
|
|
@ -28,10 +26,10 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
|
|||
{isLoading ? (
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="inline-flex items-center gap-2" role="status">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
|
||||
Mengecek pembayaran…
|
||||
</span>
|
||||
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik.</div>
|
||||
<div className="mt-1 text-[11px] text-black/60">Kami memeriksa otomatis setiap 3 detik.</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="mt-2 text-sm text-brand-600">Gagal memuat status. Coba refresh.</div>
|
||||
|
|
@ -47,9 +45,9 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
|
|||
<div className="text-base font-semibold">Pembayaran berhasil</div>
|
||||
</div>
|
||||
{data?.grossAmount ? (
|
||||
<div className="mt-1 text-sm text-black/70 dark:text-white/70">Total dibayar: {formatIDR(data.grossAmount)}</div>
|
||||
<div className="mt-1 text-sm text-black/70">Total dibayar: {formatIDR(data.grossAmount)}</div>
|
||||
) : null}
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Terima kasih! Pesanan Anda sedang diproses.</div>
|
||||
<div className="mt-1 text-xs text-black/60">Terima kasih! Pesanan Anda sedang diproses.</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button className="w-full sm:w-auto" onClick={() => nav.toHistory()}>Lihat riwayat pembayaran</Button>
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Kembali ke checkout</Button>
|
||||
|
|
@ -68,7 +66,7 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
|
|||
</span>
|
||||
<div className="text-base font-semibold">Pembayaran belum berhasil</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Silakan coba lagi atau pilih metode lain.</div>
|
||||
<div className="mt-1 text-xs text-black/60">Silakan coba lagi atau pilih metode lain.</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button className="w-full sm:w-auto" onClick={() => nav.toCheckout()}>Coba lagi</Button>
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => nav.toStatus(orderId, method)}>Lihat detail status</Button>
|
||||
|
|
@ -77,18 +75,18 @@ export function InlinePaymentStatus({ orderId, method, compact }: { orderId: str
|
|||
) : (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500/15 text-yellow-700 dark:text-yellow-300">
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-yellow-500/15 text-yellow-700">
|
||||
{/* hourglass/spinner icon */}
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-600/50 dark:border-yellow-300/80 border-t-transparent" aria-hidden />
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-yellow-600/50 border-t-transparent" aria-hidden />
|
||||
</span>
|
||||
<div className="text-base font-semibold">Menunggu pembayaran</div>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-black/60 dark:text-white/60">Kami memeriksa otomatis setiap 3 detik sampai selesai.</div>
|
||||
<div className="mt-1 text-[11px] text-black/60">Kami memeriksa otomatis setiap 3 detik sampai selesai.</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" className="w-full sm:w-auto" onClick={() => refetch()} aria-busy={isRefetching} disabled={isRefetching}>
|
||||
{isRefetching ? (
|
||||
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/60 border-t-transparent" aria-hidden />
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 border-t-transparent" aria-hidden />
|
||||
Memuat…
|
||||
</span>
|
||||
) : 'Refresh sekarang'}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import React from 'react'
|
||||
import type { PaymentMethod } from './PaymentMethodList'
|
||||
|
||||
export function PaymentInstructions({ method, title, steps }: { method?: PaymentMethod; title?: string; steps?: string[] }) {
|
||||
|
|
@ -6,9 +5,9 @@ export function PaymentInstructions({ method, title, steps }: { method?: Payment
|
|||
const finalSteps = computed.length ? computed : ['Ikuti instruksi yang muncul pada layar pembayaran.']
|
||||
const finalTitle = title ?? 'Cara bayar'
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border-2 border-black/30 dark:border-white/30 p-3 bg-white dark:bg-black/20" role="region" aria-label={finalTitle}>
|
||||
<div className="mt-2 rounded-lg border-2 border-gray-300 p-3 bg-white" role="region" aria-label={finalTitle}>
|
||||
<div className="text-sm font-medium mb-2">{finalTitle}</div>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-black/80 dark:text-white/80">
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-800">
|
||||
{finalSteps.map((s, i) => (
|
||||
<li key={i}>{s}</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react'
|
||||
//
|
||||
|
||||
export type BankKey = 'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata'
|
||||
|
||||
|
|
@ -17,7 +17,6 @@ function BrandImg({ src, alt, compact = false, size, fallbackSrc }: { src: strin
|
|||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const el = e.currentTarget
|
||||
const current = el.src
|
||||
const proxyUsed = el.dataset.proxy === 'used'
|
||||
const fbUsed = el.dataset.fb === 'used'
|
||||
if (fallbackSrc && !fbUsed) {
|
||||
|
|
@ -104,7 +103,7 @@ export function CStoreLogosRow({ compact = false, size }: { compact?: boolean; s
|
|||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<LogoAlfamart compact={compact} size={size} />
|
||||
<LogoIndomaret compact={compact} size={size} />
|
||||
{/* <LogoIndomaret compact={compact} size={size} /> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export interface PaymentMethodListProps {
|
|||
}
|
||||
|
||||
const baseItems: Array<{ key: PaymentMethod; title: string; subtitle: string; icon?: React.ReactNode }> = [
|
||||
{ key: 'bank_transfer', title: 'Transfer bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <img src="/logos/logo-semua-bank.PNG" alt="Semua bank yang didukung" className="h-6 sm:h-8 object-contain" /> },
|
||||
{ key: 'bank_transfer', title: 'Transfer Bank', subtitle: 'BCA • BNI • BRI • CIMB • Mandiri • Permata', icon: <img src="/logos/logo-semua-bank.PNG" alt="Semua bank yang didukung" className="h-6 sm:h-8 object-contain" /> },
|
||||
{ key: 'credit_card', title: 'Kartu kredit/debit', subtitle: 'Visa • MasterCard • JCB • Amex', icon: <CardLogosRow compact size="xs" /> },
|
||||
{ key: 'gopay', title: 'Gopay/QRIS', subtitle: 'Scan & bayar via QR', icon: <GoPayLogosRow compact size="xs" /> },
|
||||
{ key: 'cstore', title: 'Convenience Store', subtitle: '', icon: <CStoreLogosRow compact size="xs" /> },
|
||||
|
|
@ -32,26 +32,26 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
|
|||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Metode pembayaran</div>
|
||||
<div className="rounded-lg border-2 border-black/30 dark:border-white/30 divide-y-[2px] divide-black/20 dark:divide-white/20 bg-white dark:bg-black">
|
||||
<div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
|
||||
{items.map((it) => (
|
||||
<div key={it.key}>
|
||||
<button
|
||||
onClick={() => !disabled && onSelect(it.key)}
|
||||
disabled={disabled}
|
||||
className={`w-full text-left p-4 min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10 dark:hover:bg-white/15'} ${selected === it.key ? 'bg-black/10 dark:bg-white/20' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-black`}
|
||||
className={`w-full text-left p-4 min-h-[52px] flex items-center justify-between ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer hover:bg-black/10'} ${selected === it.key ? 'bg-black/10' : ''} focus-visible:outline-none focus-visible:ring-[#2563EB] focus-visible:ring-[3px] focus-visible:ring-offset-2 focus-visible:ring-offset-white`}
|
||||
aria-pressed={selected === it.key}
|
||||
aria-expanded={selected === it.key}
|
||||
aria-controls={`panel-${it.key}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-semibold text-black dark:text-white">{it.title}</div>
|
||||
<div className="text-base font-semibold text-black">{it.title}</div>
|
||||
{it.key === 'bank_transfer' && it.subtitle && (
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
<div className="mt-1 text-xs text-black/60">
|
||||
{it.subtitle}
|
||||
</div>
|
||||
)}
|
||||
{it.key === 'cpay' && it.subtitle && (
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
<div className="mt-1 text-xs text-black/60">
|
||||
{it.subtitle}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -62,11 +62,11 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
|
|||
{it.icon}
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden className={`text-black/60 dark:text-white/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
||||
<span aria-hidden className={`text-black/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
||||
</div>
|
||||
</button>
|
||||
{selected === it.key && renderPanel && (
|
||||
<div id={`panel-${it.key}`} className="p-3 bg-white dark:bg-black/20">
|
||||
<div id={`panel-${it.key}`} className="p-3 bg-white">
|
||||
{renderPanel(it.key)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ function useCountdown(expireAt: number) {
|
|||
}, [])
|
||||
const remainMs = Math.max(0, expireAt - now)
|
||||
const totalSec = Math.floor(remainMs / 1000)
|
||||
const mm = String(Math.floor(totalSec / 60)).padStart(2, '0')
|
||||
const hh = String(Math.floor(totalSec / 3600)).padStart(2, '0')
|
||||
const mm = String(Math.floor((totalSec % 3600) / 60)).padStart(2, '0')
|
||||
const ss = String(totalSec % 60).padStart(2, '0')
|
||||
return `${mm}:${ss}`
|
||||
return `${hh}:${mm}:${ss}`
|
||||
}
|
||||
|
||||
export interface PaymentSheetProps {
|
||||
|
|
@ -28,7 +29,7 @@ export interface PaymentSheetProps {
|
|||
showStatusCTA?: boolean
|
||||
}
|
||||
|
||||
export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||
export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||
const countdown = useCountdown(expireAt)
|
||||
const [expanded, setExpanded] = React.useState(true)
|
||||
return (
|
||||
|
|
@ -38,7 +39,7 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
|
|||
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="rounded bg-white text-black px-2 py-1 text-[11px] sm:text-xs font-bold" aria-hidden>
|
||||
ZARA
|
||||
SIMAYA
|
||||
</div>
|
||||
<div className="font-semibold text-sm sm:text-base">{merchantName}</div>
|
||||
</div>
|
||||
|
|
@ -57,32 +58,33 @@ export function PaymentSheet({ merchantName = 'Zara', orderId, amount, expireAt,
|
|||
onClick={() => setExpanded((v) => !v)}
|
||||
className={`text-white/80 transition-transform ${expanded ? '' : 'rotate-180'} focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-white/80 focus-visible:ring-offset-2`}
|
||||
>
|
||||
˅
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M7 14L12 9L17 14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Amount panel */}
|
||||
|
||||
{expanded && (
|
||||
<div className="p-4 border-b border-black/10 dark:border-white/10 flex items-start justify-between">
|
||||
<div className="p-4 border-b border-black/10 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Total</div>
|
||||
<div className="text-xs text-black">Total</div>
|
||||
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Order ID #{orderId}</div>
|
||||
<div className="text-xs text-black/60">Order ID #{orderId}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Body */}
|
||||
|
||||
<div className="p-4">
|
||||
{children}
|
||||
<TrustStrip location="sheet" />
|
||||
</div>
|
||||
{/* Sticky CTA (mobile-friendly) */}
|
||||
{showStatusCTA && (
|
||||
<div className="sticky bottom-0 bg-white/95 dark:bg-neutral-900/95 backdrop-blur border-t border-black/10 dark:border-white/10 p-3 pb-[env(safe-area-inset-bottom)]">
|
||||
<div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/10 p-3 pb-[env(safe-area-inset-bottom)]">
|
||||
<Link
|
||||
to={`/payments/${orderId}/status`}
|
||||
aria-label="Buka halaman status pembayaran"
|
||||
className="w-full block text-center rounded bg-[#0c1f3f] text-white py-3 text-base font-semibold hover:bg-[#0a1a35] focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f]"
|
||||
aria-label="Buka halaman Status Pembayaran"
|
||||
className="w-full block text-center rounded bg-[#0c1f3f] !text-white py-3 text-base font-semibold hover:bg-[#0a1a35] hover:!text-white focus:outline-none focus-visible:ring-3 focus-visible:ring-offset-2 focus-visible:ring-[#0c1f3f] visited:!text-white active:!text-white"
|
||||
>
|
||||
Cek status pembayaran
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import React from 'react'
|
||||
//
|
||||
|
||||
export function TrustStrip({ location = 'panel' }: { location?: 'panel' | 'sheet' }) {
|
||||
return (
|
||||
<div className={`text-[10px] ${location === 'sheet' ? 'mt-2' : ''} text-black/50 dark:text-white/50 flex items-center gap-2`}>
|
||||
<div className={`text-[10px] ${location === 'sheet' ? 'mt-2' : ''} text-black/50 flex items-center gap-2`}>
|
||||
<span aria-hidden>🔒</span>
|
||||
<span>Secure payments by Midtrans</span>
|
||||
<span aria-hidden className="ml-auto flex items-center gap-1">
|
||||
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Visa</span>
|
||||
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Mastercard</span>
|
||||
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">JCB</span>
|
||||
<span className="inline-block rounded bg-black/10 dark:bg-white/10 px-1">Amex</span>
|
||||
<span className="inline-block rounded bg-black/10 px-1">Visa</span>
|
||||
<span className="inline-block rounded bg-black/10 px-1">Mastercard</span>
|
||||
<span className="inline-block rounded bg-black/10 px-1">JCB</span>
|
||||
<span className="inline-block rounded bg-black/10 px-1">Amex</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export function usePaymentNavigation() {
|
|||
const navigate = useNavigate()
|
||||
return {
|
||||
toCheckout() {
|
||||
navigate('/checkout')
|
||||
window.location.assign('https://erpskrip.id/pembayaran-pelanggan')
|
||||
},
|
||||
toStatus(orderId: string, method?: string) {
|
||||
const qs = method ? `?m=${encodeURIComponent(method)}` : ''
|
||||
|
|
|
|||
13
src/main.tsx
13
src/main.tsx
|
|
@ -1,6 +1,19 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles/globals.css'
|
||||
|
||||
(() => {
|
||||
const html = document.documentElement
|
||||
try {
|
||||
if (html.classList.contains('dark')) {
|
||||
html.classList.remove('dark')
|
||||
}
|
||||
document.body.classList.remove('dark')
|
||||
html.style.colorScheme = 'light'
|
||||
html.setAttribute('data-theme', 'light')
|
||||
} catch {
|
||||
}
|
||||
})()
|
||||
import { AppRouter } from './app/router'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
|
|
|
|||
|
|
@ -72,13 +72,13 @@ export function CheckoutPage() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<PaymentSheet merchantName="Zara" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
|
||||
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
|
||||
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Konfirmasi data checkout</div>
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Nama</div>
|
||||
<div className="text-xs text-gray-600">Nama</div>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border px-3 py-2"
|
||||
|
|
@ -87,7 +87,7 @@ export function CheckoutPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Email atau HP</div>
|
||||
<div className="text-xs text-gray-600">Email atau HP</div>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border px-3 py-2"
|
||||
|
|
@ -96,7 +96,7 @@ export function CheckoutPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Alamat</div>
|
||||
<div className="text-xs text-gray-600">Alamat</div>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border px-3 py-2"
|
||||
|
|
@ -105,7 +105,7 @@ export function CheckoutPage() {
|
|||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Catatan</div>
|
||||
<div className="text-xs text-gray-600">Catatan</div>
|
||||
<textarea
|
||||
className="w-full rounded border px-3 py-2"
|
||||
value={form.notes}
|
||||
|
|
@ -157,7 +157,15 @@ export function CheckoutPage() {
|
|||
}
|
||||
}}
|
||||
disabled={locked}
|
||||
enabled={runtimeCfg?.paymentToggles}
|
||||
enabled={runtimeCfg?.paymentToggles
|
||||
? {
|
||||
bank_transfer: runtimeCfg.paymentToggles.bank_transfer,
|
||||
credit_card: runtimeCfg.paymentToggles.credit_card,
|
||||
gopay: runtimeCfg.paymentToggles.gopay,
|
||||
cstore: runtimeCfg.paymentToggles.cstore,
|
||||
cpay: !!runtimeCfg.paymentToggles.cpay,
|
||||
}
|
||||
: undefined}
|
||||
renderPanel={(m) => {
|
||||
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
|
||||
if (!methodEnabled[m]) {
|
||||
|
|
@ -170,14 +178,7 @@ export function CheckoutPage() {
|
|||
if (m === 'bank_transfer') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src="/logos/logo-semua-bank.PNG"
|
||||
alt="Logo semua bank yang didukung"
|
||||
className="max-h-20 sm:max-h-24 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||
<button
|
||||
|
|
@ -188,7 +189,7 @@ export function CheckoutPage() {
|
|||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
|
||||
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||
>
|
||||
<BankLogo bank={bk} />
|
||||
|
|
@ -196,8 +197,8 @@ export function CheckoutPage() {
|
|||
))}
|
||||
</div>
|
||||
{isBusy && (
|
||||
<div className="text-xs text-black/60 dark:text-white/60 inline-flex items-center gap-2">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-black/40 dark:border-white/40 border-t-transparent" aria-hidden />
|
||||
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Menyiapkan VA…
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -207,7 +208,7 @@ export function CheckoutPage() {
|
|||
if (m === 'cstore') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Pilih toko untuk membuat kode pembayaran</div>
|
||||
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
|
||||
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['alfamart','indomaret'] as const).map((st) => (
|
||||
<button
|
||||
|
|
@ -218,7 +219,7 @@ export function CheckoutPage() {
|
|||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-2 flex items-center justify-center overflow-hidden hover:bg-black/5 dark:hover:bg-white/10"
|
||||
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||
>
|
||||
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||
|
|
@ -258,7 +259,7 @@ export function CheckoutPage() {
|
|||
)}
|
||||
</PaymentSheet>
|
||||
|
||||
<div className="text-xs text-black/60 dark:text-white/60">
|
||||
<div className="text-xs text-gray-600">
|
||||
API Base: {apiBase ?? '—'} | Client Key: {clientKey ? 'OK' : '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ export function DemoStorePage() {
|
|||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Produk: T-Shirt Hitam</div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Ukuran: M • Bahan: Cotton</div>
|
||||
<div className="text-xs text-black/60">Ukuran: M • Bahan: Cotton</div>
|
||||
<div className="mt-2 text-lg font-semibold">Rp 3.500.000</div>
|
||||
</div>
|
||||
<div className="w-24 h-24 rounded bg-black/10 dark:bg-white/10" aria-hidden />
|
||||
<div className="w-24 h-24 rounded bg-black/10" aria-hidden />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
export function InitPage() {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white overflow-hidden flex items-center justify-center p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/simaya.png" alt="Simaya" className="h-12 w-12 md:h-16 md:w-16" />
|
||||
<div className="text-black">
|
||||
<div className="text-xl font-semibold leading-none">Simaya Midtrans</div>
|
||||
<div className="text-sm leading-none">Payment Service</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@ export function NotFoundPage() {
|
|||
return (
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1>
|
||||
<p className="text-sm text-black/70 dark:text-white/70">Periksa URL atau kembali ke checkout.</p>
|
||||
<Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link>
|
||||
<p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p>
|
||||
{/* <Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> */}
|
||||
<Link to="/" className="text-brand-600 underline">Kembali</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
||||
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
|
||||
import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
|
||||
import { CardPanel } from '../features/payments/components/CardPanel'
|
||||
import { GoPayPanel } from '../features/payments/components/GoPayPanel'
|
||||
import { CStorePanel } from '../features/payments/components/CStorePanel'
|
||||
import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
|
||||
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||
import { Alert } from '../components/alert/Alert'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { getPaymentLinkPayload } from '../services/api'
|
||||
|
||||
type Method = PaymentMethod | null
|
||||
|
||||
export function PayPage() {
|
||||
const { token } = useParams()
|
||||
const [orderId, setOrderId] = useState<string>('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
|
||||
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
||||
const [locked, setLocked] = useState<boolean>(false)
|
||||
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null)
|
||||
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
|
||||
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
||||
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||
const { data: runtimeCfg } = usePaymentConfig()
|
||||
const [currentStep, setCurrentStep] = useState<2 | 3>(2)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function resolve() {
|
||||
if (!token) return
|
||||
try {
|
||||
const payload = await getPaymentLinkPayload(token)
|
||||
if (cancelled) return
|
||||
setOrderId(payload.order_id)
|
||||
setAmount(payload.nominal)
|
||||
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
|
||||
setAllowedMethods(payload.allowed_methods)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (cancelled) return
|
||||
setError({ code: 'TOKEN_RESOLVE_ERROR' })
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const merchantName = useMemo(() => '', [])
|
||||
|
||||
const isExpired = expireAt ? Date.now() > expireAt : false
|
||||
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
|
||||
const base = runtimeCfg?.paymentToggles
|
||||
const allow = allowedMethods
|
||||
const all: Record<PaymentMethod, boolean> = {
|
||||
bank_transfer: base?.bank_transfer ?? true,
|
||||
credit_card: base?.credit_card ?? true,
|
||||
gopay: base?.gopay ?? true,
|
||||
cstore: base?.cstore ?? true,
|
||||
cpay: base?.cpay ?? false,
|
||||
}
|
||||
if (allow && Array.isArray(allow)) {
|
||||
for (const k of (Object.keys(all) as PaymentMethod[])) {
|
||||
if (k === 'cpay') continue
|
||||
all[k] = allow.includes(k) && all[k]
|
||||
}
|
||||
}
|
||||
return all
|
||||
}, [runtimeCfg, allowedMethods])
|
||||
|
||||
if (error || isExpired) {
|
||||
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
|
||||
const msg = isExpired ? 'Silakan minta link baru dari admin atau ERP.' : 'Token tidak dapat diverifikasi. Hubungi admin untuk bantuan.'
|
||||
return (
|
||||
<PaymentSheet
|
||||
merchantName={merchantName}
|
||||
orderId={orderId || (token ?? '')}
|
||||
amount={amount}
|
||||
expireAt={expireAt}
|
||||
showStatusCTA={false}
|
||||
>
|
||||
<div className="space-y-4 px-4 py-6">
|
||||
<Alert title={title}>{msg}</Alert>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => { try { window.location.reload() } catch { } }}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Muat ulang
|
||||
</Button>
|
||||
<a
|
||||
href="mailto:retailimaya@gmail.com?subject=Permintaan%20Link%20Pembayaran&body=Order%20ID:%20"
|
||||
className="inline-flex items-center px-3 py-2 rounded border bg-gray-800 !text-white hover:!text-white focus:!text-white visited:!text-white active:!text-white w-full sm:w-auto justify-center"
|
||||
>
|
||||
Hubungi Admin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</PaymentSheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PaymentSheet
|
||||
merchantName={merchantName}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
expireAt={expireAt}
|
||||
showStatusCTA={currentStep === 3}
|
||||
>
|
||||
<div className="space-y-4 px-4 py-6">
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-3">
|
||||
<PaymentMethodList
|
||||
selected={selectedMethod ?? undefined}
|
||||
onSelect={(m) => {
|
||||
setSelectedMethod(m as Method)
|
||||
if (m === 'bank_transfer' || m === 'cstore') {
|
||||
} else if (m === 'cpay') {
|
||||
try {
|
||||
window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
|
||||
} catch { }
|
||||
} else {
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}
|
||||
}}
|
||||
disabled={locked}
|
||||
enabled={enabledMap}
|
||||
renderPanel={(m) => {
|
||||
const enabled = enabledMap[m]
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m === 'bank_transfer') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className={`grid grid-cols-2 md:grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
|
||||
<button
|
||||
key={bk}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedBank(bk)
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||
>
|
||||
<BankLogo bank={bk} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isBusy && (
|
||||
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Menyiapkan VA…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m === 'cstore') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
|
||||
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{/* {(['alfamart', 'indomaret'] as const).map((st) => ( */}
|
||||
{(['alfamart'] as const).map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedStore(st)
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center hover:bg-gray-100"
|
||||
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||
>
|
||||
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
{selectedMethod === 'bank_transfer' && (
|
||||
<BankTransferPanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => setLocked(true)}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
defaultBank={(selectedBank ?? 'bca')}
|
||||
/>
|
||||
)}
|
||||
{selectedMethod === 'credit_card' && (
|
||||
<CardPanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => setLocked(true)}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
/>
|
||||
)}
|
||||
{selectedMethod === 'gopay' && (
|
||||
<GoPayPanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => setLocked(true)}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
/>
|
||||
)}
|
||||
{selectedMethod === 'cstore' && (
|
||||
<CStorePanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => setLocked(true)}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
defaultStore={selectedStore ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PaymentSheet>
|
||||
)
|
||||
}
|
||||
|
|
@ -32,18 +32,18 @@ export function PaymentStatusPage() {
|
|||
function statusBadgeClass(s: PaymentStatusResponse['status']) {
|
||||
switch (s) {
|
||||
case 'pending':
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-200'
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-yellow-100 text-yellow-800'
|
||||
case 'settlement':
|
||||
case 'capture':
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-200'
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-green-100 text-green-800'
|
||||
case 'deny':
|
||||
case 'cancel':
|
||||
case 'expire':
|
||||
case 'refund':
|
||||
case 'chargeback':
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-200'
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-red-100 text-red-800'
|
||||
default:
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-200'
|
||||
return 'inline-block rounded px-2 py-0.5 text-xs bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -53,7 +53,7 @@ export function PaymentStatusPage() {
|
|||
<div className="card p-4">
|
||||
<div className="text-sm">Order ID: {orderId}</div>
|
||||
{method || data?.method ? (
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Metode: {data?.method ?? method}</div>
|
||||
<div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
|
||||
) : null}
|
||||
<div className="mt-2">Status: {isLoading ? (
|
||||
<span className="font-medium">memuat…</span>
|
||||
|
|
@ -62,7 +62,7 @@ export function PaymentStatusPage() {
|
|||
) : (
|
||||
<span className={statusBadgeClass(statusText)}>{statusText}</span>
|
||||
)}</div>
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
|
||||
</div>
|
||||
{/* Method-specific details */}
|
||||
|
|
@ -70,18 +70,18 @@ export function PaymentStatusPage() {
|
|||
<div className="mt-3 space-y-2 text-sm">
|
||||
{/* Bank Transfer / VA */}
|
||||
{data.vaNumber ? (
|
||||
<div className="rounded border border-black/10 dark:border-white/10 p-2">
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">Virtual Account</div>
|
||||
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
|
||||
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
|
||||
{data.billKey && data.billerCode ? (
|
||||
<div className="mt-1 text-xs text-black/60 dark:text-white/60">Mandiri E-Channel — Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
|
||||
<div className="mt-1 text-xs text-gray-600">Mandiri E-Channel — Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* C-store */}
|
||||
{data.store || data.paymentCode ? (
|
||||
<div className="rounded border border-black/10 dark:border-white/10 p-2">
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">Convenience Store</div>
|
||||
{data.store ? <div>Store: {data.store}</div> : null}
|
||||
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
|
||||
|
|
@ -89,9 +89,9 @@ export function PaymentStatusPage() {
|
|||
) : null}
|
||||
{/* E-money (GoPay/QRIS) */}
|
||||
{data.actions && data.actions.length > 0 ? (
|
||||
<div className="rounded border border-black/10 dark:border-white/10 p-2">
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">QR / Deeplink</div>
|
||||
<div className="text-xs text-black/60 dark:text-white/60">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
|
||||
<div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{data.actions.map((a, i) => (
|
||||
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
|
||||
|
|
@ -103,7 +103,7 @@ export function PaymentStatusPage() {
|
|||
) : null}
|
||||
{/* Card */}
|
||||
{data.maskedCard ? (
|
||||
<div className="rounded border border-black/10 dark:border-white/10 p-2">
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">Kartu</div>
|
||||
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
|
||||
</div>
|
||||
|
|
@ -112,7 +112,7 @@ export function PaymentStatusPage() {
|
|||
) : null}
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button onClick={() => nav.toHistory()}>Lihat Riwayat</Button>
|
||||
<Button variant="secondary" onClick={() => nav.toCheckout()}>Kembali ke Checkout</Button>
|
||||
<Button variant="secondary" onClick={() => nav.toCheckout()}>Kembali</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!Env.API_BASE_URL && (
|
||||
|
|
|
|||
|
|
@ -49,9 +49,11 @@ api.interceptors.response.use(
|
|||
return response
|
||||
},
|
||||
(error) => {
|
||||
const baseURL = error.config?.baseURL || ''
|
||||
const url = error.config?.url || ''
|
||||
const status = error.response?.status
|
||||
Logger.error('api.error', { url, status, message: error.message })
|
||||
const fullUrl = `${baseURL}${url}`
|
||||
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
|
@ -112,3 +114,37 @@ export async function getRuntimeConfig(): Promise<RuntimeConfigResponse> {
|
|||
clientKey: Env.MIDTRANS_CLIENT_KEY,
|
||||
}
|
||||
}
|
||||
|
||||
export type PaymentLinkPayload = {
|
||||
order_id: string
|
||||
nominal: number
|
||||
customer?: { name?: string; phone?: string; email?: string }
|
||||
expire_at?: number
|
||||
allowed_methods?: string[]
|
||||
}
|
||||
|
||||
export async function getPaymentLinkPayload(token: string): Promise<PaymentLinkPayload> {
|
||||
if (apiBase) {
|
||||
const { data } = await api.get(`/payment-links/${encodeURIComponent(token)}`)
|
||||
Logger.info('paymentlink.resolve', { tokenLen: token.length })
|
||||
return data as PaymentLinkPayload
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(atob(token))
|
||||
return {
|
||||
order_id: json.order_id || token,
|
||||
nominal: Number(json.nominal) || 150000,
|
||||
customer: json.customer || {},
|
||||
expire_at: json.expire_at || Date.now() + 24 * 60 * 60 * 1000
|
||||
,
|
||||
allowed_methods: json.allowed_methods || undefined,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
order_id: token,
|
||||
nominal: 150000,
|
||||
expire_at: Date.now() + 24 * 60 * 60 * 1000
|
||||
,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +1,6 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
/* Define brand palette for Tailwind v4 utility tokens */
|
||||
@theme {
|
||||
--color-brand-50: #fef2f2;
|
||||
--color-brand-100: #fee2e2;
|
||||
--color-brand-200: #fecaca;
|
||||
--color-brand-300: #fca5a5;
|
||||
--color-brand-400: #f87171;
|
||||
--color-brand-500: #ef4444;
|
||||
--color-brand-600: #dc2626;
|
||||
--color-brand-700: #b91c1c;
|
||||
--color-brand-800: #991b1b;
|
||||
--color-brand-900: #7f1d1d;
|
||||
}
|
||||
/* Brand colors are defined in tailwind.config.ts under theme.extend.colors.brand */
|
||||
|
||||
:root {
|
||||
--radius: 8px;
|
||||
|
|
@ -25,12 +13,11 @@ body {
|
|||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
.dark body { background-color: #000000; color: #ffffff; }
|
||||
a { color: #dc2626; }
|
||||
a:hover { color: #b91c1c; }
|
||||
|
||||
.focus-ring { outline: none; box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px var(--focus-offset, #ffffff); }
|
||||
.dark .focus-ring { box-shadow: 0 0 0 2px rgba(220,38,38,1), 0 0 0 4px #000000; }
|
||||
a { color: #0c1f3f; }
|
||||
a:hover { color: #0a1a35; }
|
||||
|
||||
.focus-ring { outline: none; box-shadow: 0 0 0 2px rgba(37,99,235,1), 0 0 0 4px var(--focus-offset, #ffffff); }
|
||||
|
||||
.card {
|
||||
border-radius: var(--radius);
|
||||
|
|
@ -38,7 +25,3 @@ a:hover { color: #b91c1c; }
|
|||
background-color: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.dark .card {
|
||||
background-color: #000000;
|
||||
border-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
|
@ -7,20 +7,20 @@ export default {
|
|||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
50: '#f1f5fb',
|
||||
100: '#e3e9f5',
|
||||
200: '#c7d3ea',
|
||||
300: '#a6b9dd',
|
||||
400: '#6f8bc8',
|
||||
500: '#3a5da7',
|
||||
600: '#0c1f3f',
|
||||
700: '#0a1a35',
|
||||
800: '#08152a',
|
||||
900: '#050f20',
|
||||
},
|
||||
},
|
||||
boxShadow: {
|
||||
focus: '0 0 0 3px rgba(220,38,38,0.45)',
|
||||
focus: '0 0 0 3px rgba(37,99,235,0.45)',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"mercant_id": "REFNO-001",
|
||||
"timestamp": 1731300000000,
|
||||
"deskripsi": "Bayar Internet",
|
||||
"nominal": 200000,
|
||||
"nama": "Demo User",
|
||||
"no_telepon": "081234567890",
|
||||
"email": "demo@example.com",
|
||||
"item": [
|
||||
{ "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
417582e9fb7105b479e3e7aee99a285dbee0f2ec3238869f8f6fc36b6a098dbee411cf0d3e7637b69f41803518e640a6c9ae71a66b414b29e2182f5aed2ea55a
|
||||
Binary file not shown.
|
|
@ -0,0 +1 @@
|
|||
e781ba511b1675c05974b45db5f9ddc108d6d2d0acd62ba47fa4125094000512baf9b147689254ac88c406aade53921c9e7e3ae35c154809bdd7723014264667
|
||||
Loading…
Reference in New Issue