#!/usr/bin/env node 'use strict' // Standalone local integration sandbox. Intentionally imports no gateway, database, // provider client or environment loader. Node.js 20+, no npm dependencies. const http = require('node:http') const fs = require('node:fs') const path = require('node:path') const crypto = require('node:crypto') function fail(status, code, message = code) { throw Object.assign(new Error(message), { status, code }) } function json(res, status, value) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) res.end(JSON.stringify(value)) } function localUrl(raw) { let url try { url = new URL(raw) } catch { fail(422, 'invalid_local_url') } // Literal loopback only: no DNS rebinding, redirects or production webhook targets. if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || url.username || url.password || url.hash) fail(422, 'local_url_required', 'ใช้ http://127.0.0.1:/... สำหรับระบบทดสอบบนเครื่องนี้') return url } async function body(req) { const chunks = [] let size = 0 for await (const chunk of req) { size += chunk.length if (size > 65536) fail(413, 'body_too_large') chunks.push(chunk) } const raw = Buffer.concat(chunks) let value try { value = JSON.parse(raw.toString() || '{}') } catch { fail(400, 'invalid_json') } if (!value || typeof value !== 'object' || Array.isArray(value)) fail(422, 'object_required') return { value, raw } } function store(file, initial) { let data = file && fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : initial() function save(next) { if (file) { fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true }) const tmp = file + '.' + process.pid + '.tmp' fs.writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 }) fs.renameSync(tmp, file) } data = next } save(data) return { get: () => data, update(fn) { const next = structuredClone(data) const result = fn(next) save(next) return result }, } } function text(value, max, field) { if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f\x7f]/.test(value)) fail(422, 'invalid_' + field) return value.trim() } function amount(value, min, max) { if ( typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max || !/^\d+(\.\d{1,2})?$/.test(String(value)) ) fail(422, 'invalid_amount') return Math.round(value * 100) } function recipient(raw) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail(422, 'invalid_recipient') const allowed = [ 'type', 'bank_code', 'account_no', 'account_name', 'given_name', 'surname', 'address_country', 'address_city', 'address_street', 'tax_id', 'email', ] if (Object.keys(raw).some((k) => !allowed.includes(k))) fail(422, 'invalid_recipient_field') const r = { ...raw } if (typeof r.account_no !== 'string' || !/^[0-9 -]{1,40}$/.test(r.account_no)) fail(422, 'invalid_account_no') r.account_no = r.account_no.replace(/[ -]/g, '') if (r.type === 'account') { if (typeof r.bank_code !== 'string' || !/^\d{3}$/.test(r.bank_code) || !/^\d{10,15}$/.test(r.account_no)) fail(422, 'invalid_bank_account') } else if (r.type !== 'promptpay' || r.bank_code !== undefined || !/^(0\d{9}|\d{13})$/.test(r.account_no)) fail(422, 'invalid_promptpay') for (const [key, max] of [ ['account_name', 120], ['given_name', 100], ['surname', 100], ['address_city', 100], ['address_street', 255], ]) r[key] = text(r[key], max, key) if (r.address_country !== 'TH') fail(422, 'invalid_address_country') if (r.tax_id !== undefined && r.tax_id !== '' && (typeof r.tax_id !== 'string' || !/^\d{13}$/.test(r.tax_id))) fail(422, 'invalid_tax_id') if ( r.email !== undefined && r.email !== '' && (typeof r.email !== 'string' || r.email.trim().length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.email.trim())) ) fail(422, 'invalid_email') if (typeof r.email === 'string') r.email = r.email.trim() return r } function canonical(value) { if (Array.isArray(value)) return value.map(canonical) if (value && typeof value === 'object') return Object.fromEntries( Object.keys(value) .sort() .map((k) => [k, canonical(value[k])]), ) return value } function sign(secret, raw) { return 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex') } function verify(secret, raw, signature) { return ( typeof signature === 'string' && /^sha256=[a-f0-9]{64}$/.test(signature) && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(sign(secret, raw))) ) } function localServer(handler) { const server = http.createServer(async (req, res) => { res.setHeader('Cache-Control', 'no-store') res.setHeader('X-Content-Type-Options', 'nosniff') res.setHeader('X-Frame-Options', 'DENY') res.setHeader('Referrer-Policy', 'no-referrer') res.setHeader( 'Content-Security-Policy', "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; form-action 'self'", ) try { const port = server.address().port const hosts = ['127.0.0.1:' + port, 'localhost:' + port] if ( !hosts.includes(req.headers.host) || (req.headers.origin && req.headers.origin !== 'http://' + req.headers.host) || req.headers['sec-fetch-site'] === 'cross-site' ) fail(403, 'local_origin_required') await handler(req, res, new URL(req.url, 'http://' + req.headers.host)) } catch (e) { if (!res.headersSent) json(res, e.status || 500, { error: e.status ? e.message : 'local sandbox error', code: e.code || 'sandbox_error', }) else res.end() } }) server.requestTimeout = 10000 return server } function page(title, content, script = '') { return `${title}
GATEWAY / LOCAL TEST ENVIRONMENT

