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 เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — สนิปเป็ตในบทนี้ต่อยอดจาก model ledger ที่บทที่ 2–3 วางไว้ value object Money ยกมาจากคอร์ส DDD-in-Code ทั้งดุ้น ส่วน gateway/PromptPay ในบทนี้เป็นแค่ภาพประกอบผ่านรูปทรง ACL เดิม ไม่ใช่การต่อระบบชำระเงินจริง
Trial balance — ผลรวมยอดทุกบัญชีต้องเป็นศูนย์
หัวข้อที่มีชื่อว่า “Trial balance — ผลรวมยอดทุกบัญชีต้องเป็นศูนย์”เริ่มจากด่านในบ้านก่อน กฎเหล็กของ 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 ต้องหยุดแล้วตามหารายการที่หลุดก่อน
Reconciliation — เทียบ ledger กับสิ่งที่ gateway บอกมา
หัวข้อที่มีชื่อว่า “Reconciliation — เทียบ ledger กับสิ่งที่ gateway บอกมา”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 เมื่อไหร่ กฎคือ หยุดปิดงบก่อน แล้วตามหาว่ารายการไหนหาย — ไม่ใช่แก้ตัวเลขให้ตรงเฉยๆ
Audit trail — ประวัติที่ตอบได้ทุกบาทจาก append-only log
หัวข้อที่มีชื่อว่า “Audit trail — ประวัติที่ตอบได้ทุกบาทจาก append-only log”พอเจอ 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
test code เงินแบบ property-based
หัวข้อที่มีชื่อว่า “test code เงินแบบ property-based”เหลือคำถามสุดท้าย: เรามั่นใจได้แค่ไหนว่า trial balance จะเป็นศูนย์เสมอ ไม่ว่าเงินจะเดินทางแบบไหน วิธี test ที่คนเผลอเขียนกันคือหยิบสองสามเคสที่นึกออกมาเช็ก — แต่ bug เรื่องเงินมักซ่อนอยู่ในลำดับที่ไม่มีใครนั่งเขียนด้วยมือ:
[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:
- Value Object —
Moneyเป็น value object ที่เทียบค่าเท่ากันและบวกกันได้ตรงๆ จึงเขียน property “ผลรวมทุกบัญชี = 0” เป็น test ได้สะอาด แทนการยัดยอดเงินเป็นdecimalเปล่าๆ - Aggregate —
JournalEntryเป็น aggregate ที่รักษา invariant “เดบิตรวม = เครดิตรวม” ไว้ในตัว ทำให้ทุก posting สมดุลตั้งแต่สร้าง — เป็นเหตุผลที่ property-based test ผ่านเสมอ
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3trial balance พิสูจน์อะไรเกี่ยวกับ ledger?