Chương 64. Money, Ledger và Reconciliation

Ở chương trước, ta nói về privacy, compliance và data governance.

Đó là vùng dữ liệu nhạy cảm.

Chương này nói về một vùng dữ liệu khác cũng rất nhạy:

Tiền.
Số dư.
Credit.
Thanh toán.
Refund.
Hóa đơn.
Giao dịch.

Khi hệ thống chạm vào tiền, tư duy phải thay đổi.

Không thể chỉ nghĩ:

Update một cột balance là xong.

Vì tiền có vài đặc tính rất khó chịu:

Không được mất.
Không được cộng hai lần.
Không được trừ hai lần.
Không được âm vô lý.
Không được sai mà không ai biết.
Không được thiếu lịch sử.
Không được chỉ tin một hệ thống mà không đối soát.

Thông điệp chính của chương:

> Khi hệ thống chạm vào tiền hoặc số dư có giá trị, đừng chỉ lưu trạng thái cuối. Hãy lưu lịch sử giao dịch, dùng idempotency mạnh, thiết kế state machine rõ, audit đầy đủ, và đối soát định kỳ với nguồn bên ngoài.

---

64.1. Một tình huống: AI Judge bán credit chấm bài

Hãy tưởng tượng AI Judge có gói trả phí.

Một trường mua:

100.000 grading credits.

Mỗi lần chấm một bài tự luận, hệ thống trừ:

1 credit.

Nếu dùng model mạnh hơn, có thể trừ:

3 credits.

Nếu AI call lỗi do hệ thống, có thể không trừ.

Nếu giáo viên hủy job trước khi chấm, có thể hoàn credit.

Nếu trường thanh toán online, payment provider gửi webhook:

payment.succeeded

Hệ thống cộng credit vào tài khoản tenant.

Nghe đơn giản:

tenant.credit_balance += 100000
tenant.credit_balance -= 1

Nhưng production sẽ hỏi:

Webhook payment.succeeded đến hai lần thì sao?
User bấm mua hai lần thì sao?
Payment thành công nhưng webhook đến muộn thì sao?
AI job retry ba lần thì trừ mấy lần?
Refund xảy ra thì credit xử lý thế nào?
Balance trong hệ thống lệch với payment provider thì ai đúng?
Ai đã thay đổi credit bằng admin tool?
Làm sao chứng minh tenant đã dùng bao nhiêu credit?

Nếu chỉ có một cột credit_balance, trả lời những câu này rất khó.

Đó là lý do cần ledger và reconciliation.

---

64.2. Dữ liệu nào không được sai?

Không phải mọi dữ liệu trong hệ thống đều có cùng mức nghiêm trọng.

Ví dụ:

Màu badge hiển thị sai: khó chịu.
Tên file export sai: phiền.
Dashboard chậm vài phút: có thể chấp nhận.

Nhưng một số dữ liệu không được sai âm thầm:

Số tiền đã thu.
Số tiền cần refund.
Số dư ví.
Credit của tenant.
Hóa đơn.
Trạng thái thanh toán.
Lịch sử giao dịch.
Số lượng usage tính tiền.

Sai ở đây có hậu quả thật:

Mất tiền.
Tính tiền sai khách hàng.
Mất niềm tin.
Khó đối soát kế toán.
Không giải thích được khi khách hỏi.
Không audit được.

Với dữ liệu loại này, nguyên tắc là:

Không chỉ lưu kết quả cuối.
Phải lưu lịch sử vì sao có kết quả đó.

Nếu tenant còn 80.000 credits, ta cần biết:

Ban đầu mua bao nhiêu?
Đã dùng vào việc gì?
Đã refund không?
Có điều chỉnh thủ công không?
Giao dịch nào tạo ra số dư này?

---

64.3. Vì sao chỉ update balance là nguy hiểm?

Thiết kế đơn giản:

tenants.credit_balance

Khi mua credit:

UPDATE tenants
SET credit_balance = credit_balance + 100000
WHERE id = 'school_a';

Khi chấm bài:

UPDATE tenants
SET credit_balance = credit_balance - 1
WHERE id = 'school_a';

Vấn đề là:

Ta mất lịch sử.

Nếu balance sai, không biết sai từ đâu.

Ví dụ:

Trường A hỏi: Vì sao chúng tôi còn 72.381 credits?

Nếu chỉ có balance, ta không giải thích được.

Hoặc:

Webhook payment.succeeded bị xử lý hai lần.
Balance tăng thêm 100.000 credits.

