#!/usr/bin/env node 'use strict' // Local merchant backend example. This is a learning app, not a production wallet. const fs = require('node:fs') const path = require('node:path') const crypto = require('node:crypto') const { localUrl, localServer, body, json, fail, store, amount, verify, page } = require('./sandbox.cjs') const DEMO_RECIPIENT = { type: 'account', bank_code: '004', account_no: '0012345678', account_name: 'Example Recipient', given_name: 'Example', surname: 'Recipient', address_country: 'TH', address_city: 'Bangkok', address_street: '1 Example Road', tax_id: '1234567890123', email: 'recipient@example.com', } function createMerchant(options) { const base = localUrl(options.baseUrl).origin if (!options.apiKey?.startsWith('gw_test_') || !options.webhookSecret?.startsWith('whsec_test_')) throw new Error('This example accepts only local sandbox credentials') const sandboxFingerprint = crypto.createHash('sha256').update(options.apiKey).digest('hex') const db = store(options.stateFile, () => ({ version: 1, sandboxFingerprint, balanceCents: 1000000, orders: [], received: [], probeReceived: false, connectionVerified: false, })) if (db.get().version !== 1 || db.get().sandboxFingerprint !== sandboxFingerprint) throw new Error('Merchant data belongs to another sandbox; use a new MERCHANT_STATE_FILE for a new sandbox') const inFlight = new Set() async function api(route, method = 'GET', data, key) { const response = await fetch(base + route, { method, redirect: 'error', signal: AbortSignal.timeout(5000), headers: { 'X-Api-Key': options.apiKey, 'Content-Type': 'application/json', ...(key ? { 'Idempotency-Key': key } : {}), }, ...(data ? { body: JSON.stringify(data) } : {}), }) return { status: response.status, value: await response.json() } } function apply(s, order, result) { const id = order.kind === 'deposit' ? result.payment_id : result.withdrawal_id if ( typeof id !== 'string' || (order.id && order.id !== id) || result.merchant_ref !== order.ref || result.amount !== order.amount || result.currency !== 'THB' ) fail(422, 'order_mismatch') order.id = id // A late or duplicated callback cannot undo a terminal business operation. if (order.status === 'paid' || order.status === 'rejected') return if (order.kind === 'deposit') { if (result.status === 'paid' && !order.credited) { s.balanceCents += Math.round(order.amount * 100) order.credited = true } } else { if (result.status === 'rejected' && order.reserved) { s.balanceCents += Math.round(order.amount * 100) order.reserved = false } if (result.status === 'paid') order.reserved = false } order.status = result.status order.response = result order.updated_at = new Date().toISOString() } const server = localServer(async (req, res, url) => { if (req.method === 'GET' && url.pathname === '/') { res.setHeader('Content-Type', 'text/html; charset=utf-8') res.end(merchantPage(base)) return } if (req.method === 'GET' && url.pathname === '/state') { json(res, 200, { ...db.get(), balance: db.get().balanceCents / 100, simulated: true }) return } if (req.method !== 'POST') fail(404, 'not_found') const { value: input, raw } = await body(req) if (url.pathname === '/webhook') { if (!verify(options.webhookSecret, raw, req.headers['x-gateway-signature'])) fail(401, 'invalid_signature') if (req.headers['x-gateway-event'] !== input.event) fail(422, 'event_mismatch') if (input.event === 'sandbox.test') { db.update((s) => { s.probeReceived = true }) json(res, 200, { received: true, business_verified: false }) return } if ( !['payment.paid', 'payment.cancelled', 'withdrawal.paid', 'withdrawal.rejected', 'withdrawal.unknown'].includes( input.event, ) ) fail(422, 'unsupported_event') const kind = input.event.startsWith('payment.') ? 'deposit' : 'withdraw' const order = db.get().orders.find((o) => o.ref === input.merchant_ref && o.kind === kind) if (!order) fail(404, 'unknown_order', 'สร้างรายการจาก backend ของร้านก่อน') const id = kind === 'deposit' ? input.payment_id : input.withdrawal_id if ( typeof id !== 'string' || (order.id && id !== order.id) || input.amount !== order.amount || (input.event !== 'payment.cancelled' && input.currency !== 'THB') ) fail(422, 'order_mismatch') if (input.event === 'payment.paid' && input.paid_amount !== order.amount) fail(422, 'paid_amount_mismatch') // Reconcile against authenticated GET, including callbacks delivered out of order. const result = await api('/v1/' + (kind === 'deposit' ? 'payments/' : 'withdrawals/') + encodeURIComponent(id)) if (result.status !== 200) fail(503, 'reconciliation_failed') db.update((s) => { const current = s.orders.find((o) => o.ref === order.ref) apply(s, current, result.value) const digest = crypto.createHash('sha256').update(raw).digest('hex') // Delivery header is not signed. Business state and signed payload determine dedupe. const existing = s.received.find((e) => e.digest === digest) if (!existing) { if (s.received.length >= 3000) fail(409, 'example_full') s.received.push({ digest, event: input.event, ref: order.ref, received_at: new Date().toISOString(), payload: input, }) } else if (!existing.payload) { // Older example data stored only the digest. Enrich from a verified replay, // never infer webhook-only fields (such as late) from the GET response. existing.payload = input existing.payload_received_at = new Date().toISOString() } }) json(res, 200, { received: true }) return } if (url.pathname === '/connection') { const result = await api('/v1/balance') db.update((s) => { s.connectionVerified = result.status === 200 }) json(res, result.status, result.value) return } const poll = url.pathname.match(/^\/orders\/([^/]+)\/refresh$/) if (poll) { const order = db.get().orders.find((o) => o.ref === poll[1]) if (!order?.id) fail(404, 'order_without_id', 'ใช้ส่งคำขอเดิมซ้ำเพื่อกู้ผล request timeout') const result = await api('/v1/' + (order.kind === 'deposit' ? 'payments/' : 'withdrawals/') + order.id) if (result.status === 200) db.update((s) => apply( s, s.orders.find((o) => o.ref === order.ref), result.value, ), ) json(res, result.status, result.value) return } if (url.pathname !== '/orders') fail(404, 'not_found') const kind = input.kind if (!['deposit', 'withdraw'].includes(kind)) fail(422, 'invalid_kind') const cents = amount(input.amount, kind === 'deposit' ? 20 : 300, kind === 'deposit' ? 3000 : 1000000) const ref = input.merchant_ref if (typeof ref !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(ref)) fail(422, 'invalid_merchant_ref') if (inFlight.has(ref)) fail(409, 'request_in_progress') inFlight.add(ref) try { const order = db.update((s) => { const existing = s.orders.find((o) => o.ref === ref) if (existing) { if (existing.kind !== kind || existing.amount !== input.amount) fail(409, 'idempotency_conflict') return existing } if (s.orders.length >= 1000) fail(409, 'example_full') if (kind === 'withdraw' && s.balanceCents < cents) fail(422, 'customer_balance_insufficient') const value = { ref, kind, amount: input.amount, id: null, status: 'submitting', credited: false, reserved: kind === 'withdraw', request: { amount: input.amount, merchant_ref: ref, ...(kind === 'withdraw' ? { recipient: DEMO_RECIPIENT } : {}), }, response: null, created_at: new Date().toISOString(), } if (value.reserved) s.balanceCents -= cents s.orders.push(value) return value }) // The persisted request and reference are reused after timeouts/restarts. let result try { result = await api('/v1/' + (kind === 'deposit' ? 'payments' : 'withdrawals'), 'POST', order.request, ref) } catch { fail(504, 'result_unknown', 'ยังไม่ทราบผล กดส่งคำขอเดิมซ้ำด้วย ref เดิม ห้ามสร้างรายการใหม่') } if (result.value.payment_id || result.value.withdrawal_id) { db.update((s) => apply( s, s.orders.find((o) => o.ref === ref), result.value, ), ) } else { // Keep the hold if the result is uncertain. A corrective integration requires // explicit reconciliation, not an automatic refund on HTTP/network errors. db.update((s) => { const o = s.orders.find((o) => o.ref === ref) o.last_error = result.value }) } json(res, result.status, result.value) } finally { inFlight.delete(ref) } }) return { server, state: () => structuredClone(db.get()) } } function merchantPage(sandboxBase) { // sandboxBase is the validated literal-loopback URL origin, not request input. return page( 'เว็บร้านตัวอย่าง', `

เว็บนี้เรียก API ผ่าน Node.js backend และตรวจ webhook ก่อนบันทึกผลลงไฟล์บนเครื่อง

เปิด Sandbox Console เพื่อจำลองผล ↗

ความคืบหน้าการเชื่อมต่อ

Webhook ถึง = รับและตรวจลายเซ็นแล้ว ส่วนฝาก/ถอนผ่าน = มีออเดอร์ของเว็บนี้ที่สำเร็จแล้ว

ยอดลูกค้าจำลอง

—

เริ่มต้น 10,000 บาท · ฝากเพิ่มยอดเต็มจำนวน · ถอนกันยอดครั้งเดียว · ปฏิเสธแล้วคืนครั้งเดียว

สร้างรายการจาก backend

ถอนใช้ข้อมูลผู้รับตัวอย่างเท่านั้น หลังส่งคำขอให้กดจำลองผลใน Sandbox Console แล้วกลับมาดูยอดที่นี่

รอคำขอแรก

ออเดอร์และผลที่ร้านบันทึก

Webhook ที่ผ่านการตรวจ

payload คือข้อความที่รับและตรวจลายเซ็นแล้ว ค่า late อยู่ใน payment.paid webhook เท่านั้น ไม่อยู่ใน GET รายการเก่าที่ไม่มี payload ให้กดส่ง webhook เดิมซ้ำใน Sandbox เพื่อเก็บหลักฐาน

