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

Reconciliation & ปิด​งบ

ตลอด​เจ็ด​บท​ที่​ผ่าน​มา​เรา​ปั้น​ระบบ​เงิน​ขึ้น​มา​ที​ละ​ชั้น — บท​ที่ 1 วาง​กฎ​ว่า​เงิน​ต้อง​บันทึก​เป็น​สอง​ขา​ที่​สมดุล บท​ที่ 2 ปั้น LedgerLedgerแหล่ง​ความ​จริง​เดียว​ที่​รักษา​ประวัติ Journal Entry ทุก​รายการ​ของ​ระบบ​ไว้​เรียง​ตาม​เวลา แบบ append-only ไม่มี​การ​แก้​ของ​เก่า ยอด​เงิน​ของ​บัญชี​ไหน​ก็ตาม​คำนวณ​ได้​จาก​การ​ไล่​ผล​รวม​รายการ​ใน ledger เท่านั้น ไม่ใช่​ตัวเลข​ที่​เก็บ​แยก​ไว้​ต่างหากTactical Design ให้​รักษา​กฎ​นั้น​ใน​ตัว บท​ที่ 3 ย้าย​เงิน​ด้วย Journal EntryJournal Entryหน่วย​ที่​เล็ก​ที่สุด​ของ​การ​บันทึก​ใน ledger หนึ่ง​รายการ​มี​อย่าง​น้อย​สอง​บรรทัด (เดบิต​กับ​เครดิต) ที่​ยอด​รวม​เท่า​กัน เช่น ตอน​ลูกค้า​จ่าย​ค่า​อาหาร เกิด journal entry เดียว​ที่​กระทบ​ทั้ง​บัญชี​เงินสด​และ​บัญชี escrow — เขียน​แล้ว​ห้าม​แก้ ถ้า​ผิด​ต้อง​เขียน Reversing Entry มา​หักล้าง​แทนTactical Design แบบ idempotent บท​ที่ 4 เติม​เงิน​เข้า wallet ด้วย PromptPay บท​ที่ 5 จ่าย​ค่า​อาหาร​แล้ว​พัก​ไว้​ใน escrow บท​ที่ 6 ทำ SettlementSettlementขั้นตอน​ที่​เงิน​ซึ่ง​พัก​อยู่ (เช่น​ใน escrow หรือ clearing) ถูก 'ปิด​จบ' อย่าง​เป็น​ทางการ — กระจาย​ไป​ยัง​บัญชี​ปลายทาง​จริง​ตาม​สัดส่วน​ที่​ตกลง​ไว้ เช่น ตัด​ค่า​คอม​มิช​ชัน platform แล้ว​โอน​ส่วน​ที่​เหลือ​เข้า​บัญชี​ร้าน​อาหาร​และ​ไร​เด​อร์ มัก​เกิด​เป็น​รอบ (batch) ไม่ใช่​ทันที​ทุกออเดอร์Process แล้ว PayoutPayoutการ​จ่าย​เงิน​ที่ settle แล้ว​ออก​จาก​ระบบ​ไป​ให้​ร้าน​อาหาร​หรือ​ไร​เด​อร์จริง ๆ เช่น โอน​เข้า​บัญชี​ธนาคาร​หรือ PromptPay ของ​ร้าน — มัก​รวม​หลายออเดอร์​เป็น​รอบ payout เดียว​ต่อ​วัน​หรือ​ต่อ​สัปดาห์ แทนที่​จะ​โอน​ทุกออเดอร์​แยก​กันProcess ออก​นอก​ระบบ และ​บท​ที่ 7 คืน​เงิน​ด้วย Reversing EntryReversing EntryJournal Entry ใหม่​ที่​เขียน​ขึ้น​เพื่อ​หักล้าง​รายการ​เดิม​ทั้งหมด​หรือ​บาง​ส่วน โดย​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม เช่น ตอน​คืน​เงิน​ลูกค้า (refund) แทนที่​จะ​ลบ​รายการ​จ่าย​เงิน​เดิม ระบบ​เขียน reversing entry ใหม่​ทับ — ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจสอบ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น​บ้างTactical Design โดย​ไม่​ลบ​ประวัติ ตลอด​ทาง​เรา​พูด​คำ​สัญญา​ข้อ​เดียว​ซ้ำๆ ว่า เงิน​ไม่มี​วัน​หาย​และ​ไม่มี​วัน​โผล่​มา​เอง บท​สุดท้าย​นี้​คือ​บท​ที่​เรา พิสูจน์ ว่า​คำ​สัญญา​นั้น​เป็น​จริง — ไม่ใช่​แค่​เชื่อ​ว่า​จริง

