Fix Snap payment flow: direct to payment UI in step 2 and add customer/item details to API request

- Remove intermediate payment method selection step in PayPage and CheckoutPage
- Update PayPage: direct step 2 payment interface (no wizard)
- Update CheckoutPage: 2-step wizard (form → payment)
- Add customer details (name, email, phone) to SnapPaymentTrigger
- Add item_details to Snap token request (required by Midtrans API)
- Fix 400 error from /api/payments/snap/token endpoint
- Clean up unused imports and variables
- Create payment link generation script (scripts/create-test-payment.mjs)
This commit is contained in:
CIFO Dev 2025-12-03 17:01:12 +07:00
parent d051c46ac4
commit 4bca71aeb3
6 changed files with 146 additions and 229 deletions

View File

@ -0,0 +1,61 @@
#!/usr/bin/env node
const API_URL = 'http://localhost:8000/createtransaksi'
const API_KEY = 'dev-key'
const orderId = `SNAPTEST-${Date.now()}`
const payload = {
mercant_id: 'TESTMERCHANT',
timestamp: Date.now(),
deskripsi: 'Testing Snap Payment Mode',
nominal: 150000,
nama: 'Test Snap User',
no_telepon: '081234567890',
email: 'test@snap.com',
item: [
{
item_id: orderId,
nama: 'Test Product Snap',
harga: 150000,
qty: 1
}
]
}
try {
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': API_KEY
},
body: JSON.stringify(payload)
})
if (!response.ok) {
const error = await response.text()
console.error('❌ Error:', response.status, error)
process.exit(1)
}
const data = await response.json()
const paymentUrl = data.data?.url || data.payment_url
const token = paymentUrl ? paymentUrl.split('/pay/')[1] : null
console.log('✅ Payment link created successfully!')
console.log('\n🔗 Snap Mode Payment Link:')
console.log(paymentUrl.replace('https://midtrans-cifo.winteraccess.id', 'http://localhost:5173'))
console.log('\n📋 Order ID:', orderId)
console.log('💰 Amount: Rp 150,000')
console.log('🔑 Mode: SNAP (Hosted UI)')
if (token) {
console.log('\n📄 Token:', token.substring(0, 50) + '...')
}
} catch (error) {
console.error('❌ Failed to create payment link:', error.message)
process.exit(1)
}

View File

@ -1,10 +1,10 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom' import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppLayout } from './AppLayout' import { AppLayout } from './AppLayout'
// import { CheckoutPage } from '../pages/CheckoutPage' import { CheckoutPage } from '../pages/CheckoutPage'
import { PaymentStatusPage } from '../pages/PaymentStatusPage' import { PaymentStatusPage } from '../pages/PaymentStatusPage'
import { PaymentHistoryPage } from '../pages/PaymentHistoryPage' import { PaymentHistoryPage } from '../pages/PaymentHistoryPage'
import { NotFoundPage } from '../pages/NotFoundPage' import { NotFoundPage } from '../pages/NotFoundPage'
// import { DemoStorePage } from '../pages/DemoStorePage' import { DemoStorePage } from '../pages/DemoStorePage'
import { InitPage } from '../pages/InitialPage' import { InitPage } from '../pages/InitialPage'
import { PayPage } from '../pages/PayPage' import { PayPage } from '../pages/PayPage'
@ -15,7 +15,8 @@ const router = createBrowserRouter([
errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>, errorElement: <div role="alert">Terjadi kesalahan. Coba muat ulang.</div>,
children: [ children: [
{ index: true, element: <InitPage /> }, { index: true, element: <InitPage /> },
// { path: 'checkout', element: <CheckoutPage /> }, { path: 'checkout', element: <CheckoutPage /> },
{ path: 'demo', element: <DemoStorePage /> },
{ path: 'pay/:token', element: <PayPage /> }, { path: 'pay/:token', element: <PayPage /> },
{ path: 'payments/:orderId/status', element: <PaymentStatusPage /> }, { path: 'payments/:orderId/status', element: <PaymentStatusPage /> },
{ path: 'history', element: <PaymentHistoryPage /> }, { path: 'history', element: <PaymentHistoryPage /> },

View File

@ -10,6 +10,7 @@ import { SnapTokenService } from './SnapTokenService'
interface SnapPaymentTriggerProps { interface SnapPaymentTriggerProps {
orderId: string orderId: string
amount: number amount: number
customer?: { name?: string; phone?: string; email?: string }
paymentMethod?: string paymentMethod?: string
onSuccess?: (result: any) => void onSuccess?: (result: any) => void
onError?: (error: any) => void onError?: (error: any) => void
@ -19,6 +20,7 @@ interface SnapPaymentTriggerProps {
export function SnapPaymentTrigger({ export function SnapPaymentTrigger({
orderId, orderId,
amount, amount,
customer,
paymentMethod, paymentMethod,
onSuccess, onSuccess,
onError, onError,
@ -43,6 +45,7 @@ export function SnapPaymentTrigger({
<SnapHostedPayment <SnapHostedPayment
orderId={orderId} orderId={orderId}
amount={amount} amount={amount}
customer={customer}
onSuccess={onSuccess} onSuccess={onSuccess}
onError={onError} onError={onError}
/> />
@ -105,7 +108,7 @@ function CorePaymentComponent({ paymentMethod, orderId, amount, onChargeInitiate
return <Component orderId={orderId} amount={amount} onChargeInitiated={onChargeInitiated} /> return <Component orderId={orderId} amount={amount} onChargeInitiated={onChargeInitiated} />
} }
function SnapHostedPayment({ orderId, amount, onSuccess, onError }: Omit<SnapPaymentTriggerProps, 'paymentMethod' | 'onChargeInitiated'>) { function SnapHostedPayment({ orderId, amount, customer, onSuccess, onError }: Omit<SnapPaymentTriggerProps, 'paymentMethod' | 'onChargeInitiated'>) {
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState('') const [error, setError] = React.useState('')
@ -114,7 +117,7 @@ function SnapHostedPayment({ orderId, amount, onSuccess, onError }: Omit<SnapPay
setLoading(true) setLoading(true)
setError('') setError('')
Logger.paymentInfo('snap.payment.init', { orderId, amount }) Logger.paymentInfo('snap.payment.init', { orderId, amount, customer })
// Create Snap transaction token using service // Create Snap transaction token using service
const token = await SnapTokenService.createToken({ const token = await SnapTokenService.createToken({
@ -122,10 +125,17 @@ function SnapHostedPayment({ orderId, amount, onSuccess, onError }: Omit<SnapPay
order_id: orderId, order_id: orderId,
gross_amount: amount gross_amount: amount
}, },
// Add customer details if available customer_details: customer ? {
customer_details: { first_name: customer.name,
// These would come from props or context 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) + '...' }) Logger.paymentInfo('snap.token.received', { orderId, token: token.substring(0, 10) + '...' })

View File

@ -22,7 +22,10 @@ export interface SnapTokenRequest {
} }
export interface SnapTokenResponse { export interface SnapTokenResponse {
token: string | {
token: string token: string
redirect_url: string
}
} }
export class SnapTokenService { export class SnapTokenService {
@ -38,16 +41,28 @@ export class SnapTokenService {
const response = await api.post<SnapTokenResponse>('/payments/snap/token', request) const response = await api.post<SnapTokenResponse>('/payments/snap/token', request)
if (!response.data?.token) { // 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') throw new Error('Invalid token response from server')
} }
if (!tokenString) {
throw new Error('Empty token received from server')
}
TransactionLogger.log('SNAP', 'token.created', { TransactionLogger.log('SNAP', 'token.created', {
orderId: request.transaction_details.order_id, orderId: request.transaction_details.order_id,
tokenLength: response.data.token.length tokenLength: tokenString.length
}) })
return response.data.token return tokenString
} catch (error) { } catch (error) {
TransactionLogger.logPaymentError('SNAP', request.transaction_details.order_id, error) TransactionLogger.logPaymentError('SNAP', request.transaction_details.order_id, error)

View File

@ -25,7 +25,7 @@ export function CheckoutPage() {
const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32 const expireAt = Date.now() + 59 * 60 * 1000 + 32 * 1000 // 00:59:32
const [selected, setSelected] = React.useState<PaymentMethod | null>(null) const [selected, setSelected] = React.useState<PaymentMethod | null>(null)
const [locked, setLocked] = React.useState(false) const [locked, setLocked] = React.useState(false)
const [currentStep, setCurrentStep] = React.useState<1 | 2 | 3>(1) const [currentStep, setCurrentStep] = React.useState<1 | 2>(1)
const [isBusy, setIsBusy] = React.useState(false) const [isBusy, setIsBusy] = React.useState(false)
const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({ const [form, setForm] = React.useState<{ name: string; contact: string; address: string; notes: string }>({
name: 'Demo User', name: 'Demo User',
@ -66,8 +66,8 @@ export function CheckoutPage() {
</Alert> </Alert>
)} )}
<PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 3}> <PaymentSheet merchantName="Simaya" orderId={orderId} amount={amount} expireAt={expireAt} showStatusCTA={currentStep === 2}>
{/* Wizard 3 langkah: Step 1 (Form Dummy) → Step 2 (Pilih Metode) → Step 3 (Panel Metode) */} {/* Wizard 2 langkah: Step 1 (Form Dummy) → Step 2 (Payment - Snap/Core auto-detect) */}
{currentStep === 1 && ( {currentStep === 1 && (
<div className="space-y-3"> <div className="space-y-3">
<div className="text-sm font-medium">Konfirmasi data checkout</div> <div className="text-sm font-medium">Konfirmasi data checkout</div>
@ -114,6 +114,8 @@ export function CheckoutPage() {
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
setIsBusy(true) setIsBusy(true)
// Set default payment method (bank_transfer for demo)
setSelected('bank_transfer')
setTimeout(() => { setCurrentStep(2); setIsBusy(false) }, 400) setTimeout(() => { setCurrentStep(2); setIsBusy(false) }, 400)
}} }}
> >
@ -122,57 +124,23 @@ export function CheckoutPage() {
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/70 border-t-transparent" aria-hidden />
Memuat Memuat
</span> </span>
) : 'Next'} ) : 'Lanjut ke Pembayaran'}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<div className="space-y-3">
<PaymentMethodList
selected={selected ?? undefined}
onSelect={(m) => {
setSelected(m)
if (m === 'bank_transfer' || m === 'cstore') {
// SnapPaymentTrigger will handle the bank/store selection internally
setIsBusy(true)
setTimeout(() => { setCurrentStep(3); setIsBusy(false) }, 300)
} 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)
}
}}
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}
/>
</div>
)}
{currentStep === 3 && (
<div className="space-y-3" aria-live="polite"> <div className="space-y-3" aria-live="polite">
{selected && (
<SnapPaymentTrigger <SnapPaymentTrigger
orderId={orderId} orderId={orderId}
amount={amount} amount={amount}
paymentMethod={selected} customer={{
name: form.name,
email: form.contact.includes('@') ? form.contact : undefined,
phone: !form.contact.includes('@') ? form.contact : undefined
}}
paymentMethod={'bank_transfer'}
onChargeInitiated={() => setLocked(true)} onChargeInitiated={() => setLocked(true)}
onSuccess={(result) => { onSuccess={(result) => {
Logger.info('checkout.payment.success', { orderId, result }) Logger.info('checkout.payment.success', { orderId, result })
@ -183,13 +151,6 @@ export function CheckoutPage() {
// Handle payment error // Handle payment error
}} }}
/> />
)}
{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> </div>
)} )}
</PaymentSheet> </PaymentSheet>

View File

@ -1,13 +1,8 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { PaymentSheet } from '../features/payments/components/PaymentSheet' import { PaymentSheet } from '../features/payments/components/PaymentSheet'
import { PaymentMethodList } from '../features/payments/components/PaymentMethodList'
import type { PaymentMethod } from '../features/payments/components/PaymentMethodList' import type { PaymentMethod } from '../features/payments/components/PaymentMethodList'
import { BankTransferPanel } from '../features/payments/core/BankTransferPanel' import { SnapPaymentTrigger } from '../features/payments/snap/SnapPaymentTrigger'
import { CardPanel } from '../features/payments/core/CardPanel'
import { GoPayPanel } from '../features/payments/core/GoPayPanel'
import { CStorePanel } from '../features/payments/core/CStorePanel'
import { BankLogo, type BankKey, LogoAlfamart, LogoIndomaret } from '../features/payments/components/PaymentLogos'
import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig' import { usePaymentConfig } from '../features/payments/lib/usePaymentConfig'
import { Alert } from '../components/alert/Alert' import { Alert } from '../components/alert/Alert'
import { Button } from '../components/ui/button' import { Button } from '../components/ui/button'
@ -23,15 +18,13 @@ export function PayPage() {
const [orderId, setOrderId] = useState<string>('') const [orderId, setOrderId] = useState<string>('')
const [amount, setAmount] = useState<number>(0) const [amount, setAmount] = useState<number>(0)
const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000) const [expireAt, setExpireAt] = useState<number>(Date.now() + 24 * 60 * 60 * 1000)
const [selectedMethod, setSelectedMethod] = useState<Method>(null) const [selectedMethod] = useState<Method>(null)
const [locked, setLocked] = useState<boolean>(false) const [locked, setLocked] = useState<boolean>(false)
const [selectedBank, setSelectedBank] = useState<BankKey | null>(null) const [customer, setCustomer] = useState<{ name?: string; phone?: string; email?: string } | undefined>(undefined)
const [selectedStore, setSelectedStore] = useState<'alfamart' | 'indomaret' | null>(null)
const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined) const [allowedMethods, setAllowedMethods] = useState<string[] | undefined>(undefined)
const [error, setError] = useState<{ code?: string; message?: string } | null>(null) const [error, setError] = useState<{ code?: string; message?: string } | null>(null)
const { data: runtimeCfg } = usePaymentConfig() const { data: runtimeCfg } = usePaymentConfig()
const [currentStep, setCurrentStep] = useState<2 | 3>(2) const currentStep = 2
const [isBusy, setIsBusy] = useState(false)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -43,6 +36,7 @@ export function PayPage() {
setOrderId(payload.order_id) setOrderId(payload.order_id)
setAmount(payload.nominal) setAmount(payload.nominal)
setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000) setExpireAt(payload.expire_at ?? Date.now() + 24 * 60 * 60 * 1000)
setCustomer(payload.customer)
setAllowedMethods(payload.allowed_methods) setAllowedMethods(payload.allowed_methods)
setError(null) setError(null)
if (isOrderLocked(payload.order_id)) setLocked(true) if (isOrderLocked(payload.order_id)) setLocked(true)
@ -58,26 +52,7 @@ export function PayPage() {
}, [token]) }, [token])
const merchantName = useMemo(() => '', []) const merchantName = useMemo(() => '', [])
const isExpired = expireAt ? Date.now() > expireAt : false 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) { if (error || isExpired) {
const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid' const title = isExpired ? 'Link pembayaran telah kedaluwarsa' : 'Link pembayaran tidak valid'
@ -118,7 +93,7 @@ export function PayPage() {
orderId={orderId} orderId={orderId}
amount={amount} amount={amount}
expireAt={expireAt} expireAt={expireAt}
showStatusCTA={currentStep === 3} showStatusCTA={currentStep === 2}
> >
<div className="space-y-4 px-4 py-6"> <div className="space-y-4 px-4 py-6">
{locked && currentStep === 2 && ( {locked && currentStep === 2 && (
@ -132,130 +107,24 @@ export function PayPage() {
</Alert> </Alert>
)} )}
{currentStep === 2 && ( {currentStep === 2 && (
<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)
}
}}
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"> <div className="space-y-3" aria-live="polite">
{selectedMethod === 'bank_transfer' && ( <SnapPaymentTrigger
<BankTransferPanel
locked={locked}
onChargeInitiated={() => { lockOrder(orderId); setLocked(true) }}
orderId={orderId} orderId={orderId}
amount={amount} amount={amount}
defaultBank={(selectedBank ?? 'bca')} customer={customer}
paymentMethod={selectedMethod || 'bank_transfer'}
onChargeInitiated={() => {
lockOrder(orderId)
setLocked(true)
}}
onSuccess={(result) => {
console.log('[PayPage] Payment success:', result)
nav.toStatus(orderId, selectedMethod || undefined)
}}
onError={(error) => {
console.error('[PayPage] Payment error:', error)
}}
/> />
)}
{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>
)} )}
</div> </div>