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

Top-up ด้วย PromptPay/QR

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

แต่​การ​เติม​เงิน​ไม่​ได้​ง่าย​เหมือน​เรียก method แล้ว​เงิน​เข้า​ทันที เพราะ​คน​ที่ “จับ​เงิน” จริงๆ คือ payment gateway ภายนอก (PromptPay) ที่​อยู่​นอก​บ้าน​เรา เรา​สั่ง​ให้​มัน​สร้าง QR ได้ แต่​จังหวะ​ที่​ลูกค้า​จ่าย​เงิน​สำเร็จ​นั้น​เกิด ทีหลัง คนละ request — gateway จะ​ยิง​กลับ​มาบอก​เรา​เอง​ผ่าน webhook บท​นี้​คือ​การ​รับมือ​กับ​ความ async นั้น​ให้​เงิน​ทุก​บาท​ลง ledger อย่าง​ถูกต้อง​และ​ไม่​ซ้ำ

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

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เริ่ม​มี code domain จริง​ให้​ตาม​แล้ว ทั้ง​โครง ledger จาก​บท​ก่อน (Account, JournalEntry, LedgerEntry) และ handler ที่​รับ webhook เติม​เงิน snippet ใน​บท​วาง​เรียง​ให้​ประกาศ​ชนิด​ก่อน​ใช้​เสมอ อ่าน​ไล่​จาก​บน​ลง​ล่างได้ตรงๆ

ก่อน​จะ​รับ webhook มา​ลง​บัญชี ขอ​ทวน​ของ​ที่​บท​ที่ 2 ปั้น​ไว้​ให้​ครบ​ก่อน เพราะ​รายการ​เติม​เงิน​จะ​ประกอบ​ขึ้น​จาก​ชิ้น​ส่วน​พวก​นี้​ทั้งหมด

ค่า​เงิน​ทุก​จำนวน​คือ value object MoneyMoneyValue Object ที่​แทน​จำนวน​เงิน​พร้อม​สกุล​เงิน (เช่น จำนวน + THB) เปรียบเทียบ​กัน​ด้วย​ค่า ไม่ใช่ identity, บวก/ลบ​กัน​ได้​แต่​ต้อง​เป็น​สกุล​เดียวกัน, immutable — ห้าม​ใช้ decimal เปล่า ๆ แทน​เงิน​เพราะ​จะ​ทำให้​หน่วย​เงิน​และ​กฎ​การ​ปัดเศษ​หล่น​หาย​ไป (Primitive Obsession)Tactical Design ตัว​เดิม​จาก​คอร์ส DDD-in-Code — ห่อ​จำนวน​กับ​สกุล​เงิน​ไว้​ด้วย​กัน และ​รักษา​กฎ​ของ​ตัวเอง​ผ่าน primary constructor body เรา​ยก​มา​ใช้​ทั้งดุ้น​โดย​ไม่​แก้:

// 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 — ห่อ​เป็น value object ทั้ง​คู่ ไม่​ปล่อย​เป็น Guid เปล่าๆ กัน AccountId กับ JournalEntryId สลับ​กัน​ไม่​ได้:

FoodOrdering.Domain/Ledger/Ids.cs
public sealed record AccountId(Guid Value);
public sealed record JournalEntryId(Guid Value);

ต่อ​มา​คือ​หน่วย​บันทึก — Journal EntryJournal Entryหน่วย​ที่​เล็ก​ที่สุด​ของ​การ​บันทึก​ใน ledger หนึ่ง​รายการ​มี​อย่าง​น้อย​สอง​บรรทัด (เดบิต​กับ​เครดิต) ที่​ยอด​รวม​เท่า​กัน เช่น ตอน​ลูกค้า​จ่าย​ค่า​อาหาร เกิด journal entry เดียว​ที่​กระทบ​ทั้ง​บัญชี​เงินสด​และ​บัญชี escrow — เขียน​แล้ว​ห้าม​แก้ ถ้า​ผิด​ต้อง​เขียน Reversing Entry มา​หักล้าง​แทนTactical Design แต่ละ​รายการ​ประกอบ​จาก LedgerEntry หลาย​บรรทัด (แต่ละ​บรรทัด​คือ​หนึ่ง​ขา เดบิต​หรือ​เครดิต บน​บัญชี​หนึ่ง) LedgerEntry เขียน​แล้ว immutable แก้​ไม่​ได้ และ JournalEntry รักษา invariant เหล็ก​ของ​ทั้ง​ระบบ​ไว้​ใน constructor — เดบิต​รวม​ต้อง​เท่ากับ​เครดิต​รวม (★) ไม่​งั้น​สร้าง​ไม่​ขึ้น​ตั้งแต่​แรก:

