feat: update global styles and Tailwind configuration with Inter font; remove obsolete test files and add server documentation
- Added 'Inter' font to global CSS and Tailwind config for improved typography. - Deleted unused test files related to payment link creation and Snap token testing. - Created comprehensive README for server setup, API endpoints, and payment flow. - Added testing documentation for easier integration and usage of the payment system.
This commit is contained in:
parent
29b28a389e
commit
c32ac1882d
|
|
@ -32,3 +32,7 @@ docs/
|
||||||
*.sw?
|
*.sw?
|
||||||
.bmad/
|
.bmad/
|
||||||
.trae/
|
.trae/
|
||||||
|
|
||||||
|
# Temporary test files
|
||||||
|
temp/
|
||||||
|
!temp/README.md
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@
|
||||||
<link rel="icon" type="image/png" href="/simaya.png"/>
|
<link rel="icon" type="image/png" href="/simaya.png"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Simaya Midtrans | Retail Payment</title>
|
<title>Simaya Midtrans | Retail Payment</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1783,6 +1784,7 @@
|
||||||
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1793,6 +1795,7 @@
|
||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
|
|
@ -1853,6 +1856,7 @@
|
||||||
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.46.3",
|
"@typescript-eslint/scope-manager": "8.46.3",
|
||||||
"@typescript-eslint/types": "8.46.3",
|
"@typescript-eslint/types": "8.46.3",
|
||||||
|
|
@ -2139,6 +2143,7 @@
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -2332,6 +2337,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.19",
|
"baseline-browser-mapping": "^2.8.19",
|
||||||
"caniuse-lite": "^1.0.30001751",
|
"caniuse-lite": "^1.0.30001751",
|
||||||
|
|
@ -2822,6 +2828,7 @@
|
||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -4394,6 +4401,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
|
|
@ -4440,6 +4448,7 @@
|
||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
|
|
@ -4639,6 +4648,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -4648,6 +4658,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5089,7 +5100,8 @@
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
|
||||||
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
|
|
@ -5146,6 +5158,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5248,6 +5261,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -5359,6 +5373,7 @@
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -5452,6 +5467,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,490 @@
|
||||||
|
# Simaya Midtrans Payment Server
|
||||||
|
|
||||||
|
Backend Express.js server untuk integrasi pembayaran Midtrans dengan sistem ERP.
|
||||||
|
|
||||||
|
## 📋 Daftar Isi
|
||||||
|
|
||||||
|
- [Fitur Utama](#fitur-utama)
|
||||||
|
- [Konfigurasi Environment](#konfigurasi-environment)
|
||||||
|
- [API Endpoints](#api-endpoints)
|
||||||
|
- [Payment Flow](#payment-flow)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Logging](#logging)
|
||||||
|
|
||||||
|
## 🚀 Fitur Utama
|
||||||
|
|
||||||
|
### 1. **Dual Mode Payment**
|
||||||
|
- **CORE API**: Bank Transfer, Credit Card, GoPay/QRIS, Convenience Store
|
||||||
|
- **SNAP**: Hosted payment interface dengan UI Midtrans
|
||||||
|
|
||||||
|
### 2. **Payment Link Generation**
|
||||||
|
- Generate secure payment link dengan signature validation
|
||||||
|
- Configurable TTL (Time To Live)
|
||||||
|
- Token-based authentication
|
||||||
|
|
||||||
|
### 3. **ERP Integration**
|
||||||
|
- Notifikasi otomatis ke sistem ERP setelah pembayaran sukses
|
||||||
|
- Multi-endpoint support (comma-separated URLs)
|
||||||
|
- Signature verification untuk keamanan
|
||||||
|
|
||||||
|
### 4. **Webhook Handler**
|
||||||
|
- Unified webhook untuk CORE dan SNAP
|
||||||
|
- Signature verification
|
||||||
|
- Idempotent notification handling
|
||||||
|
|
||||||
|
### 5. **Advanced Logging**
|
||||||
|
- Level-based logging (debug, info, warn, error)
|
||||||
|
- In-memory log buffer
|
||||||
|
- Payload masking untuk sensitive data
|
||||||
|
- Jakarta timezone (WIB/UTC+7)
|
||||||
|
|
||||||
|
## ⚙️ Konfigurasi Environment
|
||||||
|
|
||||||
|
### Midtrans Configuration
|
||||||
|
```env
|
||||||
|
# Required
|
||||||
|
MIDTRANS_SERVER_KEY=your-server-key
|
||||||
|
MIDTRANS_CLIENT_KEY=your-client-key
|
||||||
|
MIDTRANS_IS_PRODUCTION=false
|
||||||
|
|
||||||
|
# Payment Method Toggles
|
||||||
|
ENABLE_BANK_TRANSFER=true
|
||||||
|
ENABLE_CREDIT_CARD=true
|
||||||
|
ENABLE_GOPAY=true
|
||||||
|
ENABLE_CSTORE=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Link Configuration
|
||||||
|
```env
|
||||||
|
# External API Access
|
||||||
|
EXTERNAL_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# Payment Link Settings
|
||||||
|
PAYMENT_LINK_SECRET=your-secret-for-signing
|
||||||
|
PAYMENT_LINK_TTL_MINUTES=1440
|
||||||
|
PAYMENT_LINK_BASE=http://localhost:5174/pay
|
||||||
|
```
|
||||||
|
|
||||||
|
### ERP Integration
|
||||||
|
```env
|
||||||
|
# Single URL (legacy)
|
||||||
|
ERP_NOTIFICATION_URL=https://your-erp.com/api/payment-notification
|
||||||
|
ERP_CLIENT_SECRET=your-erp-client-secret
|
||||||
|
|
||||||
|
# Multi-URL (recommended)
|
||||||
|
ERP_NOTIFICATION_URLS=https://erp1.com/api/notif,https://erp2.com/api/notif
|
||||||
|
|
||||||
|
# Toggle
|
||||||
|
ERP_ENABLE_NOTIF=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging Configuration
|
||||||
|
```env
|
||||||
|
# Logging Level: debug, info, warn, error
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Expose /api/logs endpoint (dev only)
|
||||||
|
LOG_EXPOSE_API=true
|
||||||
|
|
||||||
|
# In-memory buffer size
|
||||||
|
LOG_BUFFER_SIZE=1000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Configuration
|
||||||
|
```env
|
||||||
|
PORT=8000
|
||||||
|
NODE_ENV=development
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📡 API Endpoints
|
||||||
|
|
||||||
|
### Health & Config
|
||||||
|
|
||||||
|
#### `GET /api/health`
|
||||||
|
Health check endpoint.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"env": {
|
||||||
|
"isProduction": false,
|
||||||
|
"hasServerKey": true,
|
||||||
|
"hasClientKey": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/config`
|
||||||
|
Get current payment configuration.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paymentToggles": {
|
||||||
|
"bank_transfer": true,
|
||||||
|
"credit_card": true,
|
||||||
|
"gopay": true,
|
||||||
|
"cstore": true
|
||||||
|
},
|
||||||
|
"midtransEnv": "sandbox",
|
||||||
|
"clientKey": "SB-Mid-client-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/config` (Dev Only)
|
||||||
|
Update payment toggles at runtime.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paymentToggles": {
|
||||||
|
"bank_transfer": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Operations
|
||||||
|
|
||||||
|
#### `POST /api/payments/charge`
|
||||||
|
Create payment transaction via Midtrans Core API.
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_type": "bank_transfer",
|
||||||
|
"transaction_details": {
|
||||||
|
"order_id": "order-123",
|
||||||
|
"gross_amount": 150000
|
||||||
|
},
|
||||||
|
"bank_transfer": {
|
||||||
|
"bank": "bca"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status_code": "201",
|
||||||
|
"status_message": "Success",
|
||||||
|
"transaction_id": "xxx",
|
||||||
|
"order_id": "order-123",
|
||||||
|
"va_numbers": [
|
||||||
|
{
|
||||||
|
"bank": "bca",
|
||||||
|
"va_number": "12345678901"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /api/payments/snap/token`
|
||||||
|
Generate Snap token for hosted payment.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"transaction_details": {
|
||||||
|
"order_id": "order-123",
|
||||||
|
"gross_amount": 150000
|
||||||
|
},
|
||||||
|
"customer_details": {
|
||||||
|
"first_name": "John",
|
||||||
|
"email": "john@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "snap-token-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/payments/:orderId/status`
|
||||||
|
Check payment status.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status_code": "200",
|
||||||
|
"transaction_status": "settlement",
|
||||||
|
"order_id": "order-123",
|
||||||
|
"gross_amount": "150000.00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Payment Link
|
||||||
|
|
||||||
|
#### `POST /createtransaksi`
|
||||||
|
Generate payment link (external ERP endpoint).
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
```
|
||||||
|
X-API-KEY: your-external-api-key
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mercant_id": "merchant-001",
|
||||||
|
"nominal": 150000,
|
||||||
|
"nama": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"no_telepon": "081234567890",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"item_id": "product-123",
|
||||||
|
"nama": "Product Name",
|
||||||
|
"harga": 150000,
|
||||||
|
"qty": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allowed_methods": ["bank_transfer", "gopay"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "200",
|
||||||
|
"messages": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"url": "http://localhost:5174/pay/eyJ2Ijox..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /api/payment-links/:token`
|
||||||
|
Resolve payment link token.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": "merchant-001:product-123",
|
||||||
|
"nominal": 150000,
|
||||||
|
"customer": {
|
||||||
|
"name": "John Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"phone": "081234567890"
|
||||||
|
},
|
||||||
|
"expire_at": 1733280000000,
|
||||||
|
"allowed_methods": ["bank_transfer", "gopay"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Webhook
|
||||||
|
|
||||||
|
#### `POST /api/payments/notification`
|
||||||
|
Midtrans webhook handler (unified for CORE & SNAP).
|
||||||
|
|
||||||
|
**Request (from Midtrans):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": "order-123",
|
||||||
|
"transaction_status": "settlement",
|
||||||
|
"gross_amount": "150000.00",
|
||||||
|
"signature_key": "xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging (Dev Only)
|
||||||
|
|
||||||
|
#### `GET /api/logs?limit=100&level=info&q=payment`
|
||||||
|
Get recent logs.
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `limit`: Max entries (1-1000, default: 100)
|
||||||
|
- `level`: Filter by level (debug|info|warn|error)
|
||||||
|
- `q`: Search keyword
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 50,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"ts": "2024-12-04T12:00:00.000+07:00",
|
||||||
|
"level": "info",
|
||||||
|
"msg": "charge.request",
|
||||||
|
"meta": {
|
||||||
|
"id": "abc123",
|
||||||
|
"payment_type": "bank_transfer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing (Dev Only)
|
||||||
|
|
||||||
|
#### `POST /api/echo`
|
||||||
|
Echo endpoint untuk testing ERP notification.
|
||||||
|
|
||||||
|
#### `POST /api/test/notify-erp`
|
||||||
|
Manual trigger ERP notification.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"orderId": "order-123",
|
||||||
|
"nominal": "150000",
|
||||||
|
"mercant_id": "merchant-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Payment Flow
|
||||||
|
|
||||||
|
### 1. Payment Link Creation Flow
|
||||||
|
```
|
||||||
|
ERP System → POST /createtransaksi → Server generates token → Payment URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Payment Execution Flow
|
||||||
|
```
|
||||||
|
Customer → Payment URL → Frontend resolves token →
|
||||||
|
Choose method → POST /api/payments/charge or SNAP → Midtrans
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Payment Completion Flow
|
||||||
|
```
|
||||||
|
Midtrans → Webhook → Verify signature → Update ledger →
|
||||||
|
Notify ERP → Mark order complete
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. ERP Notification Format
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mercant_id": "merchant-001",
|
||||||
|
"status_code": "200",
|
||||||
|
"nominal": "150000",
|
||||||
|
"signature": "sha512-hash"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Signature Calculation:**
|
||||||
|
```
|
||||||
|
SHA512(mercant_id + status_code + nominal + ERP_CLIENT_SECRET)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
Lihat folder `tests/` untuk file-file testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test create payment link
|
||||||
|
node tests/test-create-payment-link.cjs
|
||||||
|
|
||||||
|
# Test frontend payload
|
||||||
|
node tests/test-frontend-payload.cjs
|
||||||
|
|
||||||
|
# Test snap token
|
||||||
|
node tests/test-snap-token.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Lihat `tests/README.md` untuk detail lengkap.
|
||||||
|
|
||||||
|
## 📝 Logging
|
||||||
|
|
||||||
|
### Log Levels
|
||||||
|
- **debug**: Detailed information, typically of interest only when diagnosing problems
|
||||||
|
- **info**: General informational messages
|
||||||
|
- **warn**: Warning messages for potentially harmful situations
|
||||||
|
- **error**: Error events that might still allow the application to continue running
|
||||||
|
|
||||||
|
### Log Format
|
||||||
|
```
|
||||||
|
[2024-12-04T12:00:00.000+07:00] [info] charge.request {"id": "abc123", "payment_type": "bank_transfer"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Log Events
|
||||||
|
|
||||||
|
#### Payment Lifecycle
|
||||||
|
- `charge.request`: Payment charge initiated
|
||||||
|
- `charge.success`: Charge successful
|
||||||
|
- `charge.error`: Charge failed
|
||||||
|
- `status.request`: Status check requested
|
||||||
|
- `webhook.received`: Webhook notification received
|
||||||
|
|
||||||
|
#### ERP Integration
|
||||||
|
- `erp.notify.start`: ERP notification started
|
||||||
|
- `erp.notify.success`: ERP notified successfully
|
||||||
|
- `erp.notify.error`: ERP notification failed
|
||||||
|
- `erp.notify.skip`: Notification skipped (already sent or disabled)
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- `webhook.signature.invalid`: Invalid webhook signature
|
||||||
|
- `createtransaksi.unauthorized`: Unauthorized API key
|
||||||
|
|
||||||
|
## 🔒 Security Features
|
||||||
|
|
||||||
|
1. **Signature Verification**
|
||||||
|
- Webhook signature validation
|
||||||
|
- Payment link token signing
|
||||||
|
- ERP notification signing
|
||||||
|
|
||||||
|
2. **Idempotency**
|
||||||
|
- Prevent duplicate order creation
|
||||||
|
- Prevent duplicate ERP notifications
|
||||||
|
- Block re-charge for pending orders
|
||||||
|
|
||||||
|
3. **API Key Authentication**
|
||||||
|
- External API key for `/createtransaksi`
|
||||||
|
- Dev mode fallback for easier local testing
|
||||||
|
|
||||||
|
4. **Payload Masking**
|
||||||
|
- Sensitive fields masked in logs
|
||||||
|
- Card numbers, CVV, tokens automatically hidden
|
||||||
|
|
||||||
|
## 🚀 Running the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start server
|
||||||
|
node server/index.cjs
|
||||||
|
|
||||||
|
# Server runs on http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 Related Documentation
|
||||||
|
|
||||||
|
- [Tests README](../tests/README.md) - Testing documentation
|
||||||
|
- [Temp Files README](../temp/README.md) - Temporary files info
|
||||||
|
- [Frontend README](../README.md) - Main project README
|
||||||
|
|
||||||
|
## 🐛 Common Issues
|
||||||
|
|
||||||
|
### Issue: "Transaction already pending"
|
||||||
|
**Cause**: Order ID already has pending transaction in Midtrans
|
||||||
|
**Solution**: Use existing payment instructions or create new order with different ID
|
||||||
|
|
||||||
|
### Issue: "ERP notification failed"
|
||||||
|
**Cause**: ERP endpoint unreachable or signature mismatch
|
||||||
|
**Solution**: Check `ERP_NOTIFICATION_URLS` and `ERP_CLIENT_SECRET` configuration
|
||||||
|
|
||||||
|
### Issue: "Invalid signature on webhook"
|
||||||
|
**Cause**: Incorrect server key or webhook from unauthorized source
|
||||||
|
**Solution**: Verify `MIDTRANS_SERVER_KEY` matches your Midtrans account
|
||||||
|
|
||||||
|
## 📞 Support
|
||||||
|
|
||||||
|
Untuk bantuan lebih lanjut, hubungi tim development atau lihat dokumentasi Midtrans:
|
||||||
|
- [Midtrans API Documentation](https://docs.midtrans.com/)
|
||||||
|
- [Midtrans Node.js Library](https://github.com/Midtrans/midtrans-nodejs-client)
|
||||||
328
server/index.cjs
328
server/index.cjs
|
|
@ -1,3 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Simaya Midtrans Payment Server
|
||||||
|
*
|
||||||
|
* Backend Express.js server untuk integrasi pembayaran Midtrans dengan sistem ERP.
|
||||||
|
* Mendukung CORE API dan SNAP modes dengan unified webhook handler.
|
||||||
|
*
|
||||||
|
* @author Simaya Team
|
||||||
|
* @version 2.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const cors = require('cors')
|
const cors = require('cors')
|
||||||
const dotenv = require('dotenv')
|
const dotenv = require('dotenv')
|
||||||
|
|
@ -7,8 +17,10 @@ const https = require('https')
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
// Import shared utilities (Note: This is a Node.js file, shared utilities are in TypeScript)
|
// ============================================================================
|
||||||
// We'll simulate the shared logger functionality here for now
|
// TRANSACTION LOGGER
|
||||||
|
// ============================================================================
|
||||||
|
// Shared logger functionality untuk tracking payment lifecycle
|
||||||
const TransactionLogger = {
|
const TransactionLogger = {
|
||||||
logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }),
|
logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }),
|
||||||
logPaymentSuccess: (mode, orderId, transactionId) => logInfo(`[${mode}] payment.success`, { orderId, transactionId }),
|
logPaymentSuccess: (mode, orderId, transactionId) => logInfo(`[${mode}] payment.success`, { orderId, transactionId }),
|
||||||
|
|
@ -17,10 +29,16 @@ const TransactionLogger = {
|
||||||
logWebhookProcessed: (mode, orderId, internalStatus) => logInfo(`[${mode}] webhook.processed`, { orderId, internalStatus })
|
logWebhookProcessed: (mode, orderId, internalStatus) => logInfo(`[${mode}] webhook.processed`, { orderId, internalStatus })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPRESS APP SETUP
|
||||||
|
// ============================================================================
|
||||||
const app = express()
|
const app = express()
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MIDTRANS CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
const isProduction = process.env.MIDTRANS_IS_PRODUCTION === 'true'
|
const isProduction = process.env.MIDTRANS_IS_PRODUCTION === 'true'
|
||||||
const serverKey = process.env.MIDTRANS_SERVER_KEY || ''
|
const serverKey = process.env.MIDTRANS_SERVER_KEY || ''
|
||||||
const clientKey = process.env.MIDTRANS_CLIENT_KEY || ''
|
const clientKey = process.env.MIDTRANS_CLIENT_KEY || ''
|
||||||
|
|
@ -29,13 +47,21 @@ if (!serverKey || !clientKey) {
|
||||||
console.warn('[Midtrans] Missing server/client keys in environment variables')
|
console.warn('[Midtrans] Missing server/client keys in environment variables')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Midtrans Core API client
|
||||||
const core = new midtransClient.CoreApi({
|
const core = new midtransClient.CoreApi({
|
||||||
isProduction,
|
isProduction,
|
||||||
serverKey,
|
serverKey,
|
||||||
clientKey,
|
clientKey,
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- ERP Notification Config
|
// ============================================================================
|
||||||
|
// ERP INTEGRATION CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse boolean-like environment variable values
|
||||||
|
* Supports: true/false, 1/0, yes/no, on/off
|
||||||
|
*/
|
||||||
function parseEnable(v) {
|
function parseEnable(v) {
|
||||||
if (typeof v === 'string') {
|
if (typeof v === 'string') {
|
||||||
const s = v.trim().toLowerCase()
|
const s = v.trim().toLowerCase()
|
||||||
|
|
@ -45,13 +71,10 @@ function parseEnable(v) {
|
||||||
if (typeof v === 'number') return v === 1
|
if (typeof v === 'number') return v === 1
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
|
|
||||||
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
|
|
||||||
// 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
|
/**
|
||||||
|
* Parse comma-separated list from environment variable
|
||||||
|
*/
|
||||||
function parseList(value) {
|
function parseList(value) {
|
||||||
if (!value) return []
|
if (!value) return []
|
||||||
return String(value)
|
return String(value)
|
||||||
|
|
@ -60,29 +83,58 @@ function parseList(value) {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ERP notification settings
|
||||||
|
const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || ''
|
||||||
|
const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF)
|
||||||
|
const ERP_CLIENT_SECRET = process.env.ERP_CLIENT_SECRET || process.env.ERP_CLIENT_ID || ''
|
||||||
|
|
||||||
|
// Support multiple ERP endpoints (comma-separated)
|
||||||
const ERP_NOTIFICATION_URLS = (() => {
|
const ERP_NOTIFICATION_URLS = (() => {
|
||||||
const multi = parseList(process.env.ERP_NOTIFICATION_URLS)
|
const multi = parseList(process.env.ERP_NOTIFICATION_URLS)
|
||||||
if (multi.length > 0) return multi
|
if (multi.length > 0) return multi
|
||||||
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
|
return ERP_NOTIFICATION_URL ? [ERP_NOTIFICATION_URL] : []
|
||||||
})()
|
})()
|
||||||
|
|
||||||
// --- Logger utilities
|
// In-memory tracking untuk prevent duplicate notifications
|
||||||
|
const notifiedOrders = new Set()
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOGGING UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
const LOG_LEVEL = (process.env.LOG_LEVEL || 'info').toLowerCase()
|
||||||
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
const levelOrder = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||||
const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
|
const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
|
||||||
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
|
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
|
||||||
const recentLogs = []
|
const recentLogs = []
|
||||||
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
|
|
||||||
|
/**
|
||||||
|
* Check if message should be logged based on configured level
|
||||||
|
*/
|
||||||
|
function shouldLog(level) {
|
||||||
|
return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current timestamp in Jakarta timezone (WIB/UTC+7)
|
||||||
|
*/
|
||||||
function ts() {
|
function ts() {
|
||||||
// Jakarta timezone (WIB = UTC+7)
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const jakartaOffset = 7 * 60 // minutes
|
const jakartaOffset = 7 * 60 // minutes
|
||||||
const localTime = new Date(now.getTime() + jakartaOffset * 60 * 1000)
|
const localTime = new Date(now.getTime() + jakartaOffset * 60 * 1000)
|
||||||
return localTime.toISOString().replace('Z', '+07:00')
|
return localTime.toISOString().replace('Z', '+07:00')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize object untuk logging (deep clone)
|
||||||
|
*/
|
||||||
function sanitize(obj) {
|
function sanitize(obj) {
|
||||||
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive fields dalam payload (card numbers, CVV, tokens, etc.)
|
||||||
|
*/
|
||||||
function maskPayload(obj) {
|
function maskPayload(obj) {
|
||||||
const o = sanitize(obj)
|
const o = sanitize(obj)
|
||||||
const maskKeys = ['card_number', 'cvv', 'token_id', 'server_key']
|
const maskKeys = ['card_number', 'cvv', 'token_id', 'server_key']
|
||||||
|
|
@ -97,6 +149,10 @@ function maskPayload(obj) {
|
||||||
}
|
}
|
||||||
return mask(o)
|
return mask(o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core logging function dengan in-memory buffer
|
||||||
|
*/
|
||||||
function log(level, msg, meta) {
|
function log(level, msg, meta) {
|
||||||
if (!shouldLog(level)) return
|
if (!shouldLog(level)) return
|
||||||
const line = `[${ts()}] [${level}] ${msg}`
|
const line = `[${ts()}] [${level}] ${msg}`
|
||||||
|
|
@ -112,15 +168,25 @@ function log(level, msg, meta) {
|
||||||
console.log(line)
|
console.log(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convenience logging functions
|
||||||
const logDebug = (m, meta) => log('debug', m, meta)
|
const logDebug = (m, meta) => log('debug', m, meta)
|
||||||
const logInfo = (m, meta) => log('info', m, meta)
|
const logInfo = (m, meta) => log('info', m, meta)
|
||||||
const logWarn = (m, meta) => log('warn', m, meta)
|
const logWarn = (m, meta) => log('warn', m, meta)
|
||||||
const logError = (m, meta) => log('error', m, meta)
|
const logError = (m, meta) => log('error', m, meta)
|
||||||
|
|
||||||
// Request ID + basic request/response logging
|
// ============================================================================
|
||||||
|
// REQUEST TRACKING MIDDLEWARE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique request ID untuk tracking
|
||||||
|
*/
|
||||||
function newReqId() {
|
function newReqId() {
|
||||||
return Math.random().toString(36).slice(2) + Date.now().toString(36)
|
return Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request/response logging middleware
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
req.id = newReqId()
|
req.id = newReqId()
|
||||||
logInfo('req.start', { id: req.id, method: req.method, url: req.originalUrl })
|
logInfo('req.start', { id: req.id, method: req.method, url: req.originalUrl })
|
||||||
|
|
@ -130,6 +196,10 @@ app.use((req, res, next) => {
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAYMENT METHOD TOGGLES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const ENABLE = {
|
const ENABLE = {
|
||||||
bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER),
|
bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER),
|
||||||
credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD),
|
credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD),
|
||||||
|
|
@ -137,16 +207,29 @@ const ENABLE = {
|
||||||
cstore: parseEnable(process.env.ENABLE_CSTORE),
|
cstore: parseEnable(process.env.ENABLE_CSTORE),
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Payment Link Config
|
// ============================================================================
|
||||||
|
// PAYMENT LINK CONFIGURATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''
|
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || ''
|
||||||
const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || ''
|
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_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 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' }
|
// In-memory storage
|
||||||
|
const activeOrders = new Map() // order_id -> expire_at
|
||||||
|
const orderMerchantId = new Map() // order_id -> mercant_id (untuk dynamic ERP notification)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if running in development environment
|
||||||
|
*/
|
||||||
|
function isDevEnv() {
|
||||||
|
return (process.env.NODE_ENV || '').toLowerCase() !== 'production'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify external API key dari request header
|
||||||
|
*/
|
||||||
function verifyExternalKey(req) {
|
function verifyExternalKey(req) {
|
||||||
const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString()
|
const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString()
|
||||||
if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY
|
if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY
|
||||||
|
|
@ -154,45 +237,93 @@ function verifyExternalKey(req) {
|
||||||
return isDevEnv()
|
return isDevEnv()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PAYMENT LINK TOKEN UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode data to base64url format (URL-safe)
|
||||||
|
*/
|
||||||
function base64UrlEncode(buf) {
|
function base64UrlEncode(buf) {
|
||||||
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode base64url format to string
|
||||||
|
*/
|
||||||
function base64UrlDecode(str) {
|
function base64UrlDecode(str) {
|
||||||
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4))
|
||||||
const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad
|
const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad
|
||||||
return Buffer.from(s, 'base64').toString('utf8')
|
return Buffer.from(s, 'base64').toString('utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute HMAC-SHA256 signature untuk payment link token
|
||||||
|
*/
|
||||||
function computeTokenSignature(orderId, nominal, expireAt) {
|
function computeTokenSignature(orderId, nominal, expireAt) {
|
||||||
const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}`
|
const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}`
|
||||||
return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex')
|
return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create signed payment link token
|
||||||
|
* @returns {string} Base64url-encoded signed token
|
||||||
|
*/
|
||||||
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
|
function createPaymentLinkToken({ order_id, nominal, expire_at, customer, allowed_methods }) {
|
||||||
const v = 1
|
const v = 1 // Token version
|
||||||
const sig = computeTokenSignature(order_id, nominal, expire_at)
|
const sig = computeTokenSignature(order_id, nominal, expire_at)
|
||||||
const payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods }
|
const payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods }
|
||||||
return base64UrlEncode(JSON.stringify(payload))
|
return base64UrlEncode(JSON.stringify(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve dan validate payment link token
|
||||||
|
* @returns {object} { error?, payload? }
|
||||||
|
*/
|
||||||
function resolvePaymentLinkToken(token) {
|
function resolvePaymentLinkToken(token) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(base64UrlDecode(token))
|
const json = JSON.parse(base64UrlDecode(token))
|
||||||
const { order_id, nominal, expire_at, sig } = json || {}
|
const { order_id, nominal, expire_at, sig } = json || {}
|
||||||
if (!order_id || !nominal || !expire_at || !sig) return { error: 'INVALID_TOKEN' }
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!order_id || !nominal || !expire_at || !sig) {
|
||||||
|
return { error: 'INVALID_TOKEN' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
const expected = computeTokenSignature(order_id, nominal, expire_at)
|
const expected = computeTokenSignature(order_id, nominal, expire_at)
|
||||||
if (String(sig) !== String(expected)) return { error: 'INVALID_SIGNATURE' }
|
if (String(sig) !== String(expected)) {
|
||||||
if (Date.now() > Number(expire_at)) return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
|
return { error: 'INVALID_SIGNATURE' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiration
|
||||||
|
if (Date.now() > Number(expire_at)) {
|
||||||
|
return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } }
|
||||||
|
}
|
||||||
|
|
||||||
return { payload: json }
|
return { payload: json }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { error: 'TOKEN_PARSE_ERROR', message: e?.message }
|
return { error: 'TOKEN_PARSE_ERROR', message: e?.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health check
|
// ============================================================================
|
||||||
|
// API ENDPOINTS - HEALTH & CONFIG
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check endpoint
|
||||||
|
* GET /api/health
|
||||||
|
*/
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
logDebug('health.check', { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey })
|
||||||
res.json({ ok: true, env: { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey } })
|
res.json({ ok: true, env: { isProduction, hasServerKey: !!serverKey, hasClientKey: !!clientKey } })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Runtime config (feature toggles)
|
/**
|
||||||
|
* Get runtime configuration
|
||||||
|
* GET /api/config
|
||||||
|
*/
|
||||||
app.get('/api/config', (_req, res) => {
|
app.get('/api/config', (_req, res) => {
|
||||||
const payload = {
|
const payload = {
|
||||||
paymentToggles: { ...ENABLE },
|
paymentToggles: { ...ENABLE },
|
||||||
|
|
@ -203,7 +334,10 @@ app.get('/api/config', (_req, res) => {
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Logs endpoint (dev/debug): GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
|
/**
|
||||||
|
* Get recent logs (dev/debug only)
|
||||||
|
* GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword
|
||||||
|
*/
|
||||||
app.get('/api/logs', (req, res) => {
|
app.get('/api/logs', (req, res) => {
|
||||||
if (!LOG_EXPOSE_API) {
|
if (!LOG_EXPOSE_API) {
|
||||||
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' })
|
||||||
|
|
@ -223,7 +357,10 @@ app.get('/api/logs', (req, res) => {
|
||||||
res.json({ count: sliced.length, items: sliced })
|
res.json({ count: sliced.length, items: sliced })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dev-only: allow updating toggles at runtime without restart
|
/**
|
||||||
|
* Update payment toggles at runtime (dev only)
|
||||||
|
* POST /api/config
|
||||||
|
*/
|
||||||
app.post('/api/config', (req, res) => {
|
app.post('/api/config', (req, res) => {
|
||||||
const isDev = process.env.NODE_ENV !== 'production'
|
const isDev = process.env.NODE_ENV !== 'production'
|
||||||
if (!isDev) return res.status(403).json({ error: 'FORBIDDEN', message: 'Runtime config updates disabled in production.' })
|
if (!isDev) return res.status(403).json({ error: 'FORBIDDEN', message: 'Runtime config updates disabled in production.' })
|
||||||
|
|
@ -239,7 +376,14 @@ app.post('/api/config', (req, res) => {
|
||||||
res.json(result)
|
res.json(result)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Payment Link Resolver: GET /api/payment-links/:token
|
// ============================================================================
|
||||||
|
// API ENDPOINTS - PAYMENT LINKS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve payment link token
|
||||||
|
* GET /api/payment-links/:token
|
||||||
|
*/
|
||||||
app.get('/api/payment-links/:token', (req, res) => {
|
app.get('/api/payment-links/:token', (req, res) => {
|
||||||
const { token } = req.params
|
const { token } = req.params
|
||||||
const result = resolvePaymentLinkToken(token)
|
const result = resolvePaymentLinkToken(token)
|
||||||
|
|
@ -262,7 +406,14 @@ app.get('/api/payment-links/:token', (req, res) => {
|
||||||
res.json({ order_id: p.order_id, nominal: p.nominal, customer: p.customer, expire_at: p.expire_at, allowed_methods: p.allowed_methods })
|
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)
|
// ============================================================================
|
||||||
|
// API ENDPOINTS - PAYMENT OPERATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create payment transaction via Midtrans Core API
|
||||||
|
* POST /api/payments/charge
|
||||||
|
*/
|
||||||
app.post('/api/payments/charge', async (req, res) => {
|
app.post('/api/payments/charge', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pt = req?.body?.payment_type
|
const pt = req?.body?.payment_type
|
||||||
|
|
@ -321,7 +472,10 @@ app.post('/api/payments/charge', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Snap token endpoint (for hosted payment interface)
|
/**
|
||||||
|
* Generate Snap token for hosted payment interface
|
||||||
|
* POST /api/payments/snap/token
|
||||||
|
*/
|
||||||
app.post('/api/payments/snap/token', async (req, res) => {
|
app.post('/api/payments/snap/token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const snap = new midtransClient.Snap({
|
const snap = new midtransClient.Snap({
|
||||||
|
|
@ -339,7 +493,10 @@ app.post('/api/payments/snap/token', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Status endpoint (by order_id)
|
/**
|
||||||
|
* Check payment status by order ID
|
||||||
|
* GET /api/payments/:orderId/status
|
||||||
|
*/
|
||||||
app.get('/api/payments/:orderId/status', async (req, res) => {
|
app.get('/api/payments/:orderId/status', async (req, res) => {
|
||||||
const { orderId } = req.params
|
const { orderId } = req.params
|
||||||
try {
|
try {
|
||||||
|
|
@ -379,7 +536,15 @@ app.get('/api/payments/:orderId/status', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// External ERP Create Transaction → issue payment link
|
// ============================================================================
|
||||||
|
// API ENDPOINTS - ERP INTEGRATION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External ERP endpoint to create payment link
|
||||||
|
* POST /createtransaksi
|
||||||
|
* Requires: X-API-KEY header
|
||||||
|
*/
|
||||||
app.post('/createtransaksi', async (req, res) => {
|
app.post('/createtransaksi', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!verifyExternalKey(req)) {
|
if (!verifyExternalKey(req)) {
|
||||||
|
|
@ -472,8 +637,14 @@ app.post('/createtransaksi', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Unified Webhook Helpers for Core & Snap
|
// ============================================================================
|
||||||
|
// WEBHOOK UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify webhook signature from Midtrans
|
||||||
|
* Supports both CORE and SNAP modes
|
||||||
|
*/
|
||||||
function verifyWebhookSignature(body, mode) {
|
function verifyWebhookSignature(body, mode) {
|
||||||
try {
|
try {
|
||||||
const orderId = body?.order_id
|
const orderId = body?.order_id
|
||||||
|
|
@ -500,6 +671,9 @@ function verifyWebhookSignature(body, mode) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Midtrans transaction status to internal status
|
||||||
|
*/
|
||||||
function mapStatusToInternal(transactionStatus, mode) {
|
function mapStatusToInternal(transactionStatus, mode) {
|
||||||
// Unified status mapping - both Core and Snap use similar status values
|
// Unified status mapping - both Core and Snap use similar status values
|
||||||
const status = (transactionStatus || '').toLowerCase()
|
const status = (transactionStatus || '').toLowerCase()
|
||||||
|
|
@ -523,6 +697,10 @@ function mapStatusToInternal(transactionStatus, mode) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update payment ledger (in-memory for now)
|
||||||
|
* TODO: Integrate with database in production
|
||||||
|
*/
|
||||||
function updateLedger(orderId, data) {
|
function updateLedger(orderId, data) {
|
||||||
// Simple in-memory ledger for now - can be enhanced to use database
|
// Simple in-memory ledger for now - can be enhanced to use database
|
||||||
// In production, this would update a proper ledger/payment database
|
// In production, this would update a proper ledger/payment database
|
||||||
|
|
@ -542,6 +720,10 @@ function updateLedger(orderId, data) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process payment completion and trigger ERP notifications
|
||||||
|
* Unified handler for both CORE and SNAP modes
|
||||||
|
*/
|
||||||
async function processPaymentCompletion(orderId, internalStatus, mode, body) {
|
async function processPaymentCompletion(orderId, internalStatus, mode, body) {
|
||||||
try {
|
try {
|
||||||
logInfo(`[${mode}] payment.process`, {
|
logInfo(`[${mode}] payment.process`, {
|
||||||
|
|
@ -588,6 +770,10 @@ async function processPaymentCompletion(orderId, internalStatus, mode, body) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute Midtrans webhook signature (SHA-512)
|
||||||
|
*/
|
||||||
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||||
try {
|
try {
|
||||||
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
|
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
|
||||||
|
|
@ -597,6 +783,9 @@ function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Midtrans status indicates successful payment
|
||||||
|
*/
|
||||||
function isSuccessfulMidtransStatus(body) {
|
function isSuccessfulMidtransStatus(body) {
|
||||||
const s = (body?.transaction_status || '').toLowerCase()
|
const s = (body?.transaction_status || '').toLowerCase()
|
||||||
const fraud = (body?.fraud_status || '').toLowerCase()
|
const fraud = (body?.fraud_status || '').toLowerCase()
|
||||||
|
|
@ -606,6 +795,13 @@ function isSuccessfulMidtransStatus(body) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// HTTP UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom HTTP Error class
|
||||||
|
*/
|
||||||
class HttpError extends Error {
|
class HttpError extends Error {
|
||||||
constructor(statusCode, body) {
|
constructor(statusCode, body) {
|
||||||
super(`HTTP ${statusCode}`)
|
super(`HTTP ${statusCode}`)
|
||||||
|
|
@ -614,6 +810,10 @@ class HttpError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP POST request helper with JSON payload
|
||||||
|
* Returns promise with status and body
|
||||||
|
*/
|
||||||
function postJson(url, data, extraHeaders = {}) {
|
function postJson(url, data, extraHeaders = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -669,6 +869,13 @@ function postJson(url, data, extraHeaders = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ERP NOTIFICATION UTILITIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute ERP notification signature (SHA-512)
|
||||||
|
*/
|
||||||
async function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
async function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
||||||
try {
|
try {
|
||||||
const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId)
|
const raw = String(mercantId) + String(statusCode) + String(nominal) + String(clientId)
|
||||||
|
|
@ -679,10 +886,13 @@ async function computeErpSignature(mercantId, statusCode, nominal, clientId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve mercant_id untuk sebuah order_id:
|
/**
|
||||||
// 1) gunakan map yang tersimpan dari createtransaksi
|
* Resolve mercant_id from order_id
|
||||||
// 2) jika order_id memakai skema "mercant_id:item_id", ambil prefix sebelum ':'
|
* Strategy:
|
||||||
// 3) fallback ke ERP_MERCANT_ID dari env (untuk kasus lama)
|
* 1. Check in-memory map from createtransaksi
|
||||||
|
* 2. Parse "mercant_id:item_id" pattern
|
||||||
|
* 3. Return empty string if not found
|
||||||
|
*/
|
||||||
function resolveMercantId(orderId) {
|
function resolveMercantId(orderId) {
|
||||||
try {
|
try {
|
||||||
if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId)
|
if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId)
|
||||||
|
|
@ -694,6 +904,10 @@ function resolveMercantId(orderId) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send payment notification to ERP system(s)
|
||||||
|
* Supports multiple endpoints via ERP_NOTIFICATION_URLS
|
||||||
|
*/
|
||||||
async function notifyERP({ orderId, nominal, mercantId }) {
|
async function notifyERP({ orderId, nominal, mercantId }) {
|
||||||
if (!ERP_ENABLE_NOTIF) {
|
if (!ERP_ENABLE_NOTIF) {
|
||||||
logInfo('erp.notify.skip', { reason: 'disabled' })
|
logInfo('erp.notify.skip', { reason: 'disabled' })
|
||||||
|
|
@ -751,7 +965,15 @@ async function notifyERP({ orderId, nominal, mercantId }) {
|
||||||
return okCount > 0
|
return okCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Webhook endpoint for Midtrans notifications (Core & Snap unified)
|
// ============================================================================
|
||||||
|
// WEBHOOK ENDPOINT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified webhook endpoint for Midtrans notifications
|
||||||
|
* Handles both CORE and SNAP mode webhooks
|
||||||
|
* POST /api/payments/notification
|
||||||
|
*/
|
||||||
app.post('/api/payments/notification', async (req, res) => {
|
app.post('/api/payments/notification', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body || {}
|
const body = req.body || {}
|
||||||
|
|
@ -793,9 +1015,15 @@ app.post('/api/payments/notification', async (req, res) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dev-only helpers: echo endpoint and manual ERP notify trigger
|
// ============================================================================
|
||||||
|
// DEV/TEST ENDPOINTS (only when LOG_EXPOSE_API=true)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
if (LOG_EXPOSE_API) {
|
if (LOG_EXPOSE_API) {
|
||||||
// Echo incoming JSON for local testing (can be used as ERP_NOTIFICATION_URL)
|
/**
|
||||||
|
* Echo endpoint for testing ERP notifications
|
||||||
|
* POST /api/echo
|
||||||
|
*/
|
||||||
app.post('/api/echo', async (req, res) => {
|
app.post('/api/echo', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body || {}
|
const body = req.body || {}
|
||||||
|
|
@ -812,7 +1040,10 @@ if (LOG_EXPOSE_API) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Echo kedua untuk pengujian multi-URL lokal
|
/**
|
||||||
|
* Second echo endpoint for multi-URL testing
|
||||||
|
* POST /api/echo2
|
||||||
|
*/
|
||||||
app.post('/api/echo2', async (req, res) => {
|
app.post('/api/echo2', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const body = req.body || {}
|
const body = req.body || {}
|
||||||
|
|
@ -830,7 +1061,10 @@ if (LOG_EXPOSE_API) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Manually trigger ERP notification for testing signature presence in body
|
/**
|
||||||
|
* Manually trigger ERP notification for testing
|
||||||
|
* POST /api/test/notify-erp
|
||||||
|
*/
|
||||||
app.post('/api/test/notify-erp', async (req, res) => {
|
app.post('/api/test/notify-erp', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { orderId, nominal, mercant_id } = req.body || {}
|
const { orderId, nominal, mercant_id } = req.body || {}
|
||||||
|
|
@ -846,7 +1080,19 @@ if (LOG_EXPOSE_API) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER STARTUP
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const port = process.env.PORT || 8000
|
const port = process.env.PORT || 8000
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
console.log(`[server] listening on http://localhost:${port}/ (production=${isProduction})`)
|
||||||
|
console.log('[server] Ready to accept requests')
|
||||||
|
logInfo('server.started', {
|
||||||
|
port,
|
||||||
|
isProduction,
|
||||||
|
enabledMethods: Object.keys(ENABLE).filter(k => ENABLE[k]),
|
||||||
|
erpEnabled: ERP_ENABLE_NOTIF,
|
||||||
|
erpEndpoints: ERP_NOTIFICATION_URLS.length
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -30,43 +30,43 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e
|
||||||
}
|
}
|
||||||
const items = baseItems.filter((it) => enabledMap[it.key])
|
const items = baseItems.filter((it) => enabledMap[it.key])
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
<div className="text-sm font-medium">Metode pembayaran</div>
|
<div className="text-xs sm:text-sm font-medium">Metode pembayaran</div>
|
||||||
<div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
|
<div className="rounded-lg border-2 border-black/30 divide-y-[2px] divide-black/20 bg-white">
|
||||||
{items.map((it) => (
|
{items.map((it) => (
|
||||||
<div key={it.key}>
|
<div key={it.key}>
|
||||||
<button
|
<button
|
||||||
onClick={() => !disabled && onSelect(it.key)}
|
onClick={() => !disabled && onSelect(it.key)}
|
||||||
disabled={disabled}
|
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'} ${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`}
|
className={`w-full text-left p-3 sm:p-4 min-h-[48px] sm: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-pressed={selected === it.key}
|
||||||
aria-expanded={selected === it.key}
|
aria-expanded={selected === it.key}
|
||||||
aria-controls={`panel-${it.key}`}
|
aria-controls={`panel-${it.key}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-base font-semibold text-black">{it.title}</div>
|
<div className="text-sm sm:text-base font-semibold text-black">{it.title}</div>
|
||||||
{it.key === 'bank_transfer' && it.subtitle && (
|
{it.key === 'bank_transfer' && it.subtitle && (
|
||||||
<div className="mt-1 text-xs text-black/60">
|
<div className="mt-0.5 sm:mt-1 text-[10px] sm:text-xs text-black/60">
|
||||||
{it.subtitle}
|
{it.subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{it.key === 'cpay' && it.subtitle && (
|
{it.key === 'cpay' && it.subtitle && (
|
||||||
<div className="mt-1 text-xs text-black/60">
|
<div className="mt-0.5 sm:mt-1 text-[10px] sm:text-xs text-black/60">
|
||||||
{it.subtitle}
|
{it.subtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
{it.icon && (
|
{it.icon && (
|
||||||
<span aria-hidden>
|
<span aria-hidden>
|
||||||
{it.icon}
|
{it.icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span aria-hidden className={`text-black/60 text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
<span aria-hidden className={`text-black/60 text-base sm:text-lg transition-transform ${selected === it.key ? 'rotate-90' : ''}`}>›</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{selected === it.key && renderPanel && (
|
{selected === it.key && renderPanel && (
|
||||||
<div id={`panel-${it.key}`} className="p-3 bg-white">
|
<div id={`panel-${it.key}`} className="p-2.5 sm:p-3 bg-white">
|
||||||
{renderPanel(it.key)}
|
{renderPanel(it.key)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export interface PaymentSheetProps {
|
||||||
showStatusCTA?: boolean
|
showStatusCTA?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, customerName, children, showStatusCTA = true }: PaymentSheetProps) {
|
export function PaymentSheet({ orderId, amount, customerName, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||||
const [expanded, setExpanded] = React.useState(true)
|
const [expanded, setExpanded] = React.useState(true)
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md">
|
<div className="max-w-md">
|
||||||
|
|
@ -23,10 +23,12 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, custome
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-[#0c1f3f] text-white p-3 sm:p-4 flex items-center justify-between">
|
<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="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>
|
<img
|
||||||
SIMAYA
|
src="/simaya.png"
|
||||||
</div>
|
alt="SIMAYA"
|
||||||
<div className="font-semibold text-sm sm:text-base">{merchantName}</div>
|
className="h-8 w-8 sm:h-10 sm:w-10 rounded bg-white object-contain p-1"
|
||||||
|
/>
|
||||||
|
<div className="font-semibold text-sm sm:text-base text-white">Simaya Retail Payment</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||||
|
|
@ -41,26 +43,26 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, custome
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<div className="p-4 border-b border-black/10 flex items-start justify-between">
|
<div className="p-3 sm:p-4 border-b border-black/10 flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-black">Total</div>
|
<div className="text-[10px] sm:text-xs text-black">Total</div>
|
||||||
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
<div className="text-lg sm:text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
||||||
<div className="text-xs text-black/60">Order ID #{orderId}</div>
|
<div className="text-[10px] sm:text-xs text-black/60">Order ID #{orderId}</div>
|
||||||
{customerName && <div className="text-xs text-black/60 mt-1">Nama: {customerName}</div>}
|
{customerName && <div className="text-[10px] sm:text-xs text-black/60 mt-1">Nama: {customerName}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-3 sm:p-4">
|
||||||
{children}
|
{children}
|
||||||
<TrustStrip location="sheet" />
|
<TrustStrip location="sheet" />
|
||||||
</div>
|
</div>
|
||||||
{showStatusCTA && (
|
{showStatusCTA && (
|
||||||
<div className="sticky bottom-0 bg-white/95 backdrop-blur border-t border-black/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-2.5 sm:p-3 pb-[env(safe-area-inset-bottom)]">
|
||||||
<Link
|
<Link
|
||||||
to={`/payments/${orderId}/status`}
|
to={`/payments/${orderId}/status`}
|
||||||
aria-label="Buka halaman Status Pembayaran"
|
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"
|
className="w-full block text-center rounded bg-[#0c1f3f] !text-white py-2.5 sm:py-3 text-sm sm: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
|
Cek status pembayaran
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -145,15 +145,15 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
<LoadingOverlay isLoading={busy} message="Sedang membuat kode pembayaran..." />
|
||||||
<div className="space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
<div className="font-medium">Transfer Bank</div>
|
<div className="font-medium text-sm sm:text-base">Transfer Bank</div>
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="flex items-center gap-2 text-base">
|
<div className="flex items-center gap-2 text-sm sm:text-base">
|
||||||
<span className="text-black/60">Bank:</span>
|
<span className="text-black/60">Bank:</span>
|
||||||
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
<span className="text-black/80 font-semibold">{selected.toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
<div className="text-xs sm:text-sm text-black/70">VA dibuat otomatis sesuai bank pilihan Anda.</div>
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<Alert title="Gagal membuat VA">
|
<Alert title="Gagal membuat VA">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|
@ -165,31 +165,31 @@ export function BankTransferPanel({ orderId, amount, locked, onChargeInitiated,
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{selected && (
|
{selected && (
|
||||||
<div className="pt-1">
|
<div className="pt-0.5 sm:pt-1">
|
||||||
<div className="rounded-lg p-3 border-2 border-black/30">
|
<div className="rounded-lg p-2.5 sm:p-3 border-2 border-black/30">
|
||||||
<div className="text-sm font-medium mb-2">Virtual Account</div>
|
<div className="text-xs sm:text-sm font-medium mb-1.5 sm:mb-2">Virtual Account</div>
|
||||||
<div className="text-sm text-black/70">
|
<div className="text-xs sm:text-sm text-black/70">
|
||||||
{vaCode ? (
|
{vaCode ? (
|
||||||
<span>
|
<span>
|
||||||
Nomor VA:
|
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">{vaCode}</span>
|
<span className="block break-all mt-1 font-mono text-lg sm:text-xl md:text-2xl lg:text-3xl font-semibold tracking-normal text-black">{vaCode}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="inline-flex items-center gap-2" role="status" aria-live="polite">
|
<span className="inline-flex items-center gap-1.5 sm:gap-2" role="status" aria-live="polite">
|
||||||
{busy && <span className="h-3 w-3 animate-spin rounded-full border-2 border-black/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.'}
|
{busy ? 'Membuat VA…' : 'VA akan muncul otomatis setelah transaksi dibuat.'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{billKey && (
|
{billKey && (
|
||||||
<span className="ml-3">Bill Key: <span className="font-mono text-lg font-semibold text-black">{billKey}</span></span>
|
<span className="ml-2 sm:ml-3 block sm:inline mt-1 sm:mt-0">Bill Key: <span className="font-mono text-base sm:text-lg font-semibold text-black">{billKey}</span></span>
|
||||||
)}
|
)}
|
||||||
{billerCode && (
|
{billerCode && (
|
||||||
<span className="ml-3">Biller Code: <span className="font-mono text-lg font-semibold text-black">{billerCode}</span></span>
|
<span className="ml-2 sm:ml-3 block sm:inline mt-1 sm:mt-0">Biller Code: <span className="font-mono text-base sm:text-lg font-semibold text-black">{billerCode}</span></span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
<Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode}>Copy VA</Button>
|
<Button variant="secondary" size="sm" onClick={() => copy(vaCode, 'VA')} disabled={!vaCode} className="text-xs sm:text-sm">Copy VA</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey}>Copy Bill Key</Button>
|
<Button variant="secondary" size="sm" onClick={() => copy(billKey, 'Bill Key')} disabled={!billKey} className="text-xs sm:text-sm">Copy Bill Key</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,7 @@ export function CheckoutPage() {
|
||||||
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
||||||
const [isBusy, setIsBusy] = React.useState(false)
|
const [isBusy, setIsBusy] = React.useState(false)
|
||||||
const [modalClosed, setModalClosed] = React.useState(false)
|
const [modalClosed, setModalClosed] = React.useState(false)
|
||||||
|
const [snapModalClosed, setSnapModalClosed] = React.useState(false)
|
||||||
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
||||||
name: 'Demo User',
|
name: 'Demo User',
|
||||||
contact: 'demo@example.com',
|
contact: 'demo@example.com',
|
||||||
|
|
@ -258,7 +259,7 @@ export function CheckoutPage() {
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} customerName={form.name} showStatusCTA={modalClosed}>
|
<PaymentSheet orderId={orderId} amount={amount} customerName={form.name} showStatusCTA={modalClosed}>
|
||||||
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
|
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
|
||||||
{currentStep === 1 && (
|
{currentStep === 1 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -356,8 +357,25 @@ export function CheckoutPage() {
|
||||||
onModalClosed={() => {
|
onModalClosed={() => {
|
||||||
console.log('🟢 onModalClosed callback fired - setting modalClosed to TRUE')
|
console.log('🟢 onModalClosed callback fired - setting modalClosed to TRUE')
|
||||||
setModalClosed(true) // Enable status button when modal closed
|
setModalClosed(true) // Enable status button when modal closed
|
||||||
|
setSnapModalClosed(true) // Show reload button
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tombol reload jika Snap modal ditutup tanpa pembayaran */}
|
||||||
|
{snapModalClosed && (
|
||||||
|
<div className="mt-3 sm:mt-4 space-y-2">
|
||||||
|
<div className="text-xs sm:text-sm text-gray-600 text-center px-2">
|
||||||
|
Ingin memilih metode pembayaran lain?
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full text-xs sm:text-sm"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Pilih Metode Pembayaran
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PaymentSheet>
|
</PaymentSheet>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,54 @@ import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export function NotFoundPage() {
|
export function NotFoundPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center px-4 py-8">
|
||||||
<h1 className="text-xl font-semibold">Halaman tidak ditemukan</h1>
|
<div className="max-w-md w-full">
|
||||||
<p className="text-sm text-black/70">Periksa URL atau kembali ke checkout.</p>
|
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden">
|
||||||
{/* <Link to="/checkout" className="text-brand-600 underline">Kembali ke Checkout</Link> */}
|
<div className="bg-gradient-to-r from-[#0c1f3f] to-[#1a3a5f] p-6 sm:p-8 text-center">
|
||||||
<Link to="/" className="text-brand-600 underline">Kembali</Link>
|
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full bg-white/10 flex items-center justify-center ring-4 ring-white/20">
|
||||||
|
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold text-white tracking-tight mb-2">404</h1>
|
||||||
|
<p className="text-sm sm:text-base font-medium text-white/90">Halaman Tidak Ditemukan</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 sm:p-8 text-center space-y-4 sm:space-y-5">
|
||||||
|
<p className="text-sm sm:text-base text-slate-700 leading-relaxed">
|
||||||
|
Maaf, halaman yang Anda cari tidak dapat ditemukan. Silakan periksa kembali URL yang Anda masukkan atau kembali ke halaman utama.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:gap-3 pt-2">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center justify-center gap-2 bg-[#0c1f3f] text-white px-6 py-3 rounded-lg font-semibold text-sm sm:text-base hover:bg-[#1a3a5f] transition-colors shadow-md hover:shadow-lg focus:outline-none focus:ring-3 focus:ring-[#0c1f3f] focus:ring-offset-2 !text-white hover:!text-white visited:!text-white active:!text-white"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
<span>Kembali ke Halaman Utama</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/checkout"
|
||||||
|
className="inline-flex items-center justify-center gap-2 bg-slate-100 text-slate-700 px-6 py-3 rounded-lg font-semibold text-sm sm:text-base hover:bg-slate-200 transition-colors border border-slate-300 focus:outline-none focus:ring-3 focus:ring-slate-300 focus:ring-offset-2 !text-slate-700 hover:!text-slate-800 visited:!text-slate-700"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4 sm:h-5 sm:w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Ke Halaman Checkout</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-xs sm:text-sm text-slate-600">
|
||||||
|
Butuh bantuan? Hubungi customer service kami
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -21,9 +21,10 @@ interface AutoSnapPaymentProps {
|
||||||
customer?: { name?: string; phone?: string; email?: string }
|
customer?: { name?: string; phone?: string; email?: string }
|
||||||
onSuccess?: (result: any) => void
|
onSuccess?: (result: any) => void
|
||||||
onError?: (error: any) => void
|
onError?: (error: any) => void
|
||||||
|
onModalClosed?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: AutoSnapPaymentProps) {
|
function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError, onModalClosed }: AutoSnapPaymentProps) {
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
const [paymentTriggered, setPaymentTriggered] = React.useState(false)
|
||||||
|
|
@ -108,6 +109,7 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Auto
|
||||||
onClose: () => {
|
onClose: () => {
|
||||||
Logger.paymentInfo('paypage.auto.snap.popup.closed', { orderId })
|
Logger.paymentInfo('paypage.auto.snap.popup.closed', { orderId })
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
onModalClosed?.() // Trigger callback when modal closed
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -149,7 +151,7 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Auto
|
||||||
console.log('[PayPage] Setting timeout')
|
console.log('[PayPage] Setting timeout')
|
||||||
const timer = setTimeout(triggerPayment, 500)
|
const timer = setTimeout(triggerPayment, 500)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
|
}, [orderId, amount, customer, paymentTriggered, onSuccess, onError, onModalClosed])
|
||||||
|
|
||||||
// Don't render anything until we have valid data
|
// Don't render anything until we have valid data
|
||||||
if (!orderId || !amount) {
|
if (!orderId || !amount) {
|
||||||
|
|
@ -203,6 +205,7 @@ export function PayPage() {
|
||||||
const [locked, setLocked] = useState<boolean>(false)
|
const [locked, setLocked] = useState<boolean>(false)
|
||||||
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
|
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
|
||||||
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||||
|
const [snapModalClosed, setSnapModalClosed] = useState(false)
|
||||||
usePaymentConfig()
|
usePaymentConfig()
|
||||||
const currentStep = 2
|
const currentStep = 2
|
||||||
|
|
||||||
|
|
@ -297,8 +300,29 @@ export function PayPage() {
|
||||||
}}
|
}}
|
||||||
onError={(error) => {
|
onError={(error) => {
|
||||||
console.error('[PayPage] Payment error:', error)
|
console.error('[PayPage] Payment error:', error)
|
||||||
|
setSnapModalClosed(true)
|
||||||
|
}}
|
||||||
|
onModalClosed={() => {
|
||||||
|
console.log('[PayPage] Snap modal closed')
|
||||||
|
setSnapModalClosed(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tombol reload jika Snap modal ditutup tanpa pembayaran */}
|
||||||
|
{snapModalClosed && (
|
||||||
|
<div className="mt-3 sm:mt-4 space-y-2">
|
||||||
|
<div className="text-xs sm:text-sm text-gray-600 text-center px-2">
|
||||||
|
Ingin memilih metode pembayaran lain?
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full text-xs sm:text-sm"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Pilih Metode Pembayaran
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import { useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { usePaymentStatus } from '../features/payments/lib/usePaymentStatus'
|
import { usePaymentStatus } from '../features/payments/lib/usePaymentStatus'
|
||||||
import type { PaymentStatusResponse } from '../features/payments/lib/midtrans'
|
import type { PaymentStatusResponse } from '../features/payments/lib/midtrans'
|
||||||
import { Logger } from '../lib/logger'
|
import { Logger } from '../lib/logger'
|
||||||
|
import { BankLogo, LogoGoPay, LogoQRIS, LogoAlfamart, LogoIndomaret, CardLogosRow, type BankKey } from '../features/payments/components/PaymentLogos'
|
||||||
|
|
||||||
export function PaymentStatusPage() {
|
export function PaymentStatusPage() {
|
||||||
const { orderId } = useParams()
|
const { orderId } = useParams()
|
||||||
const [search] = useSearchParams()
|
const [search] = useSearchParams()
|
||||||
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
|
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
|
||||||
const { data, isLoading, error } = usePaymentStatus(orderId)
|
const { data, isLoading, error } = usePaymentStatus(orderId)
|
||||||
|
const [copyNotification, setCopyNotification] = React.useState('')
|
||||||
|
|
||||||
// Check if error is "transaction not found" from Midtrans
|
// Check if error is "transaction not found" from Midtrans
|
||||||
const errorData = (error as any)?.response?.data
|
const errorData = (error as any)?.response?.data
|
||||||
|
|
@ -21,6 +23,19 @@ export function PaymentStatusPage() {
|
||||||
|
|
||||||
const statusText = data?.status ?? 'pending'
|
const statusText = data?.status ?? 'pending'
|
||||||
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
|
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
|
||||||
|
|
||||||
|
// Handle copy to clipboard with notification
|
||||||
|
const handleCopy = async (text: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
setCopyNotification(`${label} berhasil disalin`)
|
||||||
|
setTimeout(() => setCopyNotification(''), 3000)
|
||||||
|
} catch (err) {
|
||||||
|
setCopyNotification('Gagal menyalin')
|
||||||
|
setTimeout(() => setCopyNotification(''), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeUrl(u?: string) {
|
function sanitizeUrl(u?: string) {
|
||||||
return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '')
|
return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '')
|
||||||
}
|
}
|
||||||
|
|
@ -104,101 +119,175 @@ export function PaymentStatusPage() {
|
||||||
const statusMsg = getStatusMessage(statusText)
|
const statusMsg = getStatusMessage(statusText)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 py-8">
|
<div className="min-h-screen bg-gray-50 py-4 sm:py-6 md:py-8">
|
||||||
<div className="max-w-2xl mx-auto px-4">
|
{/* Copy Notification Toast */}
|
||||||
|
{copyNotification && (
|
||||||
|
<div className="fixed top-2 right-2 sm:top-4 sm:right-4 left-2 sm:left-auto z-50 animate-in slide-in-from-top-2 duration-300">
|
||||||
|
<div className="bg-[#0c1f3f] text-white px-4 py-2.5 sm:px-6 sm:py-3 rounded-lg shadow-lg flex items-center gap-2 sm:gap-3">
|
||||||
|
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-emerald-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
<span className="font-medium text-sm sm:text-base">{copyNotification}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-w-2xl mx-auto px-3 sm:px-4 md:px-6">
|
||||||
{/* Header Card */}
|
{/* Header Card */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
|
<div className="bg-white rounded-xl shadow-lg border border-gray-200 overflow-hidden mb-6">
|
||||||
<div className={`p-6 text-center ${
|
<div className={`p-4 sm:p-6 md:p-8 text-center ${
|
||||||
statusMsg.color === 'green' ? 'bg-green-50 border-b border-green-100' :
|
statusMsg.color === 'green' ? 'bg-gradient-to-br from-emerald-50 to-teal-50 border-b border-emerald-100' :
|
||||||
statusMsg.color === 'yellow' ? 'bg-yellow-50 border-b border-yellow-100' :
|
statusMsg.color === 'yellow' ? 'bg-gradient-to-br from-amber-50 to-yellow-50 border-b border-amber-100' :
|
||||||
statusMsg.color === 'red' ? 'bg-red-50 border-b border-red-100' :
|
statusMsg.color === 'red' ? 'bg-gradient-to-br from-rose-50 to-red-50 border-b border-rose-100' :
|
||||||
statusMsg.color === 'blue' ? 'bg-blue-50 border-b border-blue-100' :
|
statusMsg.color === 'blue' ? 'bg-gradient-to-br from-blue-50 to-indigo-50 border-b border-blue-100' :
|
||||||
'bg-gray-50 border-b border-gray-100'
|
'bg-gradient-to-br from-slate-50 to-gray-50 border-b border-slate-100'
|
||||||
}`}>
|
}`}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-4xl mb-2">⏳</div>
|
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 animate-spin rounded-full border-4 border-slate-200 border-t-[#0c1f3f]"></div>
|
||||||
<div className="text-xl font-semibold text-gray-700">Memuat status...</div>
|
<div className="text-xl sm:text-2xl font-bold text-slate-800 tracking-tight">Memuat status...</div>
|
||||||
<div className="text-sm text-gray-600 mt-1">Mohon tunggu sebentar</div>
|
<div className="text-xs sm:text-sm font-medium text-slate-600 mt-1.5 sm:mt-2">Mohon tunggu sebentar</div>
|
||||||
</>
|
</>
|
||||||
) : isTransactionNotFound ? (
|
) : isTransactionNotFound ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-4xl mb-2">📋</div>
|
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full bg-[#0c1f3f]/10 flex items-center justify-center ring-4 ring-[#0c1f3f]/5">
|
||||||
<div className="text-xl font-semibold text-blue-700">Transaksi Belum Dibuat</div>
|
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-[#0c1f3f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<div className="text-sm text-blue-600 mt-1">Silakan kembali ke halaman checkout untuk membuat pembayaran</div>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-bold text-[#0c1f3f] tracking-tight">Transaksi Belum Dibuat</div>
|
||||||
|
<div className="text-xs sm:text-sm font-medium text-slate-700 mt-1.5 sm:mt-2 max-w-md mx-auto px-2">Silakan kembali ke halaman checkout untuk membuat pembayaran</div>
|
||||||
</>
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-4xl mb-2">⚠️</div>
|
<div className="h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full bg-rose-100 flex items-center justify-center ring-4 ring-rose-50">
|
||||||
<div className="text-xl font-semibold text-red-700">Gagal Memuat Status</div>
|
<svg className="h-8 w-8 sm:h-10 sm:w-10 text-rose-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<div className="text-sm text-red-600 mt-1">Terjadi kesalahan. Silakan refresh halaman</div>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-bold text-rose-700 tracking-tight">Gagal Memuat Status</div>
|
||||||
|
<div className="text-xs sm:text-sm font-medium text-rose-600 mt-1.5 sm:mt-2">Terjadi kesalahan. Silakan refresh halaman</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="text-5xl mb-3">{statusMsg.icon}</div>
|
<div className={`h-16 w-16 sm:h-20 sm:w-20 mx-auto mb-4 sm:mb-5 rounded-full flex items-center justify-center ring-4 ${
|
||||||
<div className="text-2xl font-bold text-gray-800 mb-2">{statusMsg.title}</div>
|
statusMsg.color === 'green' ? 'bg-emerald-100 ring-emerald-50' :
|
||||||
<div className="text-sm text-gray-600">{statusMsg.desc}</div>
|
statusMsg.color === 'yellow' ? 'bg-amber-100 ring-amber-50' :
|
||||||
|
statusMsg.color === 'red' ? 'bg-rose-100 ring-rose-50' :
|
||||||
|
statusMsg.color === 'blue' ? 'bg-[#0c1f3f]/10 ring-[#0c1f3f]/5' :
|
||||||
|
'bg-slate-100 ring-slate-50'
|
||||||
|
}`}>
|
||||||
|
{statusMsg.color === 'green' ? (
|
||||||
|
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-emerald-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : statusMsg.color === 'yellow' ? (
|
||||||
|
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
) : statusMsg.color === 'red' ? (
|
||||||
|
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-rose-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
) : statusMsg.color === 'blue' ? (
|
||||||
|
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-[#0c1f3f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-9 w-9 sm:h-11 sm:w-11 text-slate-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xl sm:text-2xl font-bold text-slate-900 mb-1.5 sm:mb-2 tracking-tight">{statusMsg.title}</div>
|
||||||
|
<div className="text-xs sm:text-sm font-medium text-slate-600 max-w-md mx-auto px-2">{statusMsg.desc}</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Order Info */}
|
{/* Order Info */}
|
||||||
<div className="p-6 bg-white">
|
<div className="p-4 sm:p-6 bg-white">
|
||||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
|
<div className="flex items-center justify-between mb-4 sm:mb-5 pb-4 sm:pb-5 border-b border-slate-200">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">ID Pesanan</div>
|
<div className="text-[10px] sm:text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">ID Pesanan</div>
|
||||||
<div className="font-mono text-sm font-semibold text-gray-900">{orderId}</div>
|
<div className="font-mono text-xs sm:text-sm font-bold text-slate-900 break-all">{orderId}</div>
|
||||||
</div>
|
</div>
|
||||||
{!isLoading && !isFinal && !isTransactionNotFound && (
|
{!isLoading && !isFinal && !isTransactionNotFound && (
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
<div className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-medium text-[#0c1f3f]">
|
||||||
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse"></div>
|
<div className="h-1.5 w-1.5 sm:h-2 sm:w-2 bg-[#0c1f3f] rounded-full animate-pulse flex-shrink-0"></div>
|
||||||
<span>Memperbarui otomatis...</span>
|
<span className="hidden sm:inline">Memperbarui otomatis</span>
|
||||||
|
<span className="sm:hidden">Auto update</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{customerName ? (
|
{customerName ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4 sm:mb-5">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Nama Pelanggan</div>
|
<div className="text-[10px] sm:text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Nama Pelanggan</div>
|
||||||
<div className="text-sm font-medium text-gray-900">{customerName}</div>
|
<div className="text-xs sm:text-sm font-bold text-slate-900">{customerName}</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{method || data?.method ? (
|
{method || data?.method ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4 sm:mb-5">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Metode Pembayaran</div>
|
<div className="text-[10px] sm:text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">Metode Pembayaran</div>
|
||||||
<div className="text-sm font-medium text-gray-900 capitalize">{(data?.method ?? method)?.replace('_', ' ')}</div>
|
<div className="flex items-center gap-1.5 sm:gap-2">
|
||||||
|
{(data?.method === 'bank_transfer' || method === 'bank_transfer') && data?.bank && (
|
||||||
|
<BankLogo bank={data.bank.toLowerCase() as BankKey} size="sm" />
|
||||||
|
)}
|
||||||
|
{(data?.method === 'gopay' || method === 'gopay') && (
|
||||||
|
<LogoGoPay size="sm" />
|
||||||
|
)}
|
||||||
|
{(data?.method === 'qris' || method === 'qris') && (
|
||||||
|
<LogoQRIS size="sm" />
|
||||||
|
)}
|
||||||
|
{(data?.method === 'cstore' || method === 'cstore') && (
|
||||||
|
data?.store?.toLowerCase() === 'alfamart' ? <LogoAlfamart size="sm" /> :
|
||||||
|
data?.store?.toLowerCase() === 'indomaret' ? <LogoIndomaret size="sm" /> : null
|
||||||
|
)}
|
||||||
|
{(data?.method === 'credit_card' || method === 'credit_card') && (
|
||||||
|
<CardLogosRow size="xs" compact />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Instructions - Only show for pending status */}
|
{/* Payment Instructions - Only show for pending status */}
|
||||||
{!isLoading && !error && data && statusText === 'pending' ? (
|
{!isLoading && !error && data && statusText === 'pending' && (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
|
<div className="bg-white rounded-xl shadow-lg border border-slate-200 overflow-hidden mb-6">
|
||||||
<div className="bg-blue-50 border-b border-blue-100 px-6 py-3">
|
<div className="bg-gradient-to-r from-[#0c1f3f] to-[#1a3a5f] border-b border-[#0c1f3f] px-4 sm:px-6 py-3 sm:py-4">
|
||||||
<div className="text-sm font-semibold text-blue-900">📝 Cara Pembayaran</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-white flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-xs sm:text-sm font-bold text-white tracking-wide">Cara Pembayaran</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 space-y-4">
|
</div>
|
||||||
|
<div className="p-4 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
{/* Bank Transfer OR Mandiri E-Channel */}
|
{/* Bank Transfer OR Mandiri E-Channel */}
|
||||||
{(!method || method === 'bank_transfer' || data.method === 'bank_transfer' || data.method === 'echannel') && (data.vaNumber || (data.billKey && data.billerCode)) ? (
|
{(!method || method === 'bank_transfer' || data.method === 'bank_transfer' || data.method === 'echannel') && (data.vaNumber || (data.billKey && data.billerCode)) ? (
|
||||||
<>
|
<>
|
||||||
{data.vaNumber ? (
|
{data.vaNumber ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Nomor Virtual Account</div>
|
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide mb-2">Nomor Virtual Account</div>
|
||||||
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
|
<div className="flex items-center justify-between gap-2 bg-white rounded border border-gray-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
||||||
<div className="font-mono text-lg font-bold text-gray-900">{data.vaNumber}</div>
|
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.vaNumber}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(data.vaNumber || '')}
|
onClick={() => handleCopy(data.vaNumber || '', 'Nomor VA')}
|
||||||
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-[#0c1f3f] text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-[#1a3a5f] transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
📋 Salin
|
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Salin</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{data.bank ? (
|
{data.bank ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="text-xs text-gray-500">Bank</div>
|
<div className="text-[10px] sm:text-xs text-gray-500 mb-1.5">Bank</div>
|
||||||
<div className="text-sm font-semibold text-gray-900 uppercase">{data.bank}</div>
|
<BankLogo bank={data.bank.toLowerCase() as BankKey} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -206,30 +295,41 @@ export function PaymentStatusPage() {
|
||||||
|
|
||||||
{/* Mandiri E-Channel specific */}
|
{/* Mandiri E-Channel specific */}
|
||||||
{data.billKey && data.billerCode ? (
|
{data.billKey && data.billerCode ? (
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 sm:p-4">
|
||||||
<div className="font-semibold text-yellow-900 mb-3 text-base">💳 Mandiri E-Channel</div>
|
<div className="flex items-center gap-2 font-semibold text-yellow-900 mb-2 sm:mb-3 text-sm sm:text-base">
|
||||||
|
<svg className="h-5 w-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||||
|
</svg>
|
||||||
|
<span>Mandiri E-Channel</span>
|
||||||
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Perusahaan (Biller Code)</div>
|
<div className="text-[10px] sm:text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Perusahaan (Biller Code)</div>
|
||||||
<div className="flex items-center justify-between bg-white rounded border border-yellow-300 px-4 py-3">
|
<div className="flex items-center justify-between gap-2 bg-white rounded border border-yellow-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
||||||
<div className="font-mono text-lg font-bold text-gray-900">{data.billerCode}</div>
|
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.billerCode}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(data.billerCode || '')}
|
onClick={() => handleCopy(data.billerCode || '', 'Kode Perusahaan')}
|
||||||
className="text-xs bg-yellow-600 text-white px-3 py-1.5 rounded hover:bg-yellow-700"
|
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-amber-600 text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-amber-700 transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
📋 Salin
|
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Salin</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Bayar (Bill Key)</div>
|
<div className="text-[10px] sm:text-xs text-yellow-700 uppercase tracking-wide mb-1">Kode Bayar (Bill Key)</div>
|
||||||
<div className="flex items-center justify-between bg-white rounded border border-yellow-300 px-4 py-3">
|
<div className="flex items-center justify-between gap-2 bg-white rounded border border-yellow-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
||||||
<div className="font-mono text-lg font-bold text-gray-900">{data.billKey}</div>
|
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.billKey}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(data.billKey || '')}
|
onClick={() => handleCopy(data.billKey || '', 'Kode Bayar')}
|
||||||
className="text-xs bg-yellow-600 text-white px-3 py-1.5 rounded hover:bg-yellow-700"
|
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-amber-600 text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-amber-700 transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
📋 Salin
|
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Salin</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -237,10 +337,10 @@ export function PaymentStatusPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-xs sm:text-sm text-gray-600 space-y-2">
|
||||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||||
{data.billKey && data.billerCode ? (
|
{data.billKey && data.billerCode ? (
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
||||||
<li>Buka aplikasi Livin' by Mandiri atau ATM Mandiri</li>
|
<li>Buka aplikasi Livin' by Mandiri atau ATM Mandiri</li>
|
||||||
<li>Pilih menu <strong>Bayar</strong> / <strong>Multi Payment</strong></li>
|
<li>Pilih menu <strong>Bayar</strong> / <strong>Multi Payment</strong></li>
|
||||||
<li>Pilih penyedia jasa: <strong>Midtrans</strong> (atau cari dengan Biller Code)</li>
|
<li>Pilih penyedia jasa: <strong>Midtrans</strong> (atau cari dengan Biller Code)</li>
|
||||||
|
|
@ -250,7 +350,7 @@ export function PaymentStatusPage() {
|
||||||
<li>Simpan bukti transaksi</li>
|
<li>Simpan bukti transaksi</li>
|
||||||
</ol>
|
</ol>
|
||||||
) : (
|
) : (
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
||||||
<li>Buka aplikasi mobile banking atau ATM</li>
|
<li>Buka aplikasi mobile banking atau ATM</li>
|
||||||
<li>Pilih menu Transfer / Bayar</li>
|
<li>Pilih menu Transfer / Bayar</li>
|
||||||
<li>Masukkan nomor Virtual Account di atas</li>
|
<li>Masukkan nomor Virtual Account di atas</li>
|
||||||
|
|
@ -266,28 +366,32 @@ export function PaymentStatusPage() {
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
{data.store ? (
|
{data.store ? (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Toko</div>
|
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1.5">Toko</div>
|
||||||
<div className="text-lg font-bold text-gray-900 uppercase">{data.store}</div>
|
{data.store.toLowerCase() === 'alfamart' && <LogoAlfamart size="sm" />}
|
||||||
|
{data.store.toLowerCase() === 'indomaret' && <LogoIndomaret size="sm" />}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{data.paymentCode ? (
|
{data.paymentCode ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kode Pembayaran</div>
|
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide mb-2">Kode Pembayaran</div>
|
||||||
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
|
<div className="flex items-center justify-between gap-2 bg-white rounded border border-gray-300 px-3 py-2.5 sm:px-4 sm:py-3">
|
||||||
<div className="font-mono text-lg font-bold text-gray-900">{data.paymentCode}</div>
|
<div className="font-mono text-sm sm:text-base font-bold text-gray-900 break-all flex-1">{data.paymentCode}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigator.clipboard.writeText(data.paymentCode || '')}
|
onClick={() => handleCopy(data.paymentCode || '', 'Kode Pembayaran')}
|
||||||
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
className="flex items-center gap-1.5 sm:gap-2 text-[10px] sm:text-xs font-semibold bg-[#0c1f3f] text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-[#1a3a5f] transition-colors flex-shrink-0"
|
||||||
>
|
>
|
||||||
📋 Salin
|
<svg className="h-3.5 w-3.5 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">Salin</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-xs sm:text-sm text-gray-600 space-y-2">
|
||||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
||||||
<li>Kunjungi toko {data.store || 'convenience store'} terdekat</li>
|
<li>Kunjungi toko {data.store || 'convenience store'} terdekat</li>
|
||||||
<li>Berikan kode pembayaran kepada kasir</li>
|
<li>Berikan kode pembayaran kepada kasir</li>
|
||||||
<li>Lakukan pembayaran tunai</li>
|
<li>Lakukan pembayaran tunai</li>
|
||||||
|
|
@ -299,19 +403,19 @@ export function PaymentStatusPage() {
|
||||||
{(!method || method === 'gopay' || method === 'qris' || data.method === 'qris' || data.method === 'gopay') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
{(!method || method === 'gopay' || method === 'qris' || data.method === 'qris' || data.method === 'gopay') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
||||||
<>
|
<>
|
||||||
{qrSrc ? (
|
{qrSrc ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide text-center mb-3">Scan QR Code</div>
|
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide text-center mb-3">Scan QR Code</div>
|
||||||
<div className="bg-white rounded-lg p-4 inline-block mx-auto">
|
<div className="bg-white rounded-lg p-3 sm:p-4 inline-block mx-auto">
|
||||||
<img src={qrSrc} alt="QR Code Pembayaran" className="w-64 h-64 mx-auto" onError={(e) => {
|
<img src={qrSrc} alt="QR Code Pembayaran" className="w-48 h-48 sm:w-64 sm:h-64 mx-auto" onError={(e) => {
|
||||||
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
||||||
if (next) setQrSrc(next)
|
if (next) setQrSrc(next)
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="text-sm text-gray-600 space-y-2">
|
<div className="text-xs sm:text-sm text-gray-600 space-y-2">
|
||||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
<ol className="list-decimal list-inside space-y-1 ml-2 sm:ml-3">
|
||||||
<li>Buka aplikasi {method === 'gopay' || data.method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking yang mendukung QRIS'}</li>
|
<li>Buka aplikasi {method === 'gopay' || data.method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking yang mendukung QRIS'}</li>
|
||||||
<li>Pilih menu Scan QR atau QRIS</li>
|
<li>Pilih menu Scan QR atau QRIS</li>
|
||||||
<li>Arahkan kamera ke QR code di atas</li>
|
<li>Arahkan kamera ke QR code di atas</li>
|
||||||
|
|
@ -326,24 +430,29 @@ export function PaymentStatusPage() {
|
||||||
href={a.url}
|
href={a.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium"
|
className="inline-flex items-center gap-1.5 sm:gap-2 bg-blue-600 text-white px-3 py-2 sm:px-4 rounded-lg hover:bg-blue-700 text-xs sm:text-sm font-medium"
|
||||||
>
|
>
|
||||||
📱 {a.name || 'Buka Aplikasi'}
|
<svg className="h-4 w-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
{a.name || 'Buka Aplikasi'}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (data.method === 'qris' || data.method === 'gopay') ? (
|
) : (data.method === 'qris' || data.method === 'gopay') ? (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 sm:p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-2 sm:gap-3">
|
||||||
<div className="text-2xl">📱</div>
|
<svg className="h-6 w-6 sm:h-7 sm:w-7 text-blue-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-semibold text-blue-900 mb-2">QR Code Pembayaran</div>
|
<div className="font-semibold text-blue-900 mb-2 text-sm sm:text-base">QR Code Pembayaran</div>
|
||||||
<div className="text-sm text-blue-700 space-y-2">
|
<div className="text-xs sm:text-sm text-blue-700 space-y-2">
|
||||||
<p>QR code untuk pembayaran ini ditampilkan di jendela pembayaran Snap.</p>
|
<p>QR code untuk pembayaran ini ditampilkan di jendela pembayaran Snap.</p>
|
||||||
<p>Jika Anda menutup jendela tersebut, silakan:</p>
|
<p>Jika Anda menutup jendela tersebut, silakan:</p>
|
||||||
<ol className="list-decimal list-inside ml-2 mt-2 space-y-1">
|
<ol className="list-decimal list-inside ml-2 sm:ml-3 mt-2 space-y-1">
|
||||||
<li>Kembali ke halaman checkout</li>
|
<li>Kembali ke halaman checkout</li>
|
||||||
<li>Buat pembayaran baru dengan order ID yang sama</li>
|
<li>Buat pembayaran baru dengan order ID yang sama</li>
|
||||||
<li>QR code akan muncul kembali di jendela Snap</li>
|
<li>QR code akan muncul kembali di jendela Snap</li>
|
||||||
|
|
@ -355,29 +464,45 @@ export function PaymentStatusPage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{(!method || method === 'credit_card') && data.maskedCard ? (
|
{(!method || method === 'credit_card') && data.maskedCard ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-200">
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kartu Kredit/Debit</div>
|
<div className="text-[10px] sm:text-xs text-gray-500 uppercase tracking-wide mb-2">Kartu Kredit/Debit</div>
|
||||||
<div className="font-mono text-lg font-bold text-gray-900">{data.maskedCard}</div>
|
<div className="font-mono text-base sm:text-lg font-bold text-gray-900">{data.maskedCard}</div>
|
||||||
<div className="text-sm text-gray-600 mt-3">
|
<div className="text-xs sm:text-sm text-gray-600 mt-3">
|
||||||
Pembayaran dengan kartu telah diproses. Tunggu konfirmasi dari bank Anda.
|
Pembayaran dengan kartu telah diproses. Tunggu konfirmasi dari bank Anda.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{/* Help Section */}
|
{/* Help Section */}
|
||||||
{!isLoading && !error && (
|
{!isLoading && !error && (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-xl shadow-lg border border-slate-200 p-4 sm:p-6">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="flex items-start gap-2 sm:gap-3">
|
||||||
<p className="font-medium text-gray-900 mb-2">💡 Butuh bantuan?</p>
|
<div className="flex-shrink-0 h-8 w-8 sm:h-10 sm:w-10 bg-[#0c1f3f]/10 rounded-full flex items-center justify-center">
|
||||||
<ul className="space-y-1 text-sm">
|
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-[#0c1f3f]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<li>• Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service</li>
|
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
<li>• Simpan nomor pesanan untuk referensi</li>
|
</svg>
|
||||||
<li>• Halaman ini akan diperbarui otomatis saat status berubah</li>
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-bold text-slate-900 mb-2 sm:mb-3 text-sm sm:text-base">Butuh Bantuan?</p>
|
||||||
|
<ul className="space-y-2 sm:space-y-2.5 text-xs sm:text-sm font-medium text-slate-700">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-[#0c1f3f] mt-0.5">•</span>
|
||||||
|
<span>Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-[#0c1f3f] mt-0.5">•</span>
|
||||||
|
<span>Simpan nomor pesanan untuk referensi</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="text-[#0c1f3f] mt-0.5">•</span>
|
||||||
|
<span>Halaman ini akan diperbarui otomatis saat status berubah</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
html, body, #root { height: 100%; }
|
html, body, #root { height: 100%; }
|
||||||
body {
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ export default {
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
50: '#f1f5fb',
|
50: '#f1f5fb',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Tests
|
||||||
|
|
||||||
|
Folder ini berisi file-file testing untuk Simaya Midtrans Payment Integration.
|
||||||
|
|
||||||
|
## File Testing
|
||||||
|
|
||||||
|
### 1. test-create-payment-link.cjs
|
||||||
|
Test untuk membuat payment link menggunakan endpoint `/createtransaksi`.
|
||||||
|
|
||||||
|
**Cara menjalankan:**
|
||||||
|
```bash
|
||||||
|
node tests/test-create-payment-link.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirement:**
|
||||||
|
- Server harus running di `http://localhost:8000`
|
||||||
|
- File `temp/tmp-createtransaksi.json` harus ada dengan payload yang valid
|
||||||
|
|
||||||
|
### 2. test-frontend-payload.cjs
|
||||||
|
Test untuk mensimulasikan payload dari frontend (CheckoutPage.tsx AutoSnapPayment).
|
||||||
|
|
||||||
|
**Cara menjalankan:**
|
||||||
|
```bash
|
||||||
|
node tests/test-frontend-payload.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirement:**
|
||||||
|
- Server harus running di `http://localhost:8000`
|
||||||
|
|
||||||
|
### 3. test-snap-token.cjs
|
||||||
|
Test untuk mendapatkan Snap token dari Midtrans.
|
||||||
|
|
||||||
|
**Cara menjalankan:**
|
||||||
|
```bash
|
||||||
|
node tests/test-snap-token.cjs
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirement:**
|
||||||
|
- Server harus running di `http://localhost:8000`
|
||||||
|
|
||||||
|
### 4. coreApiSimpleExample.js
|
||||||
|
Contoh sederhana penggunaan Midtrans Core API.
|
||||||
|
|
||||||
|
**Cara menjalankan:**
|
||||||
|
```bash
|
||||||
|
node tests/coreApiSimpleExample.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catatan
|
||||||
|
|
||||||
|
- Semua test file menggunakan `axios` untuk HTTP requests
|
||||||
|
- Pastikan server backend sudah running sebelum menjalankan test
|
||||||
|
- File temporary/payload test ada di folder `temp/`
|
||||||
|
|
@ -3,7 +3,7 @@ const fs = require('fs');
|
||||||
|
|
||||||
async function createPaymentLink() {
|
async function createPaymentLink() {
|
||||||
// Read file and remove BOM if present
|
// Read file and remove BOM if present
|
||||||
let jsonContent = fs.readFileSync('c:/laragon/www/core-midtrans-cifo/tmp-createtransaksi.json', 'utf8');
|
let jsonContent = fs.readFileSync('../temp/tmp-createtransaksi.json', 'utf8');
|
||||||
// Remove BOM
|
// Remove BOM
|
||||||
if (jsonContent.charCodeAt(0) === 0xFEFF) {
|
if (jsonContent.charCodeAt(0) === 0xFEFF) {
|
||||||
jsonContent = jsonContent.slice(1);
|
jsonContent = jsonContent.slice(1);
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"mercant_id": "REFNO-004",
|
|
||||||
"timestamp": 1733331600000,
|
|
||||||
"deskripsi": "Bayar Internet",
|
|
||||||
"nominal": 250000,
|
|
||||||
"nama": "Test User 4",
|
|
||||||
"no_telepon": "081234567892",
|
|
||||||
"email": "test4@example.com",
|
|
||||||
"item": [
|
|
||||||
{ "item_id": "TKG-2512043", "nama": "Internet Desember Premium", "harga": 250000, "qty": 1 }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
417582e9fb7105b479e3e7aee99a285dbee0f2ec3238869f8f6fc36b6a098dbee411cf0d3e7637b69f41803518e640a6c9ae71a66b414b29e2182f5aed2ea55a
|
|
||||||
BIN
tmp-sig2.txt
BIN
tmp-sig2.txt
Binary file not shown.
|
|
@ -1 +0,0 @@
|
||||||
e781ba511b1675c05974b45db5f9ddc108d6d2d0acd62ba47fa4125094000512baf9b147689254ac88c406aade53921c9e7e3ae35c154809bdd7723014264667
|
|
||||||
Loading…
Reference in New Issue