Settle & payout
เราเดินเรื่องเงินมาถึงจุดสำคัญ ออเดอร์ถูกจ่ายแล้ว แต่เงินค่าอาหาร 200 บาทของลูกค้าไม่ได้วิ่งตรงเข้ากระเป๋าร้านทันที มันถูกกันไว้ในบัญชี Escrow AccountEscrow Accountบัญชีที่กันเงินไว้รอเงื่อนไขก่อนปล่อยให้ผู้รับตัวจริง เช่น เงินค่าอาหารที่ลูกค้าจ่ายจะพักอยู่ในบัญชี escrow จนกว่าออเดอร์จะถูกส่งมอบสำเร็จ ค่อยปล่อยเงินให้ร้านอาหารกับไรเดอร์ — รักษาสิทธิ์ทั้งสองฝ่ายไว้จนกว่าเงื่อนไขจะสำเร็จ ถ้าออเดอร์ถูกยกเลิกก่อนส่งมอบ เงินใน escrow จะถูกคืนแทนที่จะจ่ายออกไปTactical Design ตั้งแต่ตอนยืนยันออเดอร์ (บทที่ 5) รอจนอาหารถึงมือลูกค้าจริงๆ ก่อน บทนี้คือช่วงเวลาที่เงินก้อนนั้นถูกปิดจบ — แตกออกไปยังคนที่ควรได้ แล้วโอนออกนอกระบบไปเข้าบัญชีจริง
งานนี้มีสองจังหวะที่คนมักนึกว่าเป็นเรื่องเดียวกัน แต่จริงๆ คนละเรื่อง จังหวะแรกคือ SettlementSettlementขั้นตอนที่เงินซึ่งพักอยู่ (เช่นใน escrow หรือ clearing) ถูก 'ปิดจบ' อย่างเป็นทางการ — กระจายไปยังบัญชีปลายทางจริงตามสัดส่วนที่ตกลงไว้ เช่น ตัดค่าคอมมิชชัน platform แล้วโอนส่วนที่เหลือเข้าบัญชีร้านอาหารและไรเดอร์ มักเกิดเป็นรอบ (batch) ไม่ใช่ทันทีทุกออเดอร์Process — ปิดบัญชีเงินก้อนนี้ ภายใน ledger แตก escrow ออกเป็นสามส่วน (ร้าน ไรเดอร์ platform) ในรายการเดียวที่สมดุล จังหวะที่สองคือ PayoutPayoutการจ่ายเงินที่ settle แล้วออกจากระบบไปให้ร้านอาหารหรือไรเดอร์จริง ๆ เช่น โอนเข้าบัญชีธนาคารหรือ PromptPay ของร้าน — มักรวมหลายออเดอร์เป็นรอบ payout เดียวต่อวันหรือต่อสัปดาห์ แทนที่จะโอนทุกออเดอร์แยกกันProcess — ย้ายเงินที่ค้างจ่ายออกจากบัญชีจริงๆ ไปยัง payment gateway หรือธนาคาร บทนี้จะแยกสองจังหวะนี้ให้ชัดว่าอันไหนคืออันไหน
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — snippet ในบทนี้เป็น code ตัวอย่างประกอบแนวคิด settlement/payout ยกรูปทรง Money, LedgerEntry, JournalEntry ที่ปั้นไว้บทก่อนๆ มาต่อยอด ส่วน payment gateway / PromptPay ในบทนี้เป็นตัวอย่างประกอบ (illustrative) ไม่ใช่ integration จริง
OrderDelivered — สัญญาณให้ปิดจบเงินก้อนนี้
หัวข้อที่มีชื่อว่า “OrderDelivered — สัญญาณให้ปิดจบเงินก้อนนี้”เงินใน escrow ถูกปล่อยตอนไหน? คำตอบคือตอนที่domain Ordering บอกว่าอาหารถึงมือลูกค้าแล้ว ผ่าน Integration EventIntegration Eventเหตุการณ์ที่สื่อสารข้าม Bounded Context หรือข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้องรับฟังจาก Ordering context เพื่อรู้ว่าเมื่อไหร่ควรปล่อยเงินจาก escrow — ต่างจาก Domain Event ที่ทำงานเฉพาะภายใน context เดียว (in-process)Architecture ชื่อ OrderDelivered — event ข้าม context ที่เราทำความรู้จักในคอร์ส Bounded Contexts (คอร์ส #8) สัญญาของมันบางเฉียบตามกฎ integration event คือมีแต่ ID กับค่า primitive ไม่มี object domain ก้อนใหญ่ข้ามฝั่งมา ก่อนหน้านี้ตอนลูกค้ายืนยันจ่ายเงิน เราเห็น OrderConfirmed มาแล้วรอบหนึ่ง (จังหวะเงินเข้า escrow) รอบนี้ OrderDelivered คือสัญญาณ ปิดจบ
message broker มีนิสัยส่งซ้ำ — OrderDelivered ใบเดิมอาจวิ่งเข้ามาสองสามครั้ง ตัวรับฝั่ง Payment จึงต้องเป็น Idempotent ConsumerIdempotent Consumerตัวรับ event หรือ message ที่ประมวลผลข้อความเดิมซ้ำกี่ครั้งก็ได้ผลลัพธ์เหมือนเดิม ไม่สร้างผลข้างเคียงซ้ำ เช่น รับ PaymentCaptured ซ้ำสองครั้งจาก message broker ต้องไม่บันทึก journal entry สองรอบ — ตรวจด้วย Idempotency Key หรือเช็คว่าเคยประมวลผลรายการนี้ไปแล้วหรือยังArchitecture คือรับ event เดิมซ้ำกี่รอบก็ post รายการ settlement แค่ครั้งเดียว เราไม่สอนเรื่องนี้ใหม่ (คอร์ส #8 วางไว้แล้ว) แค่ยืมกลไกเดิมมาใช้: แนบ Idempotency KeyIdempotency Keyรหัสเฉพาะที่แนบไปกับคำขอทำธุรกรรม (เช่น เรียกเก็บเงินผ่าน PromptPay) เพื่อให้ระบบรู้ว่าคำขอที่ถูกส่งซ้ำ (retry) คือคำขอเดิม ไม่ใช่ธุรกรรมใหม่ — กันไม่ให้ลูกค้าโดนหักเงินซ้ำเวลาเครือข่ายมีปัญหาแล้ว client ยิงคำขอเดิมซ้ำArchitecture ที่ผูกกับ OrderId ไปกับรายการใน ledger ถ้า key นี้เคยถูก post แล้ว ระบบก็ข้ามให้เลย
Settlement — แตก escrow เป็นสามขาในรายการเดียว
หัวข้อที่มีชื่อว่า “Settlement — แตก escrow เป็นสามขาในรายการเดียว”หัวใจของ settlement คือมันเป็นรายการเดียว ไม่ใช่สามรายการแยกกัน Journal EntryJournal Entryหน่วยที่เล็กที่สุดของการบันทึกใน ledger หนึ่งรายการมีอย่างน้อยสองบรรทัด (เดบิตกับเครดิต) ที่ยอดรวมเท่ากัน เช่น ตอนลูกค้าจ่ายค่าอาหาร เกิด journal entry เดียวที่กระทบทั้งบัญชีเงินสดและบัญชี escrow — เขียนแล้วห้ามแก้ ถ้าผิดต้องเขียน Reversing Entry มาหักล้างแทนTactical Design หนึ่งใบเดบิตบัญชี escrow เต็มจำนวน แล้วเครดิตสามบัญชีปลายทางพร้อมกันในใบเดียว บัญชีปลายทางทั้งสามคือ:
- RestaurantPayable — เจ้าหนี้ร้านอาหาร เงินที่ platform ติดค้างต้องจ่ายให้ร้าน (หนี้สิน เครดิตแล้วยอดเพิ่ม)
- RiderPayable — เจ้าหนี้ไรเดอร์ เงินค่าส่งที่ติดค้างไรเดอร์ (หนี้สินเช่นกัน)
- PlatformRevenue — รายได้ platform ค่าคอมมิชชันที่หักเก็บเป็นของตัวเอง
ตัวอย่างออเดอร์ 200 บาท: ร้านได้ 150 ไรเดอร์ได้ 30 platform เก็บค่าคอม 20 — เดบิต escrow 200 บาท เท่ากับเครดิตสามขารวมกัน 150 + 30 + 20 = 200 พอดี ทำไมต้องยัดทั้งหมดไว้ใน journal entry เดียว? เพราะถ้าแยกเป็นหลายคำสั่งอิสระ เงินมีโอกาสหายกลางทาง:
// ❌ version ดิบ — คิดแบบ column ยอดเงินที่บทที่ 1 เตือนไว้ ยิงสี่คำสั่งอิสระescrowRow.Balance -= 200; // ลดยอด escrowrestaurantRow.Balance += 150; // เพิ่มยอดเจ้าหนี้ร้านriderRow.Balance += 30; // เพิ่มยอดเจ้าหนี้ไรเดอร์platformRow.Balance += 20; // เพิ่มรายได้ platform// ถ้า process ล่มหลังบรรทัดที่สอง: escrow ลดไป 200 แต่ปลายทางรับแค่ 150// เงิน 50 บาทหายกลางอากาศ และไม่มีใครรับประกันว่าสี่บรรทัดนี้รวมกันแล้วสมดุลversion ดิบพังเพราะไม่มีใครรักษากฎ “เดบิตรวม = เครดิตรวม” ให้อยู่ในการกระทำเดียวที่แยกไม่ได้ ทางออกคือมัดสี่ขาไว้ในใบเดียวที่ปฏิเสธตัวเองทันทีถ้าไม่สมดุล:
flowchart TB OD["OrderDelivered — อาหารถึงมือลูกค้า"] OD --> JE["Journal Entry เดียว: settle ออเดอร์ 200 บาท"] JE --> DR["เดบิต Escrow 200<br/>ปิดบัญชีพักเงินให้เหลือศูนย์"] JE --> C1["เครดิต RestaurantPayable 150"] JE --> C2["เครดิต RiderPayable 30"] JE --> C3["เครดิต PlatformRevenue 20"] DR --- BAL["เดบิต 200 = เครดิต 150 + 30 + 20 = 200 — สมดุล"] C1 --- BAL C2 --- BAL C3 --- BAL C1 --> PB["Payout batch: กวาดยอด payable ออกเป็นรอบ"] C2 --> PB PB --> OUT["เดบิต Payable / เครดิต GatewayClearing<br/>เงินออกไปธนาคาร/gateway จริง"] 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 po fill:#7c3aed,stroke:#4c1d95,color:#f8fafc; class OD,JE ev; class DR dr; class C1,C2,C3 cr; class BAL bal; class PB,OUT po;
คำบรรยายภาพ: OrderDelivered (น้ำเงิน) จุดชนวนให้เกิด journal entry เดียว (น้ำเงิน) ขาเดบิตขาเดียว (เขียว) ปิดบัญชี escrow 200 บาทให้เหลือศูนย์ ส่วนขาเครดิตสามขา (แดง) — RestaurantPayable 150, RiderPayable 30, PlatformRevenue 20 — จุดสีส้มคือกฎเหล็ก: เดบิตรวม 200 ต้องเท่ากับเครดิตรวม 150 + 30 + 20 = 200 พอดี ถ้าไม่เท่า รายการนั้นสร้างไม่ได้ตั้งแต่แรก จากนั้น (ม่วง) payout รวมรอบกวาดยอดในบัญชี payable ออกไปธนาคาร/gateway โดยเดบิต payable แล้วเครดิต GatewayClearing — เป็นอีก journal entry ที่สมดุลในตัวเอง
รูปทรงที่ต้องมี — LedgerEntry, JournalEntry และ invariant สมดุล
หัวข้อที่มีชื่อว่า “รูปทรงที่ต้องมี — LedgerEntry, JournalEntry และ invariant สมดุล”ก่อนเขียน settlement ทวนรูปทรงหลักที่บทก่อนๆ ปั้นไว้ ทุกจำนวนเงินยังเป็น value object MoneyMoneyValue Object ที่แทนจำนวนเงินพร้อมสกุลเงิน (เช่น จำนวน + THB) เปรียบเทียบกันด้วยค่า ไม่ใช่ identity, บวก/ลบกันได้แต่ต้องเป็นสกุลเดียวกัน, immutable — ห้ามใช้ decimal เปล่า ๆ แทนเงินเพราะจะทำให้หน่วยเงินและกฎการปัดเศษหล่นหายไป (Primitive Obsession)Tactical Design เดิม (FoodOrdering.Domain/Orders/Money.cs) ที่บวกกันได้และเทียบค่าเท่ากันได้ ส่วน AccountAccountหน่วยที่ใช้แยกแยะว่า 'เงินอยู่ตรงไหน' ในระบบ เช่น บัญชีเงินสด platform, บัญชีลูกหนี้ร้านอาหาร, บัญชี wallet ของลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้องระบุว่าเดบิต/เครดิตบัญชีไหนTactical Design แต่ละใบมีประเภทตายตัว และ ยอดคงเหลือของมันไม่ใช่ column ที่เก็บไว้ — เดี๋ยวจะเห็นในหัวข้อถัดไป
// ── ทวนรูปทรงจากบทก่อน (บทที่ 2 ปั้นไว้แล้ว) ──// Money — value object เดิมจาก domain Order (FoodOrdering.Domain/Orders/Money.cs)// record Money(decimal Amount, string Currency) พร้อม Money.Thb(x) และ operator +public enum Direction { Debit, Credit }
public readonly record struct AccountId(Guid Value);public readonly record struct JournalEntryId(Guid Value);
public enum AccountType{ CustomerWallet, RestaurantPayable, RiderPayable, PlatformRevenue, GatewayClearing, Escrow,}
// หนึ่งบรรทัดใน ledger — immutable ตลอดกาล เขียนแล้วห้ามแก้public sealed record LedgerEntry( AccountId AccountId, Direction Direction, Money Amount, JournalEntryId JournalEntryId);หัวใจอยู่ที่ JournalEntry — มันไม่ใช่แค่ถุงใส่ LedgerEntry แต่เป็น aggregate ที่รักษา invariant ★ ไว้ในตัวเอง: ผลรวมด้านเดบิตต้องเท่ากับผลรวมด้านเครดิตเป๊ะ ถ้าไม่เท่า สร้าง object ไม่ได้ตั้งแต่ constructor พร้อมพก idempotency key และเหตุผล (reason) ไว้ตรวจย้อนหลัง:
// JournalEntry — เซ็ตของ LedgerEntry ที่ "ต้องสมดุล" ถึงจะสร้างได้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 = SumSide(entries, Direction.Debit); var credit = SumSide(entries, Direction.Credit);
// invariant ★ — เดบิตรวม = เครดิตรวม ไม่งั้นสร้างรายการไม่ได้ตั้งแต่แรก if (debit != credit) throw new InvalidOperationException( $"journal entry ไม่สมดุล: เดบิต {debit.Amount} ≠ เครดิต {credit.Amount}");
Id = id; IdempotencyKey = idempotencyKey; Reason = reason; Entries = entries; }
private static Money SumSide(IReadOnlyList<LedgerEntry> entries, Direction side) { var total = Money.Thb(0); foreach (var line in entries) if (line.Direction == side) total += line.Amount; return total; }}SumSide ไล่บวกเฉพาะขาที่ตรงด้าน แล้วเทียบสองก้อนด้วยความเท่ากันของ Money ตรงๆ นี่คือกลไกที่ทำให้ version ดิบข้างบนเป็นไปไม่ได้ — คุณ สร้าง รายการที่ไม่สมดุลไม่ได้เลย จะเผลอทำเงินหายกลางทางก็ไม่ได้
code settlement — post รายการเดียวที่ balance ตัวเอง
หัวข้อที่มีชื่อว่า “code settlement — post รายการเดียวที่ balance ตัวเอง”ยอดที่ต้อง settle คือเท่าไร? มาจากยอดคงเหลือของบัญชี escrow — ซึ่งตามหลัก Balance as a FoldBalance as a Foldแนวคิดที่ว่ายอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ตรง ๆ แต่เป็นผลลัพธ์จากการ 'พับ' (fold) ไล่บวก-ลบทุก Journal Entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน คล้ายการ replay event ใน Event Sourcing — บัญชีจึงตรวจสอบย้อนกลับได้เสมอว่ายอดเงินมาจากไหนบ้างArchitecture ไม่ใช่ตัวเลขที่เก็บไว้ แต่คำนวณสดๆ จากการ fold ไล่รวมทุก LedgerEntry ของบัญชีนั้น:
public static class Balances{ // ยอดคงเหลือ = fold ทุก LedgerEntry ของบัญชี ไม่ใช่ column ที่เก็บไว้ต่างหาก public static (Money Debit, Money Credit) FoldSides( AccountId id, IEnumerable<LedgerEntry> ledger) { var debit = Money.Thb(0); var credit = Money.Thb(0); foreach (var e in ledger.Where(e => e.AccountId == id)) { if (e.Direction == Direction.Debit) debit += e.Amount; else credit += e.Amount; } return (debit, credit); // ยอดสุทธิ = ผลต่างสองด้านตามประเภทบัญชี }}ตัว SettlementService เป็น idempotent consumer ที่ฟัง OrderDelivered อ่านยอดใน escrow แล้วให้นโยบายค่าคอมแตกเป็นสามส่วน จากนั้น post journal entry เดียว จุดสวยงามคือ: ต่อให้นโยบายค่าคอมคำนวณพลาด (สามส่วนรวมกันไม่เท่า escrow) รายการนั้นก็จะโดน constructor ปัดทิ้งทันที เงินไม่มีทางรั่ว
public interface ILedger{ void Post(JournalEntry entry); // idempotent by entry.IdempotencyKey Money BalanceOf(AccountId account); // ยอด = fold ของบัญชี (ไม่ใช่ column)}
public interface IAccounts{ AccountId Escrow(Guid orderId); AccountId RestaurantPayable(Guid restaurantId); AccountId RiderPayable(Guid riderId); AccountId PlatformRevenue { get; } AccountId GatewayClearing { get; }}
// สัญญาบางเฉียบตามกฎ integration event ของคอร์ส #8 — มีแต่ IDpublic sealed record OrderDelivered(Guid OrderId, Guid RestaurantId, Guid RiderId);
// นโยบายค่าคอม — แตกยอด escrow เป็นสามส่วนให้รวมกันเท่า escrow เป๊ะpublic sealed record SettlementSplit(Money Restaurant, Money Rider, Money Platform);
public interface ICommissionPolicy{ SettlementSplit Split(Money escrowTotal);}
public sealed class SettlementService{ private readonly ILedger _ledger; private readonly IAccounts _accounts; private readonly ICommissionPolicy _commission;
public SettlementService(ILedger ledger, IAccounts accounts, ICommissionPolicy commission) { _ledger = ledger; _accounts = accounts; _commission = commission; }
// Idempotent consumer — รับ OrderDelivered ซ้ำกี่ครั้งก็ post รายการเดียว public void On(OrderDelivered e) { var escrow = _accounts.Escrow(e.OrderId); var held = _ledger.BalanceOf(escrow); // เช่น Money.Thb(200) — มาจากการ fold var split = _commission.Split(held); // 200 -> (ร้าน 150, ไรเดอร์ 30, platform 20)
var jeId = new JournalEntryId(Guid.NewGuid()); var entry = new JournalEntry( jeId, idempotencyKey: $"settle:{e.OrderId}", reason: $"settle order {e.OrderId}", entries: new[] { // เดบิต escrow เต็มจำนวน — ปิดบัญชีพักเงินให้เหลือศูนย์ new LedgerEntry(escrow, Direction.Debit, held, jeId), // เครดิตสามขาปลายทาง — ร้าน ไรเดอร์ platform new LedgerEntry(_accounts.RestaurantPayable(e.RestaurantId), Direction.Credit, split.Restaurant, jeId), new LedgerEntry(_accounts.RiderPayable(e.RiderId), Direction.Credit, split.Rider, jeId), new LedgerEntry(_accounts.PlatformRevenue, Direction.Credit, split.Platform, jeId), });
_ledger.Post(entry); // ถ้า idempotencyKey นี้เคย post แล้ว จะไม่ทำซ้ำ }}สังเกตว่า settlement ทั้งหมดนี้เกิดขึ้น ภายใน LedgerLedgerแหล่งความจริงเดียวที่รักษาประวัติ Journal Entry ทุกรายการของระบบไว้เรียงตามเวลา แบบ append-only ไม่มีการแก้ของเก่า ยอดเงินของบัญชีไหนก็ตามคำนวณได้จากการไล่ผลรวมรายการใน ledger เท่านั้น ไม่ใช่ตัวเลขที่เก็บแยกไว้ต่างหากTactical Design เท่านั้น — ยังไม่มีเงินสักบาทออกจากระบบไปไหน มันแค่ย้ายเงินจากช่อง “พักไว้” (escrow) ไปช่อง “ค้างจ่าย” (payable) และ “รายได้เรา” (revenue) เตรียมพร้อมสำหรับจังหวะที่สอง
Payout — ย้ายเงินออกนอกระบบจริงๆ
หัวข้อที่มีชื่อว่า “Payout — ย้ายเงินออกนอกระบบจริงๆ”นี่คือจุดที่ settlement กับ payout แยกทางกันชัดๆ settlement คือการจัดประเภทเงินใหม่ในบัญชีของเราเอง (escrow → payable + revenue) เงินยังอยู่ครบในหนังสือบัญชี payout คือการเอาเงินที่ค้างจ่ายในบัญชี payable ออกจากระบบไปเข้าบัญชีธนาคารหรือ PromptPay ของร้าน/ไรเดอร์จริงๆ
payout มักทำเป็น รอบ (batch) ไม่ใช่ยิงทุกออเดอร์ทันที — เก็บยอดค้างจ่ายของร้านหนึ่งรายสะสมทั้งวันไว้ในบัญชี RestaurantPayable แล้วรอบเย็นค่อยกวาดออกทีเดียว รอบหนึ่งวนจ่ายร้าน/ไรเดอร์ทีละราย code ข้างล่างคือหนึ่งขาของ batch นั้น: อ่านยอดค้างจ่าย (ก็ fold อีกนั่นคือ) แล้ว post journal entry ที่เดบิต payable ล้างหนี้ให้เป็นศูนย์ พร้อมเครดิต Clearing AccountClearing Accountบัญชีพักเงินชั่วคราวระหว่างสองขั้นตอนของธุรกรรม เช่น ตอนลูกค้าจ่ายเงินผ่าน PromptPay เงินจะเข้าบัญชี clearing ก่อน แล้วค่อยกระจายไปบัญชีร้านอาหาร/ไรเดอร์/ค่าคอมมิชชัน platform ทีหลัง — แยกขั้นตอน 'รับเงินเข้า' ออกจาก 'จัดสรรเงิน' ให้ตรวจสอบได้ทีละขั้นTactical Design GatewayClearing — บัญชีพักเงินที่กำลังเดินทางออกไปหาธนาคาร:
// ACL shape จากคอร์ส #8 — PromptPay/ธนาคารในบทนี้เป็นตัวอย่างประกอบ ไม่ใช่ integration จริงpublic interface IPaymentGateway{ void Transfer(Guid payeeId, Money amount, string idempotencyKey);}
public sealed class PayoutService{ private readonly ILedger _ledger; private readonly IAccounts _accounts; private readonly IPaymentGateway _gateway;
public PayoutService(ILedger ledger, IAccounts accounts, IPaymentGateway gateway) { _ledger = ledger; _accounts = accounts; _gateway = gateway; }
// รวมรอบจ่ายร้านหนึ่งราย — กวาดยอดใน RestaurantPayable ออกไป gateway/ธนาคาร public void PayOutRestaurant(Guid restaurantId, string batchId) { var payable = _accounts.RestaurantPayable(restaurantId); var amount = _ledger.BalanceOf(payable); // ยอดค้างจ่าย = fold ของบัญชี if (amount == Money.Thb(0)) return; // ไม่มีอะไรต้องจ่ายในรอบนี้
var key = $"payout:{batchId}:{restaurantId}"; var jeId = new JournalEntryId(Guid.NewGuid());
var payout = new JournalEntry( jeId, key, reason: $"payout restaurant {restaurantId} ({batchId})", entries: new[] { // ล้างหนี้ payable ให้เป็นศูนย์ new LedgerEntry(payable, Direction.Debit, amount, jeId), // เงินกำลังออกนอกระบบ — พักที่ GatewayClearing ระหว่างทาง new LedgerEntry(_accounts.GatewayClearing, Direction.Credit, amount, jeId), });
_ledger.Post(payout); // idempotent — รอบเดิมยิงซ้ำก็ไม่จ่ายซ้ำ _gateway.Transfer(restaurantId, amount, key); // สั่ง gateway โอนจริง (ACL, illustrative) }}จะเห็นว่า payout ก็เป็น journal entry ที่สมดุลอีกใบ (เดบิต payable = เครดิต GatewayClearing) และก็ idempotent ด้วย key เดิม — ยิง batch เดิมซ้ำก็ไม่จ่ายเงินให้ร้านสองรอบ ทั้ง settlement และ payout จึงเดินอยู่บนกฎเดียวกันทุกกระเบียดนิ้ว: ทุกการเคลื่อนของเงินคือรายการที่สมดุลในตัวเอง
ถ้า settle ผิดล่ะ — reversing entry ไม่ใช่การแก้
หัวข้อที่มีชื่อว่า “ถ้า settle ผิดล่ะ — reversing entry ไม่ใช่การแก้”แล้วถ้า settle พลาด — นโยบายค่าคอมคำนวณผิด หรือแตกเงินเข้าบัญชีผิดใบ — จะแก้ยังไง? กฎเหล็กของ ledger คือ ห้ามแก้ ห้ามลบรายการที่ post ไปแล้ว (Append-only LogAppend-only Logโครงสร้างการเก็บข้อมูลที่ 'เขียนเพิ่มได้อย่างเดียว' ห้าม UPDATE หรือ DELETE รายการเก่า — ledger ของระบบการเงินต้องเป็นแบบนี้เสมอ เพราะประวัติเงินต้องตรวจสอบย้อนหลังได้ทุกบรรทัด ต่างจากตารางทั่วไปที่แก้ทับค่าเดิมได้Architecture) วิธีแก้คือเขียน Reversing EntryReversing EntryJournal Entry ใหม่ที่เขียนขึ้นเพื่อหักล้างรายการเดิมทั้งหมดหรือบางส่วน โดยสลับด้านเดบิต/เครดิตของรายการเดิม เช่น ตอนคืนเงินลูกค้า (refund) แทนที่จะลบรายการจ่ายเงินเดิม ระบบเขียน reversing entry ใหม่ทับ — ประวัติเดิมยังอยู่ครบ ตรวจสอบย้อนหลังได้ว่าเกิดอะไรขึ้นบ้างTactical Design — รายการใหม่ที่สลับด้านเดบิต/เครดิตของรายการเดิมมาหักล้างให้เป็นศูนย์ แล้วค่อย post รายการที่ถูกต้องเข้าไปใหม่ ประวัติเดิมยังอยู่ครบ ตรวจย้อนได้ว่าเกิดอะไรขึ้นและถูกแก้ยังไง — บทที่ 7 เรื่อง refund จะลงลึกกลไกนี้
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”บทนี้ปิดวงจร “เงินเข้ามาแล้วออกไปหาคนที่ควรได้” ครบ — settle แตก escrow เป็นสามขาที่สมดุลภายใน ledger แล้ว payout กวาดเงินออกนอกระบบเป็นรอบ เหลืออีกสองด่านสุดท้าย:
- refund และ reversing entry (บทที่ 7) — คืนเงินเมื่อยกเลิก โดยไม่ลบประวัติเดิม
- reconciliation (บทที่ 8) — เทียบยอดใน ledger กับที่ gateway รายงานจริง พิสูจน์ว่าเงินไม่มีวันหายสักบาท
ทุกอย่างยังกลับมาที่ ledger เดียว รายการเดียวที่สมดุลเสมอ — settlement ย้ายเงินภายในหนังสือบัญชี payout พาเงินออกนอกระบบ แต่ทั้งคู่เขียนลงด้วยกฎเดบิตรวม = เครดิตรวมเหมือนกันเป๊ะ
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Aggregate —
JournalEntryคือ aggregate ที่รักษา invariant “เดบิตรวม = เครดิตรวม” ไว้ในตัวเอง สร้าง object ที่ไม่สมดุลไม่ได้เลย - Guard Clause — การเช็คสมดุลใน constructor คือ guard ที่ปัดรายการผิดทิ้งตั้งแต่ตอนสร้าง ไม่ปล่อยให้ state เพี้ยนเล็ดลอดเข้าระบบ
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3ตอน settlement เกิดขึ้น Journal Entry เดียวแตกเงินจาก escrow ออกเป็นอะไรบ้าง และทำไมต้องสมดุล?