FoodOrdering.Domain/Ledger/Journal.cs
public enum Direction { Debit, Credit }
// หนึ่งขาของรายการ — immutable แก้ไม่ได้หลังบันทึก
public sealed record LedgerEntry(
AccountId AccountId,
Direction Direction,
Money Amount,
JournalEntryId JournalEntryId);
public sealed class JournalEntry
{
public JournalEntryId Id { get; }
public string IdempotencyKey { get; } // กันบันทึกซ้ำเมื่อคำขอเดิมถูกยิงซ้ำ
public string Reason { get; } // เกิดจากอะไร เช่น "topup:promptpay:<chargeId>"
public IReadOnlyList<LedgerEntry> Entries { get; }
public JournalEntry(
JournalEntryId id, string idempotencyKey, string reason, IReadOnlyList<LedgerEntry> entries)
{
var debit = entries.Where(l => l.Direction == Direction.Debit)
.Aggregate(Money.Thb(0), (s, l) => s + l.Amount);
var credit = entries.Where(l => l.Direction == Direction.Credit)
.Aggregate(Money.Thb(0), (s, l) => s + l.Amount);
// ★ invariant: เดบิตรวม = เครดิตรวม — ไม่สมดุลก็สร้างไม่ขึ้น
if (debit != credit)
throw new InvalidOperationException(
$"journal entry ไม่สมดุล: เดบิต {debit.Amount} ≠ เครดิต {credit.Amount}");
Id = id;
IdempotencyKey = idempotencyKey;
Reason = reason;
Entries = entries;
}
}

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

FoodOrdering.Domain/Ledger/Account.cs
public enum AccountType
{
CustomerWallet, // หนี้สิน — เงินที่ platform ติดค้างลูกค้าในกระเป๋า
RestaurantPayable, // หนี้สิน — ค้างจ่ายร้านอาหาร
RiderPayable, // หนี้สิน — ค้างจ่ายไรเดอร์
PlatformRevenue, // รายได้ — ค่าคอมมิชชันที่หักเป็นของ platform
GatewayClearing, // สินทรัพย์ — เงินที่ gateway จับไว้ให้ รอโอนเข้าแบงก์จริง
Escrow, // พักเงินค่าอาหารระหว่างออเดอร์กำลังส่ง
}
public sealed class Account
{
public AccountId Id { get; }
public AccountType Type { get; }
private readonly IReadOnlyList<LedgerEntry> _entries;
public Account(AccountId id, AccountType type, IReadOnlyList<LedgerEntry> entries)
{
Id = id;
Type = type;
_entries = entries;
}
// ยอดคงเหลือ 'พับ' มาจากทุกรายการ ไม่ใช่ column ที่เก็บไว้ (ทวนบทที่ 2)
public Money Balance()
{
var plus = NormalSide(Type); // ด้านที่ทำให้บัญชีประเภทนี้ 'เพิ่ม'
var total = _entries.Aggregate(0m, (sum, e) =>
e.Direction == plus ? sum + e.Amount.Amount : sum - e.Amount.Amount);
return Money.Thb(total);
}
// สินทรัพย์เพิ่มด้วยเดบิต ที่เหลือ (หนี้สิน/รายได้) เพิ่มด้วยเครดิต
private static Direction NormalSide(AccountType type) => type switch
{
AccountType.GatewayClearing => Direction.Debit, // สินทรัพย์เท่านั้น
_ => Direction.Credit, // wallet/payable/revenue/escrow
};
}