${title}

ข้อมูลและยอดทั้งหมดเป็นการจำลองบนเครื่องนี้ ไม่มีการโอนเงินจริง

${content}

` } function createSandbox(options = {}) { const db = store(options.stateFile, () => ({ version: 1, apiKey: 'gw_test_' + crypto.randomBytes(16).toString('hex'), webhookSecret: 'whsec_test_' + crypto.randomBytes(24).toString('hex'), balanceCents: 1000000, webhookUrl: '', payments: [], withdrawals: [], deliveries: [], })) if ( db.get().version !== 1 || !db.get().apiKey.startsWith('gw_test_') || !db.get().webhookSecret.startsWith('whsec_test_') ) throw new Error('Not a sandbox state file') const stamp = () => new Date().toISOString() const id = (prefix) => prefix + 'test_' + crypto.randomUUID().replaceAll('-', '') const publicPayment = (p) => Object.fromEntries(Object.entries(p).filter(([k]) => !k.startsWith('_'))) const publicWithdrawal = (w) => Object.fromEntries(Object.entries(w).filter(([k]) => !k.startsWith('_'))) function queue(s, payload) { if (s.deliveries.length >= 3000) fail(409, 'sandbox_full', 'เริ่ม session ใหม่ด้วยไฟล์ข้อมูลใหม่') const delivery = { id: id('evt_'), payload, status: 'queued', attempt: 0, http_status: null, next_at: Date.now(), created_at: stamp(), } s.deliveries.push(delivery) return delivery } function settlePayment(s, p, status) { if (!['paid', 'expired', 'cancelled'].includes(status)) fail(422, 'invalid_payment_status') if (p.status === status) return publicPayment(p) if (p.status === 'paid' || p.status === 'cancelled' || (p.status === 'expired' && status !== 'paid')) fail(409, 'terminal_payment') const late = p.status === 'expired' p.status = status if (status === 'paid') { const cents = Math.round(p.amount * 100), fee = Math.round((cents * 160) / 10000) p.fee_amount = fee / 100 p.net_amount = (cents - fee) / 100 p.paid_at = stamp() s.balanceCents += cents - fee queue(s, { event: 'payment.paid', payment_id: p.payment_id, merchant_ref: p.merchant_ref, amount: p.amount, paid_amount: p.amount, fee_amount: p.fee_amount, net_amount: p.net_amount, currency: 'THB', paid_at: p.paid_at, late, }) } else if (status === 'cancelled') queue(s, { event: 'payment.cancelled', payment_id: p.payment_id, merchant_ref: p.merchant_ref, amount: p.amount, status, }) return publicPayment(p) } function settleWithdrawal(s, w, status) { if (!['paid', 'rejected', 'unknown', 'processing'].includes(status)) fail(422, 'invalid_withdrawal_status') if (w.status === status) return publicWithdrawal(w) if (['paid', 'rejected'].includes(w.status)) fail(409, 'terminal_withdrawal') w.status = status w.updated_at = stamp() w.reason = status === 'rejected' ? 'provider_rejected' : status === 'unknown' ? 'provider_result_unknown' : null if (status === 'paid') { w.charged_amount = w.debit_amount w.reserved_amount = 0 } if (status === 'rejected') { s.balanceCents += Math.round(w.reserved_amount * 100) w.reserved_amount = 0 } if (status !== 'processing') queue(s, { event: 'withdrawal.' + status, ...publicWithdrawal(w) }) return publicWithdrawal(w) } const server = localServer(async (req, res, url) => { const route = url.pathname if (req.method === 'GET' && (route === '/' || /^\/p\/pay_test_[a-f0-9]+$/.test(route))) { res.setHeader('Content-Type', 'text/html; charset=utf-8') res.end(sandboxPage()) return } if (req.method === 'GET' && route === '/sandbox/state') { const s = db.get() json(res, 200, { simulated: true, api_key: s.apiKey, webhook_secret: s.webhookSecret, base_url: 'http://127.0.0.1:' + server.address().port, balance: s.balanceCents / 100, webhook_url: s.webhookUrl, payments: s.payments.map(publicPayment), withdrawals: s.withdrawals.map(publicWithdrawal), deliveries: s.deliveries, }) return } if (route.startsWith('/v1/') && req.headers['x-api-key'] !== db.get().apiKey) fail(401, 'invalid_api_key', 'Sandbox รับเฉพาะ Test API key ของ session นี้') if (req.method === 'GET' && route === '/v1/balance') { json(res, 200, { balance: db.get().balanceCents / 100, currency: 'THB' }) return } if (req.method === 'GET' && route === '/v1/withdrawals') { const w = db.get().withdrawals.find((w) => w.merchant_ref === url.searchParams.get('merchant_ref')) if (!w) fail(404, 'not_found') json(res, 200, publicWithdrawal(w)) return } const resource = route.match(/^\/v1\/(payments|withdrawals)\/([^/]+)$/) if (req.method === 'GET' && resource) { const [_, kind, ref] = resource const item = db.get()[kind].find((x) => (x.payment_id || x.withdrawal_id) === ref) if (!item) fail(404, 'not_found') json(res, 200, kind === 'payments' ? publicPayment(item) : publicWithdrawal(item)) return } if (req.method !== 'POST') fail(404, 'not_found') const { value: input } = await body(req) if (route === '/sandbox/config') { const target = input.webhook_url === '' ? '' : localUrl(input.webhook_url).href db.update((s) => { s.webhookUrl = target }) json(res, 200, { webhook_url: target }) return } if (route === '/sandbox/fund') { const cents = amount(input.amount, 0.01, 1000000) db.update((s) => { if (s.balanceCents + cents > 10000000000) fail(422, 'balance_limit') s.balanceCents += cents }) json(res, 200, { balance: db.get().balanceCents / 100, simulated: true }) return } if (route === '/sandbox/probe') { json( res, 202, db.update((s) => queue(s, { event: 'sandbox.test', simulated: true, message: 'ตรวจปลายทางและลายเซ็นเท่านั้น ไม่ยืนยันการอัปเดตออเดอร์', created_at: stamp(), }), ), ) return } const replay = route.match(/^\/sandbox\/deliveries\/([^/]+)\/replay$/) if (replay) { json( res, 200, db.update((s) => { const d = s.deliveries.find((d) => d.id === replay[1]) if (!d) fail(404, 'not_found') if (d.status === 'sending') fail(409, 'delivery_in_progress') d.status = 'queued' d.attempt = 0 d.next_at = Date.now() return d }), ) return } const simulation = route.match(/^\/sandbox\/(payments|withdrawals)\/([^/]+)\/simulate$/) const cancellation = route.match(/^\/v1\/payments\/([^/]+)\/cancel$/) if (simulation || cancellation) { const kind = cancellation ? 'payments' : simulation[1], ref = cancellation ? cancellation[1] : simulation[2] json( res, 200, db.update((s) => { const item = s[kind].find((x) => (x.payment_id || x.withdrawal_id) === ref) if (!item) fail(404, 'not_found') if (cancellation && item.status === 'paid') fail(409, 'already_paid') if (cancellation && ['cancelled', 'expired'].includes(item.status)) return publicPayment(item) return kind === 'payments' ? settlePayment(s, item, cancellation ? 'cancelled' : input.status) : settleWithdrawal(s, item, input.status) }), ) return } if (route !== '/v1/payments' && route !== '/v1/withdrawals') fail(404, 'not_found') const isPayment = route === '/v1/payments' let key = req.headers['idempotency-key'] || '' if (isPayment) key = key.trim() if (typeof key !== 'string' || !key || key.length > 128 || (!isPayment && !/^[\x21-\x7e]+$/.test(key))) fail(422, 'invalid_idempotency_key') const cents = amount(input.amount, isPayment ? 20 : 300, isPayment ? 3000 : 1000000) const ref = text(input.merchant_ref, 64, 'merchant_ref') let normalized if (isPayment) { if (input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 255)) fail(422, 'invalid_description') if (input.return_url !== undefined) { let u try { u = new URL(input.return_url) } catch { fail(422, 'invalid_return_url') } if ( typeof input.return_url !== 'string' || input.return_url.length > 500 || !['http:', 'https:'].includes(u.protocol) ) fail(422, 'invalid_return_url') } } else { if (Object.keys(input).some((k) => !['amount', 'merchant_ref', 'recipient'].includes(k))) fail(422, 'unknown_field', 'ใช้ปุ่ม simulate ใน Sandbox Console เพื่อเลือกผลลัพธ์') normalized = { amount: input.amount, merchant_ref: ref, recipient: recipient(input.recipient) } } const result = db.update((s) => { const collection = isPayment ? s.payments : s.withdrawals const existing = collection.find((x) => x._key === key || (!isPayment && x.merchant_ref === ref)) const hash = isPayment ? '' : JSON.stringify(canonical(normalized)) if (existing) { if (existing.amount !== input.amount || (!isPayment && (existing._key !== key || existing._hash !== hash))) fail(409, 'idempotency_conflict') return { status: !isPayment && existing.status === 'rejected' ? 422 : 200, data: isPayment ? publicPayment(existing) : publicWithdrawal(existing), } } if (collection.length >= 1000) fail(409, 'sandbox_full', 'เริ่ม session ใหม่ด้วยไฟล์ข้อมูลใหม่') if (isPayment) { const pid = id('pay_') const p = { payment_id: pid, status: 'pending', amount: input.amount, currency: 'THB', merchant_ref: ref, checkout_url: 'http://127.0.0.1:' + server.address().port + '/p/' + pid, qr_data_url: null, fee_amount: null, net_amount: null, paid_at: null, expires_in_sec: 1800, created_at: stamp(), _key: key, } s.payments.push(p) return { status: 201, data: publicPayment(p) } } const fee = Math.ceil((cents * 20) / 10000), debit = cents + fee, rejected = s.balanceCents < debit const r = normalized.recipient const w = { withdrawal_id: id('wd_'), merchant_ref: ref, status: rejected ? 'rejected' : 'queued', reason: rejected ? 'insufficient_balance' : null, amount: input.amount, fee_amount: fee / 100, debit_amount: debit / 100, currency: 'THB', reserved_amount: rejected ? 0 : debit / 100, charged_amount: 0, recipient: { type: r.type, ...(r.bank_code ? { bank_code: r.bank_code } : {}), account_no: r.account_no, account_name: r.account_name, }, simulated: true, created_at: stamp(), updated_at: stamp(), _key: key, _hash: hash, } if (!rejected) s.balanceCents -= debit s.withdrawals.push(w) return { status: rejected ? 422 : 202, data: publicWithdrawal(w) } }) json(res, result.status, result.data) }) let busy = false // Accelerated retries for local development, deliberately different from production. const retries = options.retryMs || [1000, 2000, 5000, 10000] async function dispatch() { if (busy) return busy = true try { const due = db.get().deliveries.find((d) => d.status === 'queued' && d.next_at <= Date.now()) if (!due) return const target = db.get().webhookUrl, secret = db.get().webhookSecret db.update((s) => { const d = s.deliveries.find((x) => x.id === due.id) d.status = 'sending' d.attempt++ }) let status = null try { localUrl(target) const raw = JSON.stringify(due.payload) const response = await fetch(target, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000), headers: { 'Content-Type': 'application/json', 'X-Gateway-Event': due.payload.event, 'X-Gateway-Delivery': due.id, 'X-Gateway-Signature': sign(secret, raw), }, body: raw, }) status = response.status await response.body?.cancel() } catch { /* recorded below; no response body or secret is logged */ } db.update((s) => { const d = s.deliveries.find((x) => x.id === due.id) d.http_status = status d.status = status !== null && status >= 200 && status < 300 ? 'delivered' : d.attempt > retries.length ? 'dead' : 'queued' d.next_at = Date.now() + (retries[d.attempt - 1] || 0) }) } finally { busy = false } } // An interrupted delivery is retryable after restarting the local process. db.update((s) => { for (const d of s.deliveries) if (d.status === 'sending') { d.status = 'queued' d.next_at = Date.now() } }) const timer = setInterval(() => { dispatch().catch(() => console.error('Sandbox delivery storage error')) }, 250) timer.unref() server.on('close', () => clearInterval(timer)) return { server, state: () => structuredClone(db.get()), dispatch } } function sandboxPage() { return page( 'Sandbox Console', `