Nếu không có transaction record/idempotency, ta có thể không phát hiện.

Hoặc:

Admin sửa balance trực tiếp.

Sau này không ai biết ai sửa, lúc nào, vì sao.

Balance là trạng thái hiện tại.

Nó không phải lịch sử.

Với tiền, lịch sử quan trọng không kém trạng thái.

---

64.4. Ledger là gì?

Ledger là sổ cái ghi lại các giao dịch.

Thay vì chỉ lưu:

credit_balance = 72381

ta lưu các dòng ledger:

+100000 credits: purchase order po_1
-1 credit: grading submission sub_1
-1 credit: grading submission sub_2
+1 credit: refund failed grading job sub_3
-3 credits: premium model grading sub_4

Balance có thể được tính từ ledger:

Balance = tổng các dòng ledger

Ledger giúp trả lời:

Số dư này đến từ đâu?
Giao dịch nào đã xảy ra?
Có giao dịch trùng không?
Ai tạo điều chỉnh thủ công?
Giao dịch nào liên quan payment nào?

Ledger không nhất thiết lúc nào cũng tính balance từ đầu.

Hệ thống có thể lưu balance hiện tại để đọc nhanh.

Nhưng ledger là nguồn lịch sử để kiểm tra và audit.

Một balance không có ledger giống như số trên màn hình không có biên lai.

---

64.5. Ledger entry nên có gì?

Một ledger entry tốt thường có:

id.
tenant_id hoặc account_id.
amount.
currency hoặc unit.
direction hoặc signed amount.
type.
reference_type.
reference_id.
idempotency_key.
created_at.
created_by/system actor.
description.
metadata.

Ví dụ:

{
  "id": "led_123",
  "tenant_id": "school_a",
  "amount": -1,
  "unit": "grading_credit",
  "type": "grading_usage",
  "reference_type": "submission",
  "reference_id": "sub_456",
  "idempotency_key": "grading_usage:sub_456:v1",
  "created_at": "2026-05-12T10:00:00Z"
}

Điểm quan trọng:

Ledger entry nên là append-only.

Tức là không sửa lịch sử tùy tiện.

Nếu cần điều chỉnh, tạo entry mới.

Ví dụ sai:

Sửa dòng -3 thành -1 vì tính nhầm.

Ví dụ tốt:

Giữ dòng -3.
Thêm dòng +2 adjustment với lý do.

Như vậy lịch sử vẫn rõ.

---

64.6. Immutable history

Immutable history nghĩa là lịch sử không bị sửa hoặc xóa tùy tiện.

Với dữ liệu tiền/credit, đây là nguyên tắc rất quan trọng.

Không phải vì không bao giờ có lỗi.

Mà vì khi có lỗi, ta sửa bằng giao dịch bù.

Ví dụ:

Hệ thống trừ nhầm 10 credits.

Không nên chỉ sửa balance:

credit_balance += 10

mà không ghi gì.

Nên tạo ledger entry:

+10 credits: correction for mistaken charge, approved by admin_1

Lịch sử bây giờ nói rõ:

Đã có trừ nhầm.
Đã bù lại.
Ai approve.
Khi nào.
Vì sao.

Immutable history làm hệ thống dễ audit hơn.

Nó cũng giúp điều tra khi có tranh chấp.

Nếu lịch sử có thể bị sửa âm thầm, mọi con số đều khó tin.

---

64.7. Double-entry thinking

Double-entry accounting là kế toán ghi sổ kép.

Ý tưởng căn bản:

Tiền không tự nhiên xuất hiện hoặc biến mất.
Nó chuyển từ tài khoản này sang tài khoản khác.
Mỗi giao dịch có hai vế.

Ví dụ người dùng nạp 100 USD:

Cash/Payment receivable +100
Customer credits liability +100

Khi khách dùng 1 credit:

Customer credits liability -1
Revenue/usage earned +1

Trong nhiều app, ta không cần xây hệ thống kế toán đầy đủ từ ngày đầu.

Nhưng double-entry thinking rất hữu ích.

Nó buộc ta hỏi:

Giá trị này đi từ đâu đến đâu?
Bên nào tăng?
Bên nào giảm?
Có tổng nào phải cân bằng không?

Nếu chỉ có:

tenant.balance -= 1

ta không biết credit đó đi đâu.

Nếu có ledger hai vế, ta hiểu dòng chảy giá trị.

---

64.8. Khi nào cần double-entry thật?

Không phải mọi hệ thống cần kế toán double-entry hoàn chỉnh.

