diff --git a/.gitignore b/.gitignore index cfe3f0a..ecee33b 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ docs/ *.sw? .bmad/ .trae/ + +# Temporary test files +temp/ +!temp/README.md diff --git a/index.html b/index.html index c84619e..bea51d6 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,9 @@ Simaya Midtrans | Retail Payment + + +
diff --git a/package-lock.json b/package-lock.json index 1b187c6..b7acc98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,6 +90,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1783,6 +1784,7 @@ "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1793,6 +1795,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1853,6 +1856,7 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -2139,6 +2143,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2332,6 +2337,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -2822,6 +2828,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4394,6 +4401,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -4440,6 +4448,7 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4639,6 +4648,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4648,6 +4658,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5089,7 +5100,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -5146,6 +5158,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5248,6 +5261,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5359,6 +5373,7 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -5452,6 +5467,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..286d50a --- /dev/null +++ b/server/README.md @@ -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) diff --git a/server/index.cjs b/server/index.cjs index 08de767..8661045 100644 --- a/server/index.cjs +++ b/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 cors = require('cors') const dotenv = require('dotenv') @@ -7,8 +17,10 @@ const https = require('https') 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 = { logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }), 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 }) } +// ============================================================================ +// EXPRESS APP SETUP +// ============================================================================ const app = express() app.use(cors()) app.use(express.json()) +// ============================================================================ +// MIDTRANS CONFIGURATION +// ============================================================================ const isProduction = process.env.MIDTRANS_IS_PRODUCTION === 'true' const serverKey = process.env.MIDTRANS_SERVER_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') } +// Initialize Midtrans Core API client const core = new midtransClient.CoreApi({ isProduction, serverKey, 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) { if (typeof v === 'string') { const s = v.trim().toLowerCase() @@ -45,13 +71,10 @@ function parseEnable(v) { if (typeof v === 'number') return v === 1 return true } -const ERP_NOTIFICATION_URL = process.env.ERP_NOTIFICATION_URL || '' -const ERP_ENABLE_NOTIF = parseEnable(process.env.ERP_ENABLE_NOTIF) -// 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) { if (!value) return [] return String(value) @@ -60,29 +83,58 @@ function parseList(value) { .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 multi = parseList(process.env.ERP_NOTIFICATION_URLS) if (multi.length > 0) return multi 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 levelOrder = { debug: 0, info: 1, warn: 2, error: 3 } const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API) const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10) const recentLogs = [] -function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) } + +/** + * 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() { - // Jakarta timezone (WIB = UTC+7) const now = new Date() const jakartaOffset = 7 * 60 // minutes const localTime = new Date(now.getTime() + jakartaOffset * 60 * 1000) return localTime.toISOString().replace('Z', '+07:00') } + +/** + * Sanitize object untuk logging (deep clone) + */ function sanitize(obj) { try { return JSON.parse(JSON.stringify(obj)) } catch { return obj } } + +/** + * Mask sensitive fields dalam payload (card numbers, CVV, tokens, etc.) + */ function maskPayload(obj) { const o = sanitize(obj) const maskKeys = ['card_number', 'cvv', 'token_id', 'server_key'] @@ -97,6 +149,10 @@ function maskPayload(obj) { } return mask(o) } + +/** + * Core logging function dengan in-memory buffer + */ function log(level, msg, meta) { if (!shouldLog(level)) return const line = `[${ts()}] [${level}] ${msg}` @@ -112,15 +168,25 @@ function log(level, msg, meta) { console.log(line) } } + +// Convenience logging functions const logDebug = (m, meta) => log('debug', m, meta) const logInfo = (m, meta) => log('info', m, meta) const logWarn = (m, meta) => log('warn', 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() { return Math.random().toString(36).slice(2) + Date.now().toString(36) } + +// Request/response logging middleware app.use((req, res, next) => { req.id = newReqId() logInfo('req.start', { id: req.id, method: req.method, url: req.originalUrl }) @@ -130,6 +196,10 @@ app.use((req, res, next) => { next() }) +// ============================================================================ +// PAYMENT METHOD TOGGLES +// ============================================================================ + const ENABLE = { bank_transfer: parseEnable(process.env.ENABLE_BANK_TRANSFER), credit_card: parseEnable(process.env.ENABLE_CREDIT_CARD), @@ -137,16 +207,29 @@ const ENABLE = { cstore: parseEnable(process.env.ENABLE_CSTORE), } -// --- Payment Link Config +// ============================================================================ +// PAYMENT LINK CONFIGURATION +// ============================================================================ + const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || '' const PAYMENT_LINK_SECRET = process.env.PAYMENT_LINK_SECRET || '' const PAYMENT_LINK_TTL_MINUTES = parseInt(process.env.PAYMENT_LINK_TTL_MINUTES || '1440', 10) const PAYMENT_LINK_BASE = process.env.PAYMENT_LINK_BASE || 'http://localhost:5174/pay' -const activeOrders = new Map() // order_id -> expire_at -// Map untuk menyimpan mercant_id per order_id agar notifikasi ERP bisa dinamis -const orderMerchantId = new Map() // order_id -> mercant_id -function isDevEnv() { return (process.env.NODE_ENV || '').toLowerCase() !== 'production' } +// 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) { const key = (req.headers['x-api-key'] || req.headers['X-API-KEY'] || '').toString() if (EXTERNAL_API_KEY) return key === EXTERNAL_API_KEY @@ -154,45 +237,93 @@ function verifyExternalKey(req) { return isDevEnv() } +// ============================================================================ +// PAYMENT LINK TOKEN UTILITIES +// ============================================================================ + +/** + * Encode data to base64url format (URL-safe) + */ function base64UrlEncode(buf) { return Buffer.from(buf).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') } + +/** + * Decode base64url format to string + */ function base64UrlDecode(str) { const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4)) const s = str.replace(/-/g, '+').replace(/_/g, '/') + pad return Buffer.from(s, 'base64').toString('utf8') } + +/** + * Compute HMAC-SHA256 signature untuk payment link token + */ function computeTokenSignature(orderId, nominal, expireAt) { const canonical = `${String(orderId)}|${String(nominal)}|${String(expireAt)}` return crypto.createHmac('sha256', PAYMENT_LINK_SECRET || 'dev-secret').update(canonical).digest('hex') } + +/** + * Create signed payment link token + * @returns {string} Base64url-encoded signed token + */ 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 payload = { v, order_id, nominal, expire_at, sig, customer, allowed_methods } return base64UrlEncode(JSON.stringify(payload)) } + +/** + * Resolve dan validate payment link token + * @returns {object} { error?, payload? } + */ function resolvePaymentLinkToken(token) { try { const json = JSON.parse(base64UrlDecode(token)) const { order_id, nominal, expire_at, sig } = json || {} - if (!order_id || !nominal || !expire_at || !sig) return { error: 'INVALID_TOKEN' } + + // Validate required fields + if (!order_id || !nominal || !expire_at || !sig) { + return { error: 'INVALID_TOKEN' } + } + + // Verify signature const expected = computeTokenSignature(order_id, nominal, expire_at) - if (String(sig) !== String(expected)) return { error: 'INVALID_SIGNATURE' } - if (Date.now() > Number(expire_at)) return { error: 'TOKEN_EXPIRED', payload: { order_id, nominal, expire_at } } + if (String(sig) !== String(expected)) { + 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 } } catch (e) { 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) => { logDebug('health.check', { 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) => { const payload = { paymentToggles: { ...ENABLE }, @@ -203,7 +334,10 @@ app.get('/api/config', (_req, res) => { res.json(payload) }) -// Logs endpoint (dev/debug): GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword +/** + * Get recent logs (dev/debug only) + * GET /api/logs?limit=100&level=debug|info|warn|error&q=keyword + */ app.get('/api/logs', (req, res) => { if (!LOG_EXPOSE_API) { return res.status(403).json({ error: 'FORBIDDEN', message: 'Log API disabled. Set LOG_EXPOSE_API=true to enable.' }) @@ -223,7 +357,10 @@ app.get('/api/logs', (req, res) => { 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) => { const isDev = process.env.NODE_ENV !== '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) }) -// 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) => { const { token } = req.params 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 }) }) -// 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) => { try { 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) => { try { 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) => { const { orderId } = req.params 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) => { try { 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) { try { const orderId = body?.order_id @@ -500,6 +671,9 @@ function verifyWebhookSignature(body, mode) { } } +/** + * Map Midtrans transaction status to internal status + */ function mapStatusToInternal(transactionStatus, mode) { // Unified status mapping - both Core and Snap use similar status values 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) { // Simple in-memory ledger for now - can be enhanced to use 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) { try { 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) { try { 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) { const s = (body?.transaction_status || '').toLowerCase() const fraud = (body?.fraud_status || '').toLowerCase() @@ -606,6 +795,13 @@ function isSuccessfulMidtransStatus(body) { return false } +// ============================================================================ +// HTTP UTILITIES +// ============================================================================ + +/** + * Custom HTTP Error class + */ class HttpError extends Error { constructor(statusCode, body) { 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 = {}) { return new Promise((resolve, reject) => { 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) { try { 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 -// 2) jika order_id memakai skema "mercant_id:item_id", ambil prefix sebelum ':' -// 3) fallback ke ERP_MERCANT_ID dari env (untuk kasus lama) +/** + * Resolve mercant_id from order_id + * Strategy: + * 1. Check in-memory map from createtransaksi + * 2. Parse "mercant_id:item_id" pattern + * 3. Return empty string if not found + */ function resolveMercantId(orderId) { try { if (orderMerchantId.has(orderId)) return orderMerchantId.get(orderId) @@ -694,6 +904,10 @@ function resolveMercantId(orderId) { return '' } +/** + * Send payment notification to ERP system(s) + * Supports multiple endpoints via ERP_NOTIFICATION_URLS + */ async function notifyERP({ orderId, nominal, mercantId }) { if (!ERP_ENABLE_NOTIF) { logInfo('erp.notify.skip', { reason: 'disabled' }) @@ -751,7 +965,15 @@ async function notifyERP({ orderId, nominal, mercantId }) { 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) => { try { 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) { - // 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) => { try { 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) => { try { 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) => { try { const { orderId, nominal, mercant_id } = req.body || {} @@ -846,7 +1080,19 @@ if (LOG_EXPOSE_API) { }) } +// ============================================================================ +// SERVER STARTUP +// ============================================================================ + const port = process.env.PORT || 8000 app.listen(port, () => { 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 + }) }) diff --git a/src/features/payments/components/PaymentMethodList.tsx b/src/features/payments/components/PaymentMethodList.tsx index d297c26..2042cf3 100644 --- a/src/features/payments/components/PaymentMethodList.tsx +++ b/src/features/payments/components/PaymentMethodList.tsx @@ -30,43 +30,43 @@ export function PaymentMethodList({ selected, onSelect, renderPanel, disabled, e } const items = baseItems.filter((it) => enabledMap[it.key]) return ( -
-
Metode pembayaran
+
+
Metode pembayaran
{items.map((it) => (
{selected === it.key && renderPanel && ( -
+
{renderPanel(it.key)}
)} diff --git a/src/features/payments/components/PaymentSheet.tsx b/src/features/payments/components/PaymentSheet.tsx index 43be25f..afd3ef9 100644 --- a/src/features/payments/components/PaymentSheet.tsx +++ b/src/features/payments/components/PaymentSheet.tsx @@ -15,7 +15,7 @@ export interface PaymentSheetProps { 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) return (
@@ -23,10 +23,12 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, custome {/* Header */}
-
- SIMAYA -
-
{merchantName}
+ SIMAYA +
Simaya Retail Payment
- +
+ +
diff --git a/src/pages/CheckoutPage.tsx b/src/pages/CheckoutPage.tsx index c3dcd00..65f57d3 100644 --- a/src/pages/CheckoutPage.tsx +++ b/src/pages/CheckoutPage.tsx @@ -219,6 +219,7 @@ export function CheckoutPage() { const [currentStep, setCurrentStep] = React.useState<1 | 2>(1) const [isBusy, setIsBusy] = 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 }>({ name: 'Demo User', contact: 'demo@example.com', @@ -258,7 +259,7 @@ export function CheckoutPage() { )} - + {/* Wizard 2 langkah: Step 1 (Form Dummy) โ†’ Step 2 (Payment - Snap/Core auto-detect) */} {currentStep === 1 && (
@@ -356,8 +357,25 @@ export function CheckoutPage() { onModalClosed={() => { console.log('๐ŸŸข onModalClosed callback fired - setting modalClosed to TRUE') setModalClosed(true) // Enable status button when modal closed + setSnapModalClosed(true) // Show reload button }} /> + + {/* Tombol reload jika Snap modal ditutup tanpa pembayaran */} + {snapModalClosed && ( +
+
+ Ingin memilih metode pembayaran lain? +
+ +
+ )}
)}
diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx index 2a4f80c..f5a8165 100644 --- a/src/pages/NotFoundPage.tsx +++ b/src/pages/NotFoundPage.tsx @@ -2,11 +2,54 @@ import { Link } from 'react-router-dom' export function NotFoundPage() { return ( -
-

Halaman tidak ditemukan

-

Periksa URL atau kembali ke checkout.

- {/* Kembali ke Checkout */} - Kembali +
+
+
+
+
+ + + +
+

404

+

Halaman Tidak Ditemukan

+
+ +
+

+ Maaf, halaman yang Anda cari tidak dapat ditemukan. Silakan periksa kembali URL yang Anda masukkan atau kembali ke halaman utama. +

+ +
+ + + + + Kembali ke Halaman Utama + + + + + + + Ke Halaman Checkout + +
+
+
+ +
+

+ Butuh bantuan? Hubungi customer service kami +

+
+
) } \ No newline at end of file diff --git a/src/pages/PayPage.tsx b/src/pages/PayPage.tsx index d7a601e..48a9aff 100644 --- a/src/pages/PayPage.tsx +++ b/src/pages/PayPage.tsx @@ -21,9 +21,10 @@ interface AutoSnapPaymentProps { customer?: { name?: string; phone?: string; email?: string } onSuccess?: (result: 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 [error, setError] = React.useState('') const [paymentTriggered, setPaymentTriggered] = React.useState(false) @@ -108,6 +109,7 @@ function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: Auto onClose: () => { Logger.paymentInfo('paypage.auto.snap.popup.closed', { orderId }) 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') const timer = setTimeout(triggerPayment, 500) 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 if (!orderId || !amount) { @@ -203,6 +205,7 @@ export function PayPage() { const [locked, setLocked] = useState(false) const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined) const [error, setError] = useState<{ code?: string; message?: string } | null>(null) + const [snapModalClosed, setSnapModalClosed] = useState(false) usePaymentConfig() const currentStep = 2 @@ -297,8 +300,29 @@ export function PayPage() { }} onError={(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 && ( +
+
+ Ingin memilih metode pembayaran lain? +
+ +
+ )}
)}
diff --git a/src/pages/PaymentStatusPage.tsx b/src/pages/PaymentStatusPage.tsx index ab518d0..5cee6f0 100644 --- a/src/pages/PaymentStatusPage.tsx +++ b/src/pages/PaymentStatusPage.tsx @@ -3,12 +3,14 @@ import { useParams, useSearchParams } from 'react-router-dom' import { usePaymentStatus } from '../features/payments/lib/usePaymentStatus' import type { PaymentStatusResponse } from '../features/payments/lib/midtrans' import { Logger } from '../lib/logger' +import { BankLogo, LogoGoPay, LogoQRIS, LogoAlfamart, LogoIndomaret, CardLogosRow, type BankKey } from '../features/payments/components/PaymentLogos' export function PaymentStatusPage() { const { orderId } = useParams() const [search] = useSearchParams() const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined) const { data, isLoading, error } = usePaymentStatus(orderId) + const [copyNotification, setCopyNotification] = React.useState('') // Check if error is "transaction not found" from Midtrans const errorData = (error as any)?.response?.data @@ -21,6 +23,19 @@ export function PaymentStatusPage() { const statusText = data?.status ?? 'pending' 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) { return (u || '').replace(/[`\s]+$/g, '').replace(/^\s+|\s+$/g, '').replace(/`/g, '') } @@ -104,101 +119,175 @@ export function PaymentStatusPage() { const statusMsg = getStatusMessage(statusText) return ( -
-
+
+ {/* Copy Notification Toast */} + {copyNotification && ( +
+
+ + + + {copyNotification} +
+
+ )} + +
{/* Header Card */} -
-
+
{isLoading ? ( <> -
โณ
-
Memuat status...
-
Mohon tunggu sebentar
+
+
Memuat status...
+
Mohon tunggu sebentar
) : isTransactionNotFound ? ( <> -
๐Ÿ“‹
-
Transaksi Belum Dibuat
-
Silakan kembali ke halaman checkout untuk membuat pembayaran
+
+ + + +
+
Transaksi Belum Dibuat
+
Silakan kembali ke halaman checkout untuk membuat pembayaran
) : error ? ( <> -
โš ๏ธ
-
Gagal Memuat Status
-
Terjadi kesalahan. Silakan refresh halaman
+
+ + + +
+
Gagal Memuat Status
+
Terjadi kesalahan. Silakan refresh halaman
) : ( <> -
{statusMsg.icon}
-
{statusMsg.title}
-
{statusMsg.desc}
+
+ {statusMsg.color === 'green' ? ( + + + + ) : statusMsg.color === 'yellow' ? ( + + + + ) : statusMsg.color === 'red' ? ( + + + + ) : statusMsg.color === 'blue' ? ( + + + + ) : ( + + + + )} +
+
{statusMsg.title}
+
{statusMsg.desc}
)}
{/* Order Info */} -
-
+
+
-
ID Pesanan
-
{orderId}
+
ID Pesanan
+
{orderId}
{!isLoading && !isFinal && !isTransactionNotFound && ( -
-
- Memperbarui otomatis... +
+
+ Memperbarui otomatis + Auto update
)}
{customerName ? ( -
-
Nama Pelanggan
-
{customerName}
+
+
Nama Pelanggan
+
{customerName}
) : null} {method || data?.method ? ( -
-
Metode Pembayaran
-
{(data?.method ?? method)?.replace('_', ' ')}
+
+
Metode Pembayaran
+
+ {(data?.method === 'bank_transfer' || method === 'bank_transfer') && data?.bank && ( + + )} + {(data?.method === 'gopay' || method === 'gopay') && ( + + )} + {(data?.method === 'qris' || method === 'qris') && ( + + )} + {(data?.method === 'cstore' || method === 'cstore') && ( + data?.store?.toLowerCase() === 'alfamart' ? : + data?.store?.toLowerCase() === 'indomaret' ? : null + )} + {(data?.method === 'credit_card' || method === 'credit_card') && ( + + )} +
) : null}
{/* Payment Instructions - Only show for pending status */} - {!isLoading && !error && data && statusText === 'pending' ? ( -
-
-
๐Ÿ“ Cara Pembayaran
+ {!isLoading && !error && data && statusText === 'pending' && ( +
+
+
+ + + +
Cara Pembayaran
+
-
+
{/* Bank Transfer OR Mandiri E-Channel */} {(!method || method === 'bank_transfer' || data.method === 'bank_transfer' || data.method === 'echannel') && (data.vaNumber || (data.billKey && data.billerCode)) ? ( <> {data.vaNumber ? ( -
-
Nomor Virtual Account
-
-
{data.vaNumber}
+
+
Nomor Virtual Account
+
+
{data.vaNumber}
{data.bank ? (
-
Bank
-
{data.bank}
+
Bank
+
) : null}
@@ -206,30 +295,41 @@ export function PaymentStatusPage() { {/* Mandiri E-Channel specific */} {data.billKey && data.billerCode ? ( -
-
๐Ÿ’ณ Mandiri E-Channel
+
+
+ + + + Mandiri E-Channel +
-
Kode Perusahaan (Biller Code)
-
-
{data.billerCode}
+
Kode Perusahaan (Biller Code)
+
+
{data.billerCode}
-
Kode Bayar (Bill Key)
-
-
{data.billKey}
+
Kode Bayar (Bill Key)
+
+
{data.billKey}
@@ -237,10 +337,10 @@ export function PaymentStatusPage() {
) : null} -
+

Langkah pembayaran:

{data.billKey && data.billerCode ? ( -
    +
    1. Buka aplikasi Livin' by Mandiri atau ATM Mandiri
    2. Pilih menu Bayar / Multi Payment
    3. Pilih penyedia jasa: Midtrans (atau cari dengan Biller Code)
    4. @@ -250,7 +350,7 @@ export function PaymentStatusPage() {
    5. Simpan bukti transaksi
    ) : ( -
      +
      1. Buka aplikasi mobile banking atau ATM
      2. Pilih menu Transfer / Bayar
      3. Masukkan nomor Virtual Account di atas
      4. @@ -266,28 +366,32 @@ export function PaymentStatusPage() {
        {data.store ? (
        -
        Toko
        -
        {data.store}
        +
        Toko
        + {data.store.toLowerCase() === 'alfamart' && } + {data.store.toLowerCase() === 'indomaret' && }
        ) : null} {data.paymentCode ? ( <> -
        Kode Pembayaran
        -
        -
        {data.paymentCode}
        +
        Kode Pembayaran
        +
        +
        {data.paymentCode}
        ) : null}
        -
        +

        Langkah pembayaran:

        -
          +
          1. Kunjungi toko {data.store || 'convenience store'} terdekat
          2. Berikan kode pembayaran kepada kasir
          3. Lakukan pembayaran tunai
          4. @@ -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)) ? ( <> {qrSrc ? ( -
            -
            Scan QR Code
            -
            - QR Code Pembayaran { +
            +
            Scan QR Code
            +
            + QR Code Pembayaran { const next = qrCandidates.find((u) => u !== e.currentTarget.src) if (next) setQrSrc(next) }} />
            ) : null} -
            +

            Langkah pembayaran:

            -
              +
              1. Buka aplikasi {method === 'gopay' || data.method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking yang mendukung QRIS'}
              2. Pilih menu Scan QR atau QRIS
              3. Arahkan kamera ke QR code di atas
              4. @@ -326,24 +430,29 @@ export function PaymentStatusPage() { href={a.url} target="_blank" 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'} + + + + {a.name || 'Buka Aplikasi'} ))}
            ) : null} ) : (data.method === 'qris' || data.method === 'gopay') ? ( -
            -
            -
            ๐Ÿ“ฑ
            +
            +
            + + +
            -
            QR Code Pembayaran
            -
            +
            QR Code Pembayaran
            +

            QR code untuk pembayaran ini ditampilkan di jendela pembayaran Snap.

            Jika Anda menutup jendela tersebut, silakan:

            -
              +
              1. Kembali ke halaman checkout
              2. Buat pembayaran baru dengan order ID yang sama
              3. QR code akan muncul kembali di jendela Snap
              4. @@ -355,27 +464,43 @@ export function PaymentStatusPage() {
            ) : null} {(!method || method === 'credit_card') && data.maskedCard ? ( -
            -
            Kartu Kredit/Debit
            -
            {data.maskedCard}
            -
            +
            +
            Kartu Kredit/Debit
            +
            {data.maskedCard}
            +
            Pembayaran dengan kartu telah diproses. Tunggu konfirmasi dari bank Anda.
            ) : null}
            - ) : null} + )} {/* Help Section */} {!isLoading && !error && ( -
            -
            -

            ๐Ÿ’ก Butuh bantuan?

            -
              -
            • โ€ข Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service
            • -
            • โ€ข Simpan nomor pesanan untuk referensi
            • -
            • โ€ข Halaman ini akan diperbarui otomatis saat status berubah
            • -
            +
            +
            +
            + + + +
            +
            +

            Butuh Bantuan?

            +
              +
            • + โ€ข + Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service +
            • +
            • + โ€ข + Simpan nomor pesanan untuk referensi +
            • +
            • + โ€ข + Halaman ini akan diperbarui otomatis saat status berubah +
            • +
            +
            )} diff --git a/src/styles/globals.css b/src/styles/globals.css index e95b6d9..42f5eb5 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -8,6 +8,7 @@ html, body, #root { height: 100%; } body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background-color: #ffffff; diff --git a/tailwind.config.ts b/tailwind.config.ts index 504d824..b712a66 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -5,6 +5,9 @@ export default { darkMode: 'class', theme: { extend: { + fontFamily: { + sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], + }, colors: { brand: { 50: '#f1f5fb', diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..283ce29 --- /dev/null +++ b/tests/README.md @@ -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/` diff --git a/coreApiSimpleExample.js b/tests/coreApiSimpleExample.js similarity index 100% rename from coreApiSimpleExample.js rename to tests/coreApiSimpleExample.js diff --git a/test-create-payment-link.cjs b/tests/test-create-payment-link.cjs similarity index 89% rename from test-create-payment-link.cjs rename to tests/test-create-payment-link.cjs index 378d9ab..487eb7a 100644 --- a/test-create-payment-link.cjs +++ b/tests/test-create-payment-link.cjs @@ -3,7 +3,7 @@ const fs = require('fs'); async function createPaymentLink() { // 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 if (jsonContent.charCodeAt(0) === 0xFEFF) { jsonContent = jsonContent.slice(1); diff --git a/test-frontend-payload.cjs b/tests/test-frontend-payload.cjs similarity index 100% rename from test-frontend-payload.cjs rename to tests/test-frontend-payload.cjs diff --git a/test-snap-token.cjs b/tests/test-snap-token.cjs similarity index 100% rename from test-snap-token.cjs rename to tests/test-snap-token.cjs diff --git a/tmp-createtransaksi.json b/tmp-createtransaksi.json deleted file mode 100644 index 09f1567..0000000 --- a/tmp-createtransaksi.json +++ /dev/null @@ -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 } - ] -} diff --git a/tmp-sig.txt b/tmp-sig.txt deleted file mode 100644 index 5ebb743..0000000 --- a/tmp-sig.txt +++ /dev/null @@ -1 +0,0 @@ -417582e9fb7105b479e3e7aee99a285dbee0f2ec3238869f8f6fc36b6a098dbee411cf0d3e7637b69f41803518e640a6c9ae71a66b414b29e2182f5aed2ea55a \ No newline at end of file diff --git a/tmp-sig2.txt b/tmp-sig2.txt deleted file mode 100644 index 68890c0..0000000 Binary files a/tmp-sig2.txt and /dev/null differ diff --git a/tmp-sig3.txt b/tmp-sig3.txt deleted file mode 100644 index 825a064..0000000 --- a/tmp-sig3.txt +++ /dev/null @@ -1 +0,0 @@ -e781ba511b1675c05974b45db5f9ddc108d6d2d0acd62ba47fa4125094000512baf9b147689254ac88c406aade53921c9e7e3ae35c154809bdd7723014264667 \ No newline at end of file