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

Refund, reversal & การ​แก้​ที่​ถูกต้อง

มา​ถึง​ตอน​ที่​ทุก​ระบบ​การเงิน​ต้อง​เจอ — ลูกค้า​ยกเลิก​ออเดอร์ อาหาร​มา​ไม่​ครบ หรือ​กด​โต้แย้ง​รายการ​กับ​ธนาคาร แล้ว เงิน​ที่​ขยับ​ไป​แล้ว​ต้อง​ถอย​กลับ ตอน​บท​ที่ 4-5 เรา​จ่าย​ค่า​อาหาร​เข้า​บัญชี​พัก​ระหว่าง​ส่ง (escrow) แล้ว settle ปล่อย​เงิน​ให้​ร้าน​กับ​ไร​เด​อร์ไป​เรียบร้อย ตอน​นี้​ต้อง​คืน คำถาม​คือ​คืน​ยังไง​ใน​เมื่อ LedgerLedgerแหล่ง​ความ​จริง​เดียว​ที่​รักษา​ประวัติ Journal Entry ทุก​รายการ​ของ​ระบบ​ไว้​เรียง​ตาม​เวลา แบบ append-only ไม่มี​การ​แก้​ของ​เก่า ยอด​เงิน​ของ​บัญชี​ไหน​ก็ตาม​คำนวณ​ได้​จาก​การ​ไล่​ผล​รวม​รายการ​ใน ledger เท่านั้น ไม่ใช่​ตัวเลข​ที่​เก็บ​แยก​ไว้​ต่างหากTactical Design ของ​เรา​เป็น​สมุด​ที่ ห้าม​แก้​ห้าม​ลบ — เขียน​เพิ่ม​ได้​อย่าง​เดียว

สัญชาตญาณ​แรก​ของ​เกือบ​ทุก​คน​คือ “ก็​ไป​หา​รายการ​ที่​จ่าย​เงิน​ออก​ไป แล้ว​ลบ​มัน​ทิ้ง / แก้​ยอด​ให้​เป็น​ศูนย์​สิ” บท​นี้​จะ​แสดง​ว่า​ทำไม​นั่น​คือ​ทาง​ที่​ผิด และ​วิธี​ที่​ถูก​คือ​การ post Reversing EntryReversing EntryJournal Entry ใหม่​ที่​เขียน​ขึ้น​เพื่อ​หักล้าง​รายการ​เดิม​ทั้งหมด​หรือ​บาง​ส่วน โดย​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม เช่น ตอน​คืน​เงิน​ลูกค้า (refund) แทนที่​จะ​ลบ​รายการ​จ่าย​เงิน​เดิม ระบบ​เขียน reversing entry ใหม่​ทับ — ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจสอบ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น​บ้างTactical DesignJournal EntryJournal Entryหน่วย​ที่​เล็ก​ที่สุด​ของ​การ​บันทึก​ใน ledger หนึ่ง​รายการ​มี​อย่าง​น้อย​สอง​บรรทัด (เดบิต​กับ​เครดิต) ที่​ยอด​รวม​เท่า​กัน เช่น ตอน​ลูกค้า​จ่าย​ค่า​อาหาร เกิด journal entry เดียว​ที่​กระทบ​ทั้ง​บัญชี​เงินสด​และ​บัญชี escrow — เขียน​แล้ว​ห้าม​แก้ ถ้า​ผิด​ต้อง​เขียน Reversing Entry มา​หักล้าง​แทนTactical Design ใหม่​ที่​หักล้าง​ของ​เดิม​ให้​ยอด​สุทธิ​เป็น​ศูนย์ โดย​ไม่​แตะ​ประวัติ​เดิม​แม้แต่​บรรทัด​เดียว

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

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 ดิบ — ไป​หา​รายการ​ที่​ผิด​แล้ว​ลบ​หรือ​แก้​ยอด​ทับ
// ❌ 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 เป๊ะ: เรา​ปฏิบัติ​กับ​ประวัติ​เหมือน​มัน​เป็น สถานะ​ที่​แก้​ทับ​ได้ ทั้ง​ที่​มัน​คือ บันทึก​ของ​สิ่ง​ที่​เกิด​ขึ้น​จริง​และ​เปลี่ยน​ไม่​ได้ การ​แก้​ที่​ถูกต้อง​ไม่ใช่​การ​ลบ​เหตุการณ์ แต่​คือ​การเพิ่ม​เหตุการณ์​ใหม่​ที่​หักล้าง​ของ​เดิม

ทวน​รูปทรง​ที่​ปั้น​ไว้​บท​ที่ 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 จากรายการพวกนี้ ไม่เก็บเป็น column
public 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 เลย แค่​สร้าง​รายการ​ใหม่​ที่​สลับ​ด้าน​ของ​ทุก​บรรทัด ผล​คือ ต่อ​บัญชีหนึ่งๆ ยอดจาก​รายการ​เดิม​กับ​รายการ​ย้อน​หัก​กัน​เป็น​ศูนย์​พอดี เงิน​กลับ​มา​เท่า​เดิม แต่​ประวัติ​มี​ครบ​สอง​รายการ: “เคย​จ่าย” และ “แล้ว​คืน” ใคร​มา​ตรวจ​ย้อนหลัง​ก็​เห็น​เรื่องราว​จริง​ทั้งหมด

มา​ดู​ของ​จริง สมมติ​ตอนออเดอร์​ถูก​ยืนยัน 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 ตัว​ใหม่ การ​แก้​ที่​ถูกต้อง​จึง​เป็นการ “เพิ่ม” เสมอ ไม่ใช่ “ทับ”

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

พอ 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 แทนที่จะลบหรือแก้รายการเดิม?