และ​ปลายทาง​ที่​เรา​จะ “ลง” รายการ​คือ ledger เอง — เรา​เรียก​มัน​ผ่าน Post ซึ่ง​เป็น idempotent: ยิง JournalEntry ที่ IdempotencyKey เดิม​ซ้ำ​กี่​ครั้ง ก็​ลง​บัญชี​จริง​แค่​ครั้ง​เดียว:

FoodOrdering.Domain/Ledger/ILedger.cs
public interface ILedger
{
// idempotent: เจอ IdempotencyKey ที่เคยลงแล้ว จะข้าม ไม่บันทึกซ้ำ
Task Post(JournalEntry entry, CancellationToken ct);
}

ของ​ทั้งหมด​นี้​ไม่มี​อะไร​ใหม่​ใน​แง่​แนวคิด — มัน​คือ​ชั้น​ที่​บท​ที่ 2 กับ 3 วาง​ไว้ บท​นี้​แค่​หยิบ​มา​ใช้​กับ​เงิน​ก้อน​แรก​ที่​ไหล​เข้า​จริง

เรา​ไม่​ได้​คุย​กับ PromptPay ตรงๆ ใน domain — เรา​คุย​ผ่าน Anti-Corruption Layer ตัว​เดิม​ที่​ห่อ gateway ไว้​ที่​ขอบ ทวน​ได้​จาก​คอร์ส Bounded Contexts บท​ที่ 5 เรื่อง Anti-Corruption Layer — domain เรา​รู้จัก​แค่ interface IPaymentGateway ส่วน​เบื้องหลัง​จะ​เป็น PromptPay, AcmePay หรือ​ของ​เจ้า​ไหน​ก็​เรื่อง​ของ adapter ที่​ขอบ (ใน​คอร์ส​นี้ gateway เป็น​แค่​ตัวอย่าง​ประกอบ ไม่​ได้​ต่อ​ของ​จริง)

ขั้น​แรก​ของ​การ​เติม​เงิน​คือ​ขอ​ให้ gateway สร้าง QR ขึ้น​มา​ให้​ลูกค้า​สแกน — ส่ง​จำนวน​เงิน (เป็น Money) กับ reference ของ​เรา​ไป แล้ว​ได้ payload ของ QR กลับ​มา สังเกต​ว่า​สิ่ง​ที่ ACL คืน​กลับ​มา​เป็น primitive ล้วน ไม่ใช่ domain object:

FoodOrdering.Infrastructure/Payments/IPaymentGateway.cs
// ACL ครอบ gateway ภายนอก (ทวนจากคอร์ส Bounded Contexts) — domain ไม่รู้จัก PromptPay ตรง ๆ
public interface IPaymentGateway
{
Task<QrCharge> CreateQrCharge(Money amount, string reference, CancellationToken ct);
}
// ผลลัพธ์เป็น primitive ล้วน — ChargeId ใช้อ้างอิงตอน gateway ยืนยันกลับมาทีหลัง
public sealed record QrCharge(string ChargeId, string QrPayload);

ถึง​ตรง​นี้​ยัง ไม่มี​เงิน​เข้า ledger เลย เรา​แค่​ได้ QR ให้​ลูกค้า​สแกน — เงิน​จริง​จะ​ขยับ​ก็​ต่อ​เมื่อ​ลูกค้า​จ่าย​สำเร็จ ซึ่ง​เป็น​คนละ​เรื่อง คนละ​เวลา

ลูกค้า​เปิด app ธนาคาร สแกน QR กด​จ่าย — ทั้งหมด​นี้​เกิด​นอก​ระบบ​เรา เรา​ไม่มี​ทาง​รู้​ผล​ใน request เดิม​ที่​สร้าง QR gateway จะ​เป็น​คน​บอก​เรา​เอง​ว่า “จ่าย​สำเร็จ​แล้ว” โดย​ยิง webhook กลับ​มา​ใน​อีก​ไม่​กี่​วินาที (หรือ​นาที) ข้าง​หน้า

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

payload ที่ gateway ยิง​กลับ​มา​จึง​หน้าตา​เป็น record ของ​ค่า​ดิบ​ล้วนๆ:

// สัญญาข้ามขอบ — ID/primitive ล้วน ไม่มี domain object สักตัว
public sealed record TopUpConfirmed(
string ChargeId, // อ้างอิงของ gateway ที่ได้ตอนสร้าง QR
Guid CustomerId, // ใครเติม
decimal Amount, // เท่าไร (decimal ดิบ ยังไม่ใช่ Money)
string Currency, // สกุลเงิน (string ดิบ)
string IdempotencyKey); // รหัสกันซ้ำที่ gateway แนบมากับทุกครั้งที่ยิง

ฝั่ง​รับ​ต่างหาก​ที่​มีหน้าที่ ประกอบ domain object ขึ้น​จาก primitive พวก​นี้ — เอา Amount กับ Currency มา​ห่อ​เป็น Money แล้ว​ให้ Money ตรวจ​สกุล​เงิน​และ​ความ​ไม่​ติดลบ​ให้​เอง​ที่​ขอบ

พอ webhook ยืนยัน​เข้า​มา เรา​จะ​ลง​รายการ​อะไร? สัญชาตญาณ​แรก​คือ “เดบิต​เงินสด platform เครดิต wallet ลูกค้า” แบบ​ที่​บท​ที่ 1 วาด​ไว้​ตอน​ปู​แนวคิด — แต่​ตอน​นั้น​เรา​จงใจ​ย่อ​ให้​ง่าย เพราะ​ยัง​ไม่​ได้ model gateway ที่​เป็น async ความ​จริง​คือ ณ วินาที​ที่ webhook เข้า​มา เงิน​ยัง​ไม่​ได้​อยู่​ใน​บัญชี​ธนาคาร​ของ platform — มัน​ยัง​อยู่​กับ gateway ที่​จะ​รวม​ยอด​โอน​เข้า​แบงก์​ให้​เรา​เป็น​รอบ​ทีหลัง

นี่​คือ​เหตุผล​ที่​ต้อง​มี Clearing AccountClearing Accountบัญชี​พัก​เงิน​ชั่วคราว​ระหว่าง​สอง​ขั้นตอน​ของ​ธุรกรรม เช่น ตอน​ลูกค้า​จ่าย​เงิน​ผ่าน PromptPay เงิน​จะ​เข้า​บัญชี clearing ก่อน แล้ว​ค่อย​กระจาย​ไป​บัญชี​ร้าน​อาหาร/ไร​เด​อร์/ค่า​คอม​มิช​ชัน platform ทีหลัง — แยก​ขั้นตอน 'รับ​เงิน​เข้า' ออก​จาก 'จัดสรร​เงิน' ให้​ตรวจสอบ​ได้ที​ละ​ขั้นTactical Design — บัญชี​พัก​เงิน​ชั่วคราว​ที่​คั่น​ระหว่าง​สอง​จังหวะ​ของ​ธุรกรรม เรา​ตั้ง​บัญชี​ชื่อ GatewayClearing (ประเภท​สินทรัพย์) ไว้​แทน “เงิน​ที่ gateway จับ​ได้​แล้ว​แต่​ยัง​ไม่​เข้า​แบงก์​เรา” ตอน webhook ยืนยัน เรา​ลง​รายการ​ที่​สมดุล​ด้วย​หลัก Debit and CreditDebit and Creditสอง​ด้าน​ของ​ทุก Journal Entry ใน​ระบบ double-entry — เดบิต​กับ​เครดิต​ไม่​ได้​แปล​ว่า 'บวก' หรือ 'ลบ' ตรง ๆ แต่​ขึ้น​กับ​ประเภท​บัญชี สิ่ง​ที่​ต้อง​จริง​เสมอ​คือ​ผล​รวม​เดบิต​ของ​รายการ​หนึ่ง​ต้อง​เท่ากับ​ผล​รวม​เครดิต​พอดีTactical Design เดิม:

  • เดบิต GatewayClearing — สินทรัพย์​เพิ่ม (gateway ถือ​เงิน​ให้​เรา​เพิ่ม​ขึ้น)
  • เครดิต CustomerWallet — หนี้สิน​เพิ่ม (platform เป็น​หนี้​ลูกค้า​ใน​กระเป๋า​เพิ่ม​ขึ้น)