Nếu sản phẩm chỉ hiển thị usage đơn giản, có thể ledger một chiều là đủ ban đầu.

Nhưng nên cân nhắc double-entry thật khi:

Có ví tiền.
Có stored value.
Có marketplace.
Có tiền của nhiều bên.
Có payout.
Có refund/chargeback.
Có revenue recognition.
Có yêu cầu audit tài chính.
Có đối soát kế toán nghiêm túc.

Ví dụ:

Platform giữ tiền của buyer rồi trả seller.

Rất nên có double-entry.

Với AI Judge credit đơn giản, có thể bắt đầu bằng ledger credit append-only.

Nhưng nếu credit quy đổi thành tiền, refund phức tạp, nhiều loại gói, reseller, marketplace, thì cần tư duy kế toán mạnh hơn.

Điểm quan trọng:

Càng gần tiền thật, càng không nên đơn giản hóa quá mức.

---

64.9. Payment intent là gì?

Payment intent là ý định thanh toán.

Nó đại diện cho:

Khách hàng muốn trả một số tiền cho một mục đích cụ thể.

Ví dụ:

Tenant school_a muốn mua gói 100.000 credits giá 500 USD.

Ta tạo:

payment_intent_id = pi_123
amount = 500 USD
purpose = buy_credits
status = pending

Sau đó hệ thống chuyển user sang payment provider.

Provider xử lý thẻ/chuyển khoản.

Provider gửi kết quả về.

Payment intent giúp ta có một object nội bộ để theo dõi vòng đời thanh toán.

Không nên chỉ đợi webhook rồi mới tạo dữ liệu.

Vì webhook có thể đến muộn, đến trùng, hoặc thiếu context nếu ta không lưu trước.

Payment intent là điểm neo của luồng thanh toán.

---

64.10. Authorization và capture

Trong thanh toán thẻ, có hai khái niệm hay gặp:

Authorization.
Capture.

Authorization là giữ tiền.

Capture là thu tiền thật.

Ví dụ khách đặt phòng:

Authorize 100 USD khi đặt.
Capture sau khi xác nhận phòng.

Không phải mọi payment flow cần tách hai bước.

Nhiều thanh toán online capture ngay.

Nhưng cần hiểu khái niệm vì một số nghiệp vụ cần:

Giữ chỗ.
Đặt cọc.
Booking.
Order cần xác nhận tồn kho.

Trong AI Judge, mua credit có thể đơn giản:

Pay and capture ngay.

Nhưng nếu bán gói enterprise qua invoice, flow khác:

Invoice issued.
Payment pending.
Payment received.
Credits activated.

Điểm chính:

Payment không phải chỉ có success/fail.
Nó có vòng đời.

---

64.11. Refund

Refund là hoàn tiền.

Refund có nhiều tình huống:

Khách yêu cầu hoàn.
Thanh toán bị lỗi.
Gói mua nhầm.
Hệ thống tính tiền sai.
Service không cung cấp được.

Refund không nên chỉ là:

Gọi provider refund rồi xóa order.

Cần lưu:

Refund request.
Lý do.
Số tiền.
Trạng thái.
Provider refund id.
Ai approve.
Ledger adjustment.
Webhook refund result.

Với credit, refund còn hỏi:

Credit đã dùng chưa?
Nếu đã dùng một phần, refund bao nhiêu?
Có trừ credit còn lại không?
Nếu balance không đủ thì sao?

Ví dụ:

Tenant mua 100.000 credits.
Đã dùng 20.000.
Yêu cầu refund toàn bộ.

Không thể xử lý như chưa dùng gì.

Refund cần state machine và rule rõ.

---

64.12. State machine cho payment

Payment nên có state machine rõ.

Ví dụ:

created
-> requires_action
-> processing
-> succeeded
-> failed
-> canceled
-> refunded
-> partially_refunded

Không phải mọi provider dùng đúng tên này.

Nhưng hệ thống của ta nên có trạng thái nội bộ ổn định.

State machine giúp tránh transition vô lý:

failed -> succeeded nếu provider thật sự báo success muộn thì có thể cần xử lý.
succeeded -> failed thường không nên đơn giản ghi đè.
refunded -> succeeded lại càng phải cẩn thận.

Payment webhook có thể đến không theo thứ tự.

Ví dụ:

payment.succeeded đến trước.
payment.processing đến sau.

Nếu hệ thống ghi đè mù, trạng thái có thể đi lùi:

succeeded -> processing