การ​พิสูจน์​มี​สอง​ด่าน​ที่​ตอบ​คนละ​คำถาม ด่าน​แรก​คือ Trial BalanceTrial Balanceรายงาน​สรุป​ยอด​เดบิต​รวม​และ​เครดิต​รวม​ของ​ทุก​บัญชี​ใน ledger ณ เวลา​หนึ่ง ใช้​ตรวจ​สุขภาพ​ของ​ระบบ​บัญชี — ถ้า double-entry ถูกต้อง​เสมอ ผล​รวม​เดบิต​ทั้งหมด​ต้อง​เท่ากับ​ผล​รวม​เครดิต​ทั้งหมด​พอ​ดิบ​พอดี รักษา​สมดุล​ของ​ระบบ​ไว้ ถ้า​ไม่​เท่า​กัน​แปล​ว่า​มี bug ใน​ระบบ​บันทึก​ธุรกรรมTactical Design — ถาม​ว่า “ระบบ​บันทึก​ของ​เรา​สมดุล​ใน​ตัวเอง​ไหม” เป็นการ​ตรวจ​ภายใน ledger ล้วนๆ ด่าน​ที่​สอง​คือ ReconciliationReconciliationกระบวนการ​เทียบ​ยอด​ระหว่าง​สอง​แหล่ง​ข้อมูล​ที่​ควร​ตรง​กัน เช่น ยอด​ใน ledger ของ platform กับ​ยอด​ที่​ผู้​ให้​บริการ PromptPay รายงาน​มา​จริง — ถ้า​ไม่​ตรง​กัน​แปล​ว่า​มี​บาง​ธุรกรรม​หลุด​หาย​หรือ​ถูก​บันทึก​ผิด ต้องตา​มหา​สาเหตุ​ก่อน​ปิด​บัญชี​รอบ​นั้น รักษา​คำ​สัญญา​ที่​ว่า 'เงิน​ไม่มี​วัน​หาย' ให้​เป็น​จริงProcess — ถาม​ว่า “ยอด​ใน​บ้าน​เรา​ตรง​กับ​โลก​จริง​ข้าง​นอก​ไหม” เป็นการ​เทียบ ledger กับ​สิ่ง​ที่ payment gateway รายงาน​มา​จริง ผ่าน​ทั้ง​สอง​ด่าน​เมื่อไหร่ ถึง​จะ​ปิด​งบ​รอบ​นั้น​ได้​อย่าง​สบายใจ

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

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — สนิปเป็ต​ใน​บท​นี้​ต่อยอด​จาก model ledger ที่​บท​ที่ 2–3 วาง​ไว้ value object Money ยก​มา​จาก​คอร์ส DDD-in-Code ทั้งดุ้น ส่วน gateway/PromptPay ใน​บท​นี้​เป็น​แค่​ภาพประกอบ​ผ่าน​รูปทรง ACL เดิม ไม่ใช่​การ​ต่อ​ระบบ​ชำระ​เงิน​จริง

เริ่ม​จาก​ด่าน​ใน​บ้าน​ก่อน กฎ​เหล็ก​ของ Double-entry BookkeepingDouble-entry Bookkeepingวิธี​บันทึก​ธุรกรรม​การเงิน​ที่​ทุก​รายการ​ต้อง​มี​สอง​ด้าน​เสมอ — เดบิต​ด้าน​หนึ่ง เครดิต​อีก​ด้าน​หนึ่ง เท่า​กัน​ทุก​ครั้ง เช่น ลูกค้า​จ่าย​ค่า​อาหาร 200 บาท เดบิต​บัญชี​เงินสด เครดิต​บัญชี​เจ้าหนี้​ร้าน​ค้า 200 บาท​เท่า​กัน — ทำให้​เงิน​ไม่มี​วัน​หาย​เพราะ​ทุก​บาท​มี​ที่มา​ที่​ไป​เสมอArchitecture คือ​ทุก journal entry มี​เดบิต​รวม​เท่ากับ​เครดิต​รวม​พอดี ทีนี้​ลอง​คิด​ต่อ: ถ้า ทุก รายการ​ใน​ระบบ​สมดุล​จริง แล้ว​เรา​ให้​เดบิต​นับ​เป็น​บวก เครดิต​นับ​เป็น​ลบ ผล​รวม​ของ​ทุก​บรรทัด​ใน​ทั้ง ledger ต้อง​เป็น​ศูนย์​เป๊ะ เพราะ​แต่ละ journal entry หักล้าง​กันเอง​ใน​ตัว — นี่​คือ trial balance และ​มัน​คือ invariant ที่​ครอบ​ทั้ง​ระบบ ไม่ใช่​แค่​รายการ​เดียว