1. เปิดตัวอย่างร้านที่ 127.0.0.1:4546 และสร้างรายการ → 2. เลือกผลจำลองด้านล่าง → 3. กลับไปตรวจยอดและออเดอร์ที่ร้าน

การเชื่อมต่อ

Test credentials ใช้ใน backend เท่านั้น

แสดงค่าตั้งค่า Test

sandbox.test ตรวจการรับข้อความเท่านั้น ไม่เพิ่มยอด

ยอดร้านจำลอง

—

ค่าเริ่มต้น 10,000 บาท · ค่าฝากตัวอย่าง 1.6% / ถอน 0.2%

ยอดนี้แยกจากยอดลูกค้าในตัวอย่างร้าน

Deposit

ไม่มี QR ธนาคาร สถานะเปลี่ยนเมื่อกดจำลองเท่านั้น; expired → paid ใช้ทดสอบจ่ายล่าช้า

Withdraw

unknown ยังไม่ใช่การถอนล้มเหลว ยอดจะยังถูกกันไว้

Webhook deliveries

Retry อัตโนมัติแบบเร่ง 1, 2, 5, 10 วินาที · ปุ่มส่งซ้ำใช้ delivery ID และ payload เดิม

`, ` const el=id=>document.getElementById(id);let initial=true;const rendered={}; 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('message').textContent=(r.ok?'สำเร็จ: ':'ไม่ผ่าน: ')+JSON.stringify(b);await refresh()}catch(e){el('message').textContent=e.message}} function button(label,run){const b=document.createElement('button');b.textContent=label;b.onclick=run;return b} async function refresh(){try{const r=await fetch('/sandbox/state');if(!r.ok)throw Error('โหลดสถานะไม่สำเร็จ');const s=await r.json();el('credentials').textContent=JSON.stringify({base_url:s.base_url,api_key:s.api_key,webhook_secret:s.webhook_secret},null,2);el('balance').textContent=s.balance.toLocaleString('th-TH')+' THB';if(initial){el('webhook').value=s.webhook_url||'http://127.0.0.1:4546/webhook';initial=false} for(const kind of ['payments','withdrawals']){const signature=JSON.stringify(s[kind]);if(rendered[kind]===signature)continue;rendered[kind]=signature;el(kind).replaceChildren();if(!s[kind].length)el(kind).textContent='ยังไม่มีรายการ — สร้างจาก backend ของร้านก่อน';for(const item of [...s[kind]].reverse()){const row=document.createElement('div');row.className='row';const label=document.createElement('div');const id=item.payment_id||item.withdrawal_id;label.textContent=item.merchant_ref+' · '+item.amount+' THB · '+item.status+' · '+id;row.append(label);const statuses=kind==='payments'?['paid','expired','cancelled']:['paid','rejected','unknown','processing'];for(const status of statuses){const b=button(status,()=>action('/sandbox/'+kind+'/'+id+'/simulate',{status}));b.disabled=['paid','cancelled','rejected'].includes(item.status)||(item.status==='expired'&&status!=='paid');row.append(b)}el(kind).append(row)}} const deliverySignature=JSON.stringify(s.deliveries);if(rendered.deliveries===deliverySignature)return;rendered.deliveries=deliverySignature;el('deliveries').replaceChildren();for(const d of [...s.deliveries].reverse()){const row=document.createElement('div');row.className='row';const label=document.createElement('div');label.textContent=d.payload.event+' · '+d.status+' · HTTP '+(d.http_status??'—')+' · ครั้งที่ '+d.attempt;const details=document.createElement('details');const summary=document.createElement('summary');summary.textContent='ดู payload';const pre=document.createElement('pre');pre.textContent=JSON.stringify(d.payload,null,2);details.append(summary,pre);const b=button('ส่งซ้ำ',()=>action('/sandbox/deliveries/'+d.id+'/replay'));b.disabled=d.status==='sending';row.append(label,details,b);el('deliveries').append(row)}}catch(e){el('message').textContent=e.message}} el('save').onclick=()=>action('/sandbox/config',{webhook_url:el('webhook').value});el('probe').onclick=()=>action('/sandbox/probe');el('fund').onclick=()=>action('/sandbox/fund',{amount:10000});refresh();setInterval(refresh,2500); `, ) } module.exports = { createSandbox, localUrl, localServer, body, json, fail, store, amount, recipient, sign, verify, page, } if (require.main === module) { const stateFile = process.env.SANDBOX_STATE_FILE || path.resolve('.gateway-sandbox.json') const app = createSandbox({ stateFile }) const port = Number(process.env.SANDBOX_PORT || 4545) app.server.listen(port, '127.0.0.1', () => console.log('Sandbox only: http://127.0.0.1:' + app.server.address().port + '\nLocal state: ' + stateFile), ) app.server.on('error', (e) => { console.error(e.message) process.exitCode = 1 }) }