State machine tốt sẽ chặn transition đi lùi hoặc xử lý theo event timestamp/provider truth.

---

64.13. State machine cho order/booking

Nếu hệ thống có order hoặc booking, nó cũng cần state machine.

Ví dụ order mua credits:

draft
-> pending_payment
-> paid
-> credits_granted
-> canceled
-> refunded

Payment và order liên quan nhưng không giống nhau.

Payment nói:

Tiền đã xử lý thế nào?

Order nói:

Nghiệp vụ đã hoàn thành đến đâu?

Ví dụ:

Payment succeeded nhưng credits chưa grant vì worker lỗi.

Nếu chỉ nhìn payment, ta tưởng xong.

Nhưng order chưa hoàn tất.

Vì vậy cần trạng thái riêng:

payment_status = succeeded
fulfillment_status = pending

Với AI Judge:

Khách đã trả tiền.
Nhưng credit chưa cộng do job grant_credits fail.

Hệ thống phải phát hiện và retry/reconcile.

---

64.14. Idempotency trong giao dịch tiền

Idempotency với tiền là bắt buộc.

Nếu cùng một request bị gửi lại, hệ thống không được tạo hai giao dịch tiền.

Ví dụ user bấm "Mua credits":

Request timeout.
Frontend retry.
Backend nhận lại request.

Nếu không có idempotency key, backend có thể tạo hai payment intents.

Hoặc webhook:

payment.succeeded được provider gửi lại 3 lần.

Nếu mỗi lần đều cộng 100.000 credits, thảm họa.

Idempotency key nên gắn với hành động nghiệp vụ:

purchase_order_id.
provider_event_id.
grading_usage:submission_id:attempt.
refund_request_id.

Nguyên tắc:

Cùng một hành động nghiệp vụ chỉ tạo một effect tài chính.

Nếu request đến lại, trả lại kết quả cũ hoặc bỏ qua an toàn.

---

64.15. Idempotency key phải được lưu bền vững

Không nên chỉ giữ idempotency key trong memory.

Vì server có thể restart.

Key phải được lưu bền vững trong database hoặc storage đáng tin.

Ví dụ bảng:

idempotency_keys
- key
- operation_type
- request_hash
- response_snapshot
- status
- created_at

Hoặc lưu trực tiếp unique constraint trong bảng nghiệp vụ:

ledger_entries.idempotency_key UNIQUE

Nếu webhook evt_123 đã được xử lý, lần sau nhận lại:

Tìm thấy provider_event_id = evt_123.
Không cộng credit nữa.
Trả success cho provider.

Idempotency cũng phải xử lý concurrent request.

Hai request cùng key đến cùng lúc.

Database constraint/transaction phải bảo vệ.

Nếu chỉ check rồi insert không atomic, race condition vẫn có thể tạo trùng.

---

64.16. Webhook từ payment provider

Payment provider thường gửi webhook để báo sự kiện:

payment.succeeded
payment.failed
payment.refunded
chargeback.created
invoice.paid
invoice.payment_failed

Webhook rất quan trọng.

Nhưng webhook không nên được tin mù.

Cần:

Verify signature.
Check event id idempotency.
Lưu raw event nếu phù hợp.
Parse và validate.
Map sang payment/order nội bộ.
Xử lý retry an toàn.
Không xử lý quá lâu trong request webhook.

Provider có thể gửi webhook nhiều lần.

Webhook có thể đến muộn.

Webhook có thể đến sai thứ tự.

Webhook endpoint của ta có thể timeout.

Vì vậy webhook handler nên nhẹ:

Verify.
Store event.
Enqueue processing job.
Return 2xx nếu nhận hợp lệ.

Sau đó worker xử lý event với idempotency và state machine.

---

64.17. Đừng chỉ tin frontend redirect

Sau thanh toán, user thường được redirect về app:

/payment/success

Không nên thấy redirect success rồi cộng tiền ngay.

Frontend redirect có thể:

Bị giả.
Đến trước webhook.
Không đáng tin bằng provider API/webhook.
User đóng tab.
Network lỗi.

Nguồn sự thật nên là provider/webhook hoặc provider API verification.

Flow tốt hơn:

User quay về success page.
Frontend hiển thị "Đang xác nhận thanh toán".
Backend kiểm tra payment intent/provider status.
Webhook hoặc polling xác nhận.
Khi payment thật sự succeeded, grant credits.

Redirect là trải nghiệm UI.

Webhook/provider status là tín hiệu hệ thống.