ก่อน​คำนวณ ขอ​ทวน​รูปทรง​ที่​บท​ก่อนๆ วาง​ไว้ เท่า​ที่​บท​นี้​ต้อง​ใช้ และ​ย้ำ​จุด​สำคัญ​จาก​บท​ที่ 1: ยอด​ของ​บัญชี​ไม่ใช่ column ที่​เก็บ​ไว้ แต่​เป็น Balance as a FoldBalance as a Foldแนวคิด​ที่​ว่ายอด​คง​เหลือ​ของ​บัญชี​ไม่ใช่​ตัวเลข​ที่​เก็บ​ไว้​ตรง ๆ แต่​เป็น​ผลลัพธ์​จาก​การ 'พับ' (fold) ไล่​บวก-ลบ​ทุก Journal Entry ของ​บัญชี​นั้น​ตั้งแต่​ต้น​จนถึง​ปัจจุบัน คล้าย​การ replay event ใน Event Sourcing — บัญชี​จึง​ตรวจสอบ​ย้อน​กลับ​ได้​เสมอ​ว่ายอด​เงิน​มา​จาก​ไหน​บ้างArchitecture — ผล​ของ​การ​ไล่​รวม​ทุก บัญชีAccountหน่วย​ที่​ใช้​แยกแยะ​ว่า 'เงิน​อยู่​ตรง​ไหน' ใน​ระบบ เช่น บัญชี​เงินสด platform, บัญชี​ลูกหนี้​ร้าน​อาหาร, บัญชี wallet ของ​ลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้อง​ระบุ​ว่า​เดบิต/เครดิต​บัญชี​ไหนTactical Design จาก​รายการ​จริง เพราะ MoneyMoneyValue Object ที่​แทน​จำนวน​เงิน​พร้อม​สกุล​เงิน (เช่น จำนวน + THB) เปรียบเทียบ​กัน​ด้วย​ค่า ไม่ใช่ identity, บวก/ลบ​กัน​ได้​แต่​ต้อง​เป็น​สกุล​เดียวกัน, immutable — ห้าม​ใช้ decimal เปล่า ๆ แทน​เงิน​เพราะ​จะ​ทำให้​หน่วย​เงิน​และ​กฎ​การ​ปัดเศษ​หล่น​หาย​ไป (Primitive Obsession)Tactical Design ห้าม​ติดลบ (value object จาก​คอร์ส DDD-in-Code รักษา​กฎ​นี้​ไว้) เวลา​พิสูจน์​สมดุล​เรา​จึง fold ลง​เป็น decimal แบบ​มี​เครื่อง​หมายตรงๆ:

// — ทวนรูปทรงจากบทที่ 1–3 (ยกมาเท่าที่บทนี้ใช้) —
public sealed record Money(decimal Amount, string Currency) // value object จาก DDD-in-Code
{
public Money
{
if (Amount < 0) throw new ArgumentException("จำนวนเงินต้องไม่ติดลบ", nameof(Amount));
}
public static Money Thb(decimal amount) => new(amount, "THB");
}
public sealed record AccountId(Guid Value);
public sealed record JournalEntryId(Guid Value);
public enum Direction { Debit, Credit }
// หนึ่งบรรทัดใน ledger — immutable ตลอดกาล (บทที่ 2–3)
public sealed record LedgerEntry(
AccountId AccountId, Direction Direction, Money Amount, JournalEntryId JournalEntryId);
// journal entry: เซ็ตของ LedgerEntry ที่สมดุล (บทที่ 3) — ยกมาเท่าที่บทนี้ใช้
public sealed class JournalEntry
{
public IReadOnlyList<LedgerEntry> Entries { get; }
private JournalEntry(IReadOnlyList<LedgerEntry> entries) => Entries = entries;
// ย้ายเงินสองขา: เดบิตบัญชีหนึ่ง เครดิตอีกบัญชี จำนวนเท่ากัน = สมดุลเสมอ
public static JournalEntry BalancedTransfer(
AccountId debitAccount, AccountId creditAccount, Money amount,
string idempotencyKey, string reason)
{
var id = new JournalEntryId(Guid.NewGuid());
return new JournalEntry(new[]
{
new LedgerEntry(debitAccount, Direction.Debit, amount, id),
new LedgerEntry(creditAccount, Direction.Credit, amount, id),
});
}
}
public static class TrialBalance
{
// เดบิตเป็นบวก เครดิตเป็นลบ — คิดค่าของหนึ่งบรรทัดแบบมีเครื่องหมาย
private static decimal Signed(LedgerEntry e) =>
e.Direction == Direction.Debit ? e.Amount.Amount : -e.Amount.Amount;
// ยอดของบัญชีเดียว = fold ทุก entry ของบัญชีนั้น (ไม่ใช่ column ที่เก็บไว้)
public static decimal BalanceOf(AccountId account, IReadOnlyList<LedgerEntry> ledger) =>
ledger.Where(e => e.AccountId == account).Sum(Signed);
// ★ invariant ทั้งระบบ: ผลรวมยอดทุกบัญชีต้องเป็น 0 พอดี
public static decimal SumAcrossAllAccounts(IReadOnlyList<LedgerEntry> ledger) =>
ledger.Sum(Signed);
public static bool Holds(IReadOnlyList<LedgerEntry> ledger) =>
SumAcrossAllAccounts(ledger) == 0m;
}

