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

จ่าย​ค่า​อาหาร (escrow)

บท​ที่​แล้ว​เรา​เติม​เงิน​เข้า wallet ของ​ลูกค้า​ผ่าน PromptPay จน​มี​ยอด​พร้อม​ใช้ ตอน​นี้​ลูกค้า​กด​สั่ง​อาหาร​จริง domain Order ที่​เรา​ปั้น​มา​ตลอด​ไตร​ภาค​ก็​ทำงาน​ของ​มัน — ตรวจ​ของ​ใน​ตะกร้า คิด​ราคา แล้ว​ยืนยัน​ออเดอร์ พอ​ออเดอร์​ถูก​ยืนยัน Ordering context ก็​ประกาศ​ออก​มา​ว่า “ออเดอร์​นี้​ยืนยัน​แล้ว” คำถาม​ของ​บท​นี้​คือ ฝั่ง​เงิน​ต้อง​ขยับ​ยังไง​ต่อ — เงิน​ค่า​อาหาร​ใน​กระเป๋า​ลูกค้า​ควร​ไป​อยู่​ที่ไหน​ระหว่าง​ที่​ร้าน​กำลัง​ทำ​อาหาร​และ​ไร​เด​อร์กำลัง​วิ่ง​ไป​ส่ง คำ​ตอบ​คือ​ยัง​ไม่​จ่าย​ใคร​ทั้งนั้น แต่​กัน​เงิน​ไว้​ใน​บัญชี​พัก​ที่​เรียก​ว่า escrow ก่อน

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

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — สแนป​เพ็ต​ใน​บท​นี้​คือ​แกน​ของ handler ฝั่ง money context ที่​ฟัง OrderConfirmed แล้วกัน​เงิน​เข้า escrow ตัว value object Money ยก​มา​จาก​ไตร​ภาค​เดิม ส่วน​รูปทรง Account / JournalEntry / LedgerEntry ปั้น​ไว้​แล้ว​ใน​บท​ที่ 2

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

มี​จุด​ต้อง​ระวัง​หนึ่ง​ข้อ​ที่​คอร์ส Bounded Contexts บท​ที่ 6 เรื่อง outbox เตือน​ไว้: event ข้าม context ส่ง​แบบ at-least-once — มัน​อาจ​ถูก​ส่ง​ซ้ำ​ได้​เมื่อ broker retry ฉะนั้น​ตัว​รับ​ต้อง​เป็น Idempotent ConsumerIdempotent Consumerตัว​รับ event หรือ message ที่​ประมวล​ผล​ข้อความ​เดิม​ซ้ำ​กี่​ครั้ง​ก็ได้​ผลลัพธ์​เหมือน​เดิม ไม่​สร้าง​ผล​ข้าง​เคียง​ซ้ำ เช่น รับ PaymentCaptured ซ้ำ​สอง​ครั้ง​จาก message broker ต้อง​ไม่​บันทึก journal entry สอง​รอบ — ตรวจ​ด้วย Idempotency Key หรือ​เช็ค​ว่า​เคย​ประมวล​ผล​รายการ​นี้​ไป​แล้ว​หรือ​ยังArchitecture คือ​รับ OrderConfirmed ตัว​เดิม​ซ้ำ​กี่​ครั้ง​ก็​ต้อง​กัน​เงิน​เข้า escrow แค่​ครั้ง​เดียว เรา​ไม่​ต้อง​สอน​เรื่อง messaging ซ้ำ​ใน​บท​นี้ — แค่​ยืม​กลไก​เดิม​มา​ใช้: แนบ Idempotency KeyIdempotency Keyรหัส​เฉพาะ​ที่​แนบ​ไป​กับ​คำขอ​ทำ​ธุรกรรม (เช่น เรียก​เก็บ​เงิน​ผ่าน PromptPay) เพื่อ​ให้​ระบบ​รู้​ว่า​คำขอ​ที่​ถูก​ส่ง​ซ้ำ (retry) คือ​คำขอ​เดิม ไม่ใช่​ธุรกรรม​ใหม่ — กัน​ไม่​ให้​ลูกค้า​โดน​หัก​เงิน​ซ้ำ​เวลา​เครือข่าย​มี​ปัญหา​แล้ว client ยิง​คำขอ​เดิม​ซ้ำArchitecture ที่​ผูก​กับ​ออเดอร์​ไป​กับ​ทุก​การ post ลง ledger แล้ว dedupe ด้วย key นั้น

