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

ทำไม​เงิน​ต้อง​ใช้ 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 ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เป็น​บท​ปู​แนวคิด ยัง​ไม่มี code project ให้​ตาม มี​แค่ value object Money ที่​ยก​มา​จาก​ไตร​ภาค​เดิม บท​ที่ 2 จะ​เริ่ม​โครง project จริง

เงิน​ไม่มี​วัน​หาย — คำ​สัญญา​ที่​ต้อง​บังคับ​ให้​เป็น​จริง

หัวข้อ​ที่​มีชื่อ​ว่า “เงิน​ไม่มี​วัน​หาย — คำ​สัญญา​ที่​ต้อง​บังคับ​ให้​เป็น​จริง”

กฎ​ข้อ​เดียว​ที่​ระบบ​การเงิน​ทุก​ระบบ​ต้อง​รักษา​ไว้​ให้​ได้​คือ เงิน​ไม่มี​วัน​หาย​และ​ไม่มี​วัน​โผล่​มา​เอง ทุก​บาท​ที่​ขยับ​ต้อง​มี​ต้นทาง​และ​ปลายทาง​เสมอ ถ้า​ลูกค้า​จ่าย 200 บาท​แล้ว​เงิน​ก้อน​นั้น​หาย​ไป​ระหว่าง​ทาง หรือ​ยอด​ใน​บัญชี​ร้าน​เพิ่ม​ขึ้น​เอง​โดย​ไม่มี​ใคร​จ่าย​ให้ นั่น​ไม่ใช่ bug เล็กๆ แต่​คือ​ระบบ​ที่​เชื่อถือ​ไม่​ได้​ตั้งแต่​ราก คำ​สัญญา​นี้​ฟัง​ดู​เป็น​สามัญสำนึก แต่​การ​จะ​บังคับ​ให้​มัน​เป็น​จริง​ใน code นั้น​ขึ้น​กับ​การ​ตัดสิน​ใจ​เชิง model ข้อ​แรก​สุด — เรา​เก็บ “ยอด​เงิน” กัน​ยังไง

สัญชาตญาณ​แรก​ของ​โปรแกรมเมอร์​เกือบ​ทุก​คน​คือ: กระเป๋า​เงิน​ของ​ลูกค้า​ก็​เก็บ​ยอด​ไว้​เป็น​ตัวเลข1 column สิ จ่าย​เงิน​ก็​ลบ เติม​เงิน​ก็​บวก ง่าย​ดี

❌ version ดิบ — เก็บ​ยอด​เป็น 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 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

พอ​เรา​เลิก​เก็บ​ยอด​เป็น 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 ให้​เขียน​ทับ​กันเอง เพราะ​เรา​แค่​ต่อ​รายการ​ใหม่​ต่อ​ท้าย และ​ประวัติ​ทุก​บาท​อยู่​ครบ​เพราะ​ไม่มี​อะไร​ถูกลบ

🍜 จักรวาล wallet ที่​คอร์ส​นี้​จะ​สร้าง

บัญชี (account) หลักๆ ในระบบฟู้ด​เดลิ​เวอรี​ของ​เรา​มี​ดังนี้ — ทั้ง​คอร์ส​จะ​วน​อยู่​กับ​ตัว​ละคร​กลุ่ม​นี้:

  • เงินสด platform — เงิน​จริง​ที่ platform ถือ (สินทรัพย์)
  • wallet ลูกค้า — เงิน​ใน​กระเป๋า​ที่​ลูกค้า​ฝาก​ไว้ (platform เป็น​หนี้​ลูกค้า)
  • เจ้าหนี้​ร้าน​อาหาร / เจ้าหนี้​ไร​เด​อร์ — เงิน​ที่ platform ติด​ค้าง​ต้อง​จ่าย​ให้​ร้าน​และ​ไร​เด​อร์
  • รายได้ platform — ค่า​คอม​มิช​ชัน​ที่ platform หัก​เก็บ​เป็น​ของ​ตัวเอง