Đừng nhầm hai thứ.

---

64.18. Reconciliation là gì?

Reconciliation là đối soát.

Nghĩa là so sánh dữ liệu giữa các nguồn để phát hiện lệch.

Ví dụ:

Payment provider nói có 100 payment succeeded hôm nay.
Hệ thống nội bộ nói đã grant credits cho 99 order.

Có một lệch.

Cần tìm:

Payment nào đã thành công nhưng chưa grant credits?
Payment nào nội bộ nghĩ thành công nhưng provider không có?
Refund nào provider đã xử lý nhưng ledger chưa ghi?
Chargeback nào chưa phản ánh trong hệ thống?

Reconciliation là lưới cứu.

Vì dù webhook tốt, idempotency tốt, vẫn có lỗi:

Webhook mất.
Worker fail.
Bug mapping event.
Manual operation.
Provider outage.
Database timeout.

Không có đối soát, lệch có thể tồn tại âm thầm.

Với tiền, lệch âm thầm là rất nguy hiểm.

---

64.19. Reconciliation nên chạy thế nào?

Một reconciliation job có thể chạy định kỳ:

Mỗi giờ.
Mỗi ngày.
Cuối kỳ billing.
Trước khi xuất hóa đơn.

Nó lấy dữ liệu từ nhiều nguồn:

Payment provider reports.
Internal payments table.
Orders.
Ledger entries.
Invoices.
Credit balances.

Sau đó so sánh:

Provider payment succeeded nhưng internal chưa succeeded.
Internal succeeded nhưng không có provider record.
Ledger thiếu entry grant credits.
Refund provider có nhưng internal chưa ghi.
Balance snapshot không bằng tổng ledger.

Khi phát hiện lệch, không nên tự sửa bừa mọi thứ.

Cần phân loại:

Có thể auto-repair.
Cần human review.
Cần alert.
Cần incident.

Ví dụ:

Payment succeeded, order pending, chưa grant credits.

có thể auto retry grant nếu rule rõ.

Nhưng:

Provider amount khác internal amount.

có thể cần người xem.

---

64.20. Balance snapshot và ledger

Nếu đọc balance bằng cách sum toàn bộ ledger mỗi lần, có thể chậm.

Nên nhiều hệ thống lưu:

current_balance

để đọc nhanh.

Nhưng balance snapshot phải khớp ledger.

Ví dụ:

tenant_credit_accounts.current_balance = 72381
sum(ledger_entries.amount) = 72380

Có lệch.

Reconciliation cần phát hiện.

Có hai cách phổ biến:

Ledger là source of truth, balance là cache/snapshot.
Balance được update trong cùng transaction với ledger entry.

Khi tạo ledger entry:

BEGIN
  INSERT ledger_entry
  UPDATE account_balance
COMMIT

Nếu chỉ insert ledger mà update balance fail, lệch.

Nếu update balance mà insert ledger fail, càng nguy hiểm.

Transaction giúp giữ chúng đi cùng nhau.

Reconciliation vẫn cần để bắt lỗi hiếm và bug.

---

64.21. Transaction và lock

Giao dịch tiền cần transaction database đúng.

Ví dụ trừ credit:

Kiểm tra balance đủ.
Tạo ledger entry -1.
Cập nhật balance.
Đánh dấu submission đã charged.

Các bước này phải nhất quán.

Nếu hai worker cùng chấm cùng một submission hoặc cùng trừ balance, có thể race.

Cần:

Database transaction.
Unique constraint/idempotency key.
Row lock hoặc optimistic concurrency nếu cần.
Check balance trong transaction.

Ví dụ:

Hai request cùng thấy balance = 1.
Cả hai cùng trừ 1.
Balance thành -1.

Nếu không khóa/transaction đúng, chuyện này xảy ra.

Với tiền/credit, race condition không phải bug nhỏ.

Nó là lỗi tài chính.

---

64.22. Không dùng floating point cho tiền

Tiền không nên lưu bằng floating point.

Ví dụ:

0.1 + 0.2

trong floating point có thể không ra chính xác 0.3.

Nên lưu tiền bằng:

Integer minor units.

Ví dụ:

100 cents thay vì 1.00 USD.

Hoặc dùng decimal type phù hợp nếu database/language hỗ trợ tốt.

Với multi-currency, phải lưu currency:

amount = 1000
currency = USD

Không được cộng bừa:

100 USD + 100 EUR

Với credit nội bộ, cũng nên định nghĩa rõ unit:

grading_credit
premium_grading_credit
token_credit