จุด​ที่​สวย​ของ trial balance คือ​มัน​ไม่​ได้​เช็ก “ยอด​บัญชี A ถูก​ไหม” ที​ละ​ใบ แต่​เช็ก​สุขภาพ​ของ ทั้ง​ระบบ ใน​ตัวเลข​เดียว ถ้า​ผล​รวม​ออก​มา​ไม่​เป็น​ศูนย์ แปล​ว่า​มี​ที่ไหน​สัก​แห่ง post รายการ​ที่​เดบิต​ไม่​เท่า​เครดิต​ลง​ไป​ได้ — เป็น bug ใน​ตัว​ระบบ​บันทึก ไม่ใช่​แค่​เงิน​คลาดเคลื่อน ลอง​ดู​ภาพ​รวม​ตอน​ปิด​รอบ​ของออเดอร์​หนึ่ง​ใบ​ที่​เดิน​ครบ​วงจร:

flowchart TB
  T["ปิดรอบ: fold ยอดทุกบัญชี (เดบิต = บวก, เครดิต = ลบ)"]
  T --> B1["GatewayClearing (สินทรัพย์)<br/>+200"]
  T --> B2["CustomerWallet (หนี้สิน)<br/>0"]
  T --> B3["Escrow (หนี้สิน)<br/>0"]
  T --> B4["PlatformRevenue (รายได้)<br/>-20"]
  T --> B5["RestaurantPayable (หนี้สิน)<br/>-150"]
  T --> B6["RiderPayable (หนี้สิน)<br/>-30"]
  B1 --> SUM["ผลรวมทุกบัญชี = 200 - 20 - 150 - 30 = 0<br/>★ Trial Balance ผ่าน"]
  B2 --> SUM
  B3 --> SUM
  B4 --> SUM
  B5 --> SUM
  B6 --> SUM
  SUM --> RECON{"เทียบยอด GatewayClearing (+200)<br/>กับยอดที่ gateway รายงานจริง"}
  RECON -->|"ตรงกัน 200 = 200"| OK["ปิดงบได้ — เงินทุกสตางค์มีที่อยู่"]
  RECON -->|"ไม่ตรง 200 != 195"| DRIFT["drift! หยุดปิดงบ<br/>ตามหารายการที่หลุด"]
  classDef head fill:#1e3a8a,stroke:#1e40af,color:#f8fafc;
  classDef asset fill:#16a34a,stroke:#065f46,color:#f8fafc;
  classDef liab fill:#dc2626,stroke:#7f1d1d,color:#f8fafc;
  classDef sum fill:#f59e0b,stroke:#92400e,color:#1c1917;
  classDef ok fill:#16a34a,stroke:#065f46,color:#f8fafc;
  classDef bad fill:#7f1d1d,stroke:#450a0a,color:#f8fafc;
  class T head;
  class B1 asset;
  class B2,B3,B4,B5,B6 liab;
  class SUM,RECON sum;
  class OK ok;
  class DRIFT bad;