ทั้ง​สอง​ขา​เท่า​กัน​เป๊ะ รายการ​จึง​สมดุล ส่วน​จังหวะ​ที่ gateway โอน​เงินสด​เข้า​แบงก์​เราจริงๆ ทีหลัง จะ​เป็น​อีก​รายการ​หนึ่ง (เดบิต​เงินสด platform เครดิต GatewayClearing) ที่​ทำให้​ยอด clearing หัก​กลับ​มา​เป็น​ศูนย์ — บัญชี​พัก​จึง​ทำ​หน้าที่ แยก​จังหวะ “gateway จับ​เงิน​ได้” ออก​จาก “เงินสด​เข้า​แบงก์” ให้​ตรวจสอบ​ได้ที​ละ​ขั้น ถ้า​วัน​ไหน​ยอด clearing ค้าง​ไม่​ยอม​เป็น​ศูนย์ แปล​ว่า​มี​เงิน​ที่ gateway บอกว่า​รับ​แล้ว​แต่​ยัง​ไม่​โอน​เข้า​มา — เห็น​ปัญหา​ได้​ทันที

flowchart TB
  A["ลูกค้าขอเติมเงิน 200 บาท"] --> B["domain สั่ง IPaymentGateway<br/>สร้าง QR charge — ACL ครอบ PromptPay"]
  B --> C["ลูกค้าสแกน QR แล้วจ่ายผ่าน app ธนาคาร"]
  C -. "จ่ายสำเร็จทีหลัง — คนละ request" .-> D["gateway ยิง webhook กลับ<br/>TopUpConfirmed — ID/primitive ล้วน"]
  D --> E{"เคยเห็น IdempotencyKey นี้แล้ว?"}
  E -- "เคย" --> F["ข้าม — ไม่เครดิต wallet ซ้ำ"]
  E -- "ยังไม่เคย" --> G["ลง Journal Entry ที่สมดุล"]
  G --> H["เดบิต GatewayClearing +200<br/>สินทรัพย์เพิ่ม"]
  G --> I["เครดิต CustomerWallet +200<br/>หนี้สินเพิ่ม"]
  H --- J["เดบิตรวม 200 = เครดิตรวม 200 — สมดุล"]
  I --- J
  classDef ext 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 A,B,C,D ext;
  class H dr;
  class I cr;
  class J bal;

คำ​บรรยาย​ภาพ: การ​เติม​เงิน​หนึ่ง​ครั้ง​เดิน​เป็น​สอง​ช่วง​ที่​คั่น​ด้วย​เส้น​ประ — ช่วง​แรก (น้ำเงิน) เรา​สร้าง QR ผ่าน ACL แล้ว​ลูกค้า​ไป​จ่าย​ที่ app ธนาคาร ซึ่ง​จบ คนละ request กับ​ตอน​สร้าง QR เส้น​ประ​คือ​ช่องว่าง async ที่​เรา​ต้อง​รอ gateway ยิง webhook กลับ​มา​เอง เมื่อ TopUpConfirmed เข้า​มา เรา​เช็ก IdempotencyKey ก่อน — ถ้า​เคย​เห็น​แล้ว​ก็​ข้าม (กันลง​ซ้ำ) ถ้า​ยัง​ไม่​เคย​จึง​ลง journal entry ที่​มี​สอง​ขา​สมดุล: เดบิต GatewayClearing (เขียว, สินทรัพย์​เพิ่ม) เท่ากับ​เครดิต CustomerWallet (แดง, หนี้สิน​เพิ่ม) จุด​ส้ม​คือ invariant เหล็ก​ที่ constructor บังคับ​ไว้ — เดบิต​รวม​ต้อง​เท่า​เครดิต​รวม​เสมอ

code ที่​พัง​แบบ​เงียบๆ มัก​หน้าตา​สม​เหตุ​สม​ผล​ที่สุด webhook เข้า​มา​ก็​บวก​ยอด​เข้า​กระเป๋า​ลูกค้า​เลย จบ:

❌ version ดิบ — บวก​ยอด wallet ทุก​ครั้ง​ที่ webhook เข้า
// ❌ version ดิบ — ไม่มีบัญชีพัก ไม่เช็คว่าเคยรับ webhook นี้แล้วหรือยัง
public async Task HandleNaive(TopUpConfirmed msg)
{
var wallet = await _db.Wallets.FindAsync(msg.CustomerId);
wallet.Balance += msg.Amount; // ← เขียนทับ column ยอด + ไม่กันการรับซ้ำ
await _db.SaveChangesAsync();
}

code นี้​ทำงาน​ได้​ใน​เดโม​ที่ webhook เข้า​มา​ครั้ง​เดียว​เป๊ะๆ แต่​พัง​ใน​สอง​จุด​ที่​แก้​ทีหลัง​ไม่​ได้:

  • เครดิต​ซ้ำ​เมื่อ webhook เข้า​ซ้ำ — การ​ส่ง webhook เป็น​แบบ at-least-once เสมอ ถ้า gateway ยิง​มา​แล้ว​ไม่​ได้​รับ ack ทัน​เวลา (เน็ต​กระตุก, เรา deploy อยู่​พอดี) มัน​จะ​ยิง​ซ้ำ handler นี้​ไม่​ได้​จำ​ว่า​เคย​รับ IdempotencyKey นี้​แล้ว จึง​บวก​เงิน​ให้​ลูกค้า​สอง​รอบ — เงิน​โผล่​มา​จาก​อากาศ กฎ “เงิน​ไม่มี​วัน​หาย​หรือ​โผล่​เอง” แตก​ทันที
  • ไม่มี​บัญชี​พัก ไม่มี​สอง​ขา — บวก​ยอด column เดียว​คือ​กลับ​ไป​หา​ปัญหา​เดิม​ทั้งหมด​ของ​บท​ที่ 1: เขียน​ทับ​กันเอง ไม่มี​ประวัติ​ให้​ตรวจ และ​ไม่มี​ร่องรอย​ว่า​เงิน​ก้อน​นี้​ยัง “ค้าง” อยู่​ที่ gateway หรือ​เข้า​แบงก์​แล้ว

ทาง​แก้​ไม่ใช่​เขียน handler ให้​ระวัง​มาก​ขึ้น แต่​คือ ออกแบบ​ให้การ​รับ​ซ้ำ​ไม่​ก่อ​ผล​ซ้ำ​ตั้งแต่​ราก สอง​ชิ้น​ประกอบ​กัน:

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

handler ที่​ถูกต้อง​จึง​ประกอบ domain object จาก primitive แล้ว​สร้าง journal entry สอง​ขา​ที่​สมดุล ส่ง​ให้ Post เป็น​คน​ลง​แบบ idempotent:

FoodOrdering.Application/Payments/TopUpConfirmedHandler.cs
public interface IWalletDirectory
{
AccountId WalletOf(Guid customerId); // หา AccountId ของ wallet ลูกค้า
}
public sealed class TopUpConfirmedHandler
{
private readonly ILedger _ledger;
private readonly IWalletDirectory _wallets;
// บัญชีพักกลางตัวเดียวของ platform (ประเภท GatewayClearing)
private static readonly AccountId GatewayClearing =
new(new Guid("00000000-0000-0000-0000-0000000000c1"));
public TopUpConfirmedHandler(ILedger ledger, IWalletDirectory wallets)
{
_ledger = ledger;
_wallets = wallets;
}
public async Task Handle(TopUpConfirmed msg, CancellationToken ct)
{
// ประกอบ Money จาก primitive — Money ปฏิเสธเองถ้าสกุลไม่ใช่ THB หรือติดลบ
var amount = new Money(msg.Amount, msg.Currency);
var walletId = _wallets.WalletOf(msg.CustomerId);
var jid = new JournalEntryId(Guid.NewGuid());
// เติมเงิน = เดบิต GatewayClearing (สินทรัพย์เพิ่ม) / เครดิต CustomerWallet (หนี้สินเพิ่ม)
var entry = new JournalEntry(
id: jid,
idempotencyKey: msg.IdempotencyKey, // ← ใช้ key จาก gateway ยิงซ้ำก็ key เดิม
reason: $"topup:promptpay:{msg.ChargeId}",
entries: new[]
{
new LedgerEntry(GatewayClearing, Direction.Debit, amount, jid),
new LedgerEntry(walletId, Direction.Credit, amount, jid),
});
// Post เป็น idempotent — เจอ IdempotencyKey เดิมจะข้าม ไม่เครดิต wallet ซ้ำ
await _ledger.Post(entry, ct);
}
}

