epic-6-snap-hybrid-complete #19

Merged
root merged 3 commits from epic-6-snap-hybrid-complete into dev 2025-12-05 08:26:03 +00:00
25 changed files with 1499 additions and 274 deletions

4
.gitignore vendored
View File

@ -32,3 +32,7 @@ docs/
*.sw? *.sw?
.bmad/ .bmad/
.trae/ .trae/
# Temporary test files
temp/
!temp/README.md

View File

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

60
package-lock.json generated
View File

@ -16,7 +16,9 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"fs": "^0.0.1-security",
"midtrans-client": "^1.4.3", "midtrans-client": "^1.4.3",
"path": "^0.12.7",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.66.0",
@ -90,6 +92,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 +1786,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 +1797,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 +1858,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 +2145,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 +2339,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 +2830,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",
@ -3311,6 +3320,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/fs": {
"version": "0.0.1-security",
"resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz",
"integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==",
"license": "ISC"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -4324,6 +4339,16 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/path": {
"version": "0.12.7",
"resolved": "https://registry.npmjs.org/path/-/path-0.12.7.tgz",
"integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==",
"license": "MIT",
"dependencies": {
"process": "^0.11.1",
"util": "^0.10.3"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -4394,6 +4419,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 +4466,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"
}, },
@ -4529,6 +4556,15 @@
} }
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -4639,6 +4675,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 +4685,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 +5127,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 +5185,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 +5288,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"
@ -5337,6 +5378,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz",
"integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==",
"license": "MIT",
"dependencies": {
"inherits": "2.0.3"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -5344,6 +5394,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/util/node_modules/inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
"integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==",
"license": "ISC"
},
"node_modules/vary": { "node_modules/vary": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@ -5359,6 +5415,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 +5509,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"
}, },

View File

@ -19,7 +19,9 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"fs": "^0.0.1-security",
"midtrans-client": "^1.4.3", "midtrans-client": "^1.4.3",
"path": "^0.12.7",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.66.0",

490
server/README.md Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@ import { CheckoutPage } from '../pages/CheckoutPage'
import { PaymentStatusPage } from '../pages/PaymentStatusPage' import { PaymentStatusPage } from '../pages/PaymentStatusPage'
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage' import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
import { NotFoundPage } from '../pages/NotFoundPage' import { NotFoundPage } from '../pages/NotFoundPage'
import { DemoStorePage } from '../pages/DemoStorePage' // import { DemoStorePage } from '../pages/DemoStorePage'
import { InitPage } from '../pages/InitialPage' import { InitPage } from '../pages/InitialPage'
import { PayPage } from '../pages/PayPage' import { PayPage } from '../pages/PayPage'
@ -16,7 +16,7 @@ const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <InitPage /> }, { index: true, element: <InitPage /> },
{ path: 'checkout', element: <CheckoutPage /> }, { path: 'checkout', element: <CheckoutPage /> },
{ path: 'demo', element: <DemoStorePage /> }, // { path: 'demo', element: <DemoStorePage /> },
{ path: 'pay/:token', element: <PayPage /> }, { path: 'pay/:token', element: <PayPage /> },
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> }, { path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
{ path: 'history', element: <PaymentHistoryPage /> }, { path: 'history', element: <PaymentHistoryPage /> },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

53
tests/README.md Normal file
View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
417582e9fb7105b479e3e7aee99a285dbee0f2ec3238869f8f6fc36b6a098dbee411cf0d3e7637b69f41803518e640a6c9ae71a66b414b29e2182f5aed2ea55a

Binary file not shown.

View File

@ -1 +0,0 @@
e781ba511b1675c05974b45db5f9ddc108d6d2d0acd62ba47fa4125094000512baf9b147689254ac88c406aade53921c9e7e3ae35c154809bdd7723014264667