—
`, ` const el=id=>document.getElementById(id);el('ref').value='order-'+crypto.randomUUID(); el('kind').onchange=()=>{el('amount').min=el('kind').value==='deposit'?'20':'300';el('amount').max=el('kind').value==='deposit'?'3000':'1000000'}; async function action(url,data={}){try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});const b=await r.json();el('response').textContent='HTTP '+r.status+'\\n'+JSON.stringify(b,null,2);el('message').textContent=r.ok?'บันทึกผลแล้ว ตรวจสถานะและยอดด้านล่าง':'ตรวจ response และใช้ ref เดิมหากยังไม่ทราบผล';await refresh()}catch(e){el('message').textContent=e.message}} el('connect').onclick=()=>action('/connection');el('new').onclick=()=>{el('ref').value='order-'+crypto.randomUUID()};el('submit').onclick=()=>action('/orders',{kind:el('kind').value,amount:Number(el('amount').value),merchant_ref:el('ref').value}); async function refresh(){try{const r=await fetch('/state');if(!r.ok)throw Error('โหลดสถานะไม่สำเร็จ');const s=await r.json();el('balance').textContent=s.balance.toLocaleString('th-TH')+' THB';el('checks').textContent=[['API',s.connectionVerified],['Test webhook',s.probeReceived],['Deposit paid',s.orders.some(o=>o.kind==='deposit'&&o.status==='paid')],['Withdraw paid',s.orders.some(o=>o.kind==='withdraw'&&o.status==='paid')],['Late deposit',s.received.some(e=>e.event==='payment.paid'&&e.payload?.late===true&&s.orders.some(o=>o.id===e.payload.payment_id&&o.status==='paid'&&o.credited))]].map(([k,v])=>(v?'✓ ':'○ ')+k).join(' · ');el('orders').replaceChildren();for(const o of [...s.orders].reverse()){const row=document.createElement('div');row.className='row';const label=document.createElement('p');label.textContent=o.kind+' · '+o.ref+' · '+o.amount+' THB · '+o.status;const b=document.createElement('button');b.textContent='ตรวจสถานะผ่าน GET';b.disabled=!o.id;b.onclick=()=>action('/orders/'+o.ref+'/refresh');const retry=document.createElement('button');retry.textContent='ส่งคำขอเดิมซ้ำ';retry.onclick=()=>action('/orders',{kind:o.kind,amount:o.amount,merchant_ref:o.ref});row.append(label);if(o.kind==='deposit'){const evidence=document.createElement('p');evidence.className='note';const receipt=s.received.find(e=>e.event==='payment.paid'&&e.ref===o.ref&&e.payload?.payment_id===o.id&&typeof e.payload.late==='boolean');evidence.textContent=receipt?'Webhook payment.paid: late: '+receipt.payload.late+(receipt.payload.late?' · ยืนยันชำระล่าช้า':' · ชำระปกติ'):'ยังไม่มีหลักฐาน late จาก webhook';row.append(evidence)}row.append(b,retry);el('orders').append(row)}el('events').textContent=JSON.stringify(s.received,null,2)}catch(e){el('message').textContent=e.message}} refresh();setInterval(refresh,2000); `, ) } module.exports = { createMerchant, DEMO_RECIPIENT } if (require.main === module) { const stateFile = process.env.SANDBOX_STATE_FILE || path.resolve('.gateway-sandbox.json') let credentials = {} if (fs.existsSync(stateFile)) credentials = JSON.parse(fs.readFileSync(stateFile, 'utf8')) const app = createMerchant({ baseUrl: process.env.SANDBOX_URL || 'http://127.0.0.1:4545', apiKey: process.env.SANDBOX_API_KEY || credentials.apiKey, webhookSecret: process.env.SANDBOX_WEBHOOK_SECRET || credentials.webhookSecret, stateFile: process.env.MERCHANT_STATE_FILE || path.resolve('.gateway-merchant.json'), }) app.server.listen(Number(process.env.MERCHANT_PORT || 4546), '127.0.0.1', () => console.log('Example merchant (test only): http://127.0.0.1:' + app.server.address().port), ) app.server.on('error', (e) => { console.error(e.message) process.exitCode = 1 }) }