ผล​รวม​ของ design นี้: webhook เข้า​ซ้ำ​สิบ​ครั้ง Post ก็​ลง​บัญชี​จริง​แค่​ครั้ง​เดียว เพราะ IdempotencyKey เดิม และ​เงิน​ก้อน​นี้​มี​ร่องรอย​ชัด​ว่า​ยัง​พัก​อยู่​ที่ GatewayClearing จนกว่า​จะ settle เข้า​แบงก์

ปิด​ท้าย​ด้วย​กฎ​ที่​ห้าม​ลืม: ถ้า​ภายหลัง gateway แจ้ง​ว่า​ธุรกรรม​นี้​ถูก​ยกเลิก​หรือ​ตี​กลับ เรา ไม่​แก้​และ​ไม่​ลบ journal entry เดิม แต่​เขียน Reversing EntryReversing EntryJournal Entry ใหม่​ที่​เขียน​ขึ้น​เพื่อ​หักล้าง​รายการ​เดิม​ทั้งหมด​หรือ​บาง​ส่วน โดย​สลับ​ด้าน​เดบิต/เครดิต​ของ​รายการ​เดิม เช่น ตอน​คืน​เงิน​ลูกค้า (refund) แทนที่​จะ​ลบ​รายการ​จ่าย​เงิน​เดิม ระบบ​เขียน reversing entry ใหม่​ทับ — ประวัติ​เดิม​ยัง​อยู่​ครบ ตรวจสอบ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น​บ้างTactical Design — รายการ​ใหม่​ที่​สลับ​ด้าน​เดบิต/เครดิต​มา​หักล้าง (เดบิต CustomerWallet เครดิต GatewayClearing) ประวัติ​เดิม​ยัง​อยู่​ครบ​ทุก​บรรทัด ตรวจ​ย้อนหลัง​ได้​ว่า​เกิด​อะไร​ขึ้น — สอดคล้อง​กับ ledger ที่​เป็น append-only มา​ตั้งแต่​บท​แรก

ตอน​นี้​เงิน​ก้อน​แรก​เข้า wallet ลูกค้า​แล้ว​อย่าง​เชื่อถือ​ได้ บท​หน้า​จะ​ให้​ลูกค้า จ่าย​ค่า​อาหาร​จริง — เงิน​จาก CustomerWallet จะ​ไม่​ได้​วิ่ง​ตรง​ไป​หา​ร้าน แต่​ถูกกัน​ไว้​ใน​บัญชี escrow ระหว่าง​ที่​ออเดอร์​กำลัง​ส่ง แล้ว​รอ​เหตุการณ์ OrderDelivered ข้าม​ขอบ​เข้า​มา​ก่อน​จึง​ปล่อย​เงิน​ออก — เป็น​จุด​ที่ integration event จาก​คอร์ส Bounded Contexts ได้​ลงมือ​ใช้​จริง​เต็ม​ตัว


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

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

  • Anti-Corruption Layer — ทำไม gateway ภายนอก​อย่าง PromptPay ต้อง​ถูก​ห่อ​ด้วย ACL ที่​ขอบ ไม่​ให้​รูปทรง​ของ gateway รั่ว​เข้า​มา​ปน​กับ model ledger ใน​บ้าน
  • Domain Events — พื้นฐาน​ของ event ใน domain ใช้​เทียบ​ให้​เห็น​เส้น​แบ่ง​กับ integration event ที่​ข้าม​ขอบ (in-process เทียบ​กับ​ข้าม context/ผ่าน broker)

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

ข้อ 1 / 3

บัญชีพัก GatewayClearing ทำหน้าที่อะไรตอนลูกค้าเติมเงินผ่าน PromptPay?