Compare commits
No commits in common. "5976f8e088c82a958682ff3a3ee22ebed461418f" and "34ee97848d0e5b382d607c5da6b73b247efbe972" have entirely different histories.
5976f8e088
...
34ee97848d
|
|
@ -1,61 +0,0 @@
|
|||
#!/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)
|
||||
}
|
||||
212
server/index.cjs
212
server/index.cjs
|
|
@ -7,16 +7,6 @@ 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())
|
||||
|
|
@ -73,13 +63,7 @@ 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() {
|
||||
// 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 ts() { return new Date().toISOString() }
|
||||
function sanitize(obj) {
|
||||
try { return JSON.parse(JSON.stringify(obj)) } catch { return obj }
|
||||
}
|
||||
|
|
@ -310,35 +294,15 @@ 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
|
||||
|
|
@ -472,122 +436,7 @@ app.post('/createtransaksi', async (req, res) => {
|
|||
}
|
||||
})
|
||||
|
||||
// --- 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
|
||||
})
|
||||
}
|
||||
}
|
||||
// --- Helpers: Midtrans signature verification & ERP notify
|
||||
function computeMidtransSignature(orderId, statusCode, grossAmount, secretKey) {
|
||||
try {
|
||||
const raw = String(orderId) + String(statusCode) + String(grossAmount) + String(secretKey)
|
||||
|
|
@ -751,42 +600,45 @@ async function notifyERP({ orderId, nominal, mercantId }) {
|
|||
return okCount > 0
|
||||
}
|
||||
|
||||
// Webhook endpoint for Midtrans notifications (Core & Snap unified)
|
||||
app.post('/api/payments/notification', async (req, res) => {
|
||||
// Webhook endpoint for Midtrans notifications
|
||||
app.post('/api/payments/webhook', 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 })
|
||||
|
||||
// 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')
|
||||
// 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' })
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
// Trigger shared business logic (ERP unfreeze, etc.)
|
||||
await processPaymentCompletion(orderId, internalStatus, mode, body)
|
||||
|
||||
// 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 })
|
||||
}
|
||||
} catch (e) {
|
||||
logError('webhook.error', { message: e?.message })
|
||||
try { res.status(500).json({ error: 'WEBHOOK_ERROR' }) } catch {}
|
||||
|
|
|
|||
|
|
@ -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,8 +15,7 @@ const router = createBrowserRouter([
|
|||
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
|
||||
children: [
|
||||
{ index: true, element: <InitPage /> },
|
||||
{ path: 'checkout', element: <CheckoutPage /> },
|
||||
{ path: 'demo', element: <DemoStorePage /> },
|
||||
// { path: 'checkout', element: <CheckoutPage /> },
|
||||
{ path: 'pay/:token', element: <PayPage /> },
|
||||
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
|
||||
{ path: 'history', element: <PaymentHistoryPage /> },
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
import { Button } from '../../../components/ui/button'
|
||||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import React from 'react'
|
||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
||||
import { BcaInstructionList } from '../components/BcaInstructionList'
|
||||
import { type BankKey } from '../components/PaymentLogos'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { BcaInstructionList } from './BcaInstructionList'
|
||||
import { type BankKey } from './PaymentLogos'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { Alert } from '../../../components/alert/Alert'
|
||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||
|
|
@ -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 '../components/PaymentInstructions'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||
import { Alert } from '../../../components/alert/Alert'
|
||||
import { getErrorRecoveryAction, mapErrorToUserMessage } from '../../../lib/errorMessages'
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
import { Button } from '../../../components/ui/button'
|
||||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import React from 'react'
|
||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
||||
import { CardLogosRow } from '../components/PaymentLogos'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { CardLogosRow } from './PaymentLogos'
|
||||
import { ensureMidtrans3ds, getCardToken, authenticate3ds } from '../lib/midtrans3ds'
|
||||
import { Logger } from '../../../lib/logger'
|
||||
import { Env } from '../../../lib/env'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
|
||||
export function CardPanel({ orderId, amount, locked, onChargeInitiated }: { orderId: string; amount: number; locked?: boolean; onChargeInitiated?: () => void }) {
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { Button } from '../../../components/ui/button'
|
||||
import { usePaymentNavigation } from '../lib/navigation'
|
||||
import React from 'react'
|
||||
import { PaymentInstructions } from '../components/PaymentInstructions'
|
||||
import { GoPayLogosRow } from '../components/PaymentLogos'
|
||||
import { PaymentInstructions } from './PaymentInstructions'
|
||||
import { GoPayLogosRow } from './PaymentLogos'
|
||||
import { postCharge } from '../../../services/api'
|
||||
import { InlinePaymentStatus } from '../components/InlinePaymentStatus'
|
||||
import { InlinePaymentStatus } from './InlinePaymentStatus'
|
||||
import { toast } from '../../../components/ui/toast'
|
||||
import { LoadingOverlay } from '../../../components/LoadingOverlay'
|
||||
import { Alert } from '../../../components/alert/Alert'
|
||||
|
|
@ -25,12 +25,11 @@ 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, customerName, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||
export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireAt, children, showStatusCTA = true }: PaymentSheetProps) {
|
||||
const countdown = useCountdown(expireAt)
|
||||
const [expanded, setExpanded] = React.useState(true)
|
||||
return (
|
||||
|
|
@ -72,7 +71,6 @@ export function PaymentSheet({ merchantName = 'Simaya', orderId, amount, expireA
|
|||
<div className="text-xs text-black">Total</div>
|
||||
<div className="text-xl font-semibold">{formatCurrencyIDR(amount)}</div>
|
||||
<div className="text-xs text-black/60">Order ID #{orderId}</div>
|
||||
{customerName && <div className="text-xs text-black/60 mt-1">Nama: {customerName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
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']
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
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 (
|
||||
<CorePaymentComponent
|
||||
paymentMethod={paymentMethod || 'bank_transfer'}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
onChargeInitiated={onChargeInitiated}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Snap mode - use hosted payment interface
|
||||
return (
|
||||
<SnapHostedPayment
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
customer={customer}
|
||||
onSuccess={onSuccess}
|
||||
onError={onError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CorePaymentComponent({ paymentMethod, orderId, amount, onChargeInitiated }: {
|
||||
paymentMethod: string
|
||||
orderId: string
|
||||
amount: number
|
||||
onChargeInitiated?: () => void
|
||||
}) {
|
||||
const [Component, setComponent] = React.useState<React.ComponentType<any> | 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 <div>Loading payment component...</div>
|
||||
}
|
||||
|
||||
if (!Component) {
|
||||
return <div>Payment method not available</div>
|
||||
}
|
||||
|
||||
return <Component orderId={orderId} amount={amount} onChargeInitiated={onChargeInitiated} />
|
||||
}
|
||||
|
||||
function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Omit<SnapPaymentTriggerProps, 'paymentMethod' | 'onChargeInitiated'>) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Alert title="Pembayaran Gagal">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Klik tombol di bawah untuk melanjutkan pembayaran dengan Midtrans Snap
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleSnapPayment}
|
||||
disabled={loading}
|
||||
className="w-full max-w-xs"
|
||||
>
|
||||
{loading ? 'Memproses...' : 'Bayar Sekarang'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <LoadingOverlay isLoading={loading} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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<string> {
|
||||
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<SnapTokenResponse>('/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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,8 +13,6 @@ 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),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Env } from './env'
|
||||
import { getPaymentMode } from '../features/payments/lib/paymentMode'
|
||||
|
||||
type Level = 'debug' | 'info' | 'warn' | 'error'
|
||||
const order: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
|
|
@ -63,21 +62,4 @@ 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)
|
||||
},
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { Env } from './env'
|
||||
import { Logger } from './logger'
|
||||
|
||||
let snapLoaded = false
|
||||
let snapPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
* Dynamically loads Midtrans Snap.js script
|
||||
* Returns a promise that resolves when Snap.js is ready
|
||||
*/
|
||||
export function loadSnapScript(): Promise<void> {
|
||||
// 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<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
26
src/main.tsx
26
src/main.tsx
|
|
@ -1,7 +1,6 @@
|
|||
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
|
||||
|
|
@ -15,31 +14,6 @@ import { getPaymentMode } from './features/payments/lib/paymentMode'
|
|||
} 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'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,193 +1,17 @@
|
|||
import React from 'react'
|
||||
import { Alert } from '../components/alert/Alert'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Env } from '../lib/env'
|
||||
import { Logger } from '../lib/logger'
|
||||
import { loadSnapScript } from '../lib/snapLoader'
|
||||
import { PaymentSheet } from '../features/payments/components/PaymentSheet'
|
||||
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
|
||||
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
|
||||
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<AutoSnapPaymentProps, 'onChargeInitiated'>) {
|
||||
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 (
|
||||
<div className="text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||
<p className="text-sm text-gray-600">Memuat data pembayaran...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Alert title="Pembayaran Gagal">
|
||||
<div className="space-y-2">
|
||||
<p>{error}</p>
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer">Detail Error</summary>
|
||||
<pre className="mt-2 bg-gray-100 p-2 rounded overflow-auto">{JSON.stringify({ orderId, amount, customer }, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-600">Gagal memuat pembayaran</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-sm text-blue-600 underline"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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'
|
||||
|
||||
export function CheckoutPage() {
|
||||
const apiBase = Env.API_BASE_URL
|
||||
|
|
@ -204,9 +28,11 @@ export function CheckoutPage() {
|
|||
const amount = 3500000
|
||||
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
|
||||
const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
|
||||
const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
|
||||
const [locked, setLocked] = React.useState(false)
|
||||
const [currentStep, setCurrentStep] = React.useState<1 | 2 | 3>(1)
|
||||
const [isBusy, setIsBusy] = React.useState(false)
|
||||
const [modalClosed, setModalClosed] = 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 [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
|
||||
name: 'Demo User',
|
||||
contact: 'demo@example.com',
|
||||
|
|
@ -246,8 +72,8 @@ export function CheckoutPage() {
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} customerName={form.name} showStatusCTA={modalClosed}>
|
||||
{/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
|
||||
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}>
|
||||
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium">Konfirmasi data checkout</div>
|
||||
|
|
@ -294,8 +120,6 @@ 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)
|
||||
}}
|
||||
>
|
||||
|
|
@ -304,42 +128,135 @@ export function CheckoutPage() {
|
|||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
|
||||
Memuat…
|
||||
</span>
|
||||
) : 'Lanjut ke Pembayaran'}
|
||||
) : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
{(() => {
|
||||
console.log('Rendering step 2 - AutoSnapPayment', { orderId, amount, currentStep })
|
||||
Logger.info('checkout.step2.render', { orderId, amount })
|
||||
return null
|
||||
})()}
|
||||
<AutoSnapPayment
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
customer={{
|
||||
name: form.name,
|
||||
email: form.contact.includes('@') ? form.contact : undefined,
|
||||
phone: !form.contact.includes('@') ? form.contact : undefined
|
||||
<div className="space-y-3">
|
||||
<PaymentMethodList
|
||||
selected={selected ?? undefined}
|
||||
onSelect={(m) => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
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
|
||||
disabled={locked}
|
||||
enabled={runtimeCfg?.paymentToggles
|
||||
? {
|
||||
bank_transfer: runtimeCfg.paymentToggles.bank_transfer,
|
||||
credit_card: runtimeCfg.paymentToggles.credit_card,
|
||||
gopay: runtimeCfg.paymentToggles.gopay,
|
||||
cstore: runtimeCfg.paymentToggles.cstore,
|
||||
cpay: !!runtimeCfg.paymentToggles.cpay,
|
||||
}
|
||||
: undefined}
|
||||
renderPanel={(m) => {
|
||||
const methodEnabled = runtimeCfg?.paymentToggles ?? defaultEnabled()
|
||||
if (!methodEnabled[m]) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m === 'bank_transfer') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className={`grid grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['bca','bni','bri','cimb','mandiri','permata'] as BankKey[]).map((bk) => (
|
||||
<button
|
||||
key={bk}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedBank(bk)
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||
>
|
||||
<BankLogo bank={bk} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isBusy && (
|
||||
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Menyiapkan VA…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m === 'cstore') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
|
||||
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['alfamart','indomaret'] as const).map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedStore(st)
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white p-2 flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||
>
|
||||
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
{selected === 'bank_transfer' && (
|
||||
<BankTransferPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} defaultBank={(selectedBank ?? 'bca')} />
|
||||
)}
|
||||
{selected === 'credit_card' && (
|
||||
<CardPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} />
|
||||
)}
|
||||
{selected === 'gopay' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).gopay && (
|
||||
<GoPayPanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} />
|
||||
)}
|
||||
{selected === 'cstore' && (runtimeCfg?.paymentToggles ?? defaultEnabled()).cstore && (
|
||||
<CStorePanel orderId={orderId} amount={amount} locked={locked} onChargeInitiated={() => setLocked(true)} defaultStore={selectedStore ?? undefined} />
|
||||
)}
|
||||
{selected && !(runtimeCfg?.paymentToggles ?? defaultEnabled())[selected] && (
|
||||
<div className="mt-2">
|
||||
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||
</div>
|
||||
)}
|
||||
{/* No back/next controls on Step 3 as requested */}
|
||||
</div>
|
||||
)}
|
||||
</PaymentSheet>
|
||||
|
||||
<div className="text-xs text-gray-600">
|
||||
|
|
@ -347,4 +264,13 @@ export function CheckoutPage() {
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function defaultEnabled(): Record<PaymentMethod, boolean> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,199 +1,37 @@
|
|||
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 (
|
||||
<div className="text-center">
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||
<p className="text-sm text-gray-600">Memuat data pembayaran...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{error && (
|
||||
<Alert title="Pembayaran Gagal">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||
<p className="text-sm text-gray-600">Menyiapkan pembayaran...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-red-600">Gagal memuat pembayaran</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-sm text-blue-600 underline"
|
||||
>
|
||||
Coba lagi
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PayPage() {
|
||||
const { token } = useParams()
|
||||
const nav = usePaymentNavigation()
|
||||
const [orderId, setOrderId] = useState<string>('')
|
||||
const [amount, setAmount] = useState<number>(0)
|
||||
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
|
||||
const [selectedMethod] = useState<Method>(null)
|
||||
const [selectedMethod, setSelectedMethod] = useState<Method>(null)
|
||||
const [locked, setLocked] = useState<boolean>(false)
|
||||
const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
|
||||
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null)
|
||||
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
|
||||
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
|
||||
const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
|
||||
usePaymentConfig()
|
||||
const currentStep = 2
|
||||
const { data: runtimeCfg } = usePaymentConfig()
|
||||
const [currentStep, setCurrentStep] = useState<2 | 3>(2)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -205,7 +43,7 @@ export function PayPage() {
|
|||
setOrderId(payload.order_id)
|
||||
setAmount(payload.nominal)
|
||||
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
|
||||
setCustomer(payload.customer)
|
||||
setAllowedMethods(payload.allowed_methods)
|
||||
setError(null)
|
||||
if (isOrderLocked(payload.order_id)) setLocked(true)
|
||||
} catch {
|
||||
|
|
@ -220,7 +58,26 @@ export function PayPage() {
|
|||
}, [token])
|
||||
|
||||
const merchantName = useMemo(() => '', [])
|
||||
|
||||
const isExpired = expireAt ? Date.now() > expireAt : false
|
||||
const enabledMap: Record<PaymentMethod, boolean> = useMemo(() => {
|
||||
const base = runtimeCfg?.paymentToggles
|
||||
const allow = allowedMethods
|
||||
const all: Record<PaymentMethod, boolean> = {
|
||||
bank_transfer: base?.bank_transfer ?? true,
|
||||
credit_card: base?.credit_card ?? true,
|
||||
gopay: base?.gopay ?? true,
|
||||
cstore: base?.cstore ?? true,
|
||||
cpay: base?.cpay ?? false,
|
||||
}
|
||||
if (allow && Array.isArray(allow)) {
|
||||
for (const k of (Object.keys(all) as PaymentMethod[])) {
|
||||
if (k === 'cpay') continue
|
||||
all[k] = allow.includes(k) && all[k]
|
||||
}
|
||||
}
|
||||
return all
|
||||
}, [runtimeCfg, allowedMethods])
|
||||
|
||||
if (error || isExpired) {
|
||||
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
|
||||
|
|
@ -261,7 +118,7 @@ export function PayPage() {
|
|||
orderId={orderId}
|
||||
amount={amount}
|
||||
expireAt={expireAt}
|
||||
showStatusCTA={currentStep === 2}
|
||||
showStatusCTA={currentStep === 3}
|
||||
>
|
||||
<div className="space-y-4 px-4 py-6">
|
||||
{locked && currentStep === 2 && (
|
||||
|
|
@ -275,23 +132,132 @@ export function PayPage() {
|
|||
</Alert>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
<AutoSnapPayment
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
customer={customer}
|
||||
onSuccess={(result) => {
|
||||
console.log('[PayPage] Payment success:', result)
|
||||
lockOrder(orderId)
|
||||
setLocked(true)
|
||||
nav.toStatus(orderId, selectedMethod || undefined)
|
||||
<div className="space-y-3">
|
||||
<PaymentMethodList
|
||||
selected={selectedMethod ?? undefined}
|
||||
onSelect={(m) => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('[PayPage] Payment error:', error)
|
||||
disabled={locked}
|
||||
enabled={enabledMap}
|
||||
renderPanel={(m) => {
|
||||
const enabled = enabledMap[m]
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="p-2">
|
||||
<Alert title="Metode nonaktif">Metode pembayaran ini dinonaktifkan di konfigurasi lingkungan.</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m === 'bank_transfer') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-gray-600">Pilih bank untuk membuat Virtual Account</div>
|
||||
<div className={`grid grid-cols-2 md:grid-cols-3 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{(['bca', 'bni', 'bri', 'cimb', 'mandiri', 'permata'] as BankKey[]).map((bk) => (
|
||||
<button
|
||||
key={bk}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedBank(bk)
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center overflow-hidden hover:bg-gray-100"
|
||||
aria-label={`Pilih bank ${bk.toUpperCase()}`}
|
||||
>
|
||||
<BankLogo bank={bk} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isBusy && (
|
||||
<div className="text-xs text-gray-600 inline-flex items-center gap-2">
|
||||
<span className="h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" aria-hidden />
|
||||
Menyiapkan VA…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (m === 'cstore') {
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
<div className="text-xs text-gray-600">Pilih toko untuk membuat kode pembayaran</div>
|
||||
<div className={`grid grid-cols-2 gap-2 ${isBusy ? 'pointer-events-none opacity-60' : ''}`}>
|
||||
{/* {(['alfamart', 'indomaret'] as const).map((st) => ( */}
|
||||
{(['alfamart'] as const).map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedStore(st)
|
||||
setIsBusy(true)
|
||||
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
|
||||
}}
|
||||
className="rounded border border-gray-300 bg-white p-3 md:p-2 w-full flex items-center justify-center hover:bg-gray-100"
|
||||
aria-label={`Pilih toko ${st.toUpperCase()}`}
|
||||
>
|
||||
{st === 'alfamart' ? <LogoAlfamart /> : <LogoIndomaret />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-3" aria-live="polite">
|
||||
{selectedMethod === 'bank_transfer' && (
|
||||
<BankTransferPanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
defaultBank={(selectedBank ?? 'bca')}
|
||||
/>
|
||||
)}
|
||||
{selectedMethod === 'credit_card' && (
|
||||
<CardPanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
/>
|
||||
)}
|
||||
{selectedMethod === 'gopay' && (
|
||||
<GoPayPanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
/>
|
||||
)}
|
||||
{selectedMethod === 'cstore' && (
|
||||
<CStorePanel
|
||||
locked={locked}
|
||||
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
|
||||
orderId={orderId}
|
||||
amount={amount}
|
||||
defaultStore={selectedStore ?? undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PaymentSheet>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,15 +15,6 @@ 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'
|
||||
|
|
@ -98,250 +89,87 @@ 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 (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-2xl mx-auto px-4">
|
||||
{/* Header Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
|
||||
<div className={`p-6 text-center ${
|
||||
statusMsg.color === 'green' ? 'bg-green-50 border-b border-green-100' :
|
||||
statusMsg.color === 'yellow' ? 'bg-yellow-50 border-b border-yellow-100' :
|
||||
statusMsg.color === 'red' ? 'bg-red-50 border-b border-red-100' :
|
||||
statusMsg.color === 'blue' ? 'bg-blue-50 border-b border-blue-100' :
|
||||
'bg-gray-50 border-b border-gray-100'
|
||||
}`}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<div className="text-xl font-semibold text-gray-700">Memuat status...</div>
|
||||
<div className="text-sm text-gray-600 mt-1">Mohon tunggu sebentar</div>
|
||||
</>
|
||||
) : isTransactionNotFound ? (
|
||||
<>
|
||||
<div className="text-4xl mb-2">📋</div>
|
||||
<div className="text-xl font-semibold text-blue-700">Transaksi Belum Dibuat</div>
|
||||
<div className="text-sm text-blue-600 mt-1">Silakan kembali ke halaman checkout untuk membuat pembayaran</div>
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<div className="text-4xl mb-2">⚠️</div>
|
||||
<div className="text-xl font-semibold text-red-700">Gagal Memuat Status</div>
|
||||
<div className="text-sm text-red-600 mt-1">Terjadi kesalahan. Silakan refresh halaman</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-5xl mb-3">{statusMsg.icon}</div>
|
||||
<div className="text-2xl font-bold text-gray-800 mb-2">{statusMsg.title}</div>
|
||||
<div className="text-sm text-gray-600">{statusMsg.desc}</div>
|
||||
</>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold">Status Pembayaran</h1>
|
||||
<div className="card p-4">
|
||||
<div className="text-sm">Order ID: {orderId}</div>
|
||||
{method || data?.method ? (
|
||||
<div className="text-xs text-gray-600">Metode: {data?.method ?? method}</div>
|
||||
) : null}
|
||||
<div className="mt-2">Status: {isLoading ? (
|
||||
<span className="font-medium">memuat…</span>
|
||||
) : error ? (
|
||||
<span className="font-medium text-brand-600">gagal memuat</span>
|
||||
) : (
|
||||
<span className={statusBadgeClass(statusText)}>{statusText}</span>
|
||||
)}</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{isFinal ? 'Status final — polling dihentikan.' : 'Polling setiap 3 detik hingga status final.'}
|
||||
</div>
|
||||
{isSuccess ? (
|
||||
<div className="mt-4">
|
||||
<div className="text-lg font-semibold">✅ Pembayaran Berhasil!</div>
|
||||
<CountdownRedirect seconds={5} destination="dashboard" onComplete={handleRedirect} />
|
||||
</div>
|
||||
|
||||
{/* Order Info */}
|
||||
<div className="p-6 bg-white">
|
||||
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200">
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">ID Pesanan</div>
|
||||
<div className="font-mono text-sm font-semibold text-gray-900">{orderId}</div>
|
||||
) : null}
|
||||
{/* Method-specific details */}
|
||||
{!isLoading && !error && data ? (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
{(!method || method === 'bank_transfer') && data.vaNumber ? (
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">Virtual Account</div>
|
||||
<div>VA Number: <span className="font-mono">{data.vaNumber}</span></div>
|
||||
{data.bank ? <div>Bank: {data.bank.toUpperCase()}</div> : null}
|
||||
{data.billKey && data.billerCode ? (
|
||||
<div className="mt-1 text-xs text-gray-600">Mandiri E-Channel — Bill Key: {data.billKey}, Biller: {data.billerCode}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isLoading && !isFinal && !isTransactionNotFound && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<div className="h-2 w-2 bg-blue-500 rounded-full animate-pulse"></div>
|
||||
<span>Memperbarui otomatis...</span>
|
||||
) : null}
|
||||
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">Convenience Store</div>
|
||||
{data.store ? <div>Store: {data.store}</div> : null}
|
||||
{data.paymentCode ? <div>Payment Code: <span className="font-mono">{data.paymentCode}</span></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{(!method || method === 'gopay' || method === 'qris') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">QR / Deeplink</div>
|
||||
{qrSrc ? (
|
||||
<div className="mt-2 grid place-items-center">
|
||||
<img src={qrSrc} alt="QR untuk pembayaran" className="aspect-square w-full max-w-[260px] mx-auto rounded border border-black/10" onError={(e) => {
|
||||
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
||||
if (next) setQrSrc(next)
|
||||
}} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-600">Gunakan link berikut untuk membuka aplikasi pembayaran.</div>
|
||||
)}
|
||||
<div className="mt-1 flex flex-wrap gap-2">
|
||||
{(Array.isArray(data?.actions) ? data!.actions : []).map((a, i) => (
|
||||
<a key={i} href={a.url} target="_blank" rel="noreferrer" className="underline text-brand-600">
|
||||
{a.name || a.method || 'Buka'}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{method || data?.method ? (
|
||||
<div className="mb-4">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Metode Pembayaran</div>
|
||||
<div className="text-sm font-medium text-gray-900 capitalize">{(data?.method ?? method)?.replace('_', ' ')}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{(!method || method === 'credit_card') && data.maskedCard ? (
|
||||
<div className="rounded border border-gray-200 p-2">
|
||||
<div className="font-medium">Kartu</div>
|
||||
<div>Masked Card: <span className="font-mono">{data.maskedCard}</span></div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSuccess ? (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">🎉</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-green-900 mb-1">Transaksi Selesai!</div>
|
||||
<div className="text-sm text-green-700">Anda akan diarahkan ke halaman utama dalam beberapa detik</div>
|
||||
<CountdownRedirect seconds={5} destination="dashboard" onComplete={handleRedirect} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Payment Instructions - Only show for pending status */}
|
||||
{!isLoading && !error && data && statusText === 'pending' ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden mb-4">
|
||||
<div className="bg-blue-50 border-b border-blue-100 px-6 py-3">
|
||||
<div className="text-sm font-semibold text-blue-900">📝 Cara Pembayaran</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{(!method || method === 'bank_transfer') && data.vaNumber ? (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Nomor Virtual Account</div>
|
||||
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
|
||||
<div className="font-mono text-lg font-bold text-gray-900">{data.vaNumber}</div>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(data.vaNumber || '')}
|
||||
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
||||
>
|
||||
📋 Salin
|
||||
</button>
|
||||
</div>
|
||||
{data.bank ? (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-gray-500">Bank</div>
|
||||
<div className="text-sm font-semibold text-gray-900 uppercase">{data.bank}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>Buka aplikasi mobile banking atau ATM</li>
|
||||
<li>Pilih menu Transfer / Bayar</li>
|
||||
<li>Masukkan nomor Virtual Account di atas</li>
|
||||
<li>Konfirmasi pembayaran</li>
|
||||
<li>Simpan bukti transaksi</li>
|
||||
</ol>
|
||||
</div>
|
||||
{data.billKey && data.billerCode ? (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm">
|
||||
<div className="font-medium text-yellow-900 mb-1">Khusus Mandiri E-Channel:</div>
|
||||
<div className="text-yellow-800">Kode Biller: <span className="font-mono font-semibold">{data.billerCode}</span></div>
|
||||
<div className="text-yellow-800">Kode Bayar: <span className="font-mono font-semibold">{data.billKey}</span></div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{(!method || method === 'cstore') && (data.store || data.paymentCode) ? (
|
||||
<>
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
{data.store ? (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">Toko</div>
|
||||
<div className="text-lg font-bold text-gray-900 uppercase">{data.store}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{data.paymentCode ? (
|
||||
<>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kode Pembayaran</div>
|
||||
<div className="flex items-center justify-between bg-white rounded border border-gray-300 px-4 py-3">
|
||||
<div className="font-mono text-lg font-bold text-gray-900">{data.paymentCode}</div>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(data.paymentCode || '')}
|
||||
className="text-xs bg-blue-600 text-white px-3 py-1.5 rounded hover:bg-blue-700"
|
||||
>
|
||||
📋 Salin
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>Kunjungi toko {data.store || 'convenience store'} terdekat</li>
|
||||
<li>Berikan kode pembayaran kepada kasir</li>
|
||||
<li>Lakukan pembayaran tunai</li>
|
||||
<li>Simpan bukti pembayaran</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{(!method || method === 'gopay' || method === 'qris') && (qrSrc || (Array.isArray(data?.actions) && data.actions.length > 0)) ? (
|
||||
<>
|
||||
{qrSrc ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide text-center mb-3">Scan QR Code</div>
|
||||
<div className="bg-white rounded-lg p-4 inline-block mx-auto">
|
||||
<img src={qrSrc} alt="QR Code Pembayaran" className="w-64 h-64 mx-auto" onError={(e) => {
|
||||
const next = qrCandidates.find((u) => u !== e.currentTarget.src)
|
||||
if (next) setQrSrc(next)
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p className="font-medium text-gray-900">Langkah pembayaran:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-2">
|
||||
<li>Buka aplikasi {method === 'gopay' ? 'GoPay/Gojek' : 'e-wallet atau m-banking'}</li>
|
||||
<li>Pilih menu Scan QR atau QRIS</li>
|
||||
<li>Arahkan kamera ke QR code di atas</li>
|
||||
<li>Konfirmasi pembayaran</li>
|
||||
</ol>
|
||||
</div>
|
||||
{(Array.isArray(data?.actions) && data.actions.length > 0) ? (
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{data.actions.map((a, i) => (
|
||||
<a
|
||||
key={i}
|
||||
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"
|
||||
>
|
||||
📱 {a.name || 'Buka Aplikasi'}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{(!method || method === 'credit_card') && data.maskedCard ? (
|
||||
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">Kartu Kredit/Debit</div>
|
||||
<div className="font-mono text-lg font-bold text-gray-900">{data.maskedCard}</div>
|
||||
<div className="text-sm text-gray-600 mt-3">
|
||||
Pembayaran dengan kartu telah diproses. Tunggu konfirmasi dari bank Anda.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{/* Help Section */}
|
||||
{!isLoading && !error && (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-600">
|
||||
<p className="font-medium text-gray-900 mb-2">💡 Butuh bantuan?</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• Jika pembayaran belum terkonfirmasi dalam 24 jam, hubungi customer service</li>
|
||||
<li>• Simpan nomor pesanan untuk referensi</li>
|
||||
<li>• Halaman ini akan diperbarui otomatis saat status berubah</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Aksi bawah dihilangkan sesuai permintaan */}
|
||||
</div>
|
||||
{!Env.API_BASE_URL && (
|
||||
<Alert title="API Base belum diatur">
|
||||
Tambahkan <code>VITE_API_BASE_URL</code> di env agar status memuat dari backend; saat ini menggunakan stub.
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -53,15 +53,7 @@ api.interceptors.response.use(
|
|||
const url = error.config?.url || ''
|
||||
const status = error.response?.status
|
||||
const fullUrl = `${baseURL}${url}`
|
||||
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
|
||||
})
|
||||
Logger.error('api.error', { baseURL, url, fullUrl, status, message: error.message })
|
||||
throw error
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"mercant_id": "REFNO-003",
|
||||
"timestamp": 1733331600000,
|
||||
"mercant_id": "REFNO-001",
|
||||
"timestamp": 1731300000000,
|
||||
"deskripsi": "Bayar Internet",
|
||||
"nominal": 250000,
|
||||
"nama": "Test User 3",
|
||||
"no_telepon": "081234567891",
|
||||
"email": "test3@example.com",
|
||||
"nominal": 200000,
|
||||
"nama": "Demo User",
|
||||
"no_telepon": "081234567890",
|
||||
"email": "demo@example.com",
|
||||
"item": [
|
||||
{ "item_id": "TKG-2512042", "nama": "Internet Desember Premium", "harga": 250000, "qty": 1 }
|
||||
{ "item_id": "TKG-2511131", "nama": "Internet", "harga": 200000, "qty": 1 }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue