diff --git a/scripts/create-test-payment.mjs b/scripts/create-test-payment.mjs
new file mode 100644
index 0000000..ef9838e
--- /dev/null
+++ b/scripts/create-test-payment.mjs
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+
+const API_URL = 'http://localhost:8000/createtransaksi'
+const API_KEY = 'dev-key'
+
+const orderId = `SNAPTEST-${Date.now()}`
+
+const payload = {
+ mercant_id: 'TESTMERCHANT',
+ timestamp: Date.now(),
+ deskripsi: 'Testing Snap Payment Mode',
+ nominal: 150000,
+ nama: 'Test Snap User',
+ no_telepon: '081234567890',
+ email: 'test@snap.com',
+ item: [
+ {
+ item_id: orderId,
+ nama: 'Test Product Snap',
+ harga: 150000,
+ qty: 1
+ }
+ ]
+}
+
+try {
+ const response = await fetch(API_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-api-key': API_KEY
+ },
+ body: JSON.stringify(payload)
+ })
+
+ if (!response.ok) {
+ const error = await response.text()
+ console.error('β Error:', response.status, error)
+ process.exit(1)
+ }
+
+ const data = await response.json()
+
+ const paymentUrl = data.data?.url || data.payment_url
+ const token = paymentUrl ? paymentUrl.split('/pay/')[1] : null
+
+ console.log('β
Payment link created successfully!')
+ console.log('\nπ Snap Mode Payment Link:')
+ console.log(paymentUrl.replace('https://midtrans-cifo.winteraccess.id', 'http://localhost:5173'))
+ console.log('\nπ Order ID:', orderId)
+ console.log('π° Amount: Rp 150,000')
+ console.log('π Mode: SNAP (Hosted UI)')
+
+ if (token) {
+ console.log('\nπ Token:', token.substring(0, 50) + '...')
+ }
+
+} catch (error) {
+ console.error('β Failed to create payment link:', error.message)
+ process.exit(1)
+}
diff --git a/server/index.cjs b/server/index.cjs
index 5e50c9b..08de767 100644
--- a/server/index.cjs
+++ b/server/index.cjs
@@ -7,6 +7,16 @@ 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
+const TransactionLogger = {
+ logPaymentInit: (mode, orderId, amount) => logInfo(`[${mode}] payment.init`, { orderId, amount }),
+ logPaymentSuccess: (mode, orderId, transactionId) => logInfo(`[${mode}] payment.success`, { orderId, transactionId }),
+ logPaymentError: (mode, orderId, error) => logError(`[${mode}] payment.error`, { orderId, error: error.message }),
+ logWebhookReceived: (mode, orderId, status) => logInfo(`[${mode}] webhook.received`, { orderId, status }),
+ logWebhookProcessed: (mode, orderId, internalStatus) => logInfo(`[${mode}] webhook.processed`, { orderId, internalStatus })
+}
+
const app = express()
app.use(cors())
app.use(express.json())
@@ -63,7 +73,13 @@ const LOG_EXPOSE_API = parseEnable(process.env.LOG_EXPOSE_API)
const LOG_BUFFER_SIZE = parseInt(process.env.LOG_BUFFER_SIZE || '1000', 10)
const recentLogs = []
function shouldLog(level) { return (levelOrder[level] ?? 1) >= (levelOrder[LOG_LEVEL] ?? 1) }
-function ts() { return new Date().toISOString() }
+function 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')
+}
function sanitize(obj) {
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
}
@@ -294,15 +310,35 @@ app.post('/api/payments/charge', async (req, res) => {
return res.status(403).json({ error: 'PAYMENT_TYPE_DISABLED', message: 'Convenience Store is disabled by environment configuration.' })
}
const chargeResponse = await core.charge(req.body)
+ TransactionLogger.logPaymentInit('CORE', chargeResponse?.order_id, chargeResponse?.gross_amount)
logInfo('charge.success', { id: req.id, order_id: chargeResponse?.order_id, status_code: chargeResponse?.status_code })
res.json(chargeResponse)
} catch (e) {
const msg = e?.message || 'Charge failed'
+ TransactionLogger.logPaymentError('CORE', req?.body?.transaction_details?.order_id || req?.body?.order_id, { message: msg })
logError('charge.error', { id: req.id, message: msg })
res.status(400).json({ error: 'CHARGE_ERROR', message: msg })
}
})
+// Snap token endpoint (for hosted payment interface)
+app.post('/api/payments/snap/token', async (req, res) => {
+ try {
+ const snap = new midtransClient.Snap({
+ isProduction,
+ serverKey,
+ clientKey
+ })
+
+ const token = await snap.createTransaction(req.body)
+ TransactionLogger.logPaymentInit('SNAP', req.body.transaction_details?.order_id, req.body.transaction_details?.gross_amount)
+ res.json({ token })
+ } catch (e) {
+ TransactionLogger.logPaymentError('SNAP', req.body?.transaction_details?.order_id, e)
+ res.status(400).json({ error: 'SNAP_TOKEN_ERROR', message: e.message })
+ }
+})
+
// Status endpoint (by order_id)
app.get('/api/payments/:orderId/status', async (req, res) => {
const { orderId } = req.params
@@ -436,7 +472,122 @@ app.post('/createtransaksi', async (req, res) => {
}
})
-// --- Helpers: Midtrans signature verification & ERP notify
+// --- Unified Webhook Helpers for Core & Snap
+
+function verifyWebhookSignature(body, mode) {
+ try {
+ const orderId = body?.order_id
+ const statusCode = body?.status_code
+ const grossAmount = body?.gross_amount
+
+ if (mode === 'SNAP') {
+ // Snap signature: order_id + status_code + gross_amount + server_key
+ const signature = req.headers['x-signature'] || req.headers['signature'] || body?.signature
+ if (!signature) return false
+
+ const data = `${orderId}${statusCode}${grossAmount}${serverKey}`
+ const expectedSignature = crypto.createHash('sha512').update(data).digest('hex')
+ return signature === expectedSignature
+ } else {
+ // Core signature: signature_key field in body
+ const signatureKey = (body?.signature_key || '').toLowerCase()
+ const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey)
+ return expectedSig && signatureKey === expectedSig
+ }
+ } catch (e) {
+ logError('webhook.signature.error', { mode, message: e?.message })
+ return false
+ }
+}
+
+function mapStatusToInternal(transactionStatus, mode) {
+ // Unified status mapping - both Core and Snap use similar status values
+ const status = (transactionStatus || '').toLowerCase()
+
+ switch (status) {
+ case 'settlement':
+ case 'capture': // for cards with fraud_status=accept
+ return 'completed'
+ case 'pending':
+ return 'pending'
+ case 'deny':
+ case 'cancel':
+ case 'expire':
+ case 'failure':
+ return 'failed'
+ case 'refund':
+ return 'refunded'
+ default:
+ logWarn(`[${mode}] webhook.unknown_status`, { transaction_status: transactionStatus })
+ return 'unknown'
+ }
+}
+
+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
+ try {
+ logInfo(`[${data.source}] ledger.update`, {
+ order_id: orderId,
+ status: data.status,
+ source: data.source
+ })
+
+ // Here you would typically update a database
+ // For now, we'll just log the ledger update
+ // Future: integrate with Epic 5 ledger system
+
+ } catch (e) {
+ logError('ledger.update.error', { order_id: orderId, message: e?.message })
+ }
+}
+
+async function processPaymentCompletion(orderId, internalStatus, mode, body) {
+ try {
+ logInfo(`[${mode}] payment.process`, {
+ order_id: orderId,
+ internal_status: internalStatus,
+ transaction_status: body?.transaction_status
+ })
+
+ // Shared business logic for successful payments
+ if (internalStatus === 'completed') {
+ const grossAmount = body?.gross_amount
+ const nominal = String(grossAmount || '')
+
+ // Prevent duplicate notifications
+ if (!notifiedOrders.has(orderId)) {
+ // Mark order inactive upon completion
+ activeOrders.delete(orderId)
+
+ // Notify ERP systems
+ const mercantId = resolveMercantId(orderId)
+ const ok = await notifyERP({ orderId, nominal, mercantId })
+
+ if (ok) {
+ notifiedOrders.add(orderId)
+ logInfo(`[${mode}] erp.notify.success`, { order_id: orderId })
+ } else {
+ logWarn(`[${mode}] erp.notify.failed`, { order_id: orderId })
+ }
+ } else {
+ logInfo(`[${mode}] erp.notify.skip`, { order_id: orderId, reason: 'already_notified' })
+ }
+ } else {
+ logInfo(`[${mode}] payment.non_success`, {
+ order_id: orderId,
+ internal_status: internalStatus,
+ transaction_status: body?.transaction_status
+ })
+ }
+
+ } catch (e) {
+ logError(`[${mode}] payment.process.error`, {
+ order_id: orderId,
+ message: e?.message
+ })
+ }
+}
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
try {
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
@@ -600,45 +751,42 @@ async function notifyERP({ orderId, nominal, mercantId }) {
return okCount > 0
}
-// Webhook endpoint for Midtrans notifications
-app.post('/api/payments/webhook', async (req, res) => {
+// Webhook endpoint for Midtrans notifications (Core & Snap unified)
+app.post('/api/payments/notification', async (req, res) => {
try {
const body = req.body || {}
const orderId = body?.order_id
- const statusCode = body?.status_code
- const grossAmount = body?.gross_amount
- const signatureKey = (body?.signature_key || '').toLowerCase()
- logInfo('webhook.receive', { order_id: orderId, transaction_status: body?.transaction_status })
- // Verify signature
- const expectedSig = computeMidtransSignature(orderId, statusCode, grossAmount, serverKey)
- if (!expectedSig || signatureKey !== expectedSig) {
- logWarn('webhook.signature.invalid', { order_id: orderId })
- return res.status(401).json({ error: 'INVALID_SIGNATURE' })
+ // Mode detection: Snap has payment_type, transaction_time, settlement_time
+ const isSnap = body?.payment_type && body?.transaction_time && body?.settlement_time
+ const mode = isSnap ? 'SNAP' : 'CORE'
+
+ TransactionLogger.logWebhookReceived(mode, orderId, body?.transaction_status)
+
+ // Unified signature verification
+ const signatureValid = verifyWebhookSignature(body, mode)
+ if (!signatureValid) {
+ logError(`[${mode}] webhook.signature.invalid`, { order_id: orderId })
+ return res.status(400).send('Invalid signature')
}
+ // Unified status mapping
+ const internalStatus = mapStatusToInternal(body?.transaction_status, mode)
+
+ // Update ledger with source indicator
+ updateLedger(orderId, {
+ status: internalStatus,
+ source: mode === 'SNAP' ? 'snap_webhook' : 'core_webhook',
+ last_updated: new Date().toISOString(),
+ payload: body
+ })
+
// Acknowledge quickly
res.json({ ok: true })
- // Process success callbacks asynchronously
- if (isSuccessfulMidtransStatus(body)) {
- logInfo('webhook.success_status', { order_id: orderId, transaction_status: body?.transaction_status, fraud_status: body?.fraud_status })
- const nominal = String(grossAmount)
- if (!notifiedOrders.has(orderId)) {
- // Mark order inactive upon completion
- activeOrders.delete(orderId)
- const ok = await notifyERP({ orderId, nominal })
- if (ok) {
- notifiedOrders.add(orderId)
- } else {
- logWarn('erp.notify.defer', { orderId, reason: 'post_failed_or_missing_data' })
- }
- } else {
- logInfo('erp.notify.skip', { orderId, reason: 'already_notified' })
- }
- } else {
- logInfo('webhook.non_success', { order_id: orderId, transaction_status: body?.transaction_status })
- }
+ // Trigger shared business logic (ERP unfreeze, etc.)
+ await processPaymentCompletion(orderId, internalStatus, mode, body)
+
} catch (e) {
logError('webhook.error', { message: e?.message })
try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {}
diff --git a/src/app/router.tsx b/src/app/router.tsx
index 5cc3e8a..a89a08e 100644
--- a/src/app/router.tsx
+++ b/src/app/router.tsx
@@ -1,10 +1,10 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppLayout } from './AppLayout'
-// import { CheckoutPage } from '../pages/CheckoutPage'
+import { CheckoutPage } from '../pages/CheckoutPage'
import { PaymentStatusPage } from '../pages/PaymentStatusPage'
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
import { NotFoundPage } from '../pages/NotFoundPage'
-// import { DemoStorePage } from '../pages/DemoStorePage'
+import { DemoStorePage } from '../pages/DemoStorePage'
import { InitPage } from '../pages/InitialPage'
import { PayPage } from '../pages/PayPage'
@@ -15,7 +15,8 @@ const router = createBrowserRouter([
errorElement:
Terjadi kesalahan. Coba muat ulang.
,
children: [
{ index: true, element: },
- // { path: 'checkout', element: },
+ { path: 'checkout', element: },
+ { path: 'demo', element: },
{ path: 'pay/:token', element: },
{ path: 'payments/:orderId/status', element: },
{ path: 'history', element: },
diff --git a/src/features/payments/components/PaymentSheet.tsx b/src/features/payments/components/PaymentSheet.tsx
index 14cecf6..e7eec97 100644
--- a/src/features/payments/components/PaymentSheet.tsx
+++ b/src/features/payments/components/PaymentSheet.tsx
@@ -25,11 +25,12 @@ export interface PaymentSheetProps {
orderId: string
amount: number
expireAt: number // epoch ms
+ customerName?: string
children?: React.ReactNode
showStatusCTA?: boolean
}
-export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
+export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, customerName, children, showStatusCTA = true }: PaymentSheetProps) {
const countdown = useCountdown(expireAt)
const [expanded, setExpanded] = React.useState(true)
return (
@@ -71,6 +72,7 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireA
Total
{formatCurrencyIDR(amount)}
Order ID #{orderId}
+ {customerName && Nama: {customerName}
}
)}
diff --git a/src/features/payments/components/BankTransferPanel.tsx b/src/features/payments/core/BankTransferPanel.tsx
similarity index 98%
rename from src/features/payments/components/BankTransferPanel.tsx
rename to src/features/payments/core/BankTransferPanel.tsx
index 6276c8f..0e07242 100644
--- a/src/features/payments/components/BankTransferPanel.tsx
+++ b/src/features/payments/core/BankTransferPanel.tsx
@@ -1,12 +1,12 @@
import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
-import { PaymentInstructions } from './PaymentInstructions'
-import { BcaInstructionList } from './BcaInstructionList'
-import { type BankKey } from './PaymentLogos'
+import { PaymentInstructions } from '../components/PaymentInstructions'
+import { BcaInstructionList } from '../components/BcaInstructionList'
+import { type BankKey } from '../components/PaymentLogos'
import { postCharge } from '../../../services/api'
import { Alert } from '../../../components/alert/Alert'
-import { InlinePaymentStatus } from './InlinePaymentStatus'
+import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
import { LoadingOverlay } from '../../../components/LoadingOverlay'
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
diff --git a/src/features/payments/components/CStorePanel.tsx b/src/features/payments/core/CStorePanel.tsx
similarity index 97%
rename from src/features/payments/components/CStorePanel.tsx
rename to src/features/payments/core/CStorePanel.tsx
index 1ede78f..1ab01d4 100644
--- a/src/features/payments/components/CStorePanel.tsx
+++ b/src/features/payments/core/CStorePanel.tsx
@@ -2,9 +2,9 @@ import { Button } from '../../../components/ui/button'
import { toast } from '../../../components/ui/toast'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
-import { PaymentInstructions } from './PaymentInstructions'
+import { PaymentInstructions } from '../components/PaymentInstructions'
import { postCharge } from '../../../services/api'
-import { InlinePaymentStatus } from './InlinePaymentStatus'
+import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
import { LoadingOverlay } from '../../../components/LoadingOverlay'
import { Alert } from '../../../components/alert/Alert'
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
diff --git a/src/features/payments/components/CardPanel.tsx b/src/features/payments/core/CardPanel.tsx
similarity index 97%
rename from src/features/payments/components/CardPanel.tsx
rename to src/features/payments/core/CardPanel.tsx
index ebb5c75..2c74791 100644
--- a/src/features/payments/components/CardPanel.tsx
+++ b/src/features/payments/core/CardPanel.tsx
@@ -1,13 +1,13 @@
import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
-import { PaymentInstructions } from './PaymentInstructions'
-import { CardLogosRow } from './PaymentLogos'
+import { PaymentInstructions } from '../components/PaymentInstructions'
+import { CardLogosRow } from '../components/PaymentLogos'
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
import { Logger } from '../../../lib/logger'
import { Env } from '../../../lib/env'
import { postCharge } from '../../../services/api'
-import { InlinePaymentStatus } from './InlinePaymentStatus'
+import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
diff --git a/src/features/payments/components/GoPayPanel.tsx b/src/features/payments/core/GoPayPanel.tsx
similarity index 98%
rename from src/features/payments/components/GoPayPanel.tsx
rename to src/features/payments/core/GoPayPanel.tsx
index 4905d7f..729572f 100644
--- a/src/features/payments/components/GoPayPanel.tsx
+++ b/src/features/payments/core/GoPayPanel.tsx
@@ -1,10 +1,10 @@
import { Button } from '../../../components/ui/button'
import { usePaymentNavigation } from '../lib/navigation'
import React from 'react'
-import { PaymentInstructions } from './PaymentInstructions'
-import { GoPayLogosRow } from './PaymentLogos'
+import { PaymentInstructions } from '../components/PaymentInstructions'
+import { GoPayLogosRow } from '../components/PaymentLogos'
import { postCharge } from '../../../services/api'
-import { InlinePaymentStatus } from './InlinePaymentStatus'
+import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
import { toast } from '../../../components/ui/toast'
import { LoadingOverlay } from '../../../components/LoadingOverlay'
import { Alert } from '../../../components/alert/Alert'
diff --git a/src/features/payments/lib/PaymentAdapter.ts b/src/features/payments/lib/PaymentAdapter.ts
new file mode 100644
index 0000000..1a02bf3
--- /dev/null
+++ b/src/features/payments/lib/PaymentAdapter.ts
@@ -0,0 +1,47 @@
+import { getPaymentMode } from './paymentMode'
+
+export type PaymentMethod = 'bank_transfer' | 'credit_card' | 'gopay' | 'cstore'
+
+export class PaymentAdapter {
+ static getPaymentComponent(method: PaymentMethod) {
+ const mode = getPaymentMode()
+
+ if (mode === 'SNAP') {
+ return this.getSnapComponent(method)
+ } else {
+ return this.getCoreComponent(method)
+ }
+ }
+
+ private static getCoreComponent(method: PaymentMethod) {
+ switch (method) {
+ case 'bank_transfer':
+ return import('../core/BankTransferPanel')
+ case 'credit_card':
+ return import('../core/CardPanel')
+ case 'gopay':
+ return import('../core/GoPayPanel')
+ case 'cstore':
+ return import('../core/CStorePanel')
+ default:
+ throw new Error(`Unknown payment method: ${method}`)
+ }
+ }
+
+ private static getSnapComponent(_method: PaymentMethod) {
+ // For Snap, most methods are handled by hosted interface
+ // But we still need to return the SnapPaymentTrigger for consistency
+ return import('../snap/SnapPaymentTrigger')
+ }
+
+ static shouldUseHostedInterface(_method: PaymentMethod): boolean {
+ const mode = getPaymentMode()
+ return mode === 'SNAP'
+ }
+
+ static getAvailableMethods(): PaymentMethod[] {
+ // Return methods available in current mode
+ // This could be extended to filter based on environment config
+ return ['bank_transfer', 'credit_card', 'gopay', 'cstore']
+ }
+}
\ No newline at end of file
diff --git a/src/features/payments/lib/paymentMode.ts b/src/features/payments/lib/paymentMode.ts
new file mode 100644
index 0000000..33af8c3
--- /dev/null
+++ b/src/features/payments/lib/paymentMode.ts
@@ -0,0 +1,18 @@
+export const PaymentMode = {
+ CORE: 'CORE',
+ SNAP: 'SNAP'
+} as const
+
+export type PaymentModeType = typeof PaymentMode[keyof typeof PaymentMode]
+
+export function getPaymentMode(): PaymentModeType {
+ return (import.meta.env.VITE_PAYMENT_GATEWAY_MODE as PaymentModeType) || 'SNAP'
+}
+
+export function isSnapMode(): boolean {
+ return getPaymentMode() === PaymentMode.SNAP
+}
+
+export function isCoreMode(): boolean {
+ return getPaymentMode() === PaymentMode.CORE
+}
\ No newline at end of file
diff --git a/src/features/payments/shared/CustomerDataHandler.ts b/src/features/payments/shared/CustomerDataHandler.ts
new file mode 100644
index 0000000..43d0a18
--- /dev/null
+++ b/src/features/payments/shared/CustomerDataHandler.ts
@@ -0,0 +1,131 @@
+import { TransactionLogger } from './TransactionLogger'
+import { getPaymentMode } from '../lib/paymentMode'
+
+export interface CustomerData {
+ first_name?: string
+ last_name?: string
+ name?: string
+ email?: string
+ phone?: string
+ address?: string
+ city?: string
+ postal_code?: string
+ country_code?: string
+}
+
+export interface SanitizedCustomerData {
+ name: string
+ email: string
+ phone?: string
+ address?: string
+}
+
+export class CustomerDataHandler {
+ static sanitizeCustomerData(customer: CustomerData): SanitizedCustomerData {
+ const mode = getPaymentMode()
+
+ try {
+ // Combine first_name and last_name if available, otherwise use name
+ const name = customer.name ||
+ (customer.first_name && customer.last_name
+ ? `${customer.first_name} ${customer.last_name}`
+ : customer.first_name || customer.last_name || 'Unknown')
+
+ // Basic email validation
+ const email = customer.email?.toLowerCase().trim()
+ if (!email || !this.isValidEmail(email)) {
+ TransactionLogger.log(mode, 'customer.data.warning', {
+ field: 'email',
+ reason: 'invalid_or_missing'
+ })
+ }
+
+ // Sanitize phone number (remove non-numeric characters except +)
+ const phone = customer.phone?.replace(/[^\d+]/g, '')
+
+ // Create sanitized address if available
+ const address = this.buildAddressString(customer)
+
+ const sanitized: SanitizedCustomerData = {
+ name: name.trim(),
+ email: email || '',
+ phone,
+ address
+ }
+
+ TransactionLogger.log(mode, 'customer.data.sanitized', {
+ hasName: !!sanitized.name,
+ hasEmail: !!sanitized.email,
+ hasPhone: !!sanitized.phone,
+ hasAddress: !!sanitized.address
+ })
+
+ return sanitized
+
+ } catch (error) {
+ TransactionLogger.log(mode, 'customer.data.sanitization.error', {
+ error: error instanceof Error ? error.message : String(error)
+ })
+ // Return minimal safe data on error
+ return {
+ name: 'Unknown Customer',
+ email: '',
+ phone: customer.phone,
+ address: customer.address
+ }
+ }
+ }
+
+ static validateCustomerData(customer: CustomerData): { isValid: boolean; errors: string[] } {
+ const errors: string[] = []
+
+ if (!customer.name && !customer.first_name && !customer.last_name) {
+ errors.push('Name is required')
+ }
+
+ if (!customer.email) {
+ errors.push('Email is required')
+ } else if (!this.isValidEmail(customer.email)) {
+ errors.push('Invalid email format')
+ }
+
+ if (!customer.phone) {
+ errors.push('Phone number is required')
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ }
+ }
+
+ static buildAddressString(customer: CustomerData): string | undefined {
+ const parts: string[] = []
+
+ if (customer.address) parts.push(customer.address)
+ if (customer.city) parts.push(customer.city)
+ if (customer.postal_code) parts.push(customer.postal_code)
+ if (customer.country_code) parts.push(customer.country_code)
+
+ return parts.length > 0 ? parts.join(', ') : undefined
+ }
+
+ private static isValidEmail(email: string): boolean {
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+ return emailRegex.test(email)
+ }
+
+ static formatForMidtrans(customer: SanitizedCustomerData) {
+ // Format customer data for Midtrans API (both Core and Snap)
+ return {
+ first_name: customer.name.split(' ')[0] || '',
+ last_name: customer.name.split(' ').slice(1).join(' ') || '',
+ email: customer.email,
+ phone: customer.phone || '',
+ address: customer.address || '',
+ city: '',
+ postal_code: '',
+ country_code: 'IDN'
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/features/payments/shared/OrderManager.ts b/src/features/payments/shared/OrderManager.ts
new file mode 100644
index 0000000..dbdc7f2
--- /dev/null
+++ b/src/features/payments/shared/OrderManager.ts
@@ -0,0 +1,85 @@
+import { api } from '../../../services/api'
+import { TransactionLogger } from './TransactionLogger'
+import { getPaymentMode } from '../lib/paymentMode'
+
+export class OrderManager {
+ static async validateOrder(orderId: string, amount: number): Promise {
+ const mode = getPaymentMode()
+
+ try {
+ // Basic validation
+ if (!orderId || amount <= 0) {
+ TransactionLogger.log(mode, 'order.validation.failed', {
+ orderId,
+ amount,
+ reason: 'invalid_parameters'
+ })
+ return false
+ }
+
+ // Additional business rules can be added here
+ // For now, just check if order exists in our system
+ const orderDetails = await this.getOrderDetails(orderId)
+
+ if (!orderDetails) {
+ TransactionLogger.log(mode, 'order.validation.failed', {
+ orderId,
+ reason: 'order_not_found'
+ })
+ return false
+ }
+
+ // Check amount matches
+ if (orderDetails.amount !== amount) {
+ TransactionLogger.log(mode, 'order.validation.failed', {
+ orderId,
+ expectedAmount: orderDetails.amount,
+ providedAmount: amount,
+ reason: 'amount_mismatch'
+ })
+ return false
+ }
+
+ TransactionLogger.log(mode, 'order.validation.success', { orderId, amount })
+ return true
+ } catch (error) {
+ TransactionLogger.logPaymentError(mode, orderId, error)
+ return false
+ }
+ }
+
+ static async getOrderDetails(orderId: string) {
+ try {
+ // This would typically call your ERP or order management system
+ // For now, return mock data or call existing API
+ const response = await api.get(`/orders/${orderId}`)
+ return response.data
+ } catch (error) {
+ // If API doesn't exist yet, return null (will be implemented in Epic 5)
+ console.warn(`Order details API not available for ${orderId}:`, error instanceof Error ? error.message : String(error))
+ return null
+ }
+ }
+
+ static async updateOrderStatus(orderId: string, status: string, source: string) {
+ const mode = getPaymentMode()
+
+ try {
+ // This would update your ERP system to unfreeze inventory, etc.
+ // For now, just log the update
+ TransactionLogger.log(mode, 'order.status.updated', {
+ orderId,
+ status,
+ source,
+ timestamp: new Date().toISOString()
+ })
+
+ // TODO: Implement actual ERP integration in Epic 5
+ // await api.post('/erp/orders/update-status', { orderId, status, source })
+
+ } catch (error) {
+ TransactionLogger.logPaymentError(mode, orderId, error)
+ throw error
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/features/payments/shared/TransactionLogger.ts b/src/features/payments/shared/TransactionLogger.ts
new file mode 100644
index 0000000..9033902
--- /dev/null
+++ b/src/features/payments/shared/TransactionLogger.ts
@@ -0,0 +1,31 @@
+import { Logger } from '../../../lib/logger'
+
+export class TransactionLogger {
+ static log(mode: 'CORE' | 'SNAP', event: string, data: any) {
+ Logger.info(`[${mode}] ${event}`, {
+ ...data,
+ mode,
+ timestamp: new Date().toISOString()
+ })
+ }
+
+ static logPaymentInit(mode: 'CORE' | 'SNAP', orderId: string, amount: number) {
+ this.log(mode, 'payment.init', { orderId, amount })
+ }
+
+ static logPaymentSuccess(mode: 'CORE' | 'SNAP', orderId: string, transactionId: string) {
+ this.log(mode, 'payment.success', { orderId, transactionId })
+ }
+
+ static logPaymentError(mode: 'CORE' | 'SNAP', orderId: string, error: any) {
+ this.log(mode, 'payment.error', { orderId, error: error.message })
+ }
+
+ static logWebhookReceived(mode: 'CORE' | 'SNAP', orderId: string, status: string) {
+ this.log(mode, 'webhook.received', { orderId, status })
+ }
+
+ static logWebhookProcessed(mode: 'CORE' | 'SNAP', orderId: string, internalStatus: string) {
+ this.log(mode, 'webhook.processed', { orderId, internalStatus })
+ }
+}
\ No newline at end of file
diff --git a/src/features/payments/snap/SnapPaymentTrigger.tsx b/src/features/payments/snap/SnapPaymentTrigger.tsx
new file mode 100644
index 0000000..0befb0e
--- /dev/null
+++ b/src/features/payments/snap/SnapPaymentTrigger.tsx
@@ -0,0 +1,204 @@
+import React from 'react'
+import { Button } from '../../../components/ui/button'
+import { getPaymentMode } from '../lib/paymentMode'
+import { Alert } from '../../../components/alert/Alert'
+import { LoadingOverlay } from '../../../components/LoadingOverlay'
+import { mapErrorToUserMessage } from '../../../lib/errorMessages'
+import { Logger } from '../../../lib/logger'
+import { SnapTokenService } from './SnapTokenService'
+
+interface SnapPaymentTriggerProps {
+ orderId: string
+ amount: number
+ customer?: { name?: string; phone?: string; email?: string }
+ paymentMethod?: string
+ onSuccess?: (result: any) => void
+ onError?: (error: any) => void
+ onChargeInitiated?: () => void
+}
+
+export function SnapPaymentTrigger({
+ orderId,
+ amount,
+ customer,
+ paymentMethod,
+ onSuccess,
+ onError,
+ onChargeInitiated
+}: SnapPaymentTriggerProps) {
+ const mode = getPaymentMode()
+
+ // If Core mode, render the appropriate Core component
+ if (mode === 'CORE') {
+ return (
+
+ )
+ }
+
+ // Snap mode - use hosted payment interface
+ return (
+
+ )
+}
+
+function CorePaymentComponent({ paymentMethod, orderId, amount, onChargeInitiated }: {
+ paymentMethod: string
+ orderId: string
+ amount: number
+ onChargeInitiated?: () => void
+}) {
+ const [Component, setComponent] = React.useState | null>(null)
+ const [loading, setLoading] = React.useState(true)
+
+ React.useEffect(() => {
+ const loadComponent = async () => {
+ try {
+ let componentModule: any
+
+ switch (paymentMethod) {
+ case 'bank_transfer':
+ componentModule = await import('../core/BankTransferPanel')
+ setComponent(() => componentModule.BankTransferPanel)
+ break
+ case 'credit_card':
+ componentModule = await import('../core/CardPanel')
+ setComponent(() => componentModule.CardPanel)
+ break
+ case 'gopay':
+ componentModule = await import('../core/GoPayPanel')
+ setComponent(() => componentModule.GoPayPanel)
+ break
+ case 'cstore':
+ componentModule = await import('../core/CStorePanel')
+ setComponent(() => componentModule.CStorePanel)
+ break
+ default:
+ componentModule = await import('../core/BankTransferPanel')
+ setComponent(() => componentModule.BankTransferPanel)
+ }
+ } catch (error) {
+ console.error('Failed to load payment component:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ loadComponent()
+ }, [paymentMethod])
+
+ if (loading) {
+ return Loading payment component...
+ }
+
+ if (!Component) {
+ return Payment method not available
+ }
+
+ return
+}
+
+function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Omit) {
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState('')
+
+ const handleSnapPayment = async () => {
+ try {
+ setLoading(true)
+ setError('')
+
+ Logger.paymentInfo('snap.payment.init', { orderId, amount, customer })
+
+ // Create Snap transaction token using service
+ const token = await SnapTokenService.createToken({
+ transaction_details: {
+ order_id: orderId,
+ gross_amount: amount
+ },
+ customer_details: customer ? {
+ first_name: customer.name,
+ email: customer.email,
+ phone: customer.phone
+ } : undefined,
+ item_details: [{
+ id: orderId,
+ name: 'Payment',
+ price: amount,
+ quantity: 1
+ }]
+ })
+
+ Logger.paymentInfo('snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
+
+ // Trigger Snap payment popup
+ if (window.snap && typeof window.snap.pay === 'function') {
+ window.snap.pay(token, {
+ onSuccess: (result: any) => {
+ Logger.paymentInfo('snap.payment.success', { orderId, transactionId: result.transaction_id })
+ onSuccess?.(result)
+ },
+ onPending: (result: any) => {
+ Logger.paymentInfo('snap.payment.pending', { orderId, transactionId: result.transaction_id })
+ // Handle pending state
+ },
+ onError: (result: any) => {
+ Logger.paymentError('snap.payment.error', { orderId, error: result })
+ const message = mapErrorToUserMessage(result)
+ setError(message)
+ onError?.(result)
+ },
+ onClose: () => {
+ Logger.paymentInfo('snap.popup.closed', { orderId })
+ // User closed the popup without completing payment
+ }
+ })
+ } else {
+ throw new Error('Snap.js not loaded')
+ }
+
+ } catch (e: any) {
+ Logger.paymentError('snap.payment.error', { orderId, error: e.message })
+ const message = mapErrorToUserMessage(e)
+ setError(message)
+ onError?.(e)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ Klik tombol di bawah untuk melanjutkan pembayaran dengan Midtrans Snap
+
+
+
+ {loading ? 'Memproses...' : 'Bayar Sekarang'}
+
+
+
+ {loading &&
}
+
+ )
+}
\ No newline at end of file
diff --git a/src/features/payments/snap/SnapTokenService.ts b/src/features/payments/snap/SnapTokenService.ts
new file mode 100644
index 0000000..dfedc98
--- /dev/null
+++ b/src/features/payments/snap/SnapTokenService.ts
@@ -0,0 +1,100 @@
+import { api } from '../../../services/api'
+import { TransactionLogger } from '../shared/TransactionLogger'
+import { getPaymentMode } from '../lib/paymentMode'
+
+export interface SnapTokenRequest {
+ transaction_details: {
+ order_id: string
+ gross_amount: number
+ }
+ customer_details?: {
+ first_name?: string
+ last_name?: string
+ email?: string
+ phone?: string
+ }
+ item_details?: Array<{
+ id: string
+ price: number
+ quantity: number
+ name: string
+ }>
+}
+
+export interface SnapTokenResponse {
+ token: string | {
+ token: string
+ redirect_url: string
+ }
+}
+
+export class SnapTokenService {
+ static async createToken(request: SnapTokenRequest): Promise {
+ const mode = getPaymentMode()
+
+ if (mode !== 'SNAP') {
+ throw new Error('Snap token creation only available in SNAP mode')
+ }
+
+ try {
+ TransactionLogger.logPaymentInit('SNAP', request.transaction_details.order_id, request.transaction_details.gross_amount)
+
+ const response = await api.post('/payments/snap/token', request)
+
+ // Handle both response formats:
+ // 1. Direct string: { token: "abc123" }
+ // 2. Nested object: { token: { token: "abc123", redirect_url: "..." } }
+ let tokenString: string
+ if (typeof response.data?.token === 'string') {
+ tokenString = response.data.token
+ } else if (response.data?.token && typeof response.data.token === 'object') {
+ tokenString = response.data.token.token
+ } else {
+ throw new Error('Invalid token response from server')
+ }
+
+ if (!tokenString) {
+ throw new Error('Empty token received from server')
+ }
+
+ TransactionLogger.log('SNAP', 'token.created', {
+ orderId: request.transaction_details.order_id,
+ tokenLength: tokenString.length
+ })
+
+ return tokenString
+
+ } catch (error) {
+ TransactionLogger.logPaymentError('SNAP', request.transaction_details.order_id, error)
+ throw error
+ }
+ }
+
+ static validateTokenRequest(request: SnapTokenRequest): { isValid: boolean; errors: string[] } {
+ const errors: string[] = []
+
+ if (!request.transaction_details?.order_id) {
+ errors.push('Order ID is required')
+ }
+
+ if (!request.transaction_details?.gross_amount || request.transaction_details.gross_amount <= 0) {
+ errors.push('Valid gross amount is required')
+ }
+
+ // Validate item details sum matches gross amount
+ if (request.item_details && request.item_details.length > 0) {
+ const totalFromItems = request.item_details.reduce((sum, item) => {
+ return sum + (item.price * item.quantity)
+ }, 0)
+
+ if (totalFromItems !== request.transaction_details.gross_amount) {
+ errors.push(`Item total (${totalFromItems}) does not match gross amount (${request.transaction_details.gross_amount})`)
+ }
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/lib/env.ts b/src/lib/env.ts
index d71823a..be98afe 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -13,6 +13,8 @@ export const Env = {
MIDTRANS_CLIENT_KEY: import.meta.env.VITE_MIDTRANS_CLIENT_KEY || '',
MIDTRANS_ENV: (import.meta.env.VITE_MIDTRANS_ENV as 'sandbox' | 'production') || 'sandbox',
LOG_LEVEL: ((import.meta.env.VITE_LOG_LEVEL as string) || 'info').toLowerCase() as 'debug' | 'info' | 'warn' | 'error',
+ // Payment gateway mode: CORE (custom UI) or SNAP (hosted interface)
+ PAYMENT_GATEWAY_MODE: (import.meta.env.VITE_PAYMENT_GATEWAY_MODE as 'CORE' | 'SNAP') || 'SNAP',
// Feature toggles per payment type (frontend)
ENABLE_BANK_TRANSFER: parseEnable(import.meta.env.VITE_ENABLE_BANK_TRANSFER),
ENABLE_CREDIT_CARD: parseEnable(import.meta.env.VITE_ENABLE_CREDIT_CARD),
diff --git a/src/lib/logger.ts b/src/lib/logger.ts
index 0438e36..773e1fa 100644
--- a/src/lib/logger.ts
+++ b/src/lib/logger.ts
@@ -1,4 +1,5 @@
import { Env } from './env'
+import { getPaymentMode } from '../features/payments/lib/paymentMode'
type Level = 'debug' | 'info' | 'warn' | 'error'
const order: Record = { debug: 0, info: 1, warn: 2, error: 3 }
@@ -62,4 +63,21 @@ export const Logger = {
mask(meta: any) {
return maskSensitive(meta)
},
+ // Payment-specific logging with mode prefix
+ paymentInfo(msg: string, meta?: any) {
+ const mode = getPaymentMode()
+ this.info(`[${mode}] ${msg}`, meta)
+ },
+ paymentDebug(msg: string, meta?: any) {
+ const mode = getPaymentMode()
+ this.debug(`[${mode}] ${msg}`, meta)
+ },
+ paymentWarn(msg: string, meta?: any) {
+ const mode = getPaymentMode()
+ this.warn(`[${mode}] ${msg}`, meta)
+ },
+ paymentError(msg: string, meta?: any) {
+ const mode = getPaymentMode()
+ this.error(`[${mode}] ${msg}`, meta)
+ },
}
\ No newline at end of file
diff --git a/src/lib/snapLoader.ts b/src/lib/snapLoader.ts
new file mode 100644
index 0000000..ccb5c20
--- /dev/null
+++ b/src/lib/snapLoader.ts
@@ -0,0 +1,132 @@
+import { Env } from './env'
+import { Logger } from './logger'
+
+let snapLoaded = false
+let snapPromise: Promise | null = null
+
+/**
+ * Dynamically loads Midtrans Snap.js script
+ * Returns a promise that resolves when Snap.js is ready
+ */
+export function loadSnapScript(): Promise {
+ // Return existing promise if already loading
+ if (snapPromise) {
+ return snapPromise
+ }
+
+ // Already loaded
+ if (snapLoaded && window.snap) {
+ return Promise.resolve()
+ }
+
+ // Start loading
+ snapPromise = new Promise((resolve, reject) => {
+ try {
+ const clientKey = Env.MIDTRANS_CLIENT_KEY
+ const midtransEnv = Env.MIDTRANS_ENV || 'sandbox'
+
+ if (!clientKey) {
+ const error = 'MIDTRANS_CLIENT_KEY not configured'
+ Logger.error('snap.load.error', { error })
+ reject(new Error(error))
+ return
+ }
+
+ // Determine Snap.js URL based on environment
+ const snapUrl = midtransEnv === 'production'
+ ? 'https://app.midtrans.com/snap/snap.js'
+ : 'https://app.sandbox.midtrans.com/snap/snap.js'
+
+ Logger.info('snap.load.start', { snapUrl, clientKey: clientKey.substring(0, 10) + '...' })
+
+ // Check if script already exists
+ const existingScript = document.querySelector(`script[src="${snapUrl}"]`)
+ if (existingScript) {
+ Logger.info('snap.load.exists', { snapUrl })
+ // Wait a bit and check if window.snap is available
+ setTimeout(() => {
+ if (window.snap) {
+ snapLoaded = true
+ Logger.info('snap.load.ready', { hasSnap: true })
+ resolve()
+ } else {
+ Logger.error('snap.load.error', { error: 'Script loaded but window.snap not available' })
+ reject(new Error('Snap.js loaded but window.snap not available'))
+ }
+ }, 500)
+ return
+ }
+
+ // Create script element
+ const script = document.createElement('script')
+ script.src = snapUrl
+ script.setAttribute('data-client-key', clientKey)
+
+ script.onload = () => {
+ Logger.info('snap.script.loaded', { snapUrl })
+ console.log('Snap.js script loaded, waiting for initialization...')
+ // Wait a bit for Snap to initialize
+ setTimeout(() => {
+ console.log('After 500ms delay - window.snap:', window.snap)
+ console.log('After 500ms delay - window.snap?.pay:', window.snap?.pay)
+ if (window.snap && typeof window.snap.pay === 'function') {
+ snapLoaded = true
+ Logger.info('snap.load.success', { hasSnap: true, hasPay: true })
+ console.log('β Snap.js ready!')
+ resolve()
+ } else {
+ const error = 'Snap.js loaded but window.snap.pay not available'
+ Logger.error('snap.load.error', { error, hasSnap: !!window.snap })
+ console.error('β Snap.js error:', error, { hasSnap: !!window.snap, snapObj: window.snap })
+ reject(new Error(error))
+ }
+ }, 500)
+ }
+
+ script.onerror = (error) => {
+ Logger.error('snap.script.error', { error, snapUrl })
+ reject(new Error('Failed to load Snap.js script'))
+ }
+
+ // Append script to document
+ document.head.appendChild(script)
+ Logger.info('snap.script.appended', { snapUrl })
+
+ } catch (error: any) {
+ Logger.error('snap.load.exception', { error: error.message })
+ reject(error)
+ }
+ })
+
+ return snapPromise
+}
+
+/**
+ * Check if Snap.js is already loaded and ready
+ */
+export function isSnapReady(): boolean {
+ return snapLoaded && !!window.snap && typeof window.snap.pay === 'function'
+}
+
+/**
+ * Wait for Snap.js to be ready, with timeout
+ */
+export function waitForSnap(timeoutMs: number = 5000): Promise {
+ return new Promise((resolve, reject) => {
+ if (isSnapReady()) {
+ resolve()
+ return
+ }
+
+ const startTime = Date.now()
+ const checkInterval = setInterval(() => {
+ if (isSnapReady()) {
+ clearInterval(checkInterval)
+ resolve()
+ } else if (Date.now() - startTime > timeoutMs) {
+ clearInterval(checkInterval)
+ reject(new Error(`Snap.js not ready after ${timeoutMs}ms`))
+ }
+ }, 100)
+ })
+}
diff --git a/src/main.tsx b/src/main.tsx
index 837b8d6..97fd77d 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/globals.css'
+import { getPaymentMode } from './features/payments/lib/paymentMode'
(() => {
const html = document.documentElement
@@ -14,6 +15,31 @@ import './styles/globals.css'
} catch {
}
})()
+
+// Validate payment gateway mode on startup
+const mode = getPaymentMode()
+if (!['CORE', 'SNAP'].includes(mode)) {
+ throw new Error(`Invalid PAYMENT_GATEWAY_MODE: ${mode}. Must be 'CORE' or 'SNAP'`)
+}
+console.log(`[PAYMENT] Mode: ${mode}`)
+
+// Load Snap.js script conditionally for Snap mode
+if (mode === 'SNAP') {
+ const script = document.createElement('script')
+ script.src = 'https://app.sandbox.midtrans.com/snap/snap.js'
+ script.setAttribute('data-client-key', import.meta.env.VITE_MIDTRANS_CLIENT_KEY || '')
+ script.async = true
+ document.head.appendChild(script)
+
+ script.onload = () => {
+ console.log('[SNAP] Snap.js loaded successfully')
+ }
+
+ script.onerror = () => {
+ console.error('[SNAP] Failed to load Snap.js')
+ }
+}
+
import { AppRouter } from './app/router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
diff --git a/src/pages/CheckoutPage.tsx b/src/pages/CheckoutPage.tsx
index 4e4f416..cb55340 100644
--- a/src/pages/CheckoutPage.tsx
+++ b/src/pages/CheckoutPage.tsx
@@ -1,17 +1,193 @@
+import React from 'react'
import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button'
import { Env } from '../lib/env'
-import { PaymentSheet } from '../features/payments/components/PaymentSheet'
-import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
-import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
-import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
-import { CardPanel } from '../features/payments/components/CardPanel'
-import { GoPayPanel } from '../features/payments/components/GoPayPanel'
-import { CStorePanel } from '../features/payments/components/CStorePanel'
-import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
-import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import { Logger } from '../lib/logger'
-import React from 'react'
+import { loadSnapScript } from '../lib/snapLoader'
+import { PaymentSheet } from '../features/payments/components/PaymentSheet'
+import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
+import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
+import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
+interface AutoSnapPaymentProps {
+ orderId: string
+ amount: number
+ customer?: { name?: string; phone?: string; email?: string }
+ onChargeInitiated?: () => void
+ onSuccess?: (result: any) => void
+ onError?: (error: any) => void
+ onModalClosed?: () => void
+}
+
+function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError, onModalClosed }: Omit) {
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState('')
+ const [paymentTriggered, setPaymentTriggered] = React.useState(false)
+
+ // Debug log immediately on component mount
+ console.log('AutoSnapPayment mounted with:', { orderId, amount, customer })
+ Logger.info('autosnapPayment.mount', { orderId, amount, hasCustomer: !!customer })
+
+ React.useEffect(() => {
+ console.log('AutoSnapPayment useEffect triggered', { orderId, amount, paymentTriggered })
+ // Only trigger when we have valid orderId and amount and not already triggered
+ if (!orderId || !amount || paymentTriggered) {
+ console.log('AutoSnapPayment useEffect early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
+ return
+ }
+
+ const triggerPayment = async () => {
+ console.log('triggerPayment function called!')
+ setPaymentTriggered(true) // Mark as triggered immediately
+ try {
+ setLoading(true)
+ setError('')
+
+ Logger.paymentInfo('checkout.auto.snap.init', { orderId, amount, customer })
+
+ // Load Snap.js first
+ Logger.paymentInfo('checkout.auto.snap.loading_script', { orderId })
+ await loadSnapScript()
+ Logger.paymentInfo('checkout.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
+
+ // Create Snap transaction token
+ Logger.paymentInfo('checkout.auto.snap.calling_api', { orderId, amount })
+ const token = await SnapTokenService.createToken({
+ transaction_details: {
+ order_id: orderId,
+ gross_amount: amount
+ },
+ customer_details: customer ? {
+ first_name: customer.name,
+ email: customer.email,
+ phone: customer.phone
+ } : undefined,
+ item_details: [{
+ id: orderId,
+ name: 'Payment',
+ price: amount,
+ quantity: 1
+ }]
+ })
+
+ Logger.paymentInfo('checkout.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
+ console.log('Token berhasil dibuat:', token)
+
+ // Verify Snap.js is loaded
+ console.log('window.snap:', window.snap)
+ console.log('window.snap.pay:', window.snap?.pay)
+ console.log('typeof window.snap?.pay:', typeof window.snap?.pay)
+
+ if (!window.snap || typeof window.snap.pay !== 'function') {
+ const errorMsg = `Snap.js not properly loaded: hasSnap=${!!window.snap}, hasPay=${typeof window.snap?.pay}`
+ console.error(errorMsg)
+ throw new Error(errorMsg)
+ }
+
+ // Auto-trigger Snap payment popup
+ console.log('Memanggil window.snap.pay dengan token:', token.substring(0, 20) + '...')
+ console.log('Full token:', token)
+ setLoading(false) // Stop loading indicator before showing modal
+
+ window.snap.pay(token, {
+ onSuccess: (result: any) => {
+ Logger.paymentInfo('checkout.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
+ onSuccess?.(result)
+ },
+ onPending: (result: any) => {
+ Logger.paymentInfo('checkout.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
+ },
+ onError: (result: any) => {
+ Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: result })
+ const message = 'Pembayaran gagal. Silakan coba lagi.'
+ setError(message)
+ setLoading(false)
+ onError?.(result)
+ },
+ onClose: () => {
+ Logger.paymentInfo('checkout.auto.snap.popup.closed', { orderId })
+ setLoading(false)
+ onModalClosed?.() // Enable status button when modal closed
+ }
+ })
+
+ } catch (e: any) {
+ Logger.paymentError('checkout.auto.snap.payment.error', { orderId, error: e.message, stack: e.stack })
+ console.error('Error membuat token Snap:', e)
+
+ // Handle specific error: order_id already taken
+ const errorMessage = e.response?.data?.message || e.message || ''
+ const isOrderTaken = errorMessage.includes('already been taken') ||
+ errorMessage.includes('order_id has already been taken')
+
+ if (isOrderTaken) {
+ const message = 'Order ID sudah digunakan. Pembayaran untuk order ini sudah dibuat. Silakan cek halaman status pembayaran.'
+ setError(message)
+ } else {
+ const message = e.response?.data?.message || e.message || 'Gagal memuat pembayaran. Silakan refresh halaman.'
+ setError(message)
+ }
+
+ onError?.(e)
+ setLoading(false)
+ }
+ }
+
+ // Small delay to ensure UI is rendered
+ console.log('Setting timeout to call triggerPayment in 500ms...')
+ const timer = setTimeout(triggerPayment, 500)
+ return () => {
+ console.log('Cleanup: clearing timeout')
+ clearTimeout(timer)
+ }
+ }, [orderId, amount, customer, paymentTriggered, onSuccess, onError, onModalClosed])
+
+ // Don't render anything until we have valid data
+ if (!orderId || !amount) {
+ return (
+
+
+
+
Memuat data pembayaran...
+
+
+ )
+ }
+
+ return (
+
+ {error && (
+
+
+
{error}
+
+ Detail Error
+ {JSON.stringify({ orderId, amount, customer }, null, 2)}
+
+
+
+ )}
+
+
+ {loading ? (
+
+
+
Menyiapkan pembayaran...
+
+ ) : error ? (
+
+
Gagal memuat pembayaran
+
window.location.reload()}
+ className="text-sm text-blue-600 underline"
+ >
+ Coba lagi
+
+
+ ) : null}
+
+
+ )
+}
export function CheckoutPage() {
const apiBase = Env.API_BASE_URL
@@ -28,11 +204,9 @@ export function CheckoutPage() {
const amount = 3500000
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
const [selected, setSelected] = React.useState(null)
- const [locked, setLocked] = React.useState(false)
- const [currentStep, setCurrentStep] = React.useState<1 | 2 | 3>(1)
+ const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
const [isBusy, setIsBusy] = React.useState(false)
- const [selectedBank, setSelectedBank] = React.useState<'bca' | 'bni' | 'bri' | 'cimb' | 'mandiri' | 'permata' | null>(null)
- const [selectedStore, setSelectedStore] = React.useState<'alfamart' | 'indomaret' | null>(null)
+ const [modalClosed, setModalClosed] = React.useState(false)
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
name: 'Demo User',
contact: 'demo@example.com',
@@ -72,8 +246,8 @@ export function CheckoutPage() {
)}
-
- {/* Wizard 3 langkah: Step 1 (Form Dummy) β Step 2 (Pilih Metode) β Step 3 (Panel Metode) */}
+
+ {/* Wizard 2 langkah: Step 1 (Form Dummy) β Step 2 (Payment - Snap/Core auto-detect) */}
{currentStep === 1 && (
Konfirmasi data checkout
@@ -120,6 +294,8 @@ export function CheckoutPage() {
disabled={isBusy}
onClick={() => {
setIsBusy(true)
+ // Set default payment method (bank_transfer for demo)
+ setSelected('bank_transfer')
setTimeout(() => { setCurrentStep(2); setIsBusy(false) }, 400)
}}
>
@@ -128,135 +304,42 @@ export function CheckoutPage() {
Memuatβ¦
- ) : 'Next'}
+ ) : 'Lanjut ke Pembayaran'}
)}
{currentStep === 2 && (
-
-
{
- setSelected(m)
- if (m === 'bank_transfer' || m === 'cstore') {
- // Panel akan tampil di bawah item menggunakan renderPanel
- } else if (m === 'cpay') {
- // Redirect ke aplikasi cPay (CIFO Token) di Play Store
- try {
- Logger.info('cpay.redirect.start')
- window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
- Logger.info('cpay.redirect.done')
- } catch (e) {
- Logger.error('cpay.redirect.error', { message: (e as Error)?.message })
- }
- } else {
- setIsBusy(true)
- setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
- }
+
+ {(() => {
+ console.log('Rendering step 2 - AutoSnapPayment', { orderId, amount, currentStep })
+ Logger.info('checkout.step2.render', { orderId, amount })
+ return null
+ })()}
+
{
- const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
- if (!methodEnabled[m]) {
- return (
-
-
Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.
-
- )
- }
- if (m === 'bank_transfer') {
- return (
-
-
Pilih bank untuk membuat Virtual Account
-
- {(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
- {
- setSelectedBank(bk)
- setIsBusy(true)
- setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
- }}
- className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
- aria-label={`Pilih bank ${bk.toUpperCase()}`}
- >
-
-
- ))}
-
- {isBusy && (
-
-
- Menyiapkan VAβ¦
-
- )}
-
- )
- }
- if (m === 'cstore') {
- return (
-
-
Pilih toko untuk membuat kode pembayaran
-
- {(['alfamart','indomaret'] as const).map((st) => (
- {
- setSelectedStore(st)
- setIsBusy(true)
- setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
- }}
- className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
- aria-label={`Pilih toko ${st.toUpperCase()}`}
- >
- {st === 'alfamart' ? : }
-
- ))}
-
-
- )
- }
- return null
+ onSuccess={(result) => {
+ Logger.info('checkout.payment.success', { orderId, result })
+ // Handle successful payment
+ }}
+ onError={(error) => {
+ Logger.error('checkout.payment.error', { orderId, error })
+ setModalClosed(true) // Enable status button on error
+ // Handle payment error
+ }}
+ onModalClosed={() => {
+ setModalClosed(true) // Enable status button when modal closed
}}
/>
)}
-
- {currentStep === 3 && (
-
- {selected === 'bank_transfer' && (
-
setLocked(true)} defaultBank={(selectedBank ?? 'bca')} />
- )}
- {selected === 'credit_card' && (
- setLocked(true)} />
- )}
- {selected === 'gopay' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).gopay && (
- setLocked(true)} />
- )}
- {selected === 'cstore' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).cstore && (
- setLocked(true)} defaultStore={selectedStore ?? undefined} />
- )}
- {selected && !(runtimeCfg?.paymentToggles ?? defaultEnabled())[selected] && (
-
-
Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.
-
- )}
- {/* No back/next controls on Step 3 as requested */}
-
- )}
@@ -264,13 +347,4 @@ export function CheckoutPage() {
)
-}
-function defaultEnabled(): Record {
- return {
- bank_transfer: Env.ENABLE_BANK_TRANSFER,
- credit_card: Env.ENABLE_CREDIT_CARD,
- gopay: Env.ENABLE_GOPAY,
- cstore: Env.ENABLE_CSTORE,
- cpay: Env.ENABLE_CPAY,
- }
}
\ No newline at end of file
diff --git a/src/pages/PayPage.tsx b/src/pages/PayPage.tsx
index 2d9162e..9989a39 100644
--- a/src/pages/PayPage.tsx
+++ b/src/pages/PayPage.tsx
@@ -1,37 +1,199 @@
import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
-import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
-import { BankTransferPanel } from '../features/payments/components/BankTransferPanel'
-import { CardPanel } from '../features/payments/components/CardPanel'
-import { GoPayPanel } from '../features/payments/components/GoPayPanel'
-import { CStorePanel } from '../features/payments/components/CStorePanel'
-import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button'
import { getPaymentLinkPayload } from '../services/api'
import { isOrderLocked, lockOrder } from '../features/payments/lib/chargeLock'
import { usePaymentNavigation } from '../features/payments/lib/navigation'
+import { Logger } from '../lib/logger'
+import { loadSnapScript } from '../lib/snapLoader'
+import { SnapTokenService } from '../features/payments/snap/SnapTokenService'
+import React from 'react'
type Method = PaymentMethod | null
+interface AutoSnapPaymentProps {
+ orderId: string
+ amount: number
+ customer?: { name?: string; phone?: string; email?: string }
+ onSuccess?: (result: any) => void
+ onError?: (error: any) => void
+}
+
+function AutoSnapPayment({ orderId, amount, customer, onSuccess, onError }: AutoSnapPaymentProps) {
+ const [loading, setLoading] = React.useState(false)
+ const [error, setError] = React.useState('')
+ const [paymentTriggered, setPaymentTriggered] = React.useState(false)
+
+ console.log('[PayPage] AutoSnapPayment mounted:', { orderId, amount, customer })
+ Logger.info('paypage.autosnapPayment.mount', { orderId, amount, hasCustomer: !!customer })
+
+ React.useEffect(() => {
+ console.log('[PayPage] useEffect triggered', { orderId, amount, paymentTriggered })
+ if (!orderId || !amount || paymentTriggered) {
+ console.log('[PayPage] Early return', { hasOrderId: !!orderId, hasAmount: !!amount, alreadyTriggered: paymentTriggered })
+ return
+ }
+
+ const triggerPayment = async () => {
+ console.log('[PayPage] triggerPayment called')
+ setPaymentTriggered(true)
+ try {
+ setLoading(true)
+ setError('')
+
+ Logger.paymentInfo('paypage.auto.snap.init', { orderId, amount, customer })
+
+ // Load Snap.js first
+ await loadSnapScript()
+ Logger.paymentInfo('paypage.auto.snap.script_loaded', { orderId, hasSnap: !!window.snap })
+
+ // Create Snap transaction token
+ const token = await SnapTokenService.createToken({
+ transaction_details: {
+ order_id: orderId,
+ gross_amount: amount
+ },
+ customer_details: customer ? {
+ first_name: customer.name,
+ email: customer.email,
+ phone: customer.phone
+ } : undefined,
+ item_details: [{
+ id: orderId,
+ name: 'Payment',
+ price: amount,
+ quantity: 1
+ }]
+ })
+
+ Logger.paymentInfo('paypage.auto.snap.token.received', { orderId, token: token.substring(0, 10) + '...' })
+ console.log('[PayPage] Token received:', token)
+
+ if (!window.snap || typeof window.snap.pay !== 'function') {
+ throw new Error(`Snap.js not loaded: hasSnap=${!!window.snap}`)
+ }
+
+ console.log('[PayPage] Calling window.snap.pay')
+ setLoading(false)
+
+ window.snap.pay(token, {
+ onSuccess: (result: any) => {
+ Logger.paymentInfo('paypage.auto.snap.payment.success', { orderId, transactionId: result.transaction_id })
+ onSuccess?.(result)
+ },
+ onPending: (result: any) => {
+ Logger.paymentInfo('paypage.auto.snap.payment.pending', { orderId, transactionId: result.transaction_id })
+ },
+ onError: (result: any) => {
+ Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: result })
+ setError('Pembayaran gagal. Silakan coba lagi.')
+ setLoading(false)
+ onError?.(result)
+ },
+ onClose: () => {
+ Logger.paymentInfo('paypage.auto.snap.popup.closed', { orderId })
+ setLoading(false)
+ }
+ })
+
+ } catch (e: any) {
+ Logger.paymentError('paypage.auto.snap.payment.error', { orderId, error: e.message })
+ console.error('[PayPage] Error:', e)
+
+ // Handle specific errors with user-friendly messages
+ const errorMessage = e.response?.data?.message || e.message || ''
+ const errorMessages = e.response?.data?.error_messages || []
+
+ // Check for "order_id already used" from Midtrans
+ const isOrderIdUsed = errorMessage.includes('sudah digunakan') ||
+ errorMessage.includes('already been taken') ||
+ errorMessage.includes('order_id has already been taken') ||
+ errorMessages.some((msg: string) => msg.includes('sudah digunakan'))
+
+ if (isOrderIdUsed) {
+ // Order already has payment, redirect to status page
+ Logger.paymentInfo('paypage.order.already_exists', { orderId })
+ console.log('[PayPage] Order already has payment, redirecting to status...')
+
+ // Show user-friendly message then redirect
+ setError('Pembayaran untuk pesanan ini sudah dibuat sebelumnya. Anda akan diarahkan ke halaman status pembayaran...')
+ setTimeout(() => {
+ window.location.href = `/payments/${orderId}/status`
+ }, 2000)
+ } else {
+ // Generic error with user-friendly message
+ const userMessage = 'Maaf, terjadi kesalahan saat memuat pembayaran. Silakan coba lagi atau hubungi customer service.'
+ setError(userMessage)
+ }
+
+ onError?.(e)
+ setLoading(false)
+ }
+ }
+
+ console.log('[PayPage] Setting timeout')
+ const timer = setTimeout(triggerPayment, 500)
+ return () => clearTimeout(timer)
+ }, [orderId, amount, customer, paymentTriggered, onSuccess, onError])
+
+ // Don't render anything until we have valid data
+ if (!orderId || !amount) {
+ return (
+
+
+
+
Memuat data pembayaran...
+
+
+ )
+ }
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {loading ? (
+
+
+
Menyiapkan pembayaran...
+
+ ) : error ? (
+
+
Gagal memuat pembayaran
+
window.location.reload()}
+ className="text-sm text-blue-600 underline"
+ >
+ Coba lagi
+
+
+ ) : null}
+
+
+ )
+}
+
export function PayPage() {
const { token } = useParams()
const nav = usePaymentNavigation()
const [orderId, setOrderId] = useState('')
const [amount, setAmount] = useState(0)
const [expireAt, setExpireAt] = useState(Date.now() + 24 * 60 * 60 * 1000)
- const [selectedMethod, setSelectedMethod] = useState(null)
+ const [selectedMethod] = useState(null)
const [locked, setLocked] = useState(false)
- const [selectedBank, setSelectedBank] = useState(null)
- const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
- const [allowedMethods, setAllowedMethods] = useState(undefined)
+ const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
- const { data: runtimeCfg } = usePaymentConfig()
- const [currentStep, setCurrentStep] = useState<2 | 3>(2)
- const [isBusy, setIsBusy] = useState(false)
+ usePaymentConfig()
+ const currentStep = 2
useEffect(() => {
let cancelled = false
@@ -43,7 +205,7 @@ export function PayPage() {
setOrderId(payload.order_id)
setAmount(payload.nominal)
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
- setAllowedMethods(payload.allowed_methods)
+ setCustomer(payload.customer)
setError(null)
if (isOrderLocked(payload.order_id)) setLocked(true)
} catch {
@@ -58,26 +220,7 @@ export function PayPage() {
}, [token])
const merchantName = useMemo(() => '', [])
-
const isExpired = expireAt ? Date.now() > expireAt : false
- const enabledMap: Record = useMemo(() => {
- const base = runtimeCfg?.paymentToggles
- const allow = allowedMethods
- const all: Record = {
- bank_transfer: base?.bank_transfer ?? true,
- credit_card: base?.credit_card ?? true,
- gopay: base?.gopay ?? true,
- cstore: base?.cstore ?? true,
- cpay: base?.cpay ?? false,
- }
- if (allow && Array.isArray(allow)) {
- for (const k of (Object.keys(all) as PaymentMethod[])) {
- if (k === 'cpay') continue
- all[k] = allow.includes(k) && all[k]
- }
- }
- return all
- }, [runtimeCfg, allowedMethods])
if (error || isExpired) {
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
@@ -118,7 +261,7 @@ export function PayPage() {
orderId={orderId}
amount={amount}
expireAt={expireAt}
- showStatusCTA={currentStep === 3}
+ showStatusCTA={currentStep === 2}
>
{locked && currentStep === 2 && (
@@ -132,132 +275,23 @@ export function PayPage() {
)}
{currentStep === 2 && (
-
-
{
- setSelectedMethod(m as Method)
- if (m === 'bank_transfer' || m === 'cstore') {
- void 0
- } else if (m === 'cpay') {
- try {
- window.open('https://play.google.com/store/apps/details?id=com.cifo.walanja', '_blank')
- } catch { void 0 }
- } else {
- setIsBusy(true)
- setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
- }
+
+
{
+ console.log('[PayPage] Payment success:', result)
+ lockOrder(orderId)
+ setLocked(true)
+ nav.toStatus(orderId, selectedMethod || undefined)
}}
- disabled={locked}
- enabled={enabledMap}
- renderPanel={(m) => {
- const enabled = enabledMap[m]
- if (!enabled) {
- return (
-
-
Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.
-
- )
- }
- if (m === 'bank_transfer') {
- return (
-
-
Pilih bank untuk membuat Virtual Account
-
- {(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
- {
- setSelectedBank(bk)
- setIsBusy(true)
- setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
- }}
- className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center overflow-hidden hover:bg-gray-100"
- aria-label={`Pilih bank ${bk.toUpperCase()}`}
- >
-
-
- ))}
-
- {isBusy && (
-
-
- Menyiapkan VAβ¦
-
- )}
-
- )
- }
- if (m === 'cstore') {
- return (
-
-
Pilih toko untuk membuat kode pembayaran
-
- {/* {(['alfamart', 'indomaret'] as const).map((st) => ( */}
- {(['alfamart'] as const).map((st) => (
- {
- setSelectedStore(st)
- setIsBusy(true)
- setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
- }}
- className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center hover:bg-gray-100"
- aria-label={`Pilih toko ${st.toUpperCase()}`}
- >
- {st === 'alfamart' ? : }
-
- ))}
-
-
- )
- }
- return null
+ onError={(error) => {
+ console.error('[PayPage] Payment error:', error)
}}
/>
)}
-
- {currentStep === 3 && (
-
- {selectedMethod === 'bank_transfer' && (
- { lockOrder(orderId); setLocked(true) }}
- orderId={orderId}
- amount={amount}
- defaultBank={(selectedBank ?? 'bca')}
- />
- )}
- {selectedMethod === 'credit_card' && (
- { lockOrder(orderId); setLocked(true) }}
- orderId={orderId}
- amount={amount}
- />
- )}
- {selectedMethod === 'gopay' && (
- { lockOrder(orderId); setLocked(true) }}
- orderId={orderId}
- amount={amount}
- />
- )}
- {selectedMethod === 'cstore' && (
- { lockOrder(orderId); setLocked(true) }}
- orderId={orderId}
- amount={amount}
- defaultStore={selectedStore ?? undefined}
- />
- )}
-
- )}
)
diff --git a/src/pages/PaymentStatusPage.tsx b/src/pages/PaymentStatusPage.tsx
index 0beade1..66f4ce1 100644
--- a/src/pages/PaymentStatusPage.tsx
+++ b/src/pages/PaymentStatusPage.tsx
@@ -15,6 +15,15 @@ export function PaymentStatusPage() {
const method = (search.get('m') ?? undefined) as ('bank_transfer' | 'gopay' | 'qris' | 'cstore' | 'credit_card' | undefined)
const { data, isLoading, error } = usePaymentStatus(orderId)
+ // Check if error is "transaction not found" from Midtrans
+ const errorData = (error as any)?.response?.data
+ const isTransactionNotFound = error &&
+ (String(error).includes("doesn't exist") ||
+ String(error).includes("404") ||
+ String(error).includes("Transaction doesn't exist") ||
+ errorData?.message?.includes("doesn't exist") ||
+ errorData?.message?.includes("404"))
+
const statusText = data?.status ?? 'pending'
const isFinal = ['settlement', 'capture', 'expire', 'cancel', 'deny', 'refund', 'chargeback'].includes(statusText)
const isSuccess = statusText === 'settlement' || statusText === 'capture'
@@ -89,87 +98,250 @@ export function PaymentStatusPage() {
}
}
+ // User-friendly status messages
+ function getStatusMessage(s: PaymentStatusResponse['status']) {
+ switch (s) {
+ case 'pending':
+ return { title: 'Menunggu Pembayaran', desc: 'Silakan selesaikan pembayaran Anda', icon: 'β³', color: 'yellow' }
+ case 'settlement':
+ case 'capture':
+ return { title: 'Pembayaran Berhasil', desc: 'Terima kasih! Pembayaran Anda telah dikonfirmasi', icon: 'β
', color: 'green' }
+ case 'deny':
+ return { title: 'Pembayaran Ditolak', desc: 'Maaf, pembayaran Anda ditolak. Silakan coba metode lain', icon: 'β', color: 'red' }
+ case 'cancel':
+ return { title: 'Pembayaran Dibatalkan', desc: 'Transaksi telah dibatalkan', icon: 'π«', color: 'red' }
+ case 'expire':
+ return { title: 'Pembayaran Kedaluwarsa', desc: 'Waktu pembayaran habis. Silakan buat transaksi baru', icon: 'β°', color: 'red' }
+ case 'refund':
+ return { title: 'Pembayaran Dikembalikan', desc: 'Dana telah dikembalikan ke rekening Anda', icon: 'β©οΈ', color: 'blue' }
+ default:
+ return { title: 'Status Tidak Diketahui', desc: 'Hubungi customer service untuk bantuan', icon: 'βΉοΈ', color: 'gray' }
+ }
+ }
+
+ const statusMsg = getStatusMessage(statusText)
+
return (
-
-
Status Pembayaran
-
-
Order ID: {orderId}
- {method || data?.method ? (
-
Metode: {data?.method ?? method}
- ) : null}
-
Status: {isLoading ? (
- memuatβ¦
- ) : error ? (
- gagal memuat
- ) : (
- {statusText}
- )}
-
- {isFinal ? 'Status final β polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
-
- {isSuccess ? (
-
-
β
Pembayaran Berhasil!
-
+
+
+ {/* Header Card */}
+
+
+ {isLoading ? (
+ <>
+
β³
+
Memuat status...
+
Mohon tunggu sebentar
+ >
+ ) : isTransactionNotFound ? (
+ <>
+
π
+
Transaksi Belum Dibuat
+
Silakan kembali ke halaman checkout untuk membuat pembayaran
+ >
+ ) : error ? (
+ <>
+
β οΈ
+
Gagal Memuat Status
+
Terjadi kesalahan. Silakan refresh halaman
+ >
+ ) : (
+ <>
+
{statusMsg.icon}
+
{statusMsg.title}
+
{statusMsg.desc}
+ >
+ )}
- ) : null}
- {/* Method-specific details */}
- {!isLoading && !error && data ? (
-
- {(!method || method === 'bank_transfer') && data.vaNumber ? (
-
-
Virtual Account
-
VA Number: {data.vaNumber}
- {data.bank ?
Bank: {data.bank.toUpperCase()}
: null}
- {data.billKey && data.billerCode ? (
-
Mandiri E-Channel β Bill Key: {data.billKey}, Biller: {data.billerCode}
- ) : null}
+
+ {/* Order Info */}
+
+
+
- ) : null}
- {(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
-
-
Convenience Store
- {data.store ?
Store: {data.store}
: null}
- {data.paymentCode ?
Payment Code: {data.paymentCode}
: null}
-
- ) : null}
- {(!method || method === 'gopay' || method === 'qris') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
-
-
QR / Deeplink
- {qrSrc ? (
-
-
{
- const next = qrCandidates.find((u) => u !== e.currentTarget.src)
- if (next) setQrSrc(next)
- }} />
-
- ) : (
-
Gunakan link berikut untuk membuka aplikasi pembayaran.
- )}
-
- {(Array.isArray(data?.actions) ? data!.actions : []).map((a, i) => (
-
- {a.name || a.method || 'Buka'}
-
- ))}
+ {!isLoading && !isFinal && !isTransactionNotFound && (
+
+
+
Memperbarui otomatis...
-
- ) : null}
- {(!method || method === 'credit_card') && data.maskedCard ? (
-
-
Kartu
-
Masked Card: {data.maskedCard}
+ )}
+
+
+ {method || data?.method ? (
+
+
Metode Pembayaran
+
{(data?.method ?? method)?.replace('_', ' ')}
) : null}
+
+
+ {isSuccess ? (
+
+
+
π
+
+
Transaksi Selesai!
+
Anda akan diarahkan ke halaman utama dalam beberapa detik
+
+
+
+
) : null}
- {/* Aksi bawah dihilangkan sesuai permintaan */}
+ {/* Payment Instructions - Only show for pending status */}
+ {!isLoading && !error && data && statusText === 'pending' ? (
+
+
+
+ {(!method || method === 'bank_transfer') && data.vaNumber ? (
+ <>
+
+
Nomor Virtual Account
+
+
{data.vaNumber}
+
navigator.clipboard.writeText(data.vaNumber || '')}
+ className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
+ >
+ π Salin
+
+
+ {data.bank ? (
+
+ ) : null}
+
+
+
Langkah pembayaran:
+
+ Buka aplikasi mobile banking atau ATM
+ Pilih menu Transfer / Bayar
+ Masukkan nomor Virtual Account di atas
+ Konfirmasi pembayaran
+ Simpan bukti transaksi
+
+
+ {data.billKey && data.billerCode ? (
+
+
Khusus Mandiri E-Channel:
+
Kode Biller: {data.billerCode}
+
Kode Bayar: {data.billKey}
+
+ ) : null}
+ >
+ ) : null}
+ {(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
+ <>
+
+ {data.store ? (
+
+ ) : null}
+ {data.paymentCode ? (
+ <>
+
Kode Pembayaran
+
+
{data.paymentCode}
+
navigator.clipboard.writeText(data.paymentCode || '')}
+ className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
+ >
+ π Salin
+
+
+ >
+ ) : null}
+
+
+
Langkah pembayaran:
+
+ Kunjungi toko {data.store || 'convenience store'} terdekat
+ Berikan kode pembayaran kepada kasir
+ Lakukan pembayaran tunai
+ Simpan bukti pembayaran
+
+
+ >
+ ) : null}
+ {(!method || method === 'gopay' || method === 'qris') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
+ <>
+ {qrSrc ? (
+
+
Scan QR Code
+
+
{
+ const next = qrCandidates.find((u) => u !== e.currentTarget.src)
+ if (next) setQrSrc(next)
+ }} />
+
+
+ ) : null}
+
+
Langkah pembayaran:
+
+ Buka aplikasi {method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking'}
+ Pilih menu Scan QR atau QRIS
+ Arahkan kamera ke QR code di atas
+ Konfirmasi pembayaran
+
+
+ {(Array.isArray(data?.actions) && data.actions.length > 0) ? (
+
+ ) : null}
+ >
+ ) : null}
+ {(!method || method === 'credit_card') && 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
+
+
+
+ )}
- {!Env.API_BASE_URL && (
-
- Tambahkan VITE_API_BASE_URL di env agar status memuat dari backend; saat ini menggunakan stub.
-
- )}
)
}
\ No newline at end of file
diff --git a/src/services/api.ts b/src/services/api.ts
index 1420c05..ef8dc65 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -53,7 +53,15 @@ api.interceptors.response.use(
const url = error.config?.url || ''
const status = error.response?.status
const fullUrl = `${baseURL}${url}`
- Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
+ const responseData = error.response?.data
+ Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message, responseData })
+ console.error('API Error:', {
+ fullUrl,
+ status,
+ message: error.message,
+ responseData,
+ config: error.config
+ })
throw error
}
)
diff --git a/src/types/snap.d.ts b/src/types/snap.d.ts
new file mode 100644
index 0000000..830568b
--- /dev/null
+++ b/src/types/snap.d.ts
@@ -0,0 +1,17 @@
+// Midtrans Snap.js type definitions
+interface SnapPaymentOptions {
+ onSuccess?: (result: any) => void
+ onPending?: (result: any) => void
+ onError?: (result: any) => void
+ onClose?: () => void
+}
+
+interface Snap {
+ pay: (token: string, options?: SnapPaymentOptions) => void
+ hide: () => void
+ show: () => void
+}
+
+interface Window {
+ snap?: Snap
+}
diff --git a/test-create-payment-link.cjs b/test-create-payment-link.cjs
new file mode 100644
index 0000000..378d9ab
--- /dev/null
+++ b/test-create-payment-link.cjs
@@ -0,0 +1,34 @@
+const axios = require('axios');
+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');
+ // Remove BOM
+ if (jsonContent.charCodeAt(0) === 0xFEFF) {
+ jsonContent = jsonContent.slice(1);
+ }
+
+ const payload = JSON.parse(jsonContent);
+
+ try {
+ console.log('Creating payment link...');
+ console.log('Payload:', JSON.stringify(payload, null, 2));
+
+ const response = await axios.post('http://localhost:8000/createtransaksi', payload, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-KEY': 'dev-key'
+ }
+ });
+
+ console.log('\nβ Success!');
+ console.log('Response:', JSON.stringify(response.data, null, 2));
+ console.log('\nπ Payment URL:', response.data.data.url);
+ } catch (error) {
+ console.log('β Error:', error.response?.status, error.response?.data);
+ console.log('Full error:', error.message);
+ }
+}
+
+createPaymentLink();
diff --git a/test-frontend-payload.cjs b/test-frontend-payload.cjs
new file mode 100644
index 0000000..e369ab7
--- /dev/null
+++ b/test-frontend-payload.cjs
@@ -0,0 +1,35 @@
+const axios = require('axios');
+
+async function testFrontendPayload() {
+ // Simulate the exact payload sent from CheckoutPage.tsx AutoSnapPayment
+ const payload = {
+ transaction_details: {
+ order_id: 'order-1733280000000-12345', // example orderId
+ gross_amount: 3500000
+ },
+ customer_details: {
+ first_name: 'Demo User',
+ email: 'demo@example.com',
+ phone: undefined // as sent from frontend when contact is email
+ },
+ item_details: [{
+ id: 'order-1733280000000-12345',
+ name: 'Payment',
+ price: 3500000,
+ quantity: 1
+ }]
+ };
+
+ try {
+ console.log('Testing frontend-like payload...');
+ console.log('Payload:', JSON.stringify(payload, null, 2));
+
+ const response = await axios.post('http://localhost:8000/api/payments/snap/token', payload);
+ console.log('Success:', response.data);
+ } catch (error) {
+ console.log('Error:', error.response?.status, error.response?.data);
+ console.log('Full error:', error.message);
+ }
+}
+
+testFrontendPayload();
\ No newline at end of file
diff --git a/test-snap-token.cjs b/test-snap-token.cjs
new file mode 100644
index 0000000..87daf13
--- /dev/null
+++ b/test-snap-token.cjs
@@ -0,0 +1,34 @@
+const axios = require('axios');
+
+async function testSnapToken() {
+ const payload = {
+ transaction_details: {
+ order_id: 'test-order-123',
+ gross_amount: 100000
+ },
+ customer_details: {
+ first_name: 'Test User',
+ email: 'test@example.com',
+ phone: '08123456789'
+ },
+ item_details: [{
+ id: 'test-order-123',
+ name: 'Test Payment',
+ price: 100000,
+ quantity: 1
+ }]
+ };
+
+ try {
+ console.log('Testing Snap token creation...');
+ console.log('Payload:', JSON.stringify(payload, null, 2));
+
+ const response = await axios.post('http://localhost:8000/api/payments/snap/token', payload);
+ console.log('Success:', response.data);
+ } catch (error) {
+ console.log('Error:', error.response?.status, error.response?.data);
+ console.log('Full error:', error.message);
+ }
+}
+
+testSnapToken();
\ No newline at end of file
diff --git a/tmp-createtransaksi.json b/tmp-createtransaksi.json
index 49d2134..cde8748 100644
--- a/tmp-createtransaksi.json
+++ b/tmp-createtransaksi.json
@@ -1,12 +1,12 @@
ο»Ώ{
- "mercant_id": "REFNO-001",
- "timestamp": 1731300000000,
+ "mercant_id": "REFNO-003",
+ "timestamp": 1733331600000,
"deskripsi": "Bayar Internet",
- "nominal": 200000,
- "nama": "Demo User",
- "no_telepon": "081234567890",
- "email": "demo@example.com",
+ "nominal": 250000,
+ "nama": "Test User 3",
+ "no_telepon": "081234567891",
+ "email": "test3@example.com",
"item": [
- { "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 }
+ { "item_id": "TKG-2512042", "nama": "Internet Desember Premium", "harga": 250000, "qty": 1 }
]
}