ข้าม​ไป​ยัง​เนื้อหา

Settle & payout

เรา​เดิน​เรื่อง​เงิน​มา​ถึง​จุด​สำคัญ ออเดอร์​ถูก​จ่าย​แล้ว แต่​เงิน​ค่า​อาหาร 200 บาท​ของ​ลูกค้า​ไม่​ได้​วิ่ง​ตรง​เข้า​กระเป๋า​ร้าน​ทันที มัน​ถูกกัน​ไว้​ใน​บัญชี Escrow AccountEscrow Accountบัญชี​ที่​กัน​เงิน​ไว้​รอ​เงื่อนไข​ก่อน​ปล่อย​ให้​ผู้รับ​ตัว​จริง เช่น เงิน​ค่า​อาหาร​ที่​ลูกค้า​จ่าย​จะ​พัก​อยู่​ใน​บัญชี escrow จน​กว่า​ออเดอร์​จะ​ถูก​ส่ง​มอบ​สำเร็จ ค่อย​ปล่อย​เงิน​ให้​ร้าน​อาหาร​กับ​ไร​เด​อร์ — รักษา​สิทธิ์​ทั้ง​สอง​ฝ่าย​ไว้​จนกว่า​เงื่อนไข​จะ​สำเร็จ ถ้า​ออเดอร์​ถูก​ยกเลิก​ก่อน​ส่ง​มอบ เงิน​ใน escrow จะ​ถูก​คืน​แทนที่​จะ​จ่าย​ออก​ไปTactical Design ตั้งแต่​ตอน​ยืนยัน​ออเดอร์ (บท​ที่ 5) รอ​จน​อาหาร​ถึง​มือ​ลูก​ค้าจริงๆ ก่อน บท​นี้​คือ​ช่วง​เวลา​ที่​เงิน​ก้อน​นั้น​ถูก​ปิด​จบ — แตก​ออก​ไป​ยัง​คน​ที่​ควร​ได้ แล้ว​โอน​ออก​นอก​ระบบ​ไป​เข้า​บัญชี​จริง

งาน​นี้​มี​สอง​จังหวะ​ที่​คน​มัก​นึก​ว่า​เป็น​เรื่อง​เดียวกัน แต่จริงๆ คนละ​เรื่อง จังหวะ​แรก​คือ SettlementSettlementขั้นตอน​ที่​เงิน​ซึ่ง​พัก​อยู่ (เช่น​ใน escrow หรือ clearing) ถูก 'ปิด​จบ' อย่าง​เป็น​ทางการ — กระจาย​ไป​ยัง​บัญชี​ปลายทาง​จริง​ตาม​สัดส่วน​ที่​ตกลง​ไว้ เช่น ตัด​ค่า​คอม​มิช​ชัน platform แล้ว​โอน​ส่วน​ที่​เหลือ​เข้า​บัญชี​ร้าน​อาหาร​และ​ไร​เด​อร์ มัก​เกิด​เป็น​รอบ (batch) ไม่ใช่​ทันที​ทุกออเดอร์Process — ปิด​บัญชี​เงิน​ก้อน​นี้ ภายใน ledger แตก escrow ออก​เป็น​สาม​ส่วน (ร้าน ไร​เด​อร์ platform) ใน​รายการ​เดียว​ที่​สมดุล จังหวะ​ที่​สอง​คือ PayoutPayoutการ​จ่าย​เงิน​ที่ settle แล้ว​ออก​จาก​ระบบ​ไป​ให้​ร้าน​อาหาร​หรือ​ไร​เด​อร์จริง ๆ เช่น โอน​เข้า​บัญชี​ธนาคาร​หรือ PromptPay ของ​ร้าน — มัก​รวม​หลายออเดอร์​เป็น​รอบ payout เดียว​ต่อ​วัน​หรือ​ต่อ​สัปดาห์ แทนที่​จะ​โอน​ทุกออเดอร์​แยก​กันProcess — ย้าย​เงิน​ที่​ค้าง​จ่าย​ออก​จาก​บัญชีจริงๆ ไป​ยัง payment gateway หรือ​ธนาคาร บท​นี้​จะ​แยก​สอง​จังหวะ​นี้​ให้​ชัด​ว่า​อัน​ไหน​คือ​อัน​ไหน

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — snippet ใน​บท​นี้​เป็น code ตัวอย่าง​ประกอบ​แนวคิด settlement/payout ยก​รูปทรง Money, LedgerEntry, JournalEntry ที่​ปั้น​ไว้​บท​ก่อนๆ มา​ต่อยอด ส่วน payment gateway / PromptPay ใน​บท​นี้​เป็น​ตัวอย่าง​ประกอบ (illustrative) ไม่ใช่ integration จริง

