#!/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 บาท · ฝากเพิ่มยอดเต็มจำนวน · ถอนกันยอดครั้งเดียว · ปฏิเสธแล้วคืนครั้งเดียว
ถอนใช้ข้อมูลผู้รับตัวอย่างเท่านั้น หลังส่งคำขอให้กดจำลองผลใน Sandbox Console แล้วกลับมาดูยอดที่นี่
รอคำขอแรก
payload คือข้อความที่รับและตรวจลายเซ็นแล้ว ค่า late อยู่ใน payment.paid webhook เท่านั้น ไม่อยู่ใน GET รายการเก่าที่ไม่มี payload ให้กดส่ง webhook เดิมซ้ำใน Sandbox เพื่อเก็บหลักฐาน
—