บท​ต่อๆ ไป​จะ​เพิ่ม​บัญชี​พัก​เงิน​ระหว่าง​ส่ง​อาหาร (escrow) และ​บัญชี​พัก​ตอน​รับ​เงิน​เข้า (clearing) เข้า​มา แล้ว​เดิน​เรื่อง​ครบ​วงจร: เติม​เงิน → จ่าย​ค่า​อาหาร → พัก​เงิน​ระหว่าง​ส่ง → ตัด​คอม​มิช​ชัน​แล้ว​ปล่อย​ให้​ร้าน/ไร​เด​อร์ → คืน​เงิน​เมื่อ​ยกเลิก → เทียบ​ยอด​ปิด​รอบ

ก่อน​จบ​บท ทวน​ของ​ชิ้น​เดียว​ที่​เรา​จะ​ใช้​ตลอด​คอร์ส เงิน​ทุก​จำนวน​ใน​ระบบ​นี้​ไม่ใช่ 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 คือ​แหล่ง​ความ​จริง​เดียว จาก​นี้​เรา​จะ​ลงมือ​สร้าง​จริง​ที​ละ​ชั้น:

  1. model ledger, account และ journal entry (บท​ที่ 2) — ปั้น aggregate ของ ledger ให้​บังคับ​กฎ​สมดุล​ใน​ตัวเอง
  2. เติม​เงิน​เข้า wallet ด้วย PromptPay (บท​ที่ 3) — จาก gateway ภายนอก​สู่​รายการ​ใน ledger พร้อม idempotency key กัน​หัก​เงิน​ซ้ำ
  3. จ่าย​ค่า​อาหาร​และ​พัก​เงิน​ใน escrow (บท​ที่ 4) — กัน​เงิน​ไว้​ระหว่าง​อาหาร​กำลัง​ส่ง
  4. settlement — ตัด​คอม​มิช​ชัน​แล้ว​ปล่อย​เงิน (บท​ที่ 5) — กระจาย​เงิน​จาก escrow ไป​ร้าน​และ​ไร​เด​อร์
  5. payout ออก​นอก​ระบบ (บท​ที่ 6) — รวม​รอบ​จ่าย​เงิน​ให้​ร้าน/ไร​เด​อร์จริง
  6. refund และ reversing entry (บท​ที่ 7) — คืน​เงิน​โดย​ไม่​ลบ​ประวัติ​เดิม
  7. reconciliation — เทียบ​ยอด​ปิด​รอบ (บท​ที่ 8) — พิสูจน์​ว่า​เงิน​ไม่มี​วัน​หายจริงๆ ทุก​บาท

ทุก​บท​กลับ​มา​ที่ ledger เดียวกัน​เสมอ — ไม่มี column ยอด​เงิน​ที่​เขียน​ทับ​ได้ มี​แต่​รายการ​ที่​ต่อ​ท้าย​ไป​เรื่อยๆ และ​ตรวจ​ย้อนหลัง​ได้​ทุก​บรรทัด


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

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

  • Value Object — ทำไม Money ต้อง​เป็น value object ที่​ห่อ​จำนวน​กับ​สกุล​เงิน​ไว้​ด้วย​กัน แทนที่​จะ​เป็น decimal เปล่าๆ
  • Primitive Obsession — กลิ่น code ที่​เกิด​เมื่อ​เรา​ใช้ type พื้นฐาน​แทน​แนวคิด​ใน domain เช่น เก็บ​ยอด​เงิน​เป็น decimal แล้ว​ปล่อย​ให้​หน่วย​เงิน​และ​กฎ​การ​ปัดเศษ​หล่น​หาย

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

ข้อ 1 / 3

ทำไมยอดเงินคงเหลือของ wallet ลูกค้าไม่ควรเก็บเป็น column ที่เขียนทับได้?