Skip to content

ย้ายจากผู้ให้บริการเดิม ​

อ่านหัวข้อ "ความต่างที่ทำให้โค้ดเดิมพัง" ก่อนตาราง mapping

ตาราง mapping field แก้ได้ในบ่ายเดียว ความต่างเชิงพฤติกรรมคือสิ่งที่ทำให้โค้ดเดิมพังเงียบ ๆ แล้วรู้ตัวตอนกระทบยอดสิ้นเดือน

โดยเฉพาะ 3 ข้อนี้ที่ผ่าน QA ได้สบาย ๆ แล้วไปพังบน production: การไม่ตรวจลายเซ็น callback · การไม่มี version guard · การปิดออร์เดอร์ทันทีที่เห็น expired

หน้านี้เขียนถึงรูปแบบที่พบทั่วไปในตลาด ไม่ได้อ้างถึงผู้ให้บริการรายใดรายหนึ่ง ถ้าของเดิมของคุณไม่ตรงกับที่เขียนไว้ ให้ยึดพฤติกรรมของเราตามที่อธิบาย


12 ความต่างที่จะทำให้โค้ดเดิมพัง ​

เรียงตามความน่าจะพังจากมากไปน้อย

1. เราบังคับให้แจ้งบัญชีผู้โอนตั้งแต่ตอนสร้างรายการ ​

ของเดิม: ส่ง {amount, user_ref} แล้วได้ QR กลับมา ของเรา: ไม่ส่ง sender{} หรือ sender_id ⇒ 422 sender_required สร้างรายการไม่ได้เลย

js
// ❌ โค้ดเดิม — จะได้ 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

js
// ❌ โค้ดเดิมแบบนี้ยังทำงานกับเราได้ และนั่นคือปัญหา
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 อย่าง

js
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 เพิ่งมาถึง!
js
// ❌ พัง — ยกเลิกออร์เดอร์ที่ settle ไปแล้ว ลูกค้าเสียเงินจริง
switch (event.type) {
  case 'deposit.expired': await cancelOrder(id); break;
}

วิธีที่ถูก — เขียนสถานะด้วย UPDATE เดียวที่มี version guard แล้วผูก side effect กับ rowCount

sql
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 แล้วกระทบยอดไม่เจอทั้งเดือน

sql
-- ✅ คอลัมน์ต้องเป็น 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: []

js
// ❌ โค้ดเดิมจะได้ 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 เปลี่ยน)

js
// ❌ โค้ดเดิม — ผู้ใช้เห็น "อัปสลิปไม่สำเร็จ" ทั้งที่สลิปเข้าระบบเรียบร้อยแล้ว
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 ไปแล้ว

js
// ❌ พัง
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 ครั้ง เพราะยอดถูกเติมจากผู้ฝากคนละคน

js
// ❌ พัง — ปิดรายการตั้งแต่ก้อนแรก ผู้ใช้ได้เงินไม่ครบแต่ระบบขึ้นว่าสำเร็จ
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 ทั้งหมด

js
// รูปแบบที่ทนต่อ 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เพิ่มคอลัมน์ใหม่ในตารางเดิมแบบ nullabledeploy ได้โดยไม่กระทบระบบเดิมเลย
3เขียน webhook receiver ใหม่ที่ path ใหม่ อย่าแก้ path เดิมให้สองระบบอยู่ด้วยกันได้ระหว่างย้าย
4เพิ่มหน้าจอเลือกบัญชีผู้โอนงานใหญ่ที่สุดและเป็นเส้นทางวิกฤต ⇒ เริ่มพร้อมขั้นที่ 1
5ยิง traffic จริง 5% แล้วดูอัตรา under_reviewเกิน 10% แปลว่าหน้าจอผู้โอนยังมีปัญหา 🔴 อย่าเพิ่มสัดส่วน
6เพิ่มเป็น 50% แล้ว 100%เมื่อ under_review < 5% และ job กระทบยอดตรงติดกัน 3 วัน
7ปิดระบบเดิมหลังไม่มีรายการค้างเกิน 24 ชั่วโมง (เพราะหน้าต่างอัปสลิปย้อนหลัง)

คอลัมน์ที่ต้องเพิ่มในขั้นที่ 2

sql
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

เอกสารชุดนี้อิงสัญญา API เวอร์ชัน 2026-08-01