เงิน​ใน escrow ถูก​ปล่อย​ตอน​ไหน? คำ​ตอบ​คือ​ตอน​ที่domain Ordering บอกว่า​อาหาร​ถึง​มือ​ลูกค้า​แล้ว ผ่าน Integration EventIntegration Eventเหตุการณ์​ที่​สื่อสาร​ข้าม Bounded Context หรือ​ข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้อง​รับฟัง​จาก Ordering context เพื่อ​รู้​ว่า​เมื่อไหร่​ควร​ปล่อย​เงิน​จาก escrow — ต่าง​จาก Domain Event ที่​ทำงาน​เฉพาะ​ภายใน context เดียว (in-process)Architecture ชื่อ OrderDelivered — event ข้าม context ที่​เรา​ทำความ​รู้จัก​ใน​คอร์ส Bounded Contexts (คอร์ส #8) สัญญา​ของ​มัน​บาง​เฉียบ​ตาม​กฎ integration event คือ​มี​แต่ ID กับ​ค่า primitive ไม่มี object domain ก้อน​ใหญ่​ข้าม​ฝั่ง​มา ก่อนหน้า​นี้​ตอน​ลูกค้า​ยืนยัน​จ่าย​เงิน เรา​เห็น OrderConfirmed มา​แล้ว​รอบ​หนึ่ง (จังหวะ​เงิน​เข้า escrow) รอบ​นี้ OrderDelivered คือ​สัญญาณ ปิด​จบ

message broker มี​นิสัย​ส่ง​ซ้ำ — OrderDelivered ใบ​เดิม​อาจ​วิ่ง​เข้า​มาสอง​สาม​ครั้ง ตัว​รับ​ฝั่ง Payment จึง​ต้อง​เป็น Idempotent ConsumerIdempotent Consumerตัว​รับ event หรือ message ที่​ประมวล​ผล​ข้อความ​เดิม​ซ้ำ​กี่​ครั้ง​ก็ได้​ผลลัพธ์​เหมือน​เดิม ไม่​สร้าง​ผล​ข้าง​เคียง​ซ้ำ เช่น รับ PaymentCaptured ซ้ำ​สอง​ครั้ง​จาก message broker ต้อง​ไม่​บันทึก journal entry สอง​รอบ — ตรวจ​ด้วย Idempotency Key หรือ​เช็ค​ว่า​เคย​ประมวล​ผล​รายการ​นี้​ไป​แล้ว​หรือ​ยังArchitecture คือ​รับ event เดิม​ซ้ำ​กี่​รอบ​ก็ post รายการ settlement แค่​ครั้ง​เดียว เรา​ไม่​สอน​เรื่อง​นี้​ใหม่ (คอร์ส #8 วาง​ไว้​แล้ว) แค่​ยืม​กลไก​เดิม​มา​ใช้: แนบ Idempotency KeyIdempotency Keyรหัส​เฉพาะ​ที่​แนบ​ไป​กับ​คำขอ​ทำ​ธุรกรรม (เช่น เรียก​เก็บ​เงิน​ผ่าน PromptPay) เพื่อ​ให้​ระบบ​รู้​ว่า​คำขอ​ที่​ถูก​ส่ง​ซ้ำ (retry) คือ​คำขอ​เดิม ไม่ใช่​ธุรกรรม​ใหม่ — กัน​ไม่​ให้​ลูกค้า​โดน​หัก​เงิน​ซ้ำ​เวลา​เครือข่าย​มี​ปัญหา​แล้ว client ยิง​คำขอ​เดิม​ซ้ำArchitecture ที่​ผูก​กับ OrderId ไป​กับ​รายการ​ใน ledger ถ้า key นี้​เคย​ถูก post แล้ว ระบบ​ก็​ข้าม​ให้​เลย

หัวใจ​ของ settlement คือ​มัน​เป็นรายการ​เดียว ไม่ใช่​สาม​รายการ​แยก​กัน Journal EntryJournal Entryหน่วย​ที่​เล็ก​ที่สุด​ของ​การ​บันทึก​ใน ledger หนึ่ง​รายการ​มี​อย่าง​น้อย​สอง​บรรทัด (เดบิต​กับ​เครดิต) ที่​ยอด​รวม​เท่า​กัน เช่น ตอน​ลูกค้า​จ่าย​ค่า​อาหาร เกิด journal entry เดียว​ที่​กระทบ​ทั้ง​บัญชี​เงินสด​และ​บัญชี escrow — เขียน​แล้ว​ห้าม​แก้ ถ้า​ผิด​ต้อง​เขียน Reversing Entry มา​หักล้าง​แทนTactical Design หนึ่ง​ใบ​เดบิต​บัญชี escrow เต็ม​จำนวน แล้ว​เครดิต​สาม​บัญชี​ปลายทาง​พร้อม​กัน​ใน​ใบ​เดียว บัญชี​ปลายทาง​ทั้ง​สาม​คือ:

  • RestaurantPayable — เจ้าหนี้​ร้าน​อาหาร เงิน​ที่ platform ติด​ค้าง​ต้อง​จ่าย​ให้​ร้าน (หนี้สิน เครดิต​แล้ว​ยอด​เพิ่ม)
  • RiderPayable — เจ้าหนี้​ไร​เด​อร์ เงิน​ค่า​ส่ง​ที่​ติด​ค้าง​ไร​เด​อร์ (หนี้สิน​เช่น​กัน)
  • PlatformRevenue — รายได้ platform ค่า​คอม​มิช​ชัน​ที่​หัก​เก็บ​เป็น​ของ​ตัวเอง

ตัวอย่างออเดอร์ 200 บาท: ร้าน​ได้ 150 ไร​เด​อร์ได้ 30 platform เก็บ​ค่า​คอม 20 — เดบิต escrow 200 บาท เท่ากับ​เครดิต​สามขา​รวม​กัน 150 + 30 + 20 = 200 พอดี ทำไม​ต้อง​ยัด​ทั้งหมด​ไว้​ใน journal entry เดียว? เพราะ​ถ้า​แยก​เป็น​หลาย​คำ​สั่ง​อิสระ เงิน​มี​โอกาส​หาย​กลาง​ทาง:

❌ version ดิบ — settle ด้วย​การ​อัปเดต​ยอด​สี่​บัญชี​แยก​กัน​ที​ละ​คำ​สั่ง
// ❌ version ดิบ — คิดแบบ column ยอดเงินที่บทที่ 1 เตือนไว้ ยิงสี่คำสั่งอิสระ
escrowRow.Balance -= 200; // ลดยอด escrow
restaurantRow.Balance += 150; // เพิ่มยอดเจ้าหนี้ร้าน
riderRow.Balance += 30; // เพิ่มยอดเจ้าหนี้ไรเดอร์
platformRow.Balance += 20; // เพิ่มรายได้ platform
// ถ้า process ล่มหลังบรรทัดที่สอง: escrow ลดไป 200 แต่ปลายทางรับแค่ 150
// เงิน 50 บาทหายกลางอากาศ และไม่มีใครรับประกันว่าสี่บรรทัดนี้รวมกันแล้วสมดุล

version ดิบ​พัง​เพราะ​ไม่มี​ใครรักษากฎ “เดบิต​รวม = เครดิต​รวม” ให้​อยู่​ใน​การกระทำ​เดียว​ที่​แยก​ไม่​ได้ ทางออก​คือ​มัด​สี่​ขา​ไว้​ใน​ใบ​เดียว​ที่​ปฏิเสธ​ตัวเอง​ทันที​ถ้า​ไม่​สมดุล:

flowchart TB
  OD["OrderDelivered — อาหารถึงมือลูกค้า"]
  OD --> JE["Journal Entry เดียว: settle ออเดอร์ 200 บาท"]
  JE --> DR["เดบิต Escrow 200<br/>ปิดบัญชีพักเงินให้เหลือศูนย์"]
  JE --> C1["เครดิต RestaurantPayable 150"]
  JE --> C2["เครดิต RiderPayable 30"]
  JE --> C3["เครดิต PlatformRevenue 20"]
  DR --- BAL["เดบิต 200 = เครดิต 150 + 30 + 20 = 200 — สมดุล"]
  C1 --- BAL
  C2 --- BAL
  C3 --- BAL
  C1 --> PB["Payout batch: กวาดยอด payable ออกเป็นรอบ"]
  C2 --> PB
  PB --> OUT["เดบิต Payable / เครดิต GatewayClearing<br/>เงินออกไปธนาคาร/gateway จริง"]
  classDef ev fill:#1e3a8a,stroke:#1e40af,color:#f8fafc;
  classDef dr fill:#16a34a,stroke:#065f46,color:#f8fafc;
  classDef cr fill:#dc2626,stroke:#7f1d1d,color:#f8fafc;
  classDef bal fill:#f59e0b,stroke:#92400e,color:#1c1917;
  classDef po fill:#7c3aed,stroke:#4c1d95,color:#f8fafc;
  class OD,JE ev;
  class DR dr;
  class C1,C2,C3 cr;
  class BAL bal;
  class PB,OUT po;

คำ​บรรยาย​ภาพ: OrderDelivered (น้ำเงิน) จุด​ชนวน​ให้​เกิด journal entry เดียว (น้ำเงิน) ขา​เดบิต​ขา​เดียว (เขียว) ปิด​บัญชี escrow 200 บาท​ให้​เหลือ​ศูนย์ ส่วน​ขา​เครดิต​สามขา (แดง) — RestaurantPayable 150, RiderPayable 30, PlatformRevenue 20 — จุด​สี​ส้ม​คือ​กฎ​เหล็ก: เดบิต​รวม 200 ต้อง​เท่ากับ​เครดิต​รวม 150 + 30 + 20 = 200 พอดี ถ้า​ไม่​เท่า รายการ​นั้น​สร้าง​ไม่​ได้​ตั้งแต่​แรก จาก​นั้น (ม่วง) payout รวม​รอบ​กวาด​ยอด​ใน​บัญชี payable ออก​ไป​ธนาคาร/gateway โดย​เดบิต payable แล้ว​เครดิต GatewayClearing — เป็น​อีก journal entry ที่​สมดุล​ใน​ตัวเอง

ก่อน​เขียน settlement ทวน​รูปทรง​หลัก​ที่​บท​ก่อนๆ ปั้น​ไว้ ทุก​จำนวน​เงิน​ยัง​เป็น value object MoneyMoneyValue Object ที่​แทน​จำนวน​เงิน​พร้อม​สกุล​เงิน (เช่น จำนวน + THB) เปรียบเทียบ​กัน​ด้วย​ค่า ไม่ใช่ identity, บวก/ลบ​กัน​ได้​แต่​ต้อง​เป็น​สกุล​เดียวกัน, immutable — ห้าม​ใช้ decimal เปล่า ๆ แทน​เงิน​เพราะ​จะ​ทำให้​หน่วย​เงิน​และ​กฎ​การ​ปัดเศษ​หล่น​หาย​ไป (Primitive Obsession)Tactical Design เดิม (FoodOrdering.Domain/Orders/Money.cs) ที่​บวก​กัน​ได้​และ​เทียบ​ค่า​เท่า​กัน​ได้ ส่วน AccountAccountหน่วย​ที่​ใช้​แยกแยะ​ว่า 'เงิน​อยู่​ตรง​ไหน' ใน​ระบบ เช่น บัญชี​เงินสด platform, บัญชี​ลูกหนี้​ร้าน​อาหาร, บัญชี wallet ของ​ลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้อง​ระบุ​ว่า​เดบิต/เครดิต​บัญชี​ไหนTactical Design แต่ละ​ใบ​มี​ประเภท​ตายตัว และ ยอด​คง​เหลือ​ของ​มัน​ไม่ใช่ column ที่​เก็บ​ไว้ — เดี๋ยว​จะ​เห็น​ใน​หัวข้อ​ถัด​ไป

// ── ทวนรูปทรงจากบทก่อน (บทที่ 2 ปั้นไว้แล้ว) ──
// Money — value object เดิมจาก domain Order (FoodOrdering.Domain/Orders/Money.cs)
// record Money(decimal Amount, string Currency) พร้อม Money.Thb(x) และ operator +
public enum Direction { Debit, Credit }
public readonly record struct AccountId(Guid Value);
public readonly record struct JournalEntryId(Guid Value);
public enum AccountType
{
CustomerWallet, RestaurantPayable, RiderPayable,
PlatformRevenue, GatewayClearing, Escrow,
}
// หนึ่งบรรทัดใน ledger — immutable ตลอดกาล เขียนแล้วห้ามแก้
public sealed record LedgerEntry(
AccountId AccountId,
Direction Direction,
Money Amount,
JournalEntryId JournalEntryId);

หัวใจ​อยู่​ที่ JournalEntry — มัน​ไม่ใช่​แค่​ถุง​ใส่ LedgerEntry แต่​เป็น aggregate ที่รักษา invariant ★ ไว้​ใน​ตัวเอง: ผล​รวม​ด้าน​เดบิต​ต้อง​เท่ากับ​ผล​รวม​ด้าน​เครดิต​เป๊ะ ถ้า​ไม่​เท่า สร้าง object ไม่​ได้​ตั้งแต่ constructor พร้อม​พก idempotency key และ​เหตุผล (reason) ไว้​ตรวจ​ย้อนหลัง:

// JournalEntry — เซ็ตของ LedgerEntry ที่ "ต้องสมดุล" ถึงจะสร้างได้
public sealed class JournalEntry
{
public JournalEntryId Id { get; }
public string IdempotencyKey { get; }
public string Reason { get; }
public IReadOnlyList<LedgerEntry> Entries { get; }
public JournalEntry(
JournalEntryId id, string idempotencyKey, string reason,
IReadOnlyList<LedgerEntry> entries)
{
var debit = SumSide(entries, Direction.Debit);
var credit = SumSide(entries, Direction.Credit);
// invariant ★ — เดบิตรวม = เครดิตรวม ไม่งั้นสร้างรายการไม่ได้ตั้งแต่แรก
if (debit != credit)
throw new InvalidOperationException(
$"journal entry ไม่สมดุล: เดบิต {debit.Amount} ≠ เครดิต {credit.Amount}");
Id = id;
IdempotencyKey = idempotencyKey;
Reason = reason;
Entries = entries;
}
private static Money SumSide(IReadOnlyList<LedgerEntry> entries, Direction side)
{
var total = Money.Thb(0);
foreach (var line in entries)
if (line.Direction == side) total += line.Amount;
return total;
}
}

SumSide ไล่​บวก​เฉพาะ​ขา​ที่​ตรง​ด้าน แล้ว​เทียบ​สอง​ก้อน​ด้วย​ความ​เท่า​กัน​ของ Money ตรงๆ นี่​คือ​กลไก​ที่​ทำให้ version ดิบ​ข้าง​บน​เป็น​ไป​ไม่​ได้ — คุณ สร้าง รายการ​ที่​ไม่​สมดุล​ไม่​ได้​เลย จะ​เผลอ​ทำ​เงิน​หาย​กลาง​ทาง​ก็​ไม่​ได้

ยอด​ที่​ต้อง settle คือ​เท่าไร? มา​จาก​ยอด​คง​เหลือ​ของ​บัญชี escrow — ซึ่ง​ตาม​หลัก Balance as a FoldBalance as a Foldแนวคิด​ที่​ว่ายอด​คง​เหลือ​ของ​บัญชี​ไม่ใช่​ตัวเลข​ที่​เก็บ​ไว้​ตรง ๆ แต่​เป็น​ผลลัพธ์​จาก​การ 'พับ' (fold) ไล่​บวก-ลบ​ทุก Journal Entry ของ​บัญชี​นั้น​ตั้งแต่​ต้น​จนถึง​ปัจจุบัน คล้าย​การ replay event ใน Event Sourcing — บัญชี​จึง​ตรวจสอบ​ย้อน​กลับ​ได้​เสมอ​ว่ายอด​เงิน​มา​จาก​ไหน​บ้างArchitecture ไม่ใช่​ตัวเลข​ที่​เก็บ​ไว้ แต่คำนวณสดๆ จาก​การ fold ไล่​รวม​ทุก LedgerEntry ของ​บัญชี​นั้น:

public static class Balances
{
// ยอดคงเหลือ = fold ทุก LedgerEntry ของบัญชี ไม่ใช่ column ที่เก็บไว้ต่างหาก
public static (Money Debit, Money Credit) FoldSides(
AccountId id, IEnumerable<LedgerEntry> ledger)
{
var debit = Money.Thb(0);
var credit = Money.Thb(0);
foreach (var e in ledger.Where(e => e.AccountId == id))
{
if (e.Direction == Direction.Debit) debit += e.Amount;
else credit += e.Amount;
}
return (debit, credit); // ยอดสุทธิ = ผลต่างสองด้านตามประเภทบัญชี
}
}

ตัว SettlementService เป็น idempotent consumer ที่​ฟัง OrderDelivered อ่าน​ยอด​ใน escrow แล้ว​ให้​นโยบาย​ค่า​คอม​แตก​เป็น​สาม​ส่วน จาก​นั้น post journal entry เดียว จุด​สวยงาม​คือ: ต่อ​ให้​นโยบาย​ค่า​คอม​คำนวณ​พลาด (สาม​ส่วนรวม​กัน​ไม่​เท่า escrow) รายการ​นั้น​ก็​จะ​โดน constructor ปัด​ทิ้ง​ทันที เงิน​ไม่มี​ทาง​รั่ว

public interface ILedger
{
void Post(JournalEntry entry); // idempotent by entry.IdempotencyKey
Money BalanceOf(AccountId account); // ยอด = fold ของบัญชี (ไม่ใช่ column)
}
public interface IAccounts
{
AccountId Escrow(Guid orderId);
AccountId RestaurantPayable(Guid restaurantId);
AccountId RiderPayable(Guid riderId);
AccountId PlatformRevenue { get; }
AccountId GatewayClearing { get; }
}
// สัญญาบางเฉียบตามกฎ integration event ของคอร์ส #8 — มีแต่ ID
public sealed record OrderDelivered(Guid OrderId, Guid RestaurantId, Guid RiderId);
// นโยบายค่าคอม — แตกยอด escrow เป็นสามส่วนให้รวมกันเท่า escrow เป๊ะ
public sealed record SettlementSplit(Money Restaurant, Money Rider, Money Platform);
public interface ICommissionPolicy
{
SettlementSplit Split(Money escrowTotal);
}
public sealed class SettlementService
{
private readonly ILedger _ledger;
private readonly IAccounts _accounts;
private readonly ICommissionPolicy _commission;
public SettlementService(ILedger ledger, IAccounts accounts, ICommissionPolicy commission)
{
_ledger = ledger;
_accounts = accounts;
_commission = commission;
}
// Idempotent consumer — รับ OrderDelivered ซ้ำกี่ครั้งก็ post รายการเดียว
public void On(OrderDelivered e)
{
var escrow = _accounts.Escrow(e.OrderId);
var held = _ledger.BalanceOf(escrow); // เช่น Money.Thb(200) — มาจากการ fold
var split = _commission.Split(held); // 200 -> (ร้าน 150, ไรเดอร์ 30, platform 20)
var jeId = new JournalEntryId(Guid.NewGuid());
var entry = new JournalEntry(
jeId,
idempotencyKey: $"settle:{e.OrderId}",
reason: $"settle order {e.OrderId}",
entries: new[]
{
// เดบิต escrow เต็มจำนวน — ปิดบัญชีพักเงินให้เหลือศูนย์
new LedgerEntry(escrow, Direction.Debit, held, jeId),
// เครดิตสามขาปลายทาง — ร้าน ไรเดอร์ platform
new LedgerEntry(_accounts.RestaurantPayable(e.RestaurantId), Direction.Credit, split.Restaurant, jeId),
new LedgerEntry(_accounts.RiderPayable(e.RiderId), Direction.Credit, split.Rider, jeId),
new LedgerEntry(_accounts.PlatformRevenue, Direction.Credit, split.Platform, jeId),
});
_ledger.Post(entry); // ถ้า idempotencyKey นี้เคย post แล้ว จะไม่ทำซ้ำ
}
}

สังเกต​ว่า settlement ทั้งหมด​นี้​เกิด​ขึ้น ภายใน LedgerLedgerแหล่ง​ความ​จริง​เดียว​ที่​รักษา​ประวัติ Journal Entry ทุก​รายการ​ของ​ระบบ​ไว้​เรียง​ตาม​เวลา แบบ append-only ไม่มี​การ​แก้​ของ​เก่า ยอด​เงิน​ของ​บัญชี​ไหน​ก็ตาม​คำนวณ​ได้​จาก​การ​ไล่​ผล​รวม​รายการ​ใน ledger เท่านั้น ไม่ใช่​ตัวเลข​ที่​เก็บ​แยก​ไว้​ต่างหากTactical Design เท่านั้น — ยัง​ไม่มี​เงิน​สัก​บาท​ออก​จาก​ระบบ​ไป​ไหน มัน​แค่​ย้าย​เงิน​จาก​ช่อง “พัก​ไว้” (escrow) ไป​ช่อง “ค้าง​จ่าย” (payable) และ “รายได้​เรา” (revenue) เตรียม​พร้อม​สำหรับ​จังหวะ​ที่​สอง

นี่​คือ​จุด​ที่ settlement กับ payout แยก​ทาง​กันชัดๆ settlement คือ​การ​จัด​ประเภท​เงิน​ใหม่​ใน​บัญชี​ของ​เรา​เอง (escrow → payable + revenue) เงิน​ยัง​อยู่​ครบ​ใน​หนังสือ​บัญชี payout คือ​การ​เอา​เงิน​ที่​ค้าง​จ่าย​ใน​บัญชี payable ออก​จาก​ระบบ​ไป​เข้า​บัญชี​ธนาคาร​หรือ PromptPay ของ​ร้าน/ไร​เด​อร์จริงๆ

payout มัก​ทำ​เป็น รอบ (batch) ไม่ใช่​ยิง​ทุกออเดอร์​ทันที — เก็บ​ยอด​ค้าง​จ่าย​ของ​ร้าน​หนึ่ง​ราย​สะสม​ทั้ง​วัน​ไว้​ใน​บัญชี RestaurantPayable แล้ว​รอบ​เย็น​ค่อย​กวาด​ออก​ที​เดียว รอบ​หนึ่ง​วน​จ่าย​ร้าน/ไร​เด​อร์ที​ละ​ราย code ข้าง​ล่าง​คือ​หนึ่ง​ขา​ของ batch นั้น: อ่าน​ยอด​ค้าง​จ่าย (ก็ fold อีก​นั่น​คือ) แล้ว post journal entry ที่​เดบิต payable ล้าง​หนี้​ให้​เป็น​ศูนย์ พร้อม​เครดิต Clearing AccountClearing Accountบัญชี​พัก​เงิน​ชั่วคราว​ระหว่าง​สอง​ขั้นตอน​ของ​ธุรกรรม เช่น ตอน​ลูกค้า​จ่าย​เงิน​ผ่าน PromptPay เงิน​จะ​เข้า​บัญชี clearing ก่อน แล้ว​ค่อย​กระจาย​ไป​บัญชี​ร้าน​อาหาร/ไร​เด​อร์/ค่า​คอม​มิช​ชัน platform ทีหลัง — แยก​ขั้นตอน 'รับ​เงิน​เข้า' ออก​จาก 'จัดสรร​เงิน' ให้​ตรวจสอบ​ได้ที​ละ​ขั้นTactical Design GatewayClearing — บัญชี​พัก​เงิน​ที่​กำลัง​เดิน​ทางออก​ไป​หา​ธนาคาร:

// ACL shape จากคอร์ส #8 — PromptPay/ธนาคารในบทนี้เป็นตัวอย่างประกอบ ไม่ใช่ integration จริง
public interface IPaymentGateway
{
void Transfer(Guid payeeId, Money amount, string idempotencyKey);
}
public sealed class PayoutService
{
private readonly ILedger _ledger;
private readonly IAccounts _accounts;
private readonly IPaymentGateway _gateway;
public PayoutService(ILedger ledger, IAccounts accounts, IPaymentGateway gateway)
{
_ledger = ledger;
_accounts = accounts;
_gateway = gateway;
}
// รวมรอบจ่ายร้านหนึ่งราย — กวาดยอดใน RestaurantPayable ออกไป gateway/ธนาคาร
public void PayOutRestaurant(Guid restaurantId, string batchId)
{
var payable = _accounts.RestaurantPayable(restaurantId);
var amount = _ledger.BalanceOf(payable); // ยอดค้างจ่าย = fold ของบัญชี
if (amount == Money.Thb(0)) return; // ไม่มีอะไรต้องจ่ายในรอบนี้
var key = $"payout:{batchId}:{restaurantId}";
var jeId = new JournalEntryId(Guid.NewGuid());
var payout = new JournalEntry(
jeId, key, reason: $"payout restaurant {restaurantId} ({batchId})",
entries: new[]
{
// ล้างหนี้ payable ให้เป็นศูนย์
new LedgerEntry(payable, Direction.Debit, amount, jeId),
// เงินกำลังออกนอกระบบ — พักที่ GatewayClearing ระหว่างทาง
new LedgerEntry(_accounts.GatewayClearing, Direction.Credit, amount, jeId),
});
_ledger.Post(payout); // idempotent — รอบเดิมยิงซ้ำก็ไม่จ่ายซ้ำ
_gateway.Transfer(restaurantId, amount, key); // สั่ง gateway โอนจริง (ACL, illustrative)
}
}

จะ​เห็น​ว่า payout ก็​เป็น journal entry ที่​สมดุล​อีก​ใบ (เดบิต payable = เครดิต GatewayClearing) และ​ก็ idempotent ด้วย key เดิม — ยิง batch เดิม​ซ้ำ​ก็​ไม่​จ่าย​เงิน​ให้​ร้าน​สอง​รอบ ทั้ง settlement และ payout จึง​เดิน​อยู่​บน​กฎ​เดียวกัน​ทุก​กระเบียด​นิ้ว: ทุก​การ​เคลื่อน​ของ​เงิน​คือ​รายการ​ที่​สมดุล​ใน​ตัวเอง

แล้ว​ถ้า settle พลาด — นโยบาย​ค่า​คอม​คำนวณ​ผิด หรือ​แตก​เงิน​เข้า​บัญชี​ผิด​ใบ — จะ​แก้​ยังไง? กฎ​เหล็ก​ของ ledger คือ ห้าม​แก้ ห้าม​ลบ​รายการ​ที่ post ไป​แล้ว (Append-only LogAppend-only Logโครงสร้าง​การ​เก็บ​ข้อมูล​ที่ 'เขียน​เพิ่ม​ได้​อย่าง​เดียว' ห้าม UPDATE หรือ DELETE รายการ​เก่า — ledger ของ​ระบบ​การเงิน​ต้อง​เป็น​แบบ​นี้​เสมอ เพราะ​ประวัติ​เงิน​ต้อง​ตรวจสอบ​ย้อนหลัง​ได้​ทุก​บรรทัด ต่าง​จาก​ตาราง​ทั่วไป​ที่​แก้​ทับ​ค่า​เดิม​ได้Architecture) วิธี​แก้​คือ​เขียน Reversing EntryReversing EntryJournal Entry ใหม่​ที่​เขียน​ขึ้น​เพื่อ​หักล้าง​รายการ​เดิม​ทั้งหมด​หรือ​บาง​ส่วน โดย​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม เช่น ตอน​คืน​เงิน​ลูกค้า (refund) แทนที่​จะ​ลบ​รายการ​จ่าย​เงิน​เดิม ระบบ​เขียน reversing entry ใหม่​ทับ — ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจสอบ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น​บ้างTactical Design — รายการ​ใหม่​ที่​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม​มา​หักล้าง​ให้​เป็น​ศูนย์ แล้ว​ค่อย post รายการ​ที่​ถูกต้อง​เข้าไป​ใหม่ ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจ​ย้อน​ได้​ว่า​เกิด​อะไร​ขึ้น​และ​ถูก​แก้​ยังไง — บท​ที่ 7 เรื่อง refund จะ​ลง​ลึก​กลไก​นี้

บท​นี้​ปิด​วงจร “เงิน​เข้า​มา​แล้ว​ออก​ไป​หา​คน​ที่​ควร​ได้” ครบ — settle แตก escrow เป็น​สามขา​ที่​สมดุล​ภายใน ledger แล้ว payout กวาด​เงิน​ออก​นอก​ระบบ​เป็น​รอบ เหลือ​อีก​สอง​ด่าน​สุดท้าย:

  1. refund และ reversing entry (บท​ที่ 7) — คืน​เงิน​เมื่อ​ยกเลิก โดย​ไม่​ลบ​ประวัติ​เดิม
  2. reconciliation (บท​ที่ 8) — เทียบ​ยอด​ใน ledger กับ​ที่ gateway รายงาน​จริง พิสูจน์​ว่า​เงิน​ไม่มี​วัน​หาย​สัก​บาท

ทุก​อย่าง​ยัง​กลับ​มา​ที่ ledger เดียว รายการ​เดียว​ที่​สมดุล​เสมอ — settlement ย้าย​เงิน​ภายใน​หนังสือ​บัญชี payout พา​เงิน​ออก​นอก​ระบบ แต่​ทั้ง​คู่​เขียน​ลง​ด้วย​กฎ​เดบิต​รวม = เครดิต​รวม​เหมือนกันเป๊ะ


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ:

  • AggregateJournalEntry คือ aggregate ที่​รักษา invariant “เดบิต​รวม = เครดิต​รวม” ไว้​ใน​ตัวเอง สร้าง object ที่​ไม่​สมดุล​ไม่​ได้​เลย
  • Guard Clause — การ​เช็ค​สมดุล​ใน constructor คือ guard ที่​ปัด​รายการ​ผิด​ทิ้ง​ตั้งแต่​ตอน​สร้าง ไม่​ปล่อย​ให้ state เพี้ยน​เล็ดลอด​เข้า​ระบบ

เช็กความเข้าใจ — บทที่ 6

ข้อ 1 / 3

ตอน settlement เกิดขึ้น Journal Entry เดียวแตกเงินจาก escrow ออกเป็นอะไรบ้าง และทำไมต้องสมดุล?