Đừng để một cột number mơ hồ.

Mơ hồ trong tiền rất đắt.

---

64.23. Chargeback và dispute

Chargeback là khi khách hàng tranh chấp giao dịch qua ngân hàng/thẻ.

Provider có thể gửi event:

chargeback.created
dispute.opened
dispute.won
dispute.lost

Hệ thống phải xử lý vì tiền có thể bị lấy lại.

Nếu khách đã mua credits và dùng rồi, chargeback tạo câu hỏi:

Có khóa tenant không?
Có trừ credit còn lại không?
Có tạo balance âm không?
Có thông báo billing admin không?
Có dừng service không?

Không phải hệ thống nào cũng gặp chargeback ngay.

Nhưng nếu nhận thanh toán online, cần biết provider có loại event nào và hệ thống phản ứng ra sao.

Chargeback là ví dụ cho thấy payment không kết thúc ở succeeded.

Giao dịch tiền có vòng đời dài hơn ta nghĩ.

---

64.24. Invoice và billing cycle

Với SaaS B2B, không phải lúc nào cũng charge ngay từng giao dịch.

Có thể có invoice:

Cuối tháng tính usage.
Xuất hóa đơn.
Khách thanh toán sau.

Billing cycle cần dữ liệu usage chính xác.

Ví dụ:

Tenant dùng 1.200.000 AI tokens.
Chấm 35.000 bài.
Export 12 báo cáo lớn.

Nếu usage tracking sai, invoice sai.

Nên usage records cũng cần idempotency và audit.

Không nhất thiết mỗi usage là ledger tài chính ngay.

Nhưng khi nó ảnh hưởng billing, nó phải đáng tin.

Một pattern:

Usage event -> usage ledger -> invoice line items -> invoice -> payment.

Nếu khách tranh chấp invoice, ta phải giải thích được line item đến từ đâu.

---

64.25. Audit trail cho tiền

Audit trail trong tiền phải rất rõ.

Cần biết:

Ai tạo order?
Payment nào liên quan?
Webhook nào được xử lý?
Ledger entry nào được tạo?
Ai approve refund?
Ai điều chỉnh balance?
Job nào grant credits?
Reconciliation phát hiện gì?

Audit record nên gắn:

actor.
tenant/account.
operation.
amount.
currency/unit.
reference.
before/after nếu phù hợp.
reason.
timestamp.
approval.

Đặc biệt với admin tool:

Admin cộng/trừ credit thủ công.

phải có reason và approval nếu số tiền lớn.

Không nên có nút:

Set balance = 999999

mà không có ledger/audit.

Admin tool thường là nơi phá invariants nếu không thiết kế kỹ.

---

64.26. Invariant là gì?

Invariant là điều luôn phải đúng.

Với hệ thống tiền/credit, invariant có thể là:

Balance không âm nếu không cho phép nợ.
Ledger entry không bị sửa sau khi posted.
Mỗi provider event chỉ xử lý một lần.
Mỗi submission chỉ bị charge một lần.
Refund không vượt số tiền đã thanh toán.
Invoice total bằng tổng line items.
Balance snapshot bằng tổng ledger entries.

Invariant nên được bảo vệ bằng nhiều lớp:

Code.
Database constraint.
Transaction.
Unique index.
Reconciliation.
Monitoring.
Tests.

Nếu một invariant bị phá, đó có thể là incident.

Không nên xem nhẹ.

Ví dụ:

Balance snapshot lệch ledger.

Không phải chỉ là bug UI.

Đó là dấu hiệu hệ thống tài chính mất nhất quán.

---

64.27. Monitoring cho money flow

Money flow cần monitoring riêng.

Không chỉ:

API 500.
Latency.
CPU.

Mà còn:

Payment succeeded count.
Payment failed count.
Webhook processing lag.
Duplicate webhook count.
Ledger entry count.
Failed grant credits.
Refund count.
Reconciliation mismatch count.
Balance negative count.
Invoice generation failure.
Provider API error.

Một số alert nên rất nghiêm túc:

Payment succeeded nhưng credits không grant sau 10 phút.
Webhook processing stopped.
Ledger-balance mismatch.
Refund failure.
Negative balance xuất hiện bất thường.
Reconciliation mismatch vượt ngưỡng.

Tiền sai không phải lúc nào cũng tạo 500.

Nhiều khi API vẫn xanh, nhưng dữ liệu tiền đang lệch.

Vì vậy cần metric nghiệp vụ.

---

64.28. Testing money flow

Money flow cần test rộng hơn happy path.

Test nên bao gồm:

Payment succeeded grant credits đúng một lần.
Webhook duplicate không cộng credit hai lần.
Webhook đến muộn vẫn xử lý đúng.
Payment failed không grant credits.
Refund tạo ledger adjustment đúng.
AI job retry không trừ credit hai lần.
Concurrent charge không làm balance âm.
Balance snapshot khớp ledger.
Reconciliation phát hiện payment thiếu grant.
Admin adjustment tạo audit.

Test cũng nên có state machine cases:

processing -> succeeded.
succeeded -> refunded.
succeeded -> chargeback.
failed event đến sau succeeded không làm đi lùi sai.

Với tiền, test duplicate và concurrency rất quan trọng.

Nếu chỉ test một payment thành công bình thường, ta bỏ sót phần dễ gây tiền sai nhất.

---

64.29. Admin adjustment

Sẽ có lúc cần điều chỉnh thủ công.

Ví dụ:

Tặng credits cho khách.
Bù credits do sự cố.
Trừ credits do abuse.
Sửa lỗi migration.
Xử lý hợp đồng enterprise.

Admin adjustment phải đi qua ledger.

Không nên update thẳng balance.

Một adjustment tốt cần:

Amount.
Reason.
Actor.
Approval nếu cần.
Tenant/account.
Reference ticket/incident.
Audit log.

Ví dụ:

+500 credits
type = goodwill_credit
reason = incident INC-123 affected grading
approved_by = ops_lead

Sau này khách hỏi, team trả lời được.

Admin tools càng mạnh càng cần guardrail.

Vì thao tác thủ công là nguồn lỗi rất thật.

---

64.30. AI Judge: thiết kế credit an toàn

Một thiết kế thực dụng cho AI Judge credit:

Account:

tenant_credit_accounts
- tenant_id
- current_balance
- updated_at

Ledger:

credit_ledger_entries
- id
- tenant_id
- amount
- unit
- type
- reference_type
- reference_id
- idempotency_key unique
- created_at
- created_by

Purchase:

purchase_orders
- id
- tenant_id
- amount_money
- credit_amount
- status
- provider_payment_id

Webhook:

payment_events
- provider_event_id unique
- provider
- event_type
- payload
- processed_at
- status

Khi payment succeeded:

1. Verify webhook.
2. Store provider_event_id.
3. Find purchase_order.
4. If not already granted, create ledger +credit_amount.
5. Update current_balance in same transaction.
6. Mark order credits_granted.

Khi chấm bài:

1. Validate submission/job.
2. Use idempotency_key = grading_charge:submission_id:attempt.
3. Create ledger -credits in transaction.
4. Update balance.
5. Mark charge associated with grading_result.

Nếu job retry, unique key ngăn trừ lại.

---

64.31. AI Judge: đối soát credit

Reconciliation cho AI Judge có thể kiểm tra:

Payment provider succeeded nhưng purchase_order chưa credits_granted.
Purchase_order credits_granted nhưng thiếu ledger entry.
Ledger sum khác current_balance.
Submission graded nhưng chưa có charge nếu policy yêu cầu charge.
Charge tồn tại nhưng grading_result fail và phải refund.
Refund provider completed nhưng credit adjustment chưa ghi.
Duplicate provider events.

Một job đối soát hằng ngày có thể tạo report:

Matched: 10.000 transactions.
Auto-fixed: 12 delayed grants.
Needs review: 2 amount mismatches.
Critical: 1 balance mismatch.

Đối soát không chỉ là việc kế toán.

Nó là một phần của hệ thống đáng tin.

Nhất là khi payment provider, queue, worker, webhook và ledger đều là các thành phần riêng.

Không có đối soát, ta chỉ hy vọng mọi thành phần luôn hoàn hảo.

Hy vọng không đủ cho tiền.

---

64.32. Khi nào không được chỉ update một cột balance?

Không nên chỉ update một cột balance khi:

Balance đại diện cho tiền hoặc giá trị mua được.
Khách hàng có thể tranh chấp.
Có refund.
Có webhook external provider.
Có retry/concurrency.
Có admin adjustment.
Có invoice/billing.
Có audit/compliance.
Có nhiều nguồn làm thay đổi balance.

Trong các trường hợp đó, chỉ update balance là thiếu lịch sử và thiếu khả năng giải thích.

Có thể vẫn lưu balance để đọc nhanh.

Nhưng thay đổi balance phải đi qua:

Ledger entry.
Transaction.
Idempotency.
Audit.
Reconciliation.

Nếu dữ liệu có giá trị tài chính, hãy đối xử với nó như dữ liệu tài chính.

Đừng đối xử như counter bình thường.

---

64.33. Những sai lầm phổ biến

Sai lầm thứ nhất:

Chỉ lưu balance, không lưu ledger.

Khi sai không giải thích được.

Sai lầm thứ hai:

Webhook duplicate cộng tiền hai lần.

Thiếu idempotency provider_event_id.

Sai lầm thứ ba:

AI job retry trừ credit nhiều lần.

Thiếu idempotency theo submission/job.

Sai lầm thứ tư:

Tin frontend success redirect.

Redirect không phải nguồn sự thật thanh toán.

Sai lầm thứ năm:

Không có state machine.

Event đến muộn làm trạng thái đi lùi hoặc sai.

Sai lầm thứ sáu:

Không đối soát với provider.

Webhook mất hoặc worker lỗi làm lệch âm thầm.

Sai lầm thứ bảy:

Admin sửa balance trực tiếp.

Không ledger, không audit, không reason.

Sai lầm thứ tám:

Dùng floating point cho tiền.

Sai số nhỏ có thể tích tụ thành vấn đề lớn.

---

64.34. Checklist money flow

Khi thiết kế phần tiền/credit/billing, hãy hỏi:

  • Dữ liệu nào không được sai?
  • Có ledger không?
  • Ledger có append-only không?
  • Balance có phải snapshot từ ledger không?
  • Update ledger và balance có cùng transaction không?
  • Có idempotency key cho payment request không?
  • Có idempotency cho provider webhook không?
  • Có idempotency cho usage/charge không?
  • Webhook có verify signature không?
  • Webhook duplicate có an toàn không?
  • Webhook đến muộn/sai thứ tự có xử lý đúng không?
  • Payment/order/refund có state machine không?
  • Refund có rule rõ không?
  • Admin adjustment có ledger/audit/reason không?
  • Có reconciliation với provider không?
  • Có reconciliation ledger và balance không?
  • Có alert khi mismatch không?
  • Có test concurrency không?
  • Có dùng integer/decimal đúng cho tiền không?
  • Có audit trail đủ để giải thích cho khách không?

Nếu nhiều câu trả lời là "không biết", phần money flow chưa đủ chắc.

---

64.35. Bảng nhìn nhanh

| Khái niệm | Hiểu đơn giản | |---|---| | Payment intent | Ý định thanh toán nội bộ trước khi provider xử lý | | Authorization | Giữ tiền, chưa thu thật | | Capture | Thu tiền thật | | Refund | Hoàn tiền | | Ledger | Sổ ghi lịch sử giao dịch | | Ledger entry | Một dòng thay đổi giá trị | | Immutable history | Không sửa lịch sử, sai thì ghi giao dịch bù | | Double-entry thinking | Mỗi giá trị chuyển từ đâu đến đâu | | Idempotency | Cùng hành động lặp lại không tạo effect trùng | | State machine | Trạng thái hợp lệ và chuyển trạng thái rõ | | Webhook | Provider báo sự kiện thanh toán cho hệ thống | | Reconciliation | Đối soát dữ liệu giữa các nguồn | | Invariant | Điều luôn phải đúng |

---

64.36. Kết luận của chương

Khi hệ thống chạm vào tiền, credit, billing hoặc số dư có giá trị, kiến trúc phải nghiêm túc hơn.

Balance hiện tại là chưa đủ.

Cần biết lịch sử tạo ra balance đó.

Cần idempotency để retry/webhook không tạo giao dịch trùng.

Cần state machine để payment/refund/order không đi lung tung.

Cần ledger để audit.

Cần immutable history để sửa sai có dấu vết.

Cần reconciliation để phát hiện lệch giữa provider, hệ thống nội bộ và ledger.

Thông điệp cần nhớ:

> Với tiền, lỗi nguy hiểm nhất không phải lúc nào cũng là request fail. Lỗi nguy hiểm là hệ thống vẫn chạy, nhưng số liệu tài chính lệch âm thầm. Ledger, idempotency, state machine và reconciliation là cách ta chống lại kiểu lỗi đó.

Ở chương tiếp theo, ta sẽ nói về Realtime State Synchronization: presence, typing, delivery receipt, fan-out, ordering, offline sync, conflict resolution, và khi nào realtime thật sự cần thay vì chỉ polling đơn giản.