ย้ายจากผู้ให้บริการเดิม
อ่านหัวข้อ "ความต่างที่ทำให้โค้ดเดิมพัง" ก่อนตาราง mapping
ตาราง mapping field แก้ได้ในบ่ายเดียว ความต่างเชิงพฤติกรรมคือสิ่งที่ทำให้โค้ดเดิมพังเงียบ ๆ แล้วรู้ตัวตอนกระทบยอดสิ้นเดือน
โดยเฉพาะ 3 ข้อนี้ที่ผ่าน QA ได้สบาย ๆ แล้วไปพังบน production: การไม่ตรวจลายเซ็น callback · การไม่มี version guard · การปิดออร์เดอร์ทันทีที่เห็น expired
หน้านี้เขียนถึงรูปแบบที่พบทั่วไปในตลาด ไม่ได้อ้างถึงผู้ให้บริการรายใดรายหนึ่ง ถ้าของเดิมของคุณไม่ตรงกับที่เขียนไว้ ให้ยึดพฤติกรรมของเราตามที่อธิบาย
12 ความต่างที่จะทำให้โค้ดเดิมพัง
เรียงตามความน่าจะพังจากมากไปน้อย
1. เราบังคับให้แจ้งบัญชีผู้โอนตั้งแต่ตอนสร้างรายการ
ของเดิม: ส่ง {amount, user_ref} แล้วได้ QR กลับมา ของเรา: ไม่ส่ง sender{} หรือ sender_id ⇒ 422 sender_required สร้างรายการไม่ได้เลย
// ❌ โค้ดเดิม — จะได้ 422 ทันที
await createDeposit({ amount_minor: 500000, currency: 'THB', end_user_ref: 'u_88213' });
// ✅ ทางที่ดีที่สุด — ให้ผู้ใช้เลือกบัญชีที่เคยยืนยันแล้ว
const { data: senders } = await getSenders('u_88213');
await createDeposit({
amount_minor: 500000,
currency: 'THB',
merchant_reference: 'ORD-9981',
end_user_ref: 'u_88213',
sender_id: senders[0].sender_id, // การันตีว่าไม่เข้าคิวเพราะชื่อ/บัญชีไม่ตรง
client: { device_fingerprint: 'df_9a1c7e', ip: '203.0.113.44', user_agent: req.headers['user-agent'] }
});
// ✅ ผู้ใช้ใหม่ที่ยังไม่มีบัญชีในระบบ
await createDeposit({
amount_minor: 500000,
currency: 'THB',
merchant_reference: 'ORD-9982',
end_user_ref: 'u_90114',
sender: { bank_code: '002', account_number: '1234567890', account_name_th: 'สมชาย ใจดี' },
client: { device_fingerprint: 'df_9a1c7e', ip: '203.0.113.44', user_agent: req.headers['user-agent'] }
});ทำไมเราถึงบังคับ — เงินของเราเข้าบัญชีบุคคลของผู้ถอนโดยตรง ไม่มีบัญชีกลางในเส้นทางปกติ ⇒ ระบบต้องรู้ล่วงหน้าว่าสลิปใบไหนเป็นของรายการไหน การจับคู่ทำด้วย ตัวตนผู้โอน แทนที่จะทำด้วยเศษสตางค์
งานจริงไม่ใช่การแก้ payload แต่คือการเพิ่มหน้าจอ
งานที่ใหญ่ที่สุดของการย้ายคือ หน้าจอเลือกบัญชีผู้โอน ซึ่งของเดิมไม่มี — ให้เริ่มทำพร้อมกับงานเซ็น request ตั้งแต่วันแรก
ทางลัดที่ผิด: หยิบชื่อเจ้าของบัญชีจากฐานข้อมูลตัวเองมาใส่ให้ผ่าน 422 ⇒ ชื่อไม่ตรงกับสลิปจริง ⇒ ทุกรายการเข้า under_review ⇒ คิวเจ้าหน้าที่ล้นตั้งแต่วันแรก และผู้ใช้ทุกคนต้องรอตรวจด้วยมือ
โอนจากบัญชีอื่นที่ไม่ได้แจ้งไว้ ไม่ได้ถูกปฏิเสธ — เข้าคิวเจ้าหน้าที่ตรวจสอบแทน ⇒ เงินไม่หาย แต่ช้า และนั่นคือเหตุผลที่ต้องบังคับให้เลือกบัญชีให้ถูกตั้งแต่ต้น
ต้องลบทิ้ง: โค้ดที่ทำ amount + random_cents เพื่อจับคู่รายการ และตัวจับคู่สลิปที่ใช้เศษสตางค์เป็น key — ของเรายอดต้องกลม ไม่มีเศษสตางค์ ⇒ 422 amount_not_supported
2. เราเซ็น callback ด้วยคู่กุญแจ และโค้ดเดิมของคุณจะ "ทำงานได้" ทั้งที่ไม่ตรวจ
นี่คือข้อที่อันตรายที่สุดในหน้านี้ เพราะมันไม่พัง — มันผ่าน QA แล้วขึ้น production
// ❌ โค้ดเดิมแบบนี้ยังทำงานกับเราได้ และนั่นคือปัญหา
app.post('/cb', (req, res) => { credit(req.body); res.send('ok'); });ใครก็ตามที่เดา URL ของคุณได้ ยิง {"data":{"object":{"status":"settled"}}} เข้ามาแล้วได้ของฟรี
ของเรา: ทุก webhook เซ็นด้วย Ed25519 — เราถือ private key คุณถือ public key ที่ดึงได้จาก GET /v1/webhooks/signing-keys โดยไม่ต้อง auth ⇒ ฐานข้อมูลฝั่งเราหลุดก็ปลอม webhook ไม่ได้
สิ่งที่ต้องเพิ่ม 4 อย่าง
import express from 'express';
import crypto from 'node:crypto';
const app = express();
// 🔴 ต้องได้ raw bytes — ถ้า framework parse JSON ไปก่อน ลายเซ็นจะไม่มีวันตรง
app.post('/webhooks/pg', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body; // Buffer ดิบ ห้าม re-serialize
// 1) parse header แบบ key=value ไม่ใช่ด้วยตำแหน่ง
const parts = Object.fromEntries(
(req.get('X-PG-Signature') ?? '').split(',').map((kv) => kv.split('='))
);
const { kid, t, s } = parts;
// 2) หน้าต่างเวลา 300 วินาที
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(400).end();
// 3) verify บน raw bytes ก่อนแตะ body
const eventId = req.get('X-PG-Event-Id');
const signed = Buffer.concat([Buffer.from(`v1.${t}.${eventId}.`, 'utf8'), raw]);
const ok = crypto.verify(null, signed, await publicKeyFor(kid), Buffer.from(s, 'base64url'));
if (!ok) return res.status(401).end();
const event = JSON.parse(raw.toString('utf8'));
if (event.id !== eventId) return res.status(400).end(); // body ต้องตรงกับ header
// 4) transactional inbox + outbox — helper นี้ commit ทั้งสองแถวพร้อมกัน
// ห้าม markSeen แล้ว enqueue คนละขั้น เพราะ crash ตรงกลางทำ event หายถาวร
await persistWebhookAtomically({ event, raw }); // schema: /webhooks/signature
res.status(200).end(); // ตอบหลัง commit เท่านั้น
});verify ผ่านแล้วก็ยังต้องยึด status ในตัว payload
ลายเซ็นพิสูจน์แค่ว่า "ข้อความนี้มาจากเราจริงและไม่ถูกแก้" ไม่ได้แปลว่า "เครดิตได้"
การเครดิตยังต้องยึดเงื่อนไข data.object.status === 'settled' เสมอ · โค้ดที่เครดิตเพราะ event.type === 'deposit.settled' จะเครดิตซ้ำเมื่อ event เก่า retry มาถึงหลังการกลับรายการ
persistWebhookAtomically ต้องใช้ UNIQUE(event_id) และเก็บ inbox อย่างน้อย 31 วัน ก่อน outbox worker ประมวลผล version guard + business DB effect + outbox สำหรับ external effect ใน transaction เดียว
หมุนกุญแจ: เราประกาศ webhook.key.rotating ล่วงหน้า 7 วัน และ event นี้ ปิดไม่ได้ ⇒ ต้องมี handler ที่ดึง key ใหม่ · แคช public key ได้ไม่เกิน 24 ชั่วโมง เจอ kid ที่ไม่รู้จักให้ดึงใหม่ 1 ครั้งก่อนตัดสินว่าไม่ผ่าน
3. ลำดับ event ไม่การันตี และหน้าต่าง retry ยาว ~92 ชั่วโมง
ของเดิม: callback มาตามลำดับ ใช้ switch (type) แล้วทำตามได้เลย ของเรา: retry 11 ครั้ง กินเวลาถึง ~92 ชั่วโมง 36 นาที ⇒ event เก่ามาถึงหลังของใหม่ได้เป็นเรื่องปกติ
เคสที่เกิดบ่อยที่สุดคืออัปสลิปย้อนหลัง
สร้าง event ถึง endpoint ของคุณ
10:04:41 deposit.expired v6 10:04:41 expired v6 ✓ (แล้ว endpoint คุณล่ม 3 นาที → attempt 1 fail)
10:31:02 deposit.slip_received v7 10:31:02 slip_received v7 ✓
10:33:15 deposit.settled v9 10:33:15 settled v9 ✓ ← เครดิต 5,000 ปล่อยของ
10:35:10 expired v6 ← retry รอบ 4 เพิ่งมาถึง!// ❌ พัง — ยกเลิกออร์เดอร์ที่ settle ไปแล้ว ลูกค้าเสียเงินจริง
switch (event.type) {
case 'deposit.expired': await cancelOrder(id); break;
}วิธีที่ถูก — เขียนสถานะด้วย UPDATE เดียวที่มี version guard แล้วผูก side effect กับ rowCount
UPDATE deposits
SET status = $2, version = $3, is_terminal = $4, updated_at = now()
WHERE deposit_id = $1 AND version < $3
RETURNING status, credited_user_at;rowCount = 0 ⇒ event เก่า ⇒ ตอบ 200 แล้วไม่ทำอะไร (ปลอดภัย เพราะเรารับประกันว่า event สองใบของ object เดียวกันจะไม่มี version เท่ากัน)
เคสที่เสียหายหนักที่สุดคือ settled มาถึงหลัง reversed — ถ้าไม่มี guard คุณจะเครดิตและปล่อยของทั้งที่รายการถูกกลับรายการไปแล้ว
4. id ของเราคนละรูปแบบกับ reference เดิม
ของเดิม: reference string สั้น ๆ ที่บางทีบอกประเภทได้จาก prefix ของเรา: dep_01K3M7Y9RS5TQ0WH2E7XKJ4C6B · wdr_… · evt_… · btxn_…
จุดที่พังเงียบที่สุดคือ คอลัมน์ varchar(20) — id ถูกตัดปลาย แถวถูกเขียนสำเร็จ ไม่มี error แล้วกระทบยอดไม่เจอทั้งเดือน
-- ✅ คอลัมน์ต้องเป็น text ไม่ใช่ varchar(20)
ALTER TABLE topups ADD COLUMN deposit_id text;
CREATE UNIQUE INDEX ON topups (deposit_id) WHERE deposit_id IS NOT NULL;🔴 ห้าม parse ห้ามเดา prefix ความยาวและรูปแบบเปลี่ยนได้ · โค้ดที่แยกประเภทรายการจาก prefix ให้เปลี่ยนไปดู event.type แทน
5. 202 ตอนสร้างรายการ — สถานะที่ "ยังไม่มีปลายทาง" และไม่ใช่ error
ของเดิม: สร้างเสร็จได้ QR ทันที ถ้าไม่ได้คือ error ของเรา: ถ้ายังจับคู่ผู้ถอนไม่ได้ เราตอบ 202 พร้อมออร์เดอร์ที่สร้างแล้วจริง สถานะ awaiting_liquidity และ legs: []
// ❌ โค้ดเดิมจะได้ undefined แล้วหน้าจอว่าง
const qr = res.data.legs[0].qr_payload;
// ✅ ต้องมีสถานะ "กำลังจัดหาช่องทาง" ที่ไม่ใช่หน้า error
if (res.status === 202) {
showWaitingScreen({
suggested: res.data.liquidity.suggested_amounts_minor, // เสนอยอดที่จับคู่ได้ทันที
retryAfter: res.data.retry_after_seconds
});
// 🔴 ห้ามยิง POST ซ้ำ — idempotency จะคืนออร์เดอร์เดิมอยู่ดี
// รอ webhook deposit.matched หรือ poll แบบ backoff 3→5→10→30 วินาที
}สุดทางจริง ๆ จะได้อะไร — ถ้าหาช่องทางไม่ได้เลย คุณจะได้ webhook deposit.canceled ที่มี cancel_reason_code: "no_liquidity_available" 🔴 ไม่ใช่ deposit.expired และไม่ใช่ HTTP 503 (เราสร้างออร์เดอร์ให้เสมอ)
6. durable slip intake สำเร็จตอบ 202 ไม่มี 409 duplicate_slip แบบทันที
ของเดิม: ตรวจสลิปแบบ synchronous ในคำสั่งเดียวกัน สลิปซ้ำได้ 409 กลับมาทันที ของเรา: POST /v1/pay/deposits/{id}/slip ที่ผ่าน capability auth/validation และยืนยัน storage+queue สำเร็จตอบ 202; ถ้ายังยืนยัน intake ไม่ครบตอบ retryable 503 แล้ว retry logical evidence/key เดิม (evidence_fingerprint เดิมแม้ boundary/key order/base64 formatting เปลี่ยน)
// ❌ โค้ดเดิม — ผู้ใช้เห็น "อัปสลิปไม่สำเร็จ" ทั้งที่สลิปเข้าระบบเรียบร้อยแล้ว
if (res.status !== 200) { showError('อัปโหลดไม่สำเร็จ'); return; }
// ❌ พังกว่านั้น — โค้ดที่ retry เพราะคิดว่า 202 คือ failure ⇒ ผู้ใช้เห็นปุ่มค้าง แล้วกดโอนซ้ำ
// ✅ ถูก
if (res.ok) showPendingScreen('กำลังตรวจสอบสลิปกับธนาคาร ใช้เวลาไม่เกิน 1 นาที');ผลตรวจมาทางไหน — webhook หรือ GET /v1/pay/deposits/{id} เท่านั้น
- ผ่านครบ ⇒
deposit.settled - สลิปซ้ำ ⇒
deposit.under_reviewที่มีreview.reasons[0].code = "DUPLICATE_SLIP" - รูปอ่านไม่ออก ⇒
deposit.slip_rejectedที่retryable: true— ไม่ใช่การปฏิเสธเงิน เป็นการขอรูปใหม่
ทำไมออกแบบแบบนี้ — จุดอัปสลิปคือจุดที่เงินผู้ใช้ออกจากบัญชีไปแล้วจริง ระบบจึงตอบ accepted ต่อเมื่อเขียนไฟล์และยืนยัน queue แล้วเท่านั้น หากขั้นใดยังไม่สำเร็จ 503 ต้องทำให้ retry ด้วย receipt เดิม ไม่ใช่บอกให้ผู้ใช้โอนซ้ำ
สิ่งที่ต้องทำเพิ่ม: หน้าจอหลังอัปสลิปต้องเป็น "กำลังตรวจสอบ" ไม่ใช่ "สำเร็จ" และต้องมี handler ของ deposit.under_review ตั้งแต่วันแรก
7. ยอดฝากมี 4 ตัวหลัก ไม่ใช่ตัวเดียว
ของเดิม: ยอดเข้ากระเป๋าตัวเลขเดียว ของเรา: ordered_amount_minor (X) · transferred_amount_minor (V) · amount_received_minor (A) · overpayment.amount_minor (E, หรือ 0) และยังต้องแยก fee_total_minor (+ fee_breakdown[]) / credited_amount_minor
amount_received_minor = min(transferred_amount_minor, ordered_amount_minor)
0 ≤ fee_total_minor ≤ amount_received_minor
amount_received_minor − fee_total_minor = credited_amount_minor
same-slip overpay: transferred_amount_minor = amount_received_minor + overpayment.amount_minor- เครดิตผู้ใช้ปลายทาง ⇒
amount_received_minorซึ่งเป็นยอดที่ระบบรับเข้า order (A) · exact/underpay จะเท่ากับV; overpay ถูกจำกัดที่X - ขยับกระเป๋า merchant ⇒
credited_amount_minor - เมื่อมี
overpayment{}⇒ รายการสำเร็จตามA = X; แสดงclaim_idและให้ผู้ใช้ติดต่อ support · ห้ามเครดิตหรือคืนส่วนเกินอัตโนมัติ
โค้ดเดิมที่กระทบยอดด้วยตัวเลขเดียวจะหยิบ amount_received_minor โดยสัญชาตญาณ (หน้าตาเหมือนของเดิมที่สุด) ⇒ ยอดในระบบคุณสูงกว่าจริงเท่ากับค่าธรรมเนียมทุกใบ
ฝั่งถอนกลับด้าน: ของเดิมหลายเจ้าหักค่าธรรมเนียมออกจากยอดถอน ของเราบวกเพิ่ม ⇒ ต้องมี available_minor ≥ amount_minor + fee_total_minor ไม่งั้น 402 insufficient_balance และไม่มีออร์เดอร์ถูกสร้าง ⇒ ต้องเพิ่มกิ่งปลดล็อกยอดผู้ใช้คืนในเคสนี้ · เช็คล่วงหน้าด้วย GET /v1/pay/quote?channel=withdrawal&amount_minor=1000000
ค่าธรรมเนียมคงที่ผูกกับ ใบ ไม่ใช่ต่อก้อนที่ถูกเติม
8. ยอดที่เพิ่งเข้ายังถอนไม่ได้อีก 72 ชั่วโมง
ของเดิม: เงินเข้ากระเป๋าแล้วสร้างรายการถอนได้ทันที ของเรา: ยอดเข้าไปอยู่ในถัง pending_clearing ก่อน แล้วย้ายไป available ที่ T+72 ชั่วโมง (คุณจะได้ event balance.cleared ตอนนั้น)
| ต้องการทำอะไร | อ่านจากไหน |
|---|---|
| แสดงยอดให้ผู้ใช้เห็น | available_minor + pending_clearing_minor |
| คำนวณว่าถอนได้เท่าไหร่ | 🔴 available_minor อย่างเดียว |
นี่เป็นเรื่องกระแสเงินสด ไม่ใช่เรื่องเทคนิค
ต้องบอกฝ่ายการเงินก่อน go-live ไม่ใช่หลัง — ระยะเวลาที่เงินจะหมุนได้เปลี่ยนไป 3 วัน
balance.cleared คือ การย้ายถัง ไม่ใช่เงินเข้าใหม่ ⇒ ถ้าโค้ดคุณบวกยอดตาม event จะได้ยอดสองเท่า
9. expired และ canceled ไม่ปิดตาย
ของเดิม: หมดเวลาแล้วจบ ปิดออร์เดอร์ได้เลย ของเรา: ยังรับสลิปต่อได้อีก 24 ชั่วโมง ⇒ คุณจะได้ deposit.settled ของออร์เดอร์ที่คุณ mark เป็น failed ไปแล้ว
// ❌ พัง
if (obj.status === 'expired') closeOrder(id);
// ✅ เช็ค is_terminal ไม่ใช่ชื่อสถานะ
if (obj.is_terminal) closeOrder(id);
else keepUploadButtonUntil(obj.late_slip_accepted_until);🔴 canceled ก็มีหน้าต่างนี้ได้ — เคสผู้ใช้กดยกเลิกแล้วเผลอกดโอนพร้อมกัน เกิดสัปดาห์ละหลายครั้ง · deposit.canceled ที่มี late_slip_accepted_until และ is_terminal: false ต้องเหลือปุ่มอัปสลิปไว้เหมือน expired
handler ของคุณต้องรับ settled ได้จากทุกสถานะภายในที่คุณเก็บไว้ และต้อง idempotent
10. รายการถอนหนึ่งใบได้ callback หลายใบ
ของเดิม: 1 รายการถอน = 1 callback ตอนจบ ของเรา: 1 รายการถอนอาจได้ withdrawal.filled ถึง 7 ครั้ง เพราะยอดถูกเติมจากผู้ฝากคนละคน
// ❌ พัง — ปิดรายการตั้งแต่ก้อนแรก ผู้ใช้ได้เงินไม่ครบแต่ระบบขึ้นว่าสำเร็จ
if (event.type === 'withdrawal.filled') closeWithdrawal(id);
// ✅ ถูก — สองอย่างนี้ทำคนละหน้าที่ ต้องมีทั้งคู่
// 1) บันทึกบรรทัดก้อน กันซ้ำด้วย leg_ref
await db.query(
`INSERT INTO withdrawal_fills (withdrawal_id, leg_ref, amount_minor, settled_at)
VALUES ($1,$2,$3,$4) ON CONFLICT (leg_ref) DO NOTHING`,
[obj.id, obj.fill.leg_ref, obj.fill.amount_minor, obj.fill.settled_at]
);
// 2) ยอดรวมของใบ เขียนทับเฉพาะเมื่อ version สูงกว่า
const r = await db.query(
`UPDATE withdrawals SET status=$2, filled_minor=$3, remaining_minor=$4, version=$5
WHERE id=$1 AND version < $5`,
[obj.id, obj.status, obj.filled_minor, obj.remaining_minor, obj.version]
);
// 3) ปิดใบเมื่อครบสองเงื่อนไขเท่านั้น
if (r.rowCount === 1 && obj.status === 'settled' && obj.remaining_minor === 0) closeWithdrawal(obj.id);🔴 ห้าม SUM() จากตารางก้อนฝั่งคุณเพื่อขับตรรกะเรื่องเงิน — event อาจยังมาไม่ครบ · ตารางก้อนมีไว้ทำ timeline และตรวจสอบย้อนหลัง แหล่งความจริงคือ filled_minor / remaining_minor ที่ผ่าน version guard
และ ใบที่ expired ก็อาจเคยจ่ายไปแล้วบางส่วน — ถอน 10,000 จ่ายไป 4,000 แล้วหมดอายุ ⇒ status: "expired" ที่ filled_minor: 400000 ⇒ คืนเงินผู้ใช้ตาม refunded_minor ไม่ใช่ตาม amount_total_minor
11. รายการที่วิ่งผ่านช่องทางสำรองไม่มี reference ของต้นทางให้
ของเดิม: log และหน้าจอ support แสดง reference ของผู้ให้บริการต้นทาง ของเรา: ไม่มีค่านั้นให้เลย ทุกช่องทางอ้างอิงด้วย deposit_id ของเราตัวเดียว
สิ่งเดียวที่บอกที่มาคือ liquidity_source ∈ internal | external_gateway ซึ่ง 🔴 ห้ามเอาไปตัดสินใจเครดิต ใช้ทำรายงานได้อย่างเดียว
⇒ หน้า support และข้อความหาลูกค้า ให้อ้างอิงด้วย deposit_id ทุกช่องทาง และแสดงให้ copy ได้
12. Idempotency บังคับทุก POST
ของเดิม: มักเป็น optional โค้ด retry ยิงซ้ำดื้อ ๆ ได้ ของเรา: ไม่ส่ง header Idempotency-Key ⇒ 400 idempotency_key_required ทุกครั้ง
| ทำแบบนี้ | ได้อะไร |
|---|---|
| key เดิม + body เหมือนเดิม + request แรกจบแล้ว | คืน response เดิมทั้งดุ้น รวม HTTP status เดิม + header Idempotent-Replay: true |
| key เดิม + body ต่าง | 409 idempotency_key_reused |
| key เดิม + request แรกยังไม่จบ | 409 idempotency_in_flight + retry_after_seconds: 1 (ไม่ block ไม่รอ) |
| key หมดอายุเกิน 7 วันแล้วยิงซ้ำ | ทำงานเหมือน request ใหม่ ⇒ สร้างออร์เดอร์ใหม่ |
🔴 สร้าง key แล้วเขียนลง DB ก่อนยิง request · retry ทุกครั้งอ่าน key เดิมจาก DB · key เป็นฟังก์ชันของ เนื้อหา ไม่ใช่ของ session (ผู้ใช้แก้ยอดแล้วกดใหม่ = key ใหม่) · TTL ฝั่งคุณต้องไม่สั้นกว่า 7 วัน
🔴 อย่าสับสน Idempotency-Key กับ PG-Nonce — retry ต้องเปลี่ยน nonce ทุกครั้ง (ไม่งั้น 401 nonce_replayed) แต่ key ต้องคงเดิม ไม่งั้นได้ออร์เดอร์ซ้ำ
ตาราง mapping field
อ่านหลังจากเข้าใจ 12 ข้อข้างบนแล้ว
| แนวคิดเดิม | ของเรา | หมายเหตุ |
|---|---|---|
| API key เดี่ยวใน header เฉพาะ | Authorization: Bearer sk_live_* บวก PG-Signature (HMAC-SHA256 บน canonical 9 บรรทัด) | key อย่างเดียวไม่พอ · Idempotency-Key และ PG-Version อยู่ใน canonical ด้วย |
| reference string ของรายการฝาก | deposit_id | 🔴 รูปแบบ/ความยาวต่างจากเดิม ห้าม parse ห้ามเดา prefix · คอลัมน์ต้องเป็น text |
| user id ที่ส่งให้ผู้ให้บริการ | end_user_ref | ใช้กับ GET /v1/pay/senders ด้วย |
| ยอดที่ใส่เศษสตางค์เพื่อจับคู่ | amount_minor ยอดกลมเสมอ | จับคู่ด้วยตัวตนผู้โอนแทน · เศษสตางค์ ⇒ 422 amount_not_supported |
| — (ไม่มีของเดิม) | sender{} หรือ sender_id | 🔴 ฟิลด์ใหม่ และบังคับ |
| ข้อมูลบัญชีปลายทางชุดเดียว | legs[] (+ checkout_url) | ปลายทางเป็นบัญชีบุคคล และมีได้มากกว่า 1 |
| เวลาหมดอายุที่คำนวณเอง | expires_at จาก response | 🔴 ห้าม hardcode · บางช่องทางหมดอายุจริงใน ~4 นาที และค่านี้เปลี่ยนได้ระหว่างทาง |
| ยอดเข้ากระเป๋าตัวเลขเดียว | 4 ยอดหลัก: ordered_amount_minor / transferred_amount_minor / amount_received_minor / overpayment.amount_minor และแยก fee / merchant credit เพิ่ม | ดูข้อ 7 |
| ยอดคงเหลือช่องเดียว | available_minor / pending_clearing_minor / reserved_minor | ดูข้อ 8 |
| callback body ดิบไม่มีลายเซ็น | payload + X-PG-Signature (Ed25519) + X-PG-Event-Id + data.object.version | ดูข้อ 2 และ 3 |
envelope {code, message, result} ที่คืน HTTP 200 เสมอ | HTTP status จริง (201 202 402 409 410 422 429 503) | ดูข้อถัดไป |
| id ของก้อนย่อยที่ใช้ join สองฝั่ง | leg_ref | 🔴 สองฝั่งได้ค่าคนละตัวโดยตั้งใจ join ข้าม merchant ไม่ได้ |
HTTP status จริง กับ envelope แบบเดิม
โค้ดเดิมที่เขียน if (res.ok) บน API ที่คืน 200 เสมอ จะตีความ error เป็นสำเร็จ หรือตีความ 202 เป็นล้มเหลว
เขียนตาราง mapping error เก่า → ใหม่ให้เสร็จก่อนแตะโค้ด แล้วค่อยแก้ · รายชื่อ error ทั้งหมดอยู่ที่ Error code ทั้งหมด
// รูปแบบที่ทนต่อ code ใหม่ที่เราเพิ่มทีหลัง
if (res.ok) return body; // 200 · 201 · 202 คือสำเร็จหมด
const err = body.error;
if (err.retryable) return scheduleRetry(err.retry_after_seconds);
throw new PermanentError(err.code, err.request_id); // 🔴 log request_id เสมอลำดับการย้ายที่แนะนำ
| ขั้น | ทำอะไร | ทำไมต้องลำดับนี้ |
|---|---|---|
| 1 | ต่อ sandbox แบบอ่านอย่างเดียวก่อน (GET /v1/pay/banks และ /v1/pay/quote) จนเซ็น request ผ่าน | พิสูจน์การเซ็นให้ผ่านก่อนแตะเรื่องเงิน · ใช้ test vector ทั้ง 3 ชุดจาก GET /v1/webhooks/test-vectors |
| 2 | เพิ่มคอลัมน์ใหม่ในตารางเดิมแบบ nullable | deploy ได้โดยไม่กระทบระบบเดิมเลย |
| 3 | เขียน webhook receiver ใหม่ที่ path ใหม่ อย่าแก้ path เดิม | ให้สองระบบอยู่ด้วยกันได้ระหว่างย้าย |
| 4 | เพิ่มหน้าจอเลือกบัญชีผู้โอน | งานใหญ่ที่สุดและเป็นเส้นทางวิกฤต ⇒ เริ่มพร้อมขั้นที่ 1 |
| 5 | ยิง traffic จริง 5% แล้วดูอัตรา under_review | เกิน 10% แปลว่าหน้าจอผู้โอนยังมีปัญหา 🔴 อย่าเพิ่มสัดส่วน |
| 6 | เพิ่มเป็น 50% แล้ว 100% | เมื่อ under_review < 5% และ job กระทบยอดตรงติดกัน 3 วัน |
| 7 | ปิดระบบเดิม | หลังไม่มีรายการค้างเกิน 24 ชั่วโมง (เพราะหน้าต่างอัปสลิปย้อนหลัง) |
คอลัมน์ที่ต้องเพิ่มในขั้นที่ 2
ALTER TABLE topups
ADD COLUMN deposit_id text,
ADD COLUMN version bigint,
ADD COLUMN is_terminal boolean,
ADD COLUMN late_slip_accepted_until timestamptz,
ADD COLUMN ordered_amount_minor bigint,
ADD COLUMN transferred_amount_minor bigint,
ADD COLUMN amount_received_minor bigint,
ADD COLUMN overpayment_amount_minor bigint NOT NULL DEFAULT 0,
ADD COLUMN fee_total_minor bigint,
ADD COLUMN credited_amount_minor bigint,
ADD COLUMN credited_user_at timestamptz, -- 🔴 กันเครดิตซ้ำ
ADD COLUMN sender_bank_code text,
ADD COLUMN sender_account_masked text,
ADD COLUMN sender_id text;
CREATE UNIQUE INDEX ON topups (deposit_id) WHERE deposit_id IS NOT NULL;
CREATE TABLE pg_webhook_inbox ( -- กัน event ซ้ำ เก็บ ≥ 31 วัน
event_id text PRIMARY KEY,
event_type text NOT NULL,
event jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX ON pg_webhook_inbox (received_at);
-- สร้าง outbox row ใน transaction เดียวกับ inbox; schema เต็มอยู่ที่ /webhooks/signatureรายการโค้ดเก่าที่ต้องลบทิ้ง
| ลบอะไร | เพราะอะไร |
|---|---|
ตัวสร้างยอด amount + random_cents | ยอดของเราต้องกลม ⇒ 422 amount_not_supported |
| ตัวจับคู่สลิปที่ใช้เศษสตางค์เป็น key | เราจับคู่ด้วยตัวตนผู้โอน |
if (res.status !== 200) throw ทุกที่ | 201 และ 202 คือสำเร็จ |
switch (callback.type) ที่มี side effect ตรง ๆ | ต้องผ่าน version guard ก่อน |
if (callback) closeWithdrawal() | หนึ่งใบได้หลาย callback |
การอ่าน legs[0] แล้วจบ | หนึ่งรายการมีได้หลายปลายทาง |
| การ hardcode TTL / countdown | ใช้ expires_at จาก response |
| การ log หรือแสดง reference ของผู้ให้บริการต้นทาง | ไม่มีค่านั้นอีกแล้ว ⇒ ใช้ deposit_id |
การ retry POST โดย generate Idempotency-Key ใหม่ | ได้ออร์เดอร์ซ้ำ ล็อกเงินซ้ำ |
การเก็บ qr_payload ลง DB หรือลง log | 🔴 ห้ามเก็บ ห้าม log ห้ามส่งต่อ ใช้แสดงผลแล้วทิ้ง |
ขั้นต่อไป
| ถ้าคุณกำลัง… | ไปที่ |
|---|---|
| เริ่มต่อจริง | ต่อให้เสร็จใน 15 นาที |
| ทำหน้าจอเลือกบัญชีผู้โอน (งานใหญ่ที่สุด) | บัญชีผู้โอน (บังคับ) |
| แก้ handler ของ webhook | ตรวจลายเซ็น Ed25519 · ลำดับไม่การันตี |
ทำหน้ารอตอน 202 | รอสภาพคล่อง (awaiting_liquidity) |
| รับ fill หลายก้อนฝั่งถอน | หนึ่งใบรับได้หลายก้อน |
| เขียน mapping error เก่า → ใหม่ | Error code ทั้งหมด |
| ทดสอบทุกเส้นทางก่อนยิง traffic จริง | Sandbox |
| จะขึ้น production | เช็กลิสต์ก่อน go-live |
