Refund, reversal & การแก้ที่ถูกต้อง
มาถึงตอนที่ทุกระบบการเงินต้องเจอ — ลูกค้ายกเลิกออเดอร์ อาหารมาไม่ครบ หรือกดโต้แย้งรายการกับธนาคาร แล้ว เงินที่ขยับไปแล้วต้องถอยกลับ ตอนบทที่ 4-5 เราจ่ายค่าอาหารเข้าบัญชีพักระหว่างส่ง (escrow) แล้ว settle ปล่อยเงินให้ร้านกับไรเดอร์ไปเรียบร้อย ตอนนี้ต้องคืน คำถามคือคืนยังไงในเมื่อ LedgerLedgerแหล่งความจริงเดียวที่รักษาประวัติ Journal Entry ทุกรายการของระบบไว้เรียงตามเวลา แบบ append-only ไม่มีการแก้ของเก่า ยอดเงินของบัญชีไหนก็ตามคำนวณได้จากการไล่ผลรวมรายการใน ledger เท่านั้น ไม่ใช่ตัวเลขที่เก็บแยกไว้ต่างหากTactical Design ของเราเป็นสมุดที่ ห้ามแก้ห้ามลบ — เขียนเพิ่มได้อย่างเดียว
สัญชาตญาณแรกของเกือบทุกคนคือ “ก็ไปหารายการที่จ่ายเงินออกไป แล้วลบมันทิ้ง / แก้ยอดให้เป็นศูนย์สิ” บทนี้จะแสดงว่าทำไมนั่นคือทางที่ผิด และวิธีที่ถูกคือการ post Reversing EntryReversing EntryJournal Entry ใหม่ที่เขียนขึ้นเพื่อหักล้างรายการเดิมทั้งหมดหรือบางส่วน โดยสลับด้านเดบิต/เครดิตของรายการเดิม เช่น ตอนคืนเงินลูกค้า (refund) แทนที่จะลบรายการจ่ายเงินเดิม ระบบเขียน reversing entry ใหม่ทับ — ประวัติเดิมยังอยู่ครบ ตรวจสอบย้อนหลังได้ว่าเกิดอะไรขึ้นบ้างTactical Design — Journal EntryJournal Entryหน่วยที่เล็กที่สุดของการบันทึกใน ledger หนึ่งรายการมีอย่างน้อยสองบรรทัด (เดบิตกับเครดิต) ที่ยอดรวมเท่ากัน เช่น ตอนลูกค้าจ่ายค่าอาหาร เกิด journal entry เดียวที่กระทบทั้งบัญชีเงินสดและบัญชี escrow — เขียนแล้วห้ามแก้ ถ้าผิดต้องเขียน Reversing Entry มาหักล้างแทนTactical Design ใหม่ที่หักล้างของเดิมให้ยอดสุทธิเป็นศูนย์ โดยไม่แตะประวัติเดิมแม้แต่บรรทัดเดียว
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้ต่อยอดจาก model ledger, account และ journal entry ที่ปั้นไว้บทที่ 2 และวงจร escrow → settlement จากบทที่ 4-5 สไนป์เพ็ตในบทนี้ recall type เหล่านั้นมาก่อนใช้เสมอ เพื่อให้อ่านจบในตัว
ทำไมห้ามลบ ห้ามแก้รายการเดิม
หัวข้อที่มีชื่อว่า “ทำไมห้ามลบ ห้ามแก้รายการเดิม”ก่อนจะเข้าเรื่องวิธีที่ถูก ต้องเห็นก่อนว่าทำไมวิธีที่ผิดถึงผิด สมมติออเดอร์ #A จ่ายเงินเข้า escrow ไป 200 บาทแล้ว ลูกค้ายกเลิก โปรแกรมเมอร์ที่ยังไม่ชินกับ Append-only LogAppend-only Logโครงสร้างการเก็บข้อมูลที่ 'เขียนเพิ่มได้อย่างเดียว' ห้าม UPDATE หรือ DELETE รายการเก่า — ledger ของระบบการเงินต้องเป็นแบบนี้เสมอ เพราะประวัติเงินต้องตรวจสอบย้อนหลังได้ทุกบรรทัด ต่างจากตารางทั่วไปที่แก้ทับค่าเดิมได้Architecture จะเอื้อมไปทำแบบนี้:
// ❌ version ดิบ — "ยกเลิกก็ลบรายการที่จ่ายไปสิ"ledger.Entries.Remove(badEntry); // ลบ journal entry เดิมทิ้งทั้งรายการ
// ❌ หรือแก้ยอดของเดิมให้เป็นศูนย์// UPDATE journal_entries SET amount = 0 WHERE id = @badId; // แก้ทับในฐานข้อมูล// DELETE FROM journal_entries WHERE id = @badId; // หรือลบทิ้งไปเลยcode นี้ “ได้ผลลัพธ์” ที่ดูถูก — ยอดเงินกลับมาเท่าเดิม แต่มันทำลายทุกอย่างที่ทำให้ ledger มีค่า:
- ประวัติหายไปเงียบๆ — วันที่ลูกค้าโวยว่า “ทำไมโดนหักแล้วไม่เห็นได้คืน” หรือทีมบัญชีถามว่า “ออเดอร์นี้เกิดอะไรขึ้นบ้าง” เราตอบไม่ได้เลย เพราะรายการที่เคยเกิดถูกลบเกลี้ยง ไม่เหลือร่องรอยว่าเคยจ่ายแล้วคืน มีแต่ “ไม่เคยจ่าย” ที่เป็นเรื่องโกหก
- Balance as a FoldBalance as a Foldแนวคิดที่ว่ายอดคงเหลือของบัญชีไม่ใช่ตัวเลขที่เก็บไว้ตรง ๆ แต่เป็นผลลัพธ์จากการ 'พับ' (fold) ไล่บวก-ลบทุก Journal Entry ของบัญชีนั้นตั้งแต่ต้นจนถึงปัจจุบัน คล้ายการ replay event ใน Event Sourcing — บัญชีจึงตรวจสอบย้อนกลับได้เสมอว่ายอดเงินมาจากไหนบ้างArchitecture พังทันที — ยอดคงเหลือของทุกบัญชีคำนวณจากการไล่รวมรายการทั้งหมด การลบหรือแก้รายการกลางทางเท่ากับปลอมประวัติ ยอดที่ fold ออกมา “ถูก” แต่ที่มาของมันเป็นเท็จ ตรวจย้อนกลับไม่ได้อีกต่อไป
- ReconciliationReconciliationกระบวนการเทียบยอดระหว่างสองแหล่งข้อมูลที่ควรตรงกัน เช่น ยอดใน ledger ของ platform กับยอดที่ผู้ให้บริการ PromptPay รายงานมาจริง — ถ้าไม่ตรงกันแปลว่ามีบางธุรกรรมหลุดหายหรือถูกบันทึกผิด ต้องตามหาสาเหตุก่อนปิดบัญชีรอบนั้น รักษาคำสัญญาที่ว่า 'เงินไม่มีวันหาย' ให้เป็นจริงProcess จับไม่ได้ว่าเคยมีปัญหา — บทหน้าเราจะเทียบยอด ledger กับสิ่งที่ gateway รายงานมา ถ้ารายการฝั่งเราถูกลบไป การเทียบจะเพี้ยนโดยไม่มีเบาะแสว่าเพี้ยนเพราะอะไร
รากของปัญหาเหมือนบทที่ 1 เป๊ะ: เราปฏิบัติกับประวัติเหมือนมันเป็น สถานะที่แก้ทับได้ ทั้งที่มันคือ บันทึกของสิ่งที่เกิดขึ้นจริงและเปลี่ยนไม่ได้ การแก้ที่ถูกต้องไม่ใช่การลบเหตุการณ์ แต่คือการเพิ่มเหตุการณ์ใหม่ที่หักล้างของเดิม
Reversing entry — แก้โดยเขียนรายการใหม่มาหักล้าง
หัวข้อที่มีชื่อว่า “Reversing entry — แก้โดยเขียนรายการใหม่มาหักล้าง”ทวนรูปทรงที่ปั้นไว้บทที่ 2 ก่อนสั้นๆ เพื่อให้บทนี้อ่านจบในตัว — เงินทุกจำนวนคือ value object MoneyMoneyValue Object ที่แทนจำนวนเงินพร้อมสกุลเงิน (เช่น จำนวน + THB) เปรียบเทียบกันด้วยค่า ไม่ใช่ identity, บวก/ลบกันได้แต่ต้องเป็นสกุลเดียวกัน, immutable — ห้ามใช้ decimal เปล่า ๆ แทนเงินเพราะจะทำให้หน่วยเงินและกฎการปัดเศษหล่นหายไป (Primitive Obsession)Tactical Design ตัวเดิมจากคอร์ส DDD-in-Code (path เดิม FoodOrdering.Domain/Orders/Money.cs) ยกมาทั้งดุ้น:
// FoodOrdering.Domain/Orders/Money.cs — ยกมาจากคอร์ส DDD-in-Code ทั้งดุ้น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);}จากนั้นคือหน่วยของ ledger เอง — AccountAccountหน่วยที่ใช้แยกแยะว่า 'เงินอยู่ตรงไหน' ในระบบ เช่น บัญชีเงินสด platform, บัญชีลูกหนี้ร้านอาหาร, บัญชี wallet ของลูกค้า, บัญชี escrow ของออเดอร์ — ทุก Journal Entry ต้องระบุว่าเดบิต/เครดิตบัญชีไหนTactical Design แต่ละใบมีประเภทตายตัว และยอดคงเหลือ ไม่ใช่ column ที่เก็บไว้ แต่ fold ออกมาจากรายการเสมอ ส่วน LedgerEntry กับ JournalEntry เป็นข้อมูลที่ immutable — เขียนแล้วเปลี่ยนไม่ได้:
public readonly record struct AccountId(Guid Value);public readonly record struct JournalEntryId(Guid Value);
public enum Direction { Debit, Credit }
// บัญชีทั้งหมดในจักรวาลฟู้ดเดลิเวอรีของเราpublic enum AccountType{ CustomerWallet, // wallet ลูกค้า (หนี้สิน: platform ติดค้างลูกค้า) RestaurantPayable, // เจ้าหนี้ร้านอาหาร RiderPayable, // เจ้าหนี้ไรเดอร์ PlatformRevenue, // รายได้ค่าคอมมิชชัน GatewayClearing, // บัญชีพักตอนรับเงินเข้าจาก gateway Escrow, // พักเงินระหว่างอาหารกำลังส่ง}
// หนึ่งบรรทัดในสมุด — immutable ตลอดกาล ยอดบัญชี fold จากรายการพวกนี้ ไม่เก็บเป็น columnpublic sealed record LedgerEntry( AccountId AccountId, Direction Direction, Money Amount, JournalEntryId JournalEntryId);หัวใจคือ JournalEntry — ชุดของ LedgerEntry ที่ สมดุล (invariant ★: เดบิตรวม = เครดิตรวม) พร้อม idempotency key และ “เหตุผล” ของรายการ ทั้งหมดถูกรักษาไว้ใน constructor — ถ้าไม่สมดุล สร้าง object ไม่ได้ตั้งแต่แรก:
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 debits = entries.Where(e => e.Direction == Direction.Debit) .Aggregate(Money.Thb(0), (sum, e) => sum + e.Amount); var credits = entries.Where(e => e.Direction == Direction.Credit) .Aggregate(Money.Thb(0), (sum, e) => sum + e.Amount);
// ★ invariant ที่ต้องรักษาไว้เสมอ: เดบิตรวมต้องเท่าเครดิตรวมพอดี if (debits != credits) throw new InvalidOperationException("journal entry ไม่สมดุล: เดบิตรวมต้องเท่าเครดิตรวม");
Id = id; IdempotencyKey = idempotencyKey; Reason = reason; Entries = entries; }
// reversing entry: สลับด้านเดบิต↔เครดิตของทุกบรรทัด จำนวนเท่าเดิม อ้างถึงรายการต้นทาง public static JournalEntry Reverse( JournalEntry original, JournalEntryId newId, string idempotencyKey) { var flipped = original.Entries .Select(e => new LedgerEntry( e.AccountId, e.Direction == Direction.Debit ? Direction.Credit : Direction.Debit, e.Amount, newId)) .ToList();
// ยังสมดุลเสมอ: การสลับด้านทุกบรรทัดแค่สลับผลรวมเดบิต↔เครดิตที่เท่ากันอยู่แล้ว return new JournalEntry(newId, idempotencyKey, $"reversal of {original.Id.Value}", flipped); }}Reverse คือทั้งหมดของการคืนเงินที่ถูกต้อง — มันไม่แตะ original เลย แค่สร้างรายการใหม่ที่สลับด้านของทุกบรรทัด ผลคือ ต่อบัญชีหนึ่งๆ ยอดจากรายการเดิมกับรายการย้อนหักกันเป็นศูนย์พอดี เงินกลับมาเท่าเดิม แต่ประวัติมีครบสองรายการ: “เคยจ่าย” และ “แล้วคืน” ใครมาตรวจย้อนหลังก็เห็นเรื่องราวจริงทั้งหมด
รายการเดิมกับรายการย้อน — ประสานด้วย process manager
หัวข้อที่มีชื่อว่า “รายการเดิมกับรายการย้อน — ประสานด้วย process manager”มาดูของจริง สมมติตอนออเดอร์ถูกยืนยัน journal entry #A จ่ายค่าอาหาร 200 บาทจาก wallet ลูกค้าเข้า Escrow AccountEscrow Accountบัญชีที่กันเงินไว้รอเงื่อนไขก่อนปล่อยให้ผู้รับตัวจริง เช่น เงินค่าอาหารที่ลูกค้าจ่ายจะพักอยู่ในบัญชี escrow จนกว่าออเดอร์จะถูกส่งมอบสำเร็จ ค่อยปล่อยเงินให้ร้านอาหารกับไรเดอร์ — รักษาสิทธิ์ทั้งสองฝ่ายไว้จนกว่าเงื่อนไขจะสำเร็จ ถ้าออเดอร์ถูกยกเลิกก่อนส่งมอบ เงินใน escrow จะถูกคืนแทนที่จะจ่ายออกไปTactical Design — เดบิต wallet 200, เครดิต escrow 200 พอลูกค้ายกเลิกก่อนอาหารถึง เรา post reversing entry #B ที่สลับด้าน แล้ว Process ManagerProcess Managerตัวประสานงานหลายขั้นตอนของธุรกรรมการเงินที่กระจายข้าม Aggregate หรือ Bounded Context เช่น ลำดับ escrow → settlement → payout ของออเดอร์หนึ่งใบ — ฟัง event ที่เข้ามาแต่ละขั้นแล้วสั่ง command ขั้นต่อไป คอยจำสถานะว่าตอนนี้ธุรกรรมไปถึงไหนแล้ว ต่างจาก Policy ที่เป็นกฎเดี่ยว ๆ ตรงที่ process manager ต้องรักษาสถานะของกระบวนการทั้งเส้นTactical Design — ตัวประสานงานหลายขั้น — เป็นคนสั่งให้เกิดรายการย้อนนี้:
flowchart TB PM["Process Manager<br/>ตัวประสานงานคืนเงิน"] PM -->|"อ่านรายการเดิม"| O PM -->|"สั่ง post รายการย้อน"| R O["รายการเดิม A<br/>ตอนจ่ายเข้า escrow"] O --> OD["เดบิต wallet ลูกค้า 200"] O --> OC["เครดิต escrow 200"] R["reversing entry B<br/>reversal of A"] R --> RC["เครดิต wallet ลูกค้า 200"] R --> RD["เดบิต escrow 200"] OD -.->|"สุทธิ = 0"| RC OC -.->|"สุทธิ = 0"| RD RC --> NET["ยอดสุทธิต่อบัญชี = 0<br/>ประวัติเดิมยังอยู่ครบ ไม่ถูกลบ"] RD --> NET classDef pm fill:#7c3aed,stroke:#5b21b6,color:#f8fafc; classDef orig fill:#1e3a8a,stroke:#1e40af,color:#f8fafc; classDef rev fill:#b45309,stroke:#78350f,color:#f8fafc; classDef net fill:#16a34a,stroke:#065f46,color:#f8fafc; class PM pm; class O,OD,OC orig; class R,RC,RD rev; class NET net;
คำบรรยายภาพ: รายการเดิม A (น้ำเงิน) เดบิต wallet ลูกค้าและเครดิต escrow อย่างละ 200 รายการย้อน B (ส้ม) สลับด้านทุกบรรทัด — เครดิต wallet คืนและเดบิต escrow ออก อย่างละ 200 เส้นประจับคู่ให้เห็นว่าต่อบัญชีเดียวกัน เดิม + ย้อน = ศูนย์ (เขียว) เงินกลับมาเท่าเดิมโดยไม่มีบรรทัดไหนถูกลบ ทั้งหมดถูกสั่งการโดย process manager (ม่วง) ที่คอยอ่านรายการต้นทางแล้วสั่ง post รายการย้อน
จุดสำคัญของภาพนี้: process manager ไม่ได้แก้อะไร มันแค่อ่านรายการเดิมแล้วสั่งให้ ledger post reversing entry ตัวใหม่ การแก้ที่ถูกต้องจึงเป็นการ “เพิ่ม” เสมอ ไม่ใช่ “ทับ”
Refund บางส่วน — ย้อนแค่บางส่วนของยอด
หัวข้อที่มีชื่อว่า “Refund บางส่วน — ย้อนแค่บางส่วนของยอด”ไม่ใช่ทุกการคืนที่คืนเต็มจำนวน ลูกค้าสั่งสามอย่างแต่ขาดไปหนึ่ง เราคืนแค่ 80 จาก 200 การคืนบางส่วน ไม่ใช่ การ Reverse ทั้งรายการ (นั่นจะคืนเต็ม 200) แต่คือการ post journal entry สมดุลรายการใหม่ที่ขยับเฉพาะส่วนที่คืน:
// recall: strongly-typed id ของออเดอร์ (ประกาศก่อนใช้)public readonly record struct OrderId(Guid Value);
// คืนบางส่วน: ย้อนเฉพาะจำนวนที่คืน ไม่แตะรายการเดิมpublic static JournalEntry PartialRefund( AccountId escrow, AccountId wallet, Money amount, OrderId orderId){ var id = new JournalEntryId(Guid.NewGuid()); return new JournalEntry( id, idempotencyKey: $"refund:{orderId.Value}:partial:{amount.Amount}", reason: "คืนเงินบางส่วน: ของขาด 1 รายการ", entries: new[] { new LedgerEntry(escrow, Direction.Debit, amount, id), // ปล่อยออกจาก escrow new LedgerEntry(wallet, Direction.Credit, amount, id), // คืนเข้า wallet ลูกค้า });}
// เรียกใช้: คืน 80 จาก 200 ที่จ่ายไปvar refund = PartialRefund(escrowId, walletId, Money.Thb(80), orderId);รายการนี้ก็สมดุล (เดบิต 80 = เครดิต 80) ผ่าน invariant ★ เท่ากับรายการเต็ม ประวัติจึงมีครบสามรายการ: จ่าย 200, คืน 80 และยอด 120 ที่เหลือยังอยู่ใน escrow รอ settle ตามปกติ ความสวยงามคือเรา ไม่ต้องคำนวณยอดคงเหลือใหม่เอง — balance-as-fold ไล่รวมทุกรายการให้เอง wallet ลูกค้าได้คืน 80 บัญชี escrow เหลือ 120 พอดี ทุกอย่าง fold ออกมาถูกโดยอัตโนมัติ
Chargeback — เมื่อเงินถูกดึงกลับหลังจ่ายออกไปแล้ว
หัวข้อที่มีชื่อว่า “Chargeback — เมื่อเงินถูกดึงกลับหลังจ่ายออกไปแล้ว”กรณีที่หินที่สุดคือ ChargebackChargebackการที่ลูกค้าโต้แย้งรายการเรียกเก็บเงินกับธนาคารหรือผู้ให้บริการชำระเงินโดยตรง (ข้ามหัว platform) แล้วเงินถูกดึงกลับ แม้เงินอาจถูกจ่ายให้ร้านอาหาร/ไรเดอร์ไปแล้วก็ตาม — ระบบต้องรองรับกรณีนี้ด้วย Reversing Entry ย้อนหลัง ซึ่งอาจทำให้บัญชีติดลบProcess — ลูกค้าไม่ได้มาขอคืนกับเรา แต่โต้แย้งรายการกับธนาคารหรือผู้ให้บริการจ่ายเงินโดยตรง ข้ามหัว platform ไปเลย แล้วเงินถูกดึงกลับที่ต้นทาง ปัญหาคือมันมักเกิดหลังจากออเดอร์ถูกส่งมอบและ SettlementSettlementขั้นตอนที่เงินซึ่งพักอยู่ (เช่นใน escrow หรือ clearing) ถูก 'ปิดจบ' อย่างเป็นทางการ — กระจายไปยังบัญชีปลายทางจริงตามสัดส่วนที่ตกลงไว้ เช่น ตัดค่าคอมมิชชัน platform แล้วโอนส่วนที่เหลือเข้าบัญชีร้านอาหารและไรเดอร์ มักเกิดเป็นรอบ (batch) ไม่ใช่ทันทีทุกออเดอร์Process ปล่อยเงินให้ร้านกับไรเดอร์ (แล้ว PayoutPayoutการจ่ายเงินที่ settle แล้วออกจากระบบไปให้ร้านอาหารหรือไรเดอร์จริง ๆ เช่น โอนเข้าบัญชีธนาคารหรือ PromptPay ของร้าน — มักรวมหลายออเดอร์เป็นรอบ payout เดียวต่อวันหรือต่อสัปดาห์ แทนที่จะโอนทุกออเดอร์แยกกันProcess จ่ายออกไปจริง) ไปแล้ว — เงินไม่ได้อยู่ใน escrow ให้ดึงคืนง่ายๆ อีกต่อไป
gateway แจ้ง chargeback เข้ามาผ่าน ACL ตัวเดิม (รูปทรงเดียวกับ IPaymentGateway จากคอร์ส Bounded Contexts บทที่ 5 — เป็นแค่ตัวอย่างประกอบ ไม่ใช่ integration จริง):
// ACL ห่อ gateway ภายนอก — illustrative, รูปทรงเดียวกับคอร์ส Bounded Contextspublic interface IPaymentGateway{ // gateway แจ้งว่าลูกค้า dispute แล้วเงินถูกดึงกลับที่ต้นทาง event Action<ChargebackNotified> ChargebackReceived;}
// สัญญาแค่ id/ค่าพื้นฐาน ไม่ลาก model ข้าม context เข้ามาpublic sealed record ChargebackNotified(Guid OrderId, decimal Amount, string Currency);การรับมือยังเป็นหลักเดิม: post reversing entry ไม่ใช่ลบอะไร แต่มีจุดต่างที่ต้องยอมรับ — เมื่อ settle ไปแล้ว การย้อนอาจดัน GatewayClearing (หรือบัญชีขาดทุนที่ตั้งไว้) ให้ ติดลบ เพราะเงินจริงถูกดึงออกไปแล้วทั้งที่จ่ายให้ร้าน/ไรเดอร์ไปก่อนหน้า นี่ไม่ใช่ bug แต่คือ บันทึกที่ซื่อสัตย์ ว่าตอนนี้ platform กำลังแบกส่วนต่างอยู่ ยอดติดลบคือความจริงที่ต้องเห็น ไม่ใช่ความจริงที่ต้องซ่อน — บทหน้า reconciliation จะเป็นตัวจับยอดพวกนี้ขึ้นมาจัดการ
Refund หลายขั้น — compensation/saga สำหรับเงินจริง
หัวข้อที่มีชื่อว่า “Refund หลายขั้น — compensation/saga สำหรับเงินจริง”พอ chargeback เกิดหลัง settlement การคืนก็ไม่ใช่รายการเดียวจบอีกต่อไป มันเป็น ลำดับหลายขั้น ที่ต้องทำให้ครบ: ย้อน settlement (ดึงเงินจากบัญชีเจ้าหนี้ร้าน/ไรเดอร์กลับ escrow) → ย้อนการจ่ายเข้า escrow (ปล่อย escrow กลับเข้า wallet = เครดิตเงินคืนลูกค้า) → แจ้งเตือนฝ่ายที่เกี่ยวข้อง นี่คือรสชาติของ compensation/saga ที่จับต้องได้จริงกับเงิน — แต่ละขั้น “แก้กลับ” ขั้นก่อนหน้าด้วย reversing entry ตัวใหม่ ไม่ใช่ transaction ก้อนเดียวที่ rollback ได้
ตัวประสานทั้งเส้นนี้คือ process manager — มันฟัง Integration EventIntegration Eventเหตุการณ์ที่สื่อสารข้าม Bounded Context หรือข้าม service ผ่าน message broker เช่น PaymentCaptured, OrderDelivered ที่ Payment context ต้องรับฟังจาก Ordering context เพื่อรู้ว่าเมื่อไหร่ควรปล่อยเงินจาก escrow — ต่างจาก Domain Event ที่ทำงานเฉพาะภายใน context เดียว (in-process)Architecture จากคอร์ส Bounded Contexts บทที่ 8 (OrderConfirmed, OrderDelivered — สัญญาแค่ id/ค่าพื้นฐาน) เพื่อ จำว่าเงินของออเดอร์นี้ไปถึงขั้นไหนแล้ว พอมีคำขอคืนเข้ามา มันถึงเลือกชุดรายการย้อนได้ถูกตามสถานะ ก่อนอื่นคือสัญญาที่มันพึ่งพา:
public readonly record struct OrderId(Guid Value);
// integration events จากคอร์ส #8 — สัญญาแค่ id/ค่าพื้นฐานpublic sealed record OrderConfirmed(Guid OrderId);public sealed record OrderDelivered(Guid OrderId);
// คำสั่งขอคืนเงิน (จากทีม support หรือถูกจุดชนวนจาก chargeback ของ gateway)public sealed record RefundRequested(OrderId OrderId, Money Amount);
public interface INotifier{ void RefundCompleted(OrderId orderId);}
public interface ILedger{ // idempotent: post key เดิมซ้ำ ไม่บันทึกซ้ำ — ดีดูปด้วย IdempotencyKey (กลไก idempotent consumer จากคอร์ส #8) void Post(JournalEntry entry);
// อ่านรายการต้นทางจาก append-only store แล้ว post reversing entry ให้ — idempotent เช่นกัน JournalEntryId PostReversal(JournalEntryId original, string idempotencyKey);
// หา journal entry ของออเดอร์ตามบทบาท (เช่น "escrow-funding", "settlement") JournalEntryId? FindPosting(OrderId order, string purpose);}process manager เองเบามาก — เก็บแค่ “ตอนนี้ไปถึงขั้นไหน” กับ id ของรายการที่ต้องย้อน แล้วแตกกิ่งตามสถานะ:
public enum RefundStage { NothingMoved, EscrowFunded, Settled }
public sealed class RefundProcessManager{ private RefundStage _stage = RefundStage.NothingMoved; private JournalEntryId? _escrowFunding; // จำตอน OrderConfirmed private JournalEntryId? _settlement; // จำตอน OrderDelivered private readonly ILedger _ledger; private readonly INotifier _notifier;
public RefundProcessManager(ILedger ledger, INotifier notifier) { _ledger = ledger; _notifier = notifier; }
// --- ฟัง integration event เพื่อจำว่าเงินของออเดอร์นี้ขยับไปถึงไหน --- public void On(OrderConfirmed e) { _escrowFunding = _ledger.FindPosting(new OrderId(e.OrderId), "escrow-funding"); _stage = RefundStage.EscrowFunded; }
public void On(OrderDelivered e) { _settlement = _ledger.FindPosting(new OrderId(e.OrderId), "settlement"); _stage = RefundStage.Settled; }
// --- ขอคืนเงิน: เลือกชุดรายการย้อนตามสถานะที่จำไว้ --- public void Handle(RefundRequested cmd) { switch (_stage) { // จ่ายออกไปแล้ว: ย้อน settlement ก่อน (payables -> escrow) แล้วค่อยย้อน escrow -> wallet case RefundStage.Settled when _settlement is JournalEntryId s && _escrowFunding is JournalEntryId f1: _ledger.PostReversal(s, $"refund:{cmd.OrderId.Value}:settlement"); _ledger.PostReversal(f1, $"refund:{cmd.OrderId.Value}:escrow"); break;
// ยังไม่ settle: เงินยังอยู่ใน escrow — แค่ย้อน escrow -> wallet (เครดิตคืนลูกค้า) case RefundStage.EscrowFunded when _escrowFunding is JournalEntryId f2: _ledger.PostReversal(f2, $"refund:{cmd.OrderId.Value}:escrow"); break;
// ยังไม่มีเงินขยับ ไม่มีอะไรต้องย้อน case RefundStage.NothingMoved: return; }
_notifier.RefundCompleted(cmd.OrderId); }}สังเกตว่าทุก PostReversal แนบ idempotency key ที่ผูกกับออเดอร์และขั้นตอน (refund:{orderId}:settlement, refund:{orderId}:escrow) — ถ้า process manager ถูกกระตุ้นซ้ำ (integration event ส่งมาซ้ำ หรือคำขอคืนถูกยิงสองรอบ) การ post ครั้งที่สองด้วย key เดิมจะไม่บันทึกซ้ำ นี่คือกลไก Idempotent ConsumerIdempotent Consumerตัวรับ event หรือ message ที่ประมวลผลข้อความเดิมซ้ำกี่ครั้งก็ได้ผลลัพธ์เหมือนเดิม ไม่สร้างผลข้างเคียงซ้ำ เช่น รับ PaymentCaptured ซ้ำสองครั้งจาก message broker ต้องไม่บันทึก journal entry สองรอบ — ตรวจด้วย Idempotency Key หรือเช็คว่าเคยประมวลผลรายการนี้ไปแล้วหรือยังArchitecture ตัวเดิมจากคอร์ส Bounded Contexts บทที่ 8 — เรา ไม่ได้สอน messaging ใหม่ในบทนี้ แค่หยิบมาใช้ให้การคืนเงินหลายขั้นปลอดภัยจากการทำซ้ำ
ต่างจาก policy ที่เป็นกฎเดี่ยวๆ ตรงที่ process manager ต้อง รักษาสถานะของกระบวนการทั้งเส้น — มันรู้ว่าเงินไปถึงขั้น settle แล้วหรือยัง จึงเลือกได้ว่าจะย้อนแค่ escrow หรือย้อน settlement ด้วย นี่คือหน้าที่หลักของมัน: จำว่าเราอยู่ตรงไหน แล้วสั่งขั้นชดเชยที่ถูกต้อง
สรุปและก้าวต่อไป
หัวข้อที่มีชื่อว่า “สรุปและก้าวต่อไป”การแก้ที่ถูกต้องในระบบการเงินมีหลักข้อเดียว: ไม่แก้ ไม่ลบของเดิม — เพิ่มรายการใหม่มาหักล้าง
- คืนเต็ม →
Reverseสลับด้านทุกบรรทัด ยอดต่อบัญชีหักกันเป็นศูนย์ ประวัติอยู่ครบ - คืนบางส่วน → journal entry สมดุลรายการใหม่ที่ขยับเฉพาะส่วนที่คืน ยอดที่เหลือ fold ออกมาถูกเอง
- chargeback หลัง settle → ยัง post reversing entry เหมือนเดิม ยอมให้บัญชีติดลบเป็นบันทึกที่ซื่อสัตย์
- หลายขั้น → process manager จำสถานะจาก integration event แล้วประสานลำดับชดเชยแบบ compensation/saga ทุกขั้น idempotent
เหลือบทสุดท้าย — บทที่ 8 reconciliation เทียบยอดปิดรอบ เอา ledger ที่เต็มไปด้วยรายการจ่าย คืน และย้อนเหล่านี้ ไปพิสูจน์ว่าทุกบาทลงตัวจริง เดบิตรวมทั้งระบบเท่ากับเครดิตรวมพอดี และยอดฝั่งเราตรงกับที่ gateway รายงาน — ปิดคำสัญญาที่ตั้งไว้ตั้งแต่บทแรกว่า “เงินไม่มีวันหาย”
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ตอนต้องคืนเงินที่จ่ายไปแล้ว ทำไมถึง post reversing entry แทนที่จะลบหรือแก้รายการเดิม?