คำ​บรรยาย​ภาพ: ออเดอร์​หนึ่ง​ใบ​เดิน​ครบ​วงจร — ลูกค้า​เติม​เงิน 200 บาท​ผ่าน gateway แล้ว​จ่าย​ค่า​อาหาร ส่ง​ถึง settle แบ่ง​เป็น​ค่า​คอม​มิช​ชัน platform 20 ร้าน 150 ไร​เด​อร์ 30 บาท ปลาย​รอบ​เรา​ไล่ fold ยอด​ทุก​บัญชี บัญชี​สินทรัพย์ (เขียว) มี​ยอด​เดบิต​เป็น​บวก บัญชี​ยอด​เครดิต (หนี้สิน/รายได้) (แดง) มี​ยอด​เครดิต​เป็น​ลบ ผล​รวม​ทั้งหมด (ส้ม) ออก​มา 0 พอดี — trial balance ผ่าน แปล​ว่าการ​บันทึก​ภายใน​สมดุล จาก​นั้น​ถึง​ด่าน​สอง: เอา​ยอด​บัญชี GatewayClearing (+200 คือ​เงินสด​จริง​ที่​ยัง​ถือ​อยู่) ไป​เทียบ​กับ​ที่ gateway รายงาน ถ้า​ตรง​กัน​ปิด​งบ​ได้ ถ้า​ไม่​ตรง (แดง​เข้ม) แปล​ว่า​มี drift ต้อง​หยุด​แล้ว​ตามหา​รายการ​ที่​หลุด​ก่อน

trial balance ผ่าน​บอก​ได้​แค่​ว่า “เรา​บันทึก​กันเอง​สมดุล” แต่​ยัง​ไม่​การันตี​ว่ายอด​ตรง​กับ เงิน​จริง ข้าง​นอก สมมติ​เรา​พลาด​ไม่​ได้​บันทึก​การ​เติม​เงิน​หนึ่ง​รายการ​เลย — ledger ก็​ยัง​สมดุล​ใน​ตัว​มัน​เอง​อยู่ดี (เพราะ​รายการ​ที่​ขาด​หาย​ไป​ทั้ง​ใบ ไม่​ได้​ทำให้​เดบิต​กับ​เครดิต​ใน​ระบบ​เสีย​สมดุล) trial balance จับ​เคส​นี้​ไม่​ได้ นี่​คือ​เหตุผล​ที่​ต้อง​มี reconciliation

reconciliation คือ​การ​เอา “ความ​จริง​ฝั่ง​เรา” ไป​เทียบ​กับ “ความ​จริง​ฝั่ง​เขา” ฝั่ง​เรา​คือ​ยอด​บัญชี GatewayClearing ที่ fold จาก ledger ฝั่ง​เขา​คือ​ยอด​ที่ payment gateway ยืนยัน​ว่า settle เข้า​มา​จริง​ใน​รอบ​นั้น สอง​ตัวเลข​นี้ ต้อง ตรง​กัน ถ้า​ต่าง​กัน​เมื่อไหร่​เรียก​ว่า drift — สัญญาณ​ว่า​มี​รายการ​หลุด บันทึก​ซ้ำ หรือ​บันทึก​ผิด เรา​คุย​กับ gateway ผ่าน​รูปทรง ACL เดิม​จาก​คอร์ส Bounded Contexts (#8) ที่​ห่อ​ของ​ภายนอก​ไว้​ที่​ขอบ สัญญา​มี​แต่​ค่า​พื้นฐาน ไม่มี object ภายนอก​รั่ว​เข้า​มา​ใน domain:

// ACL รอบ gateway ภายนอก — รูปทรงเดียวกับคอร์ส #8 (สัญญามีแต่ primitive)
public interface IPaymentGateway
{
// ยอดจริงที่ gateway ยืนยันว่า settle เข้าบัญชี clearing ของเราในรอบนี้
Task<decimal> ReportedClearedThb(DateOnly cycle, CancellationToken ct);
}
public sealed record ReconciliationResult(decimal LedgerSide, decimal GatewaySide)
{
public decimal Drift => LedgerSide - GatewaySide; // ต่างกันเท่าไร
public bool Matches => Drift == 0m; // ตรงกันเป๊ะไหม
}
public sealed class Reconciler
{
private readonly IPaymentGateway gateway;
public Reconciler(IPaymentGateway gateway) => this.gateway = gateway;
public async Task<ReconciliationResult> Reconcile(
AccountId gatewayClearing,
IReadOnlyList<LedgerEntry> ledger,
DateOnly cycle, CancellationToken ct)
{
// ฝั่งเรา: fold ยอด GatewayClearing จาก ledger — ไม่ใช่ column ที่เก็บไว้
var ledgerSide = TrialBalance.BalanceOf(gatewayClearing, ledger);
// ฝั่งเขา: ถาม gateway ว่าจริง ๆ settle เข้ามาเท่าไร
var gatewaySide = await gateway.ReportedClearedThb(cycle, ct);
return new ReconciliationResult(ledgerSide, gatewaySide);
}
}

จุด​ที่​ต้อง​รักษา​ไว้​ให้​มั่น​คือ ledgerSide มา​จาก​การ fold รายการ​เสมอ ไม่ใช่​จาก​ยอด​ที่​เก็บ cache ไว้ ถ้า​เผลอ​ไป​เทียบ​กับ​ตัวเลข​ที่​เก็บ​ทับๆ กัน​ไว้ reconciliation ก็​เชื่อถือ​ไม่​ได้​ตั้งแต่​แรก เพราะ​ตัวเลข​นั้น​เอง​อาจ​เพี้ยน​ไป​แล้ว การ fold จาก Append-only LogAppend-only Logโครงสร้าง​การ​เก็บ​ข้อมูล​ที่ 'เขียน​เพิ่ม​ได้​อย่าง​เดียว' ห้าม UPDATE หรือ DELETE รายการ​เก่า — ledger ของ​ระบบ​การเงิน​ต้อง​เป็น​แบบ​นี้​เสมอ เพราะ​ประวัติ​เงิน​ต้อง​ตรวจสอบ​ย้อนหลัง​ได้​ทุก​บรรทัด ต่าง​จาก​ตาราง​ทั่วไป​ที่​แก้​ทับ​ค่า​เดิม​ได้Architecture ทำให้​ฝั่ง​เรา​เป็น “ความ​จริง” ที่​สืบ​กลับ​ได้​เสมอ เจอ drift เมื่อไหร่ กฎ​คือ หยุด​ปิด​งบ​ก่อน แล้ว​ตามหา​ว่า​รายการ​ไหน​หาย — ไม่ใช่​แก้ตัวเลข​ให้​ตรง​เฉยๆ

พอ​เจอ drift คำถาม​ถัด​มา​คือ “แล้ว​รายการ​ที่​หลุด​คือ​รายการ​ไหน” ตรง​นี้​เอง​ที่​การ​เลือก​เก็บ ledger เป็น append-only log ตั้งแต่​บท​ที่ 1 จ่าย​ผล​ตอบแทน เพราะ​ไม่มี​อะไร​ถูกลบ​หรือ​เขียน​ทับ ทุก LedgerEntry ยัง​อยู่​ครบ และ​แต่ละ​บรรทัด​พก JournalEntryId ที่​โยง​กลับ​ไป​หา journal entry ต้นเรื่อง — ซึ่ง​เก็บ​เหตุผล (Reason) ของ​รายการ​ไว้​ตั้งแต่​บท​ที่ 3 เรา​จึง​ไล่​ย้อน​ได้​ว่า​เงิน​ขยับ​จาก​รายการ​ไหน เมื่อไหร่ ด้วย​เหตุผล​อะไร และ​สอง​ขา​ของ​รายการ​เดียวกัน​โยง​ถึงกัน​ผ่าน JournalEntryId เดียวกัน:

// audit: ไล่ทุก entry ของบัญชีเดียว — ตอบได้ว่าเงินขยับจากรายการไหนบ้าง
public static IEnumerable<LedgerEntry> HistoryOf(
AccountId account, IReadOnlyList<LedgerEntry> ledger) =>
ledger.Where(e => e.AccountId == account);
// ดึงทั้งสองขาของ journal entry เดียว — เห็นภาพเต็มว่าเงินไปไหนต่อ
public static IEnumerable<LedgerEntry> BothLegsOf(
JournalEntryId entryId, IReadOnlyList<LedgerEntry> ledger) =>
ledger.Where(e => e.JournalEntryId == entryId);

นี่​คือ​ความ​ต่าง​ที่​แก้​ทีหลัง​ไม่​ได้​ระหว่าง​ระบบ​ที่​เก็บ​ยอด​เป็น column (เขียน​ทับ​ปุ๊บ ประวัติ​หาย​เกลี้ยง ตอบ​ลูกค้า​ไม่​ได้​ว่า​เงิน​หาย​ตอน​ไหน) กับ ledger แบบ append-only ที่​ทุก​บาท​ทุก​สตางค์​มี​ร่องรอย​เสมอ ตอน​คืน​เงิน​หรือ​แก้​รายการ​ผิด เรา​ไม่​เคย​ลบ​ของ​เก่า แต่​ต่อ reversing entry ใหม่ (บท​ที่ 7) — ประวัติ​จึง​เล่า​เรื่อง​ได้​ครบ​ว่า​เกิด​อะไร​ขึ้น​ตาม​ลำดับ นั่น​คือ​คือ​ความหมาย​จริง​ของ audit trail

เหลือ​คำถาม​สุดท้าย: เรา​มั่นใจ​ได้​แค่​ไหน​ว่า trial balance จะ​เป็น​ศูนย์​เสมอ ไม่​ว่า​เงิน​จะ​เดินทาง​แบบ​ไหน วิธี test ที่​คน​เผลอ​เขียน​กัน​คือ​หยิบ​สอง​สาม​เคส​ที่​นึก​ออกมาเช็ก — แต่ bug เรื่อง​เงิน​มัก​ซ่อน​อยู่​ใน​ลำดับ​ที่​ไม่มี​ใคร​นั่ง​เขียน​ด้วย​มือ:

❌ version ดิบ — test เฉพาะ​เคส​ที่​นึกออก​เอง
[Fact]
public void Topup_then_pay_keeps_books_balanced()
{
var clearing = new AccountId(Guid.NewGuid());
var wallet = new AccountId(Guid.NewGuid());
var escrow = new AccountId(Guid.NewGuid());
var ledger = new List<LedgerEntry>();
// เติมเงิน 200 เข้า wallet แล้วจ่ายค่าอาหาร 200 เข้า escrow
// — ลำดับเดียวที่คนเขียน test นึกออก
ledger.AddRange(JournalEntry.BalancedTransfer(
clearing, wallet, Money.Thb(200), "k1", "topup").Entries);
ledger.AddRange(JournalEntry.BalancedTransfer(
wallet, escrow, Money.Thb(200), "k2", "pay").Entries);
Assert.Equal(0m, TrialBalance.SumAcrossAllAccounts(ledger));
}

test นี้​ผ่าน แต่​มัน​พิสูจน์​แค่​ว่า ลำดับ​นี้​ลำดับ​เดียว สมดุล ลำดับ​ที่​มี refund คั่น​กลาง ตาม​ด้วย settle บาง​ส่วน แล้ว payout ซ้อน — เคส​ที่​คน​เขียน​ไม่​ได้​นึกถึง — ยัง​ไม่มี​ใคร test เลย property-based testing พลิก​วิธี​คิด: แทนที่​จะ​บอกว่า “ลำดับ​นี้​ให้​ผล​นี้” เรา​ประกาศ คุณสมบัติ​ที่​ต้อง​จริง​เสมอ แล้ว​ปล่อย​ให้ framework (เช่น FsCheck) สุ่ม​ลำดับ​การ post เป็น​ร้อย​เป็น​พัน​แบบ​มา​ยิง​ใส่ คุณสมบัติ​ของ​ระบบ​เงิน​เรา​เขียนได้ตรงๆ ว่า ไม่​ว่า​จะ post ลำดับ​ไหน ผล​รวม​ทุก​บัญชี​ต้อง​เป็น​ศูนย์​เสมอ:

// generator สุ่มการย้ายเงินหนึ่งครั้ง — ทุกครั้งเป็น balanced transfer อยู่แล้ว
public sealed record Posting(AccountId From, AccountId To, decimal Thb, string Key);
// ✅ property: post ลำดับไหนก็ได้ ผลรวมทุกบัญชีต้องเป็น 0 เสมอ
[Property]
public Property Any_sequence_of_postings_keeps_the_books_balanced(Posting[] postings)
{
var ledger = new List<LedgerEntry>();
foreach (var p in postings)
// Math.Abs กันไม่ให้ Money ติดลบ — จำนวนเท่าไรก็ตาม สองขายังหักล้างกันพอดี
ledger.AddRange(JournalEntry.BalancedTransfer(
debitAccount: p.From, creditAccount: p.To,
amount: Money.Thb(Math.Abs(p.Thb)), idempotencyKey: p.Key,
reason: "property test posting").Entries);
// ไม่ว่า postings จะยาวแค่ไหน เรียงยังไง ต้องจริงเสมอ
return (TrialBalance.SumAcrossAllAccounts(ledger) == 0m).ToProperty();
}

เหตุผล​ที่ property นี้​เป็น​จริง​เสมอ​ไม่ใช่​เรื่อง​บังเอิญ แต่​มา​จาก design: JournalEntry.BalancedTransfer (บท​ที่ 3) การันตี​ว่า​ทุก posting มี​เดบิต​เท่ากับ​เครดิต​ใน​ตัว​มัน​เอง​ตั้งแต่​สร้าง เมื่อ​ทุก​ก้อน​สมดุล ผล​รวม​ของ​ก้อน​กี่​ก้อน​เรียง​ลำดับ​ยังไง​ก็​ยัง​เป็น0 property-based test จึง​ไม่​ได้​แค่ “เพิ่ม​จำนวน​เคส” แต่​มัน​เปลี่ยน​สิ่ง​ที่​เรา​พิสูจน์ — จาก “ตัวอย่าง​นี้​ถูก” เป็น “กฎ​นี้​ถือ​เสมอ” ซึ่ง​เป็น​วิธี​ที่​เหมาะ​กับ code เงิน​ที่สุด เพราะ code เงิน​คือ​ที่​ที่​เคส​สุด​ขอบ​ที่​ไม่มี​ใคร​นึกถึง​ราคา​แพง​ที่สุด (แนวคิด​การ test ระดับ​นี้​ต่อยอด​จาก คอร์ส Testing บท​ที่ 2 — ยูนิต test domain ที่ test Money และ invariant ของ domain ไว้​แล้ว)

ปิด​คอร์ส — ledger ที่​พิสูจน์​ได้​ว่า​เงิน​ทุก​สตางค์​ไป​อยู่​ที่ไหน

หัวข้อ​ที่​มีชื่อ​ว่า “ปิด​คอร์ส — ledger ที่​พิสูจน์​ได้​ว่า​เงิน​ทุก​สตางค์​ไป​อยู่​ที่ไหน”

ย้อน​กลับ​ไป​ตั้งแต่​บท​แรก เรา​เลือก​ไม่​เก็บ​ยอด​เงิน​เป็น column ที่​เขียน​ทับ​ได้ แล้ว​หัน​มา​บันทึก​การ​เคลื่อน​ของ​เงิน​ทุก​ครั้ง​เป็น journal entry สอง​ขา​ที่​สมดุล​บน ledger แบบ append-only การ​ตัดสิน​ใจ​ข้อ​เดียว​นั้น​คือ​สิ่ง​ที่​ทำให้​ทุก​อย่าง​ใน​บท​นี้​เป็น​ไป​ได้ — trial balance พิสูจน์​สมดุล​ภายใน​ได้​ใน​ตัวเลข​เดียว reconciliation จับ drift กับ​โลก​จริง​ได้ audit trail ตอบ​ได้​ทุก​บาท และ property-based test ยืนยัน​ได้​ว่า​กฎ​นี้​ถือ​เสมอ​ไม่​ว่า​เงิน​จะ​เดินทาง​แบบ​ไหน

คำ​สัญญา​จาก​บท​ที่ 1 ที่​ว่า “เงิน​ไม่มี​วัน​หาย​และ​ไม่มี​วัน​โผล่​มา​เอง” จึงไม่ใช่ความหวังลอยๆ อีก​ต่อ​ไป แต่​เป็น​สิ่ง​ที่​โครงสร้าง​ของ​ระบบ​บังคับ​ให้​เป็น​จริง และ​เรา​ตรวจสอบ​ย้อนหลัง​ได้​ทุกเมื่อ นั่น​คือ​สิ่ง​ที่ ledger ที่​ดี​ให้​เรา — ไม่ใช่​แค่​ตัวเลข​ยอด​คง​เหลือ แต่​คือ​หลักฐาน​ที่​พิสูจน์​ได้​ว่า​เงิน​ทุก​สตางค์​ใน​ระบบ​ไป​อยู่​ที่ไหน มา​จาก​ไหน ด้วย​เหตุผล​อะไร ครบ​ทุก​บรรทัด


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

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

  • Value ObjectMoney เป็น value object ที่​เทียบ​ค่า​เท่า​กัน​และ​บวก​กันได้ตรงๆ จึง​เขียน property “ผล​รวม​ทุก​บัญชี = 0” เป็น test ได้​สะอาด แทน​การ​ยัด​ยอด​เงิน​เป็น decimal เปล่าๆ
  • AggregateJournalEntry เป็น aggregate ที่​รักษา invariant “เดบิต​รวม = เครดิต​รวม” ไว้​ใน​ตัว ทำให้​ทุก posting สมดุล​ตั้งแต่​สร้าง — เป็น​เหตุผล​ที่ property-based test ผ่าน​เสมอ

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

ข้อ 1 / 3

trial balance พิสูจน์อะไรเกี่ยวกับ ledger?