ก่อน​ลงมือ​กัน​เงิน ขอ​ทวน​เครื่องมือ​ที่​บท​ที่ 2 ปั้น​ไว้​ให้​แล้ว เพราะ​ทั้ง​บท​นี้​จะ​วน​อยู่​กับ​มัน ทุก​การ​เคลื่อน​ของ​เงิน​บันทึก​เป็น Journal EntryJournal Entryหน่วย​ที่​เล็ก​ที่สุด​ของ​การ​บันทึก​ใน ledger หนึ่ง​รายการ​มี​อย่าง​น้อย​สอง​บรรทัด (เดบิต​กับ​เครดิต) ที่​ยอด​รวม​เท่า​กัน เช่น ตอน​ลูกค้า​จ่าย​ค่า​อาหาร เกิด journal entry เดียว​ที่​กระทบ​ทั้ง​บัญชี​เงินสด​และ​บัญชี escrow — เขียน​แล้ว​ห้าม​แก้ ถ้า​ผิด​ต้อง​เขียน Reversing Entry มา​หักล้าง​แทนTactical Design หนึ่ง​รายการ ซึ่ง​ประกอบ​ด้วย​หลาย LedgerEntry (บรรทัด​เดบิต/เครดิต) ที่​อ้าง​ถึง AccountAccountหน่วย​ที่​ใช้​แยกแยะ​ว่า 'เงิน​อยู่​ตรง​ไหน' ใน​ระบบ เช่น บัญชี​เงินสด platform, บัญชี​ลูกหนี้​ร้าน​อาหาร, บัญชี wallet ของ​ลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้อง​ระบุ​ว่า​เดบิต/เครดิต​บัญชี​ไหนTactical Design ปลายทาง และ​ยอด​คง​เหลือ​ของ​ทุก​บัญชี​คือ Balance as a FoldBalance as a Foldแนวคิด​ที่​ว่ายอด​คง​เหลือ​ของ​บัญชี​ไม่ใช่​ตัวเลข​ที่​เก็บ​ไว้​ตรง ๆ แต่​เป็น​ผลลัพธ์​จาก​การ 'พับ' (fold) ไล่​บวก-ลบ​ทุก Journal Entry ของ​บัญชี​นั้น​ตั้งแต่​ต้น​จนถึง​ปัจจุบัน คล้าย​การ replay event ใน Event Sourcing — บัญชี​จึง​ตรวจสอบ​ย้อน​กลับ​ได้​เสมอ​ว่ายอด​เงิน​มา​จาก​ไหน​บ้างArchitecture — ไล่​พับ​ผล​รวม​ทุก​บรรทัด​ของ​บัญชี​นั้น ไม่ใช่ column ที่​เก็บไว้ตรงๆ

// บทที่ 2 ปั้นรูปทรงหลักของ ledger ไว้แล้ว — ทวนย่อก่อนใช้
public readonly record struct AccountId(Guid Value);
public readonly record struct JournalEntryId(Guid Value);
public enum AccountType
{
CustomerWallet, // เงินในกระเป๋าลูกค้า — platform เป็นหนี้ลูกค้า
RestaurantPayable, // เจ้าหนี้ร้านอาหาร
RiderPayable, // เจ้าหนี้ไรเดอร์
PlatformRevenue, // รายได้ platform
GatewayClearing, // บัญชีพักตอนรับเงินเข้าจาก gateway
Escrow, // บัญชีพักเงินค่าอาหารระหว่างออเดอร์กำลังส่ง
}
public enum Direction { Debit, Credit }
// หนึ่งบรรทัดใน ledger — immutable ตลอดกาล
public sealed record LedgerEntry(
AccountId AccountId,
Direction Direction,
Money Amount,
JournalEntryId JournalEntryId);

