ทำไมเงินต้องใช้ double-entry
ตลอดหลายคอร์สที่ผ่านมา — Clean Architecture, DDD-in-Code, Testing, Bounded Contexts และ EF Core — เราปั้น domain Order ของ platform ฟู้ดเดลิเวอรีขึ้นมาจนแข็งแรง — ออเดอร์ถูกวางถูกกฎ ราคาถูกคิดถูกปัดเศษ เก็บลงฐานข้อมูลได้โดยไม่ทำลาย model แต่มีอยู่เรื่องหนึ่งที่เราจงใจเลื่อนไว้เสมอ นั่นคือ เงินจริงๆ ไหลไปไหนบ้าง ลูกค้าจ่าย 200 บาท แล้วเงินก้อนนั้นเดินทางยังไงต่อ — ค้างอยู่ตรงไหนระหว่างอาหารกำลังส่ง ตัดค่าคอมมิชชัน platform ตอนไหน เหลือเท่าไรที่ต้องจ่ายร้านกับไรเดอร์ และถ้าลูกค้ายกเลิก เงินคืนมาจากไหน คอร์สนี้คือการวางระบบที่ตอบทุกคำถามนั้นได้ทุกบาททุกสตางค์
เส้นแบ่งสำคัญก่อนเริ่ม: คอร์ส Bounded Contexts บทที่ 5 เรื่อง Anti-Corruption Layer พูดถึง payment gateway ภายนอก — ของสำเร็จรูปอย่าง AcmePay หรือ PromptPay ที่เราห่อด้วย ACL ไว้ที่ขอบ หน้าที่ของมันมีแค่บอกว่า “เงินเข้ามาแล้ว” หรือ “รูดไม่ผ่าน” คอร์สนี้เป็นคนละเรื่อง — เราสร้าง money context ภายใน ของเราเอง: เมื่อ gateway บอกว่าเงินเข้าแล้ว เงินก้อนนั้นถูกบันทึกว่าอยู่บัญชีไหน เคลื่อนไปบัญชีไหนต่อ ภายในบ้านเรา gateway คือประตูหน้า ระบบที่คอร์สนี้สร้างคือบัญชีแยกประเภทที่คอยจดว่าเงินทุกบาทในบ้านอยู่ตรงไหน
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เป็นบทปูแนวคิด ยังไม่มี code project ให้ตาม มีแค่ value object Money ที่ยกมาจากไตรภาคเดิม บทที่ 2 จะเริ่มโครง project จริง
เงินไม่มีวันหาย — คำสัญญาที่ต้องบังคับให้เป็นจริง
หัวข้อที่มีชื่อว่า “เงินไม่มีวันหาย — คำสัญญาที่ต้องบังคับให้เป็นจริง”กฎข้อเดียวที่ระบบการเงินทุกระบบต้องรักษาไว้ให้ได้คือ เงินไม่มีวันหายและไม่มีวันโผล่มาเอง ทุกบาทที่ขยับต้องมีต้นทางและปลายทางเสมอ ถ้าลูกค้าจ่าย 200 บาทแล้วเงินก้อนนั้นหายไประหว่างทาง หรือยอดในบัญชีร้านเพิ่มขึ้นเองโดยไม่มีใครจ่ายให้ นั่นไม่ใช่ bug เล็กๆ แต่คือระบบที่เชื่อถือไม่ได้ตั้งแต่ราก คำสัญญานี้ฟังดูเป็นสามัญสำนึก แต่การจะบังคับให้มันเป็นจริงใน code นั้นขึ้นกับการตัดสินใจเชิง model ข้อแรกสุด — เราเก็บ “ยอดเงิน” กันยังไง
ทำไมยอดคงเหลือไม่ควรเป็น column ที่เก็บไว้
หัวข้อที่มีชื่อว่า “ทำไมยอดคงเหลือไม่ควรเป็น column ที่เก็บไว้”สัญชาตญาณแรกของโปรแกรมเมอร์เกือบทุกคนคือ: กระเป๋าเงินของลูกค้าก็เก็บยอดไว้เป็นตัวเลข1 column สิ จ่ายเงินก็ลบ เติมเงินก็บวก ง่ายดี
// ❌ version ดิบ — เงินทั้งกระเป๋าอยู่ในตัวเลขตัวเดียวpublic class Wallet{ public Guid CustomerId { get; set; } public decimal Balance { get; set; } // ← ยอดคงเหลือเป็น column ที่เขียนทับได้}
// ตอนลูกค้าจ่ายค่าอาหาร 200 บาท:wallet.Balance -= 200; // อ่านมา ลบ แล้วเขียนทับค่าเดิมcode นี้ compile ผ่านและทำงานได้ในเดโม แต่มันพังในสองแกนที่แก้ทีหลังไม่ได้:
- เขียนทับกันเอง (lost update) —
wallet.Balance -= 200คือลำดับ อ่าน → ลบ → เขียนทับ ถ้าลูกค้ากดจ่ายสองออเดอร์พร้อมกัน 2 request อ่านยอด 500 มาเท่ากัน ต่างคนต่างลบ 200 แล้วเขียน 300 ทับกัน — ความจริงต้องเหลือ 100 แต่ระบบเหลือ 300 เงิน 200 บาทโผล่มาจากอากาศ นี่คือปัญหา concurrency แบบเดียวกับที่คอร์ส EF Core เตือนไว้ตอนเก็บ aggregate ลงฐานข้อมูล เพียงแต่กับเงินมันแพงกว่ามาก - ไม่มีประวัติให้ตรวจ (no audit trail) — พอเขียนทับปุ๊บ ยอดเก่าหายเกลี้ยง เหลือแค่ตัวเลขล่าสุด วันที่ลูกค้าโวยว่า “ทำไมเงินในกระเป๋าหายไป 200” เราตอบไม่ได้เลยว่าหายตอนไหน จากธุรกรรมไหน ใครทำ ตัวเลข column เดียวไม่มีความทรงจำ
ทั้งสองปัญหามีรากเดียวกัน: เราปฏิบัติกับยอดเงินเหมือนมันเป็น สถานะปัจจุบันที่เขียนทับได้ ทั้งที่จริงมันควรเป็น ผลลัพธ์ของประวัติการเคลื่อนไหวทั้งหมด ทางออกไม่ใช่การล็อกให้แน่นขึ้น แต่คือการเปลี่ยนสิ่งที่เราเก็บ — เลิกเก็บยอด แล้วหันมาเก็บ “การเคลื่อนของเงินทุกครั้ง” แทน
Double-entry bookkeeping — เครื่องมือ model การเคลื่อนของเงิน
หัวข้อที่มีชื่อว่า “Double-entry bookkeeping — เครื่องมือ model การเคลื่อนของเงิน”วิธีที่นักบัญชีใช้แก้ปัญหานี้มาห้าร้อยปีคือ Double-entry BookkeepingDouble-entry Bookkeepingวิธีบันทึกธุรกรรมการเงินที่ทุกรายการต้องมีสองด้านเสมอ — เดบิตด้านหนึ่ง เครดิตอีกด้านหนึ่ง เท่ากันทุกครั้ง เช่น ลูกค้าจ่ายค่าอาหาร 200 บาท เดบิตบัญชีเงินสด เครดิตบัญชีเจ้าหนี้ร้านค้า 200 บาทเท่ากัน — ทำให้เงินไม่มีวันหายเพราะทุกบาทมีที่มาที่ไปเสมอArchitecture — และสำหรับเรามันไม่ใช่เรื่องบัญชี แต่เป็น เครื่องมือ model ที่บังคับกฎ “เงินไม่มีวันหาย” ให้เป็นจริงโดยอัตโนมัติ หลักการมีข้อเดียว: การเคลื่อนของเงินทุกครั้งมีสองขาเสมอ เท่ากันและตรงข้ามกัน เงินไม่เคยแค่ “หายไป” จากที่หนึ่ง มันต้อง “ไปโผล่” อีกที่หนึ่งด้วยจำนวนเท่ากันเป๊ะ
ลองดูตอนลูกค้าเติมเงินเข้า wallet 200 บาทผ่าน PromptPay แทนที่จะเขียน balance += 200 ที่เดียว เราบันทึกเป็น Journal EntryJournal Entryหน่วยที่เล็กที่สุดของการบันทึกใน ledger หนึ่งรายการมีอย่างน้อยสองบรรทัด (เดบิตกับเครดิต) ที่ยอดรวมเท่ากัน เช่น ตอนลูกค้าจ่ายค่าอาหาร เกิด journal entry เดียวที่กระทบทั้งบัญชีเงินสดและบัญชี escrow — เขียนแล้วห้ามแก้ ถ้าผิดต้องเขียน Reversing Entry มาหักล้างแทนTactical Design หนึ่งรายการที่มีสองขา:
flowchart TB E["ธุรกรรมเดียว: ลูกค้าเติมเงิน 200 บาท"] E --> L1["ขาที่ 1 — เดบิต<br/>บัญชีเงินสด platform +200"] E --> L2["ขาที่ 2 — เครดิต<br/>บัญชี wallet ลูกค้า +200"] L1 --- BAL["เดบิตรวม 200 = เครดิตรวม 200 — สมดุล"] L2 --- BAL 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; class E ev; class L1 dr; class L2 cr; class BAL bal;
คำบรรยายภาพ: ธุรกรรมเติมเงินหนึ่งครั้ง (น้ำเงิน) แตกออกเป็นสองขาเสมอ ขาซ้าย (เขียว) เดบิตบัญชีเงินสด platform 200 บาท — เงินสดในมือ platform เพิ่มขึ้น ขาขวา (แดง) เครดิตบัญชี wallet ลูกค้า 200 บาท — platform เป็นหนี้ลูกค้า (ต้องคืนเงินในกระเป๋าได้ทุกเมื่อ) เพิ่มขึ้นเท่ากัน จุดสีส้มคือกฎเหล็ก: ผลรวมเดบิตของรายการต้องเท่ากับผลรวมเครดิตพอดี ถ้าไม่เท่า แปลว่ารายการนั้นผิด บันทึกลงระบบไม่ได้ตั้งแต่แรก — เงินจึงไม่มีทางหายหรือโผล่มาเอง
เพราะทุกรายการต้องสมดุลก่อนถึงจะบันทึกได้ กฎ “เงินไม่มีวันหาย” จึงไม่ใช่สิ่งที่เราต้องคอยไล่ตรวจทีหลัง แต่เป็นสิ่งที่โครงสร้างข้อมูลบังคับให้เป็นจริงตั้งแต่ตอนเขียน นี่คือเหตุผลที่ double-entry เป็นเครื่องมือ model ไม่ใช่แค่วิธีเก็บข้อมูล — มันเข้ารหัสกฎธุรกิจที่สำคัญที่สุดของ domain ไว้ในรูปทรงของข้อมูลเอง
เดบิตกับเครดิต แปลให้โปรแกรมเมอร์ฟัง
หัวข้อที่มีชื่อว่า “เดบิตกับเครดิต แปลให้โปรแกรมเมอร์ฟัง”คำว่า Debit and CreditDebit and Creditสองด้านของทุก Journal Entry ในระบบ double-entry — เดบิตกับเครดิตไม่ได้แปลว่า 'บวก' หรือ 'ลบ' ตรง ๆ แต่ขึ้นกับประเภทบัญชี สิ่งที่ต้องจริงเสมอคือผลรวมเดบิตของรายการหนึ่งต้องเท่ากับผลรวมเครดิตพอดีTactical Design คือจุดที่โปรแกรมเมอร์ส่วนใหญ่เริ่มถอดใจ เพราะมันชนกับสัญชาตญาณจากสมุดบัญชีธนาคาร — ใน app ธนาคาร “เครดิต” คือเงินเข้า “เดบิต” คือเงินออก แต่ในระบบ double-entry ของเราไม่ใช่แบบนั้น ลืมความหมายจากบัญชีธนาคารไปได้เลย
ให้คิดง่ายๆ ว่าทุก journal entry มี2 column: column ซ้ายชื่อ เดบิต column ขวาชื่อ เครดิต การบันทึกรายการก็แค่หยอดจำนวนเงินลง column ให้ถูกข้าง โดยมีกฎเหล็กว่าผลรวม2 column ต้องเท่ากันเสมอ เดบิตกับเครดิตจึงไม่ได้แปลว่า “บวก” หรือ “ลบ” ตรงๆ — มันแปลว่า “ลงข้างไหนของบัญชี” เท่านั้น ส่วนที่ว่าลงข้างนั้นแล้วยอดบัญชี เพิ่ม หรือ ลด ขึ้นอยู่กับประเภทของบัญชี:
- บัญชีฝั่ง สินทรัพย์ (เช่น เงินสด platform) — เดบิตทำให้ยอดเพิ่ม เครดิตทำให้ยอดลด
- บัญชีฝั่ง หนี้สิน (เช่น wallet ลูกค้า, เจ้าหนี้ร้านค้า, เจ้าหนี้ไรเดอร์ — เงินที่ platform “ติดค้าง” คนอื่น) — เครดิตทำให้ยอดเพิ่ม เดบิตทำให้ยอดลด
ฉะนั้นตอนเติมเงิน 200 บาท ที่เราเดบิตเงินสด (สินทรัพย์เพิ่ม) และเครดิต wallet ลูกค้า (หนี้สินเพิ่ม) — ทั้งสองยอดเพิ่มขึ้นพร้อมกัน แต่ลงคนละ column จึงยังสมดุล สิ่งเดียวที่ต้องจริงเสมอไม่ว่าบัญชีประเภทไหนคือ เดบิตรวม = เครดิตรวม ในทุกรายการ เท่านี้พอสำหรับบทนี้ ส่วนกลไกจริงว่าบัญชีแต่ละประเภทคำนวณยอดยังไง เก็บไว้บทที่ 2
Ledger — แหล่งความจริงเดียว
หัวข้อที่มีชื่อว่า “Ledger — แหล่งความจริงเดียว”พอเราเลิกเก็บยอดเป็น column แล้วหันมาบันทึกทุกการเคลื่อนไหวเป็น journal entry ที่สมดุล สิ่งที่ได้คือ LedgerLedgerแหล่งความจริงเดียวที่รักษาประวัติ Journal Entry ทุกรายการของระบบไว้เรียงตามเวลา แบบ append-only ไม่มีการแก้ของเก่า ยอดเงินของบัญชีไหนก็ตามคำนวณได้จากการไล่ผลรวมรายการใน ledger เท่านั้น ไม่ใช่ตัวเลขที่เก็บแยกไว้ต่างหากTactical Design — สมุดบัญชีแยกประเภทที่ต่อรายการเรียงตามเวลาแบบ Append-only LogAppend-only Logโครงสร้างการเก็บข้อมูลที่ 'เขียนเพิ่มได้อย่างเดียว' ห้าม UPDATE หรือ DELETE รายการเก่า — ledger ของระบบการเงินต้องเป็นแบบนี้เสมอ เพราะประวัติเงินต้องตรวจสอบย้อนหลังได้ทุกบรรทัด ต่างจากตารางทั่วไปที่แก้ทับค่าเดิมได้Architecture คือเขียนเพิ่มได้อย่างเดียว ห้ามแก้ ห้ามลบของเก่า และ ledger นี้คือ แหล่งความจริงเดียว ของทั้งระบบ
คำถามที่ตามมาทันทีคือ “แล้วยอดเงินคงเหลือของกระเป๋าลูกค้าล่ะ ดูจากไหน” คำตอบพลิกจากเดิมสิ้นเชิง: ยอดคงเหลือไม่ใช่สิ่งที่เก็บไว้ แต่เป็นสิ่งที่คำนวณออกมาจากการไล่บวก-ลบทุก journal entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน — แนวคิดนี้เรียกว่า Balance as a FoldBalance as a Foldแนวคิดที่ว่ายอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ตรง ๆ แต่เป็นผลลัพธ์จากการ 'พับ' (fold) ไล่บวก-ลบทุก Journal Entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน คล้ายการ replay event ใน Event Sourcing — บัญชีจึงตรวจสอบย้อนกลับได้เสมอว่ายอดเงินมาจากไหนบ้างArchitecture ยอดเงินคือ ผลลัพธ์ ของประวัติ ไม่ใช่ ตัวตั้งต้น ที่ถูกเขียนทับ วิธีนี้แก้ทั้งสองปัญหาข้างบนพร้อมกัน: ไม่มี column ให้เขียนทับกันเอง เพราะเราแค่ต่อรายการใหม่ต่อท้าย และประวัติทุกบาทอยู่ครบเพราะไม่มีอะไรถูกลบ
บัญชี (account) หลักๆ ในระบบฟู้ดเดลิเวอรีของเรามีดังนี้ — ทั้งคอร์สจะวนอยู่กับตัวละครกลุ่มนี้:
- เงินสด platform — เงินจริงที่ platform ถือ (สินทรัพย์)
- wallet ลูกค้า — เงินในกระเป๋าที่ลูกค้าฝากไว้ (platform เป็นหนี้ลูกค้า)
- เจ้าหนี้ร้านอาหาร / เจ้าหนี้ไรเดอร์ — เงินที่ platform ติดค้างต้องจ่ายให้ร้านและไรเดอร์
- รายได้ platform — ค่าคอมมิชชันที่ platform หักเก็บเป็นของตัวเอง
บทต่อๆ ไปจะเพิ่มบัญชีพักเงินระหว่างส่งอาหาร (escrow) และบัญชีพักตอนรับเงินเข้า (clearing) เข้ามา แล้วเดินเรื่องครบวงจร: เติมเงิน → จ่ายค่าอาหาร → พักเงินระหว่างส่ง → ตัดคอมมิชชันแล้วปล่อยให้ร้าน/ไรเดอร์ → คืนเงินเมื่อยกเลิก → เทียบยอดปิดรอบ
Money — value object ที่ยกมาจากไตรภาคเดิม
หัวข้อที่มีชื่อว่า “Money — value object ที่ยกมาจากไตรภาคเดิม”ก่อนจบบท ทวนของชิ้นเดียวที่เราจะใช้ตลอดคอร์ส เงินทุกจำนวนในระบบนี้ไม่ใช่ decimal เปล่าๆ แต่เป็น value object MoneyMoneyValue Object ที่แทนจำนวนเงินพร้อมสกุลเงิน (เช่น จำนวน + THB) เปรียบเทียบกันด้วยค่า ไม่ใช่ identity, บวก/ลบกันได้แต่ต้องเป็นสกุลเดียวกัน, immutable — ห้ามใช้ decimal เปล่า ๆ แทนเงินเพราะจะทำให้หน่วยเงินและกฎการปัดเศษหล่นหายไป (Primitive Obsession)Tactical Design ตัวเดิมจากไตรภาค DDD-in-Code — ห่อจำนวนกับสกุลเงินไว้ด้วยกัน และรักษากฎของตัวเองผ่าน primary constructor body:
// FoodOrdering.Domain/Orders/Money.cs — ยกมาจากไตรภาคเดิมทั้งดุ้นpublic sealed record Money(decimal Amount, string Currency){ public Money { if (Amount < 0) throw new ArgumentException("จำนวนเงินต้องไม่ติดลบ", nameof(Amount)); if (Currency != "THB") throw new ArgumentException("ตอนนี้รองรับเฉพาะสกุลเงิน THB", nameof(Currency)); }
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b) { if (a.Currency != b.Currency) throw new InvalidOperationException("บวกเงินต่างสกุลกันไม่ได้"); return new Money(a.Amount + b.Amount, a.Currency); }
public static Money operator *(Money unitPrice, int quantity) => new(unitPrice.Amount * quantity, unitPrice.Currency);}Money.Thb(200) คือจำนวนเงินที่ทั้งขาเดบิตและขาเครดิตของ journal entry จะอ้างถึง การที่มันเป็น value object ที่บวกกันได้และเทียบค่าเท่ากันได้ตรงๆ ทำให้กฎ “เดบิตรวม = เครดิตรวม” เขียนเป็น code ได้สะอาดตั้งแต่บทหน้า — ไม่ต้องกังวลเรื่องหน่วยเงินหรือการปัดเศษหล่นหายกลางทาง
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”บทนี้วางแค่แนวคิดตั้งต้น — ทำไมเงินต้องบันทึกเป็นสองขาที่สมดุล และทำไม ledger คือแหล่งความจริงเดียว จากนี้เราจะลงมือสร้างจริงทีละชั้น:
- model ledger, account และ journal entry (บทที่ 2) — ปั้น aggregate ของ ledger ให้บังคับกฎสมดุลในตัวเอง
- เติมเงินเข้า wallet ด้วย PromptPay (บทที่ 3) — จาก gateway ภายนอกสู่รายการใน ledger พร้อม idempotency key กันหักเงินซ้ำ
- จ่ายค่าอาหารและพักเงินใน escrow (บทที่ 4) — กันเงินไว้ระหว่างอาหารกำลังส่ง
- settlement — ตัดคอมมิชชันแล้วปล่อยเงิน (บทที่ 5) — กระจายเงินจาก escrow ไปร้านและไรเดอร์
- payout ออกนอกระบบ (บทที่ 6) — รวมรอบจ่ายเงินให้ร้าน/ไรเดอร์จริง
- refund และ reversing entry (บทที่ 7) — คืนเงินโดยไม่ลบประวัติเดิม
- reconciliation — เทียบยอดปิดรอบ (บทที่ 8) — พิสูจน์ว่าเงินไม่มีวันหายจริงๆ ทุกบาท
ทุกบทกลับมาที่ ledger เดียวกันเสมอ — ไม่มี column ยอดเงินที่เขียนทับได้ มีแต่รายการที่ต่อท้ายไปเรื่อยๆ และตรวจย้อนหลังได้ทุกบรรทัด
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Value Object — ทำไม
Moneyต้องเป็น value object ที่ห่อจำนวนกับสกุลเงินไว้ด้วยกัน แทนที่จะเป็นdecimalเปล่าๆ - Primitive Obsession — กลิ่น code ที่เกิดเมื่อเราใช้ type พื้นฐานแทนแนวคิดใน domain เช่น เก็บยอดเงินเป็น
decimalแล้วปล่อยให้หน่วยเงินและกฎการปัดเศษหล่นหาย
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3ทำไมยอดเงินคงเหลือของ wallet ลูกค้าไม่ควรเก็บเป็น column ที่เขียนทับได้?