หัวใจ​ของ​ทั้ง​ระบบ​คือ invariant ข้อ​เดียว​ที่ JournalEntry ต้อง​รักษา​เสมอ — ผล​รวม​เดบิต​ต้อง​เท่ากับ​ผล​รวม​เครดิต​พอดี ถ้า​ไม่​เท่า สร้าง​รายการ​ไม่​ได้​ตั้งแต่​แรก และ​แต่ละ​รายการ​พก idempotency key กับ “เหตุผล” ติดตัว​ไว้​เพื่อ​ให้​ตรวจ​ย้อนหลัง​ได้​ว่า​เกิด​จาก​อะไร

// journal entry = ชุด LedgerEntry ที่สมดุล + idempotency key + เหตุผล
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 = Total(entries, Direction.Debit);
var credit = Total(entries, Direction.Credit);
if (debit != credit) // ★ invariant ที่ต้องรักษาเสมอ
throw new InvalidOperationException(
$"journal entry ไม่สมดุล: เดบิตรวม {debit.Amount} ไม่เท่ากับเครดิตรวม {credit.Amount}");
Id = id; IdempotencyKey = idempotencyKey; Reason = reason; Entries = entries;
}
static Money Total(IReadOnlyList<LedgerEntry> entries, Direction side) =>
entries.Where(l => l.Direction == side)
.Aggregate(Money.Thb(0), (sum, l) => sum + l.Amount);
}

Money ตัว​ที่ LedgerEntry.Amount อ้าง​ถึง​คือ value object ตัว​เดิม​จาก​คอร์ส DDD-in-Code (คอร์ส B — หนึ่ง​คอร์ส​ใน​ไตร​ภาค ไม่ใช่​ทั้ง​ไตร​ภาค) — ห่อ​จำนวน​กับ​สกุล​เงิน​ไว้​ด้วย​กัน บวก​กัน​ได้​และ​เทียบ​เท่ากันได้ตรงๆ กฎ “เดบิต​รวม = เครดิต​รวม” จึง​เขียนสั้นๆ ได้​ด้วย MoneyMoneyValue Object ที่​แทน​จำนวน​เงิน​พร้อม​สกุล​เงิน (เช่น จำนวน + THB) เปรียบเทียบ​กัน​ด้วย​ค่า ไม่ใช่ identity, บวก/ลบ​กัน​ได้​แต่​ต้อง​เป็น​สกุล​เดียวกัน, immutable — ห้าม​ใช้ decimal เปล่า ๆ แทน​เงิน​เพราะ​จะ​ทำให้​หน่วย​เงิน​และ​กฎ​การ​ปัดเศษ​หล่น​หาย​ไป (Primitive Obsession)Tactical Design ตัว​นี้

// 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);
}
}

สุดท้าย​คือ​ฝั่ง​ที่​พา​เรา​ไป​คุย​กับ LedgerLedgerแหล่ง​ความ​จริง​เดียว​ที่​รักษา​ประวัติ Journal Entry ทุก​รายการ​ของ​ระบบ​ไว้​เรียง​ตาม​เวลา แบบ append-only ไม่มี​การ​แก้​ของ​เก่า ยอด​เงิน​ของ​บัญชี​ไหน​ก็ตาม​คำนวณ​ได้​จาก​การ​ไล่​ผล​รวม​รายการ​ใน ledger เท่านั้น ไม่ใช่​ตัวเลข​ที่​เก็บ​แยก​ไว้​ต่างหากTactical Design จริง กับ​ตัว​ช่วย​หา​ว่า​แต่ละ​ออเดอร์​ผูก​กับ​บัญชี​ไหน — ประกาศ​ไว้​เป็น interface สั้นๆ พอ​ให้ handler ทำงาน​ได้:

public interface ILedger
{
Money BalanceOf(AccountId account); // ยอด = fold ของทุก LedgerEntry ของบัญชีนั้น
void Post(JournalEntry entry); // idempotent ตาม entry.IdempotencyKey (post ซ้ำ = no-op)
}
public interface IAccounts
{
AccountId WalletOf(Guid customerId);
AccountId EscrowOf(Guid orderId);
AccountId PayableOf(Guid orderId); // บัญชีเจ้าหนี้ร้านอาหารของออเดอร์
}
public sealed class InsufficientWalletBalance(Guid orderId, Money have, Money need)
: Exception($"wallet มี {have.Amount} แต่ต้องกัน {need.Amount} บาท สำหรับออเดอร์ {orderId}")
{ }
// contract แบบ ID/primitive ล้วน สไตล์เดียวกับ integration event ในบท 4
public sealed record OrderConfirmed(Guid OrderId, Guid CustomerId, decimal Total);

สัญชาตญาณ​แรก​คือ พอ​ออเดอร์​ยืนยัน​ก็​โอน​เงิน​จาก wallet ลูกค้า​เข้า​บัญชี​เจ้าหนี้​ร้าน​เลย​สิ จบ​ใน​ที​เดียว รายการ​ก็​สมดุล​ดี — เดบิต wallet เครดิต​เจ้าหนี้​ร้าน เท่า​กัน​เป๊ะ post ผ่าน​สบาย

❌ version ดิบ — เครดิต​บัญชี​เจ้าหนี้​ร้าน​ทันที​ตอน OrderConfirmed
// ❌ version ดิบ — พอ OrderConfirmed มาถึง ก็เครดิตเจ้าหนี้ร้านทันที
public sealed class PayRestaurantOnConfirm(ILedger ledger, IAccounts accounts)
{
public void Handle(OrderConfirmed evt)
{
var wallet = accounts.WalletOf(evt.CustomerId);
var payable = accounts.PayableOf(evt.OrderId); // บัญชีเจ้าหนี้ร้านอาหาร
var amount = Money.Thb(evt.Total);
var jid = new JournalEntryId(Guid.NewGuid());
ledger.Post(new JournalEntry(jid, $"pay:{evt.OrderId}", "จ่ายค่าอาหารให้ร้านทันที",
[
new LedgerEntry(wallet, Direction.Debit, amount, jid), // wallet ลูกค้า ลด
new LedgerEntry(payable, Direction.Credit, amount, jid), // เจ้าหนี้ร้าน เพิ่ม
]));
// 💥 ออเดอร์นี้ยังไม่ถูกส่ง ถ้าลูกค้ายกเลิกหรือร้านกดปฏิเสธ
// เงินเข้ากระเป๋าเจ้าหนี้ร้านไปแล้ว ต้องไล่ดึงคืน — ทวงยากและเสี่ยงเงินหลุด
}
}

รายการ​นี้​สมดุล​และ post ได้​จริง ปัญหา​ไม่​ได้​อยู่​ที่​บัญชี แต่​อยู่​ที่ จังหวะ​เวลา — เรา​ปล่อย​เงิน​ให้​ปลายทาง​ตัว​จริง​ก่อน​ที่​เงื่อนไข “อาหาร​ถูก​ส่ง​สำเร็จ” จะ​เกิด​ขึ้น พอ​ออเดอร์​ถูก​ยกเลิก​กลางคัน (ลูกค้า​กด​ยกเลิก ร้าน​ปฏิเสธ ไร​เด​อร์หา​คน​รับ​ไม่​ได้) เงิน​อยู่​ใน​กระเป๋า​เจ้าหนี้​ร้าน​ไป​แล้ว การ​จะ​ดึง​คืน​กลาย​เป็นการ​ไล่​ทวง​ย้อนหลัง​ที่​ยุ่ง​และ​เสี่ยง​เงิน​หลุด​ออก​นอก​ระบบ ทาง​ที่​ถูก​คือ​กัน​เงิน​ไว้​ตรง​กลาง​ก่อน แล้ว​ค่อย​ตัดสิน​ใจ​ปลายทาง​เมื่อ​เงื่อนไข​ชัดเจน

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

ทั้ง wallet ลูกค้า​และ escrow เป็น​บัญชี ฝั่ง​หนี้สิน — เป็น​เงิน​ที่ platform ถือ​ไว้​แทน​คน​อื่น ตาม​กฎ Debit and CreditDebit and Creditสอง​ด้าน​ของ​ทุก Journal Entry ใน​ระบบ double-entry — เดบิต​กับ​เครดิต​ไม่​ได้​แปล​ว่า 'บวก' หรือ 'ลบ' ตรง ๆ แต่​ขึ้น​กับ​ประเภท​บัญชี สิ่ง​ที่​ต้อง​จริง​เสมอ​คือ​ผล​รวม​เดบิต​ของ​รายการ​หนึ่ง​ต้อง​เท่ากับ​ผล​รวม​เครดิต​พอดีTactical Design ที่​บท​ที่ 1 วาง​ไว้: หนี้สิน​นั้น​เครดิต​ทำให้​ยอด​เพิ่ม เดบิต​ทำให้​ยอด​ลด การ​ย้าย​เงิน​จาก wallet ไป escrow จึง​เป็น journal entry เดียว​ที่ เดบิต wallet (หนี้สิน​ฝั่ง​ลูกค้า​ลด​ลง) และ เครดิต escrow (หนี้สิน​ฝั่ง​พัก​เงิน​เพิ่ม​ขึ้น) ยอด​หนี้สิน​รวม​ของ platform ไม่​เปลี่ยน แค่​ย้าย​กระเป๋า และ​เพราะ​เดบิต​รวม​เท่ากับ​เครดิต​รวม เงิน​จึง​ไม่มี​ทาง​หาย​หรือ​โผล่​มา​เอง

// ✅ ฟัง OrderConfirmed แล้วกันเงินเข้า escrow — idempotent consumer
public sealed class HoldFundsOnOrderConfirmed(ILedger ledger, IAccounts accounts)
{
public void Handle(OrderConfirmed evt)
{
var wallet = accounts.WalletOf(evt.CustomerId);
var escrow = accounts.EscrowOf(evt.OrderId);
var amount = Money.Thb(evt.Total); // primitive จาก contract แปลงเป็น Money ที่ขอบ
// ★ อ่านยอด (fold) แล้วตัดสินใจหัก ต้องอยู่ในจังหวะ strongly-consistent เดียวกัน
var balance = ledger.BalanceOf(wallet);
if (balance.Amount < amount.Amount)
throw new InsufficientWalletBalance(evt.OrderId, balance, amount); // reject
var jid = new JournalEntryId(Guid.NewGuid());
var hold = new JournalEntry(
jid,
idempotencyKey: $"escrow-hold:{evt.OrderId}", // จุดชนวนเดิมมาซ้ำก็ไม่หักซ้ำ
reason: $"กันเงินค่าอาหารเข้าพัก escrow ของออเดอร์ {evt.OrderId}",
entries:
[
new LedgerEntry(wallet, Direction.Debit, amount, jid), // wallet (หนี้สิน) ลด
new LedgerEntry(escrow, Direction.Credit, amount, jid), // escrow (หนี้สิน) เพิ่ม
]);
ledger.Post(hold); // idempotent ตาม idempotencyKey
}
}
flowchart TB
  EV["integration event: OrderConfirmed<br/>orderId · customerId · total 200 บาท"]
  EV --> JE["journal entry เดียว — กันเงินเข้า escrow<br/>idempotency key: escrow-hold:orderId"]
  JE --> DR["ขาเดบิต — wallet ลูกค้า<br/>เดบิต 200 บาท: หนี้สินลด ยอด wallet ลดลง 200"]
  JE --> CR["ขาเครดิต — escrow ออเดอร์<br/>เครดิต 200 บาท: หนี้สินเพิ่ม ยอด escrow เพิ่มขึ้น 200"]
  DR --- BAL["เดบิตรวม 200 = เครดิตรวม 200 — สมดุล"]
  CR --- BAL
  BAL --> HELD["เงิน 200 บาทถูกพักไว้ใน escrow<br/>รอจนส่งอาหารสำเร็จค่อยปล่อยให้ร้านและไรเดอร์"]
  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 held fill:#7c3aed,stroke:#4c1d95,color:#f8fafc;
  class EV ev;
  class JE ev;
  class DR dr;
  class CR cr;
  class BAL bal;
  class HELD held;

คำ​บรรยาย​ภาพ: เหตุการณ์ OrderConfirmed (น้ำเงิน) จุด​ชนวน​ให้​เกิด journal entry เดียว​ที่​แตก​เป็น​สอง​ขา ขา​เดบิต (เขียว) หัก wallet ลูกค้า 200 บาท — หนี้สิน​ฝั่ง​ลูกค้า​ลด​ลง ขา​เครดิต (แดง) เพิ่ม escrow 200 บาท — หนี้สิน​ฝั่ง​พัก​เงิน​เพิ่ม​ขึ้น​เท่า​กัน จุด​สี​ส้ม​คือ​กฎ​เหล็ก เดบิต​รวม​ต้อง​เท่ากับ​เครดิต​รวม รายการ​จึง​บันทึก​ได้ ผลลัพธ์ (ม่วง) คือ​เงิน 200 บาท​ถูก​พัก​ไว้​ใน escrow ยัง​ไม่​ถึง​มือ​ใคร รอ​จน​ส่ง​อาหาร​สำเร็จ​ค่อย​ปล่อย​ใน​ขั้น settlement บท​หน้า

สังเกต​บรรทัด if (balance.Amount < amount.Amount) ใน handler — ก่อน​จะ​กัน​เงิน เรา​อ่าน​ยอด wallet (ซึ่ง​เป็น fold ของ​ทุก​รายการ ไม่ใช่ column) มา​เทียบ​กับ​จำนวน​ที่​ต้อง​กัน ถ้า​ไม่​พอ​จะ​ทำ​ยังไง​ต่อ นี่​คือ การ​ตัดสิน​ใจ​เชิง domain ไม่ใช่​รายละเอียด​ทาง​เทคนิค มี​สอง​ทาง​หลัก:

  • reject — ปฏิเสธ​การ​กัน​เงิน​ไป​เลย โยน InsufficientWalletBalance โดย​ไม่ post อะไร​ลง ledger สัก​บรรทัด แล้ว​แจ้ง​กลับ Ordering ว่า​กัน​เงิน​ไม่​สำเร็จ ออเดอร์​เดิน​ต่อ​ไม่​ได้ ข้อดี​คือ ledger สะอาด ไม่มี​รายการ​ค้าง และ invariant “เดบิต​รวม = เครดิต​รวม” ไม่​เคย​ถูก​แตะ
  • hold แล้ว​เติม​ให้​พอ — กัน​เท่า​ที่​มี​ไว้​ก่อน แล้วไป​ดึง​เงิน​ส่วน​ที่​ขาด​จาก payment gateway เช่น เรียก​เก็บ​ผ่าน PromptPay เพิ่ม (ยืม​รูป IPaymentGateway แบบ ACL จาก​คอร์ส Bounded Contexts มา​เป็น​ภาพประกอบ ไม่ใช่ integration จริง​ใน​คอร์ส​นี้) แล้ว​ค่อย​กัน​ครบ

คอร์ส​นี้​เลือก​ทาง reject เพราะ​มัน​ตรง​ไป​ตรง​มา​และ​รักษา ledger ให้​สะอาด​ที่สุด สิ่ง​สำคัญ​ไม่ใช่​ว่า​เลือก​ทาง​ไหน แต่​คือ​การ​ตระหนัก​ว่า​คำถาม “เงิน​ไม่​พอแล้ว​ยังไง​ต่อ” เป็น​คำถาม​ที่​ธุรกิจ​ต้อง​ตอบ ไม่ใช่​คำถาม​ที่​โยน​ให้​ระบบ​ตัดสิน​เอง และ​ไม่​ว่า​เลือก​ทาง​ไหน กฎ​เดียว​ที่​ห้าม​ละเมิด​คือ ห้าม post รายการ​ที่​ไม่​สมดุล​หรือ​ปล่อย​ให้​บัญชี​ติด​ลบ​เงียบๆ

ใน​บท​ที่ 6 ของ​คอร์ส Bounded Contexts เรา​คุย​กัน​แล้ว​ว่า​เมื่อ context สื่อสาร​ข้าม​กัน​ด้วย event ระบบ​จะ​เป็น eventual consistencyOrderConfirmed เดินทาง​มา​ถึง money context ช้า​เร็ว​ต่าง​กัน​ได้ อาจ​มา​ซ้ำ และ​เรา​ออกแบบ​ให้​ทน at-least-once ด้วย idempotent consumer แล้ว นั่น​คือ “ข่าวสาร​ว่า​ออเดอร์​ยืนยัน​แล้ว” ที่​ยอม​ให้​ถึง​ช้า​หน่อย​ได้

แต่ การ​ตัดสิน​ใจ​ว่า​หัก​เงิน​ได้​หรือ​ไม่ เป็น​คนละ​เรื่อง​กัน​สิ้นเชิง — มัน​ต้อง​เป็น strong consistency คือ อ่าน​ยอด wallet แล้ว​ตัดสิน​ใจ​กัน​เงิน​ภายใน​ธุรกรรม​เดียว​ที่​ล็อก​กัน​ชัดเจน (synchronous) เหตุผล​คือ ถ้า​ปล่อย​ให้​ขั้นตอน “เช็ค​ยอด” กับ “หัก​เงิน” หลุด​จาก​กัน​แบบ eventual สองออเดอร์​ที่​เข้า​มา​พร้อม​กัน​อาจ​ต่าง​อ่าน​ยอด​เดิม​มา​เห็น​ว่า​เงิน​พอ​ทั้ง​คู่ แล้ว​ต่าง​กัน​เงิน​ก้อน​เดียวกัน​ไป​คนละ​ที่ — เกิด double-spend เงิน​โผล่​มา​จาก​อากาศ นี่​คือ​ปัญหา lost update เดียว​กับ​ที่​บท​ที่ 1 เตือน​ไว้ เพียง​แต่​คราว​นี้​อยู่​ระหว่าง​สองออเดอร์

เส้น​แบ่ง​จึง​ชัด: การ รับรู้ ว่า​ออเดอร์​ยืนยัน​แล้ว​เป็น eventual ได้ แต่​การ ตัดสิน​ใจ​และ​บันทึก การ​หัก​เงิน​ต้อง strong และ atomic อยู่​ภายใน​ขอบเขต​ของ ledger aggregate เดียว การ​รักษา​ยอด wallet ไม่​ให้​ติดลบ​เป็น invariant ที่​ต้อง​บังคับ ณ จุด post พอดี ไม่ใช่​ไล่​ตรวจ​ทีหลัง — ซึ่ง​เป็น​อีก​เหตุผล​ที่​ยอด​ต้อง​คำนวณ​จาก fold สดๆ ใน​ธุรกรรม​นั้น ไม่ใช่​อ่าน​จาก column ที่​อาจ​ล้าสมัย

ปิด​ท้าย​ด้วย​กฎ​ที่​จะ​ติดตัว​ไป​ทุก​บท​ถัด​จาก​นี้: ถ้า​กัน​เงิน​ผิด — กัน​เกิน กัน​ผิด​ออเดอร์ หรือ​ทีหลัง​ต้อง​คืน​เพราะ​ยกเลิก — เรา​จะ ไม่มี​วัน​ลบ​หรือ​แก้ journal entry เดิม แต่​เขียน Reversing EntryReversing EntryJournal Entry ใหม่​ที่​เขียน​ขึ้น​เพื่อ​หักล้าง​รายการ​เดิม​ทั้งหมด​หรือ​บาง​ส่วน โดย​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม เช่น ตอน​คืน​เงิน​ลูกค้า (refund) แทนที่​จะ​ลบ​รายการ​จ่าย​เงิน​เดิม ระบบ​เขียน reversing entry ใหม่​ทับ — ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจสอบ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น​บ้างTactical Design รายการ​ใหม่​ที่​สลับ​ด้าน​เดบิต/เครดิต​มา​หักล้าง​แทน ประวัติ​เดิม​ยัง​อยู่​ครบ​ทุก​บรรทัด ตรวจ​ย้อนหลัง​ได้​เสมอ​ว่า​เกิด​อะไร​ขึ้น​บ้าง ด้วย​เหตุ​นี้ Post จึง​เป็น idempotent (ยิง​ซ้ำ​ด้วย key เดิม = no-op) แต่​ไม่มี Update หรือ Delete ให้​เรียก​เลย

เงิน​ที่พัก​อยู่​ใน escrow ตอน​นี้​กำลัง​รอ​เหตุการณ์​ถัด​ไป OrderDelivered เมื่อ​อาหาร​ถึง​มือ​ลูกค้า บท​หน้า​เรา​จะ​เปิด​ขั้น SettlementSettlementขั้นตอน​ที่​เงิน​ซึ่ง​พัก​อยู่ (เช่น​ใน escrow หรือ clearing) ถูก 'ปิด​จบ' อย่าง​เป็น​ทางการ — กระจาย​ไป​ยัง​บัญชี​ปลายทาง​จริง​ตาม​สัดส่วน​ที่​ตกลง​ไว้ เช่น ตัด​ค่า​คอม​มิช​ชัน platform แล้ว​โอน​ส่วน​ที่​เหลือ​เข้า​บัญชี​ร้าน​อาหาร​และ​ไร​เด​อร์ มัก​เกิด​เป็น​รอบ (batch) ไม่ใช่​ทันที​ทุกออเดอร์Process — ปล่อย​เงิน​จาก escrow ตัด​ค่า​คอม​มิช​ชัน​เข้า​รายได้ platform แล้วกระจาย​ส่วน​ที่​เหลือ​ให้​เจ้าหนี้​ร้าน​และ​เจ้าหนี้​ไร​เด​อร์ ทั้งหมด​ยัง​เป็น journal entry สมดุล​บน ledger เดิม​ทุก​บาท


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

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

  • Aggregate — ทำไม ledger ถึง​เป็น​ขอบเขต consistency ที่​บังคับ invariant (เดบิต​รวม = เครดิต​รวม, wallet ไม่​ติดลบ) แบบ strong ภายใน​หนึ่ง​ธุรกรรม ขณะ​ที่​การ​สื่อสาร​ข้าม context เป็น eventual
  • Guard Clause — การ​เช็ค​ยอด​ไม่​พอแล้ว reject ตั้งแต่​ต้น handler ก่อน​จะ post อะไร​ลง ledger คือ guard ที่​รักษา invariant ไว้​ไม่​ให้​ถูก​ละเมิด

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

ข้อ 1 / 3

ทำไมเงินค่าอาหารต้องพักใน escrow ก่อน แทนที่จะเครดิตบัญชีเจ้าหนี้ร้านทันทีตอน OrderConfirmed?