Doc 16 · Finance Ledger Spec¶
Status: DRAFT — design-locked, finance build deferred tới sau pilot Audience: Backend (finance), Finance/Kế toán, BA, QC
Doc này chốt mô hình ledger production-correct sau accounting-review.
1. Chart of Accounts¶
| Code | Loại | Normal | Ý nghĩa | Δ so |
|---|---|---|---|---|
cash_gateway |
ASSET | Dr | Clearing tiền cổng đã confirm, chưa về bank | |
cash_bank |
ASSET | Dr | Tiền thật trong ngân hàng SMP | |
agent_cod_receivable |
ASSET | Dr | Thợ giữ cash của SMP (COD thu, chưa remit) | naming chốt |
agent_debt |
ASSET | Dr | Thợ nợ lại SMP (clawback / COD short) | |
customer_wallet |
LIABILITY | Cr | SMP nợ KH (subledger theo customer) | |
agent_payable |
LIABILITY | Cr | SMP nợ thợ — earnings (subledger theo agent) | |
partner_wallet |
LIABILITY | Cr | SMP nợ partner (prepaid) | |
cod_clearing |
LIABILITY | Cr | Suspense giữa COD_COLLECTED → SETTLED | MỚI |
refund_payable |
LIABILITY | Cr | Hoàn đã duyệt, chờ chi về nguồn gốc | |
loyalty_liability |
LIABILITY | Cr | Giá trị điểm đang lưu hành | |
service_guarantee_reserve |
LIABILITY | Cr | Quỹ bảo hành/đảm bảo dịch vụ | RENAME từ insurance_fund |
deferred_revenue |
LIABILITY | Cr | Doanh thu chưa thực hiện (gói BH) · subledger theo customer_warranty_id | MỚI v3.5 |
vat_payable |
LIABILITY | Cr | VAT phải nộp (rate đọc từ tax_config) |
|
revenue_commission |
REVENUE | Cr | Doanh thu commission SMP | |
revenue_warranty_subscription |
REVENUE | Cr | Doanh thu thuê bao BH (recognized hàng tháng) | MỚI v3.5 |
gateway_fee_expense |
EXPENSE | Dr | Phí cổng SMP gánh | MỚI |
warranty_service_cost |
EXPENSE | Dr | Chi phí phục vụ claim BH (agent payable + materials) | MỚI v3.5 |
marketing_expense |
EXPENSE | Dr | Chi phí điểm/referral | |
rounding_adjustment |
EXPENSE | Dr/Cr | Hấp thụ chênh làm tròn cross-entry | MỚI |
Rename
insurance_fund→service_guarantee_reserve: chữ "insurance" chỉ dùng khi có policy/đối tác bảo hiểm thật + legal review (ADR-003). Tránh rủi ro pháp lý/marketing.
2. Data model (Pattern 2 — control account + subledger dimension)¶
-- Control accounts: UNIQUE theo (code, currency). KHÔNG tạo 1 row/customer.
CREATE TABLE accounts (
id BIGINT PK, code VARCHAR(32), currency CHAR(3) DEFAULT 'VND',
type VARCHAR(12), -- ASSET|LIABILITY|REVENUE|EXPENSE|EQUITY
normal_side CHAR(2), -- 'Dr'|'Cr'
UNIQUE (code, currency)
);
CREATE TABLE journal_entries ( -- append-only + hash-chain
id BIGINT PK,
ref_type VARCHAR(24), ref_id VARCHAR(64),
idempotency_key VARCHAR(80) UNIQUE NOT NULL,
currency CHAR(3) DEFAULT 'VND', -- 1 entry = 1 currency (FX riêng)
memo VARCHAR(255),
prev_hash CHAR(64), row_hash CHAR(64),
created_at TIMESTAMP
);
CREATE TABLE journal_lines (
id BIGINT PK, entry_id BIGINT, account_id BIGINT,
-- SUBLEDGER DIMENSION (Pattern 2): owner sống ở line, không đẻ account row
owner_type VARCHAR(12) NULL, -- customer|agent|partner|NULL(control)
owner_id BIGINT NULL,
debit_minor BIGINT DEFAULT 0,
credit_minor BIGINT DEFAULT 0,
CONSTRAINT chk_nonneg CHECK (debit_minor >= 0 AND credit_minor >= 0),
CONSTRAINT chk_one_side CHECK ((debit_minor > 0) <> (credit_minor > 0)),
INDEX idx_subledger (account_id, owner_type, owner_id)
);
Invariant enforcement (3 lớp):
1. Single writer: mọi posting đi qua đúng 1 hàm PostJournal(entry, lines[]) trong transaction. Không service nào INSERT thẳng journal_lines.
2. Deferred constraint trigger validate SUM(debit_minor) = SUM(credit_minor) per entry_id trước COMMIT → entry không cân = rollback, không vào DB.
3. Daily reconcile job: trial balance toàn hệ thống SUM(all debit) = SUM(all credit) + so subledger balance vs control.
Balance:
- Control: balance(code) = Σdebit − Σcredit (×sign theo normal_side).
- Subledger: filter journal_lines theo account_id + owner_type + owner_id. → customer_wallet balance của KH X = subledger view, không query bảng ví riêng.
- customer_wallet cấm âm (overdraft): PostJournal reject nếu balance sau < 0 (trừ phi allow_negative flag cho nghiệp vụ đặc thù).
3. Journal Templates ( — đã sửa đúng chiều)¶
Quy ước:
P= tổng KH trả,C= commission (revenue),V= VAT,N= net thợ.P = N + C + V. VAT rate đọc từtax_config(xem §5), KHÔNG hardcode.
3.1 Thu tiền¶
| Nghiệp vụ | Debit | Credit |
|---|---|---|
Gateway pay (có phí cổng F) |
cash_gateway (P−F) + gateway_fee_expense (F) |
agent_payable N + revenue_commission C + vat_payable V |
Wallet topup (T) |
cash_gateway (T−F) + gateway_fee_expense (F) |
customer_wallet T |
| Wallet pay | customer_wallet P |
agent_payable N + revenue_commission C + vat_payable V |
| COD collected (chưa revenue) | agent_cod_receivable P |
cod_clearing P |
| COD remit → SETTLED (2 entry) | (a) cash_bank P · (b) cod_clearing P |
(a) agent_cod_receivable P · (b) agent_payable N + revenue_commission C + vat_payable V |
| Gateway settle về bank (flush clearing, không đổi state) | cash_bank X |
cash_gateway X |
COD recognize revenue tại SETTLED, không tại COLLECTED — nhất quán Doc 17 (downstream đợi SETTLED).
cod_clearinggiữ suspense giữa hai mốc.
3.2 Quỹ & loyalty¶
| Nghiệp vụ | Debit | Credit |
|---|---|---|
Trích quỹ bảo hành (R = %·C) |
revenue_commission R |
service_guarantee_reserve R |
Loyalty earn (confirmed, giá trị L) |
marketing_expense L |
loyalty_liability L |
| Loyalty redeem → ví | loyalty_liability L |
customer_wallet L |
3.3 Refund — ma trận (đã sửa, có VAT reversal)¶
| Case | Debit | Credit |
|---|---|---|
| TRƯỚC payout · full · về ví | revenue_commission C + agent_payable N + vat_payable V |
customer_wallet P |
TRƯỚC payout · partial p (split c,n,v; p=n+c+v) |
revenue_commission c + agent_payable n + vat_payable v |
customer_wallet p |
| TRƯỚC payout · về nguồn gốc | (như trên) | refund_payable p → sau đó Dr refund_payable / Cr cash_gateway |
| SAU payout · lỗi THỢ (clawback, 2 bước) | (1) revenue_commission C + vat_payable V + agent_debt N |
(1) customer_wallet P |
| ↳ bước 2 (cấn earning tương lai) | (2) agent_payable |
(2) agent_debt |
| SAU payout · lỗi SMP / goodwill (SMP gánh net) | revenue_commission C + vat_payable V + marketing_expense/refund_expense N |
customer_wallet P |
Phân biệt mấu chốt: trước payout net còn ở
agent_payable→ Dr để đảo. Sau payout net đã thành cash rời đi → dùngagent_debt(lỗi thợ) hoặc expense (lỗi SMP). KHÔNG bao giờ Cragent_payabletrong clawback (lỗi đã sửa).
3.4 COD edge & clawback¶
| Nghiệp vụ | Debit | Credit |
|---|---|---|
COD short (remit thiếu var) |
cash_bank (P−var) + agent_debt var |
agent_cod_receivable P |
| Refund đơn COD TRƯỚC remit (bug bỏ sót #3) | cod_clearing P |
customer_wallet P |
| ↳ thợ vẫn phải remit → khi remit: | cash_bank P |
agent_cod_receivable P (SMP thu hồi cash đã ứng refund) |
| Thợ không remit sau khi SMP đã refund | agent_debt P |
agent_cod_receivable P |
Bug #3 (COD-refund-before-remit): KH trả cash cho thợ rồi dispute → SMP hoàn về ví KH bằng cách đảo
cod_clearing(chưa từng recognize revenue nên sạch), nhưngagent_cod_receivablevẫn còn → thợ vẫn nợ cash, SMP thu hồi sau. Có timing exposure (SMP credit ví trước khi cầm cash) → kiểm soát bằng COD cap + agent whitelist (ADR-004).
3.5 Multi-agent step earnings¶
Khi 1 step có nhiều agents (lead + helpers + specialists), commission của step được chia ra NHIỀU dòng
agent_payablethay vì 1 dòng tổng. Schema xem Doc 02 §7.7. Compute logic xem Doc 04 §1.15.3. Rules xem Doc 15 Section Q.
3.5.1 · Pattern · Step completed → split per agent¶
Khi order_step.status chuyển sang completed:
1. Compute step_revenue = order.total × step_weight_bps / 10000
2. Compute amount_earned per agent = step_revenue × split_bps / 10000
3. Apply N=P-C-V rule per step (commission part only):
step_commission = step_revenue × commission_bps / 10000
4. For each agent: amount_earned_to_agent = step_commission × agent.split_bps / 10000
5. Lead absorbs residual rounding
6. Post 1 journal_entry containing N+1 lines:
- 1 line debit revenue_commission
- N lines credit agent_payable[agent_id]
3.5.2 · Journal template · Step completed (3 agents on step)¶
Example: order = 1,000,000 VND, step 3 weight = 30%, step_commission = 45,000 VND (15% of 300k step_revenue). Agents: lead A 40%, specialist C 40%, helper D 20%.
| Line | Account | Debit | Credit | Owner |
|---|---|---|---|---|
| 1 | revenue_commission |
45,000 | — | — |
| 2 | agent_payable[A] (lead, absorbs residual) |
— | 18,000 | A |
| 3 | agent_payable[C] (specialist) |
— | 18,000 | C |
| 4 | agent_payable[D] (helper) |
— | 9,000 | D |
| Σ | 45,000 | 45,000 | ✓ |
Journal entry metadata:
- entry_type: step_earnings
- reference_type: order_step
- reference_id: order_step_id
- event_source: StepCompleted event from Doc 18
3.5.3 · Rounding rule for multi-agent (extends §4)¶
P=99,999, step_weight=3000bps (30%), commission_bps=1500
step_revenue = 99,999 × 3000/10000 = 29,999 (29,999.7 round down)
← residual goes to lead's step if last step
step_commission = 29,999 × 1500/10000 = 4,499 (4,499.85 round)
agent_helper_D = 4,499 × 2000/10000 = 899
agent_specialist_C = 4,499 × 4000/10000 = 1,799
agent_lead_A = step_commission − agent_helper_D − agent_specialist_C
= 4,499 − 899 − 1,799 = 1,801 ← lead absorbs residual
Σ = 899 + 1,799 + 1,801 = 4,499 ✓ matches step_commission exactly
Rule: Lead always absorbs residual for both step-level and agent-level rounding.
3.5.4 · Warranty cost recovery (multi-agent)¶
Per BR-MA-006: chỉ primary_lead_agent_id chịu warranty cost.
| Scenario | Debit | Credit | Note |
|---|---|---|---|
| Warranty claim cost cho lead (BR-WARRANTY-004) | agent_payable[primary_lead] |
service_guarantee_reserve |
Lead bị trừ earnings tổng từ ledger |
| Warranty manual reroute sang specialist (Ops decision, BR-MA-006 exception) | agent_payable[specialist_id] |
service_guarantee_reserve |
Audit log required |
| Material defect (vendor liability) | material_recovery_receivable |
service_guarantee_reserve |
Helpers/lead KHÔNG bị touch |
3.5.5 · Refund matrix expansion cho multi-agent¶
Refund pre-payout (no commission recognized yet):
| Nghiệp vụ | Debit | Credit | Note |
|---|---|---|---|
| Refund full order trước khi step completed | gateway_clearing P |
customer_wallet P |
No commission ledger touched |
| Refund partial step (vd dispute step 3 only) | (chưa applicable v3.5 · sẽ design v3.6) | — | TODO |
Refund post-payout (commission recognized + distributed to agents):
| Nghiệp vụ | Debit | Credit | Note |
|---|---|---|---|
| Refund customer (lead fault) | gateway_clearing P + agent_payable[primary_lead] (claw) |
customer_wallet P + revenue_commission (reverse) |
Helpers KHÔNG bị claw |
| Refund customer (specialist fault, Ops decision) | gateway_clearing P + agent_payable[specialist] (claw) |
customer_wallet P + revenue_commission (reverse) |
Audit log required |
3.5.6 · Idempotency¶
StepEarningsCalculated event MUST be idempotent:
- Check order_step.amount_earned IS NOT NULL before posting → skip if already posted
- Hash chain in journal_entries ensures duplicate posting detected (unique reference_type+reference_id)
- DLQ retry safe: same step_id → same journal entry → same row_hash
3.6 Warranty packages (deferred revenue)¶
Khi KH mua gói bảo hành (vd 1,200,000 VND cho 12 tháng), KHÔNG được recognize revenue ngay. Phải theo chuẩn kế toán VN VAS 14 (Revenue) + IFRS 15: defer toàn bộ vào
deferred_revenue, recognize dần theo tháng. Schema xem Doc 02 §7.8, rules xem Doc 15 Section R.
3.6.1 · Lifecycle 4 sự kiện chính¶
1. Purchase (sale) → defer revenue
2. Monthly recognition → recognize 1/N của price (cron monthly)
3. Claim used → cost (separate from revenue recognition)
4. Cancellation / refund → reverse remaining deferred + refund cash
3.6.2 · Pattern · KH mua gói (purchase)¶
KH mua gói 1,200,000 VND (VAT 8% included → net 1,111,111 VND, VAT 88,889 VND):
| Line | Account | Debit | Credit | Owner |
|---|---|---|---|---|
| 1 | cash_gateway (gateway payment) |
1,200,000 | — | — |
| 2 | deferred_revenue (subledger=customer_warranty_id) |
— | 1,111,111 | warranty_123 |
| 3 | vat_payable |
— | 88,889 | — |
| Σ | 1,200,000 | 1,200,000 | ✓ |
Note: VAT recognized ngay tại sale (theo VN tax law), nhưng revenue defer.
Journal entry metadata:
- entry_type: warranty_purchase
- reference_type: customer_warranty
- reference_id: customer_warranty.id
3.6.3 · Pattern · Monthly revenue recognition (cron)¶
Mỗi tháng, cron job process warranty_revenue_recognition table:
For warranty 1,200,000 VND / 12 months = 100,000 VND/tháng (đã trừ VAT: 92,592.6 ≈ 92,593 VND/tháng net + VAT prorated):
Để đơn giản, recognize đều theo net amount (1,111,111 / 12 = 92,592 + residual ở tháng cuối):
| Line | Account | Debit | Credit | Owner |
|---|---|---|---|---|
| 1 | deferred_revenue (subledger) |
92,592 | — | warranty_123 |
| 2 | revenue_warranty_subscription |
— | 92,592 | — |
| Σ | 92,592 | 92,592 | ✓ |
Tháng thứ 12 (last): absorb residual to make total = original net 1,111,111.
Monthly amount = floor(net_amount / duration_months)
= floor(1,111,111 / 12) = 92,592
For months 1-11: each recognize 92,592 (total 1,018,512)
For month 12: recognize 1,111,111 - 1,018,512 = 92,599 (residual)
3.6.4 · Pattern · KH claim sử dụng (vd vệ sinh AC)¶
Claim approved → tạo order O-050 với amount_charged=0, is_warranty_order=TRUE. Step completed.
Cost tracking (agent payable từ warranty fund, NOT từ customer):
| Line | Account | Debit | Credit | Owner |
|---|---|---|---|---|
| 1 | warranty_service_cost |
200,000 | — | — |
| 2 | agent_payable (subledger=agent_id) |
— | 200,000 | agent_A |
| Σ | 200,000 | 200,000 | ✓ |
Important:
- Không touch deferred_revenue (recognition cứ tiếp tục theo schedule)
- Không touch customer_wallet (KH không bị charge)
- Agent vẫn earn bình thường (từ warranty_service_cost)
- Multi-agent split (Doc 16 §3.5) vẫn áp dụng cho warranty orders
Journal entry metadata:
- entry_type: warranty_claim_settled
- reference_type: warranty_claim
- reference_id: warranty_claim.id
3.6.5 · Pattern · KH cancel gói (refund proportional)¶
KH cancel sau 3 tháng (đã recognize 3 × 92,592 = 277,776 VND, còn deferred 833,335 VND):
Refund logic: trả lại phần chưa recognize, KH chấp nhận mất phần đã consumed.
Refund amount calculation:
Remaining deferred (net): 1,111,111 - 277,776 = 833,335 VND
Plus remaining VAT proportional: 88,889 × (9/12) = 66,667 VND
Total refund: 833,335 + 66,667 = 900,002 VND
| Line | Account | Debit | Credit | Owner |
|---|---|---|---|---|
| 1 | deferred_revenue (subledger) |
833,335 | — | warranty_123 |
| 2 | vat_payable (reverse VAT not yet paid · or via VAT adjustment) |
66,667 | — | — |
| 3 | customer_wallet OR gateway_clearing (back to source) |
— | 900,002 | cust_xyz |
| Σ | 900,002 | 900,002 | ✓ |
Edge case: nếu KH đã sử dụng > X claims có giá trị > 833,335 → consider:
- Option 1: vẫn refund full proportional (lose for SMP, customer-friendly)
- Option 2: cap refund tại deferred_remaining - claims_value (Ops decision case-by-case)
- v3.5: chọn Option 1 (simple). Option 2 defer v3.6.
3.6.6 · Pattern · Gói expire tự động¶
Khi customer_warranties.end_date_utc < NOW và total_amount_recognized < net_amount:
→ Hậu kỳ: residual recognize toàn bộ phần còn lại (kế toán bảo thủ · không leave dangling deferred):
| Line | Account | Debit | Credit |
|---|---|---|---|
| 1 | deferred_revenue (any residual) |
X | — |
| 2 | revenue_warranty_subscription |
— | X |
customer_warranties.status → expired.
3.6.7 · Reporting impact¶
P&L impact:
- Revenue line "Doanh thu thuê bao BH" = SUM(revenue_warranty_subscription) for period
- COGS line "Chi phí dịch vụ BH" = SUM(warranty_service_cost) for period
- Gross margin BH = Revenue - COGS (key metric · quan trọng cho pricing future packages)
Balance Sheet impact:
- deferred_revenue is short-term liability nếu < 12 months remaining, long-term nếu > 12 months
- Cần report separate "Doanh thu chưa thực hiện ngắn hạn" và "dài hạn" theo VAS
3.6.8 · Reconciliation¶
Daily reconcile:
- SUM(customer_warranties.price_paid - total_amount_recognized) == SUM(deferred_revenue) per subledger
- Drift > 0 → alert Finance Lead
Monthly reconcile:
- SUM(warranty_revenue_recognition WHERE recognition_date IN this month) == revenue from journal_lines
- Discrepancy → review unprocessed schedule items
4. Rounding rule (bug bỏ sót #1)¶
Minor units + chia % (C, V) → làm tròn độc lập có thể lệch 1 VND → entry không cân.
Rule:
1. Tính V (VAT) và C (commission) trước, làm tròn về integer minor.
2. N = P − C − V (residual) — N hấp thụ toàn bộ phần dư → Σdebit = Σcredit luôn đúng trong cùng entry.
3. Cross-entry (hiếm, vd phân bổ nhiều đơn): chênh dồn vào rounding_adjustment, alert nếu > ngưỡng.
P=99,999 rate_vat=8% rate_comm=15%(trên P−V)
V = round(99,999 × 8/108) = 7,407
base = 92,592 ; C = round(92,592 × 15%) = 13,889
N = 99,999 − 13,889 − 7,407 = 78,703 ← residual, entry cân tuyệt đối
5. VAT từ config (không hardcode 10%)¶
VAT Law 48/2024/QH15 + nghị quyết giảm tạm 10%→8% cho hàng/dịch vụ đủ điều kiện tới 31/12/2026. → vat_payable rate đọc từ:
CREATE TABLE tax_config (
id BIGINT PK, country CHAR(2) DEFAULT 'VN',
category VARCHAR(32), tax_type VARCHAR(8) DEFAULT 'VAT',
rate_bps INT, -- 800 = 8%, 1000 = 10%
effective_from DATE, effective_to DATE NULL
);
-- BR-PRICE-008 REVISED: VAT rate = lookup(tax_config, category, order_date). KHÔNG hardcode.
6. Ledger ↔ Settlement: source of truth (bug bỏ sót #2)¶
| Khía cạnh | Source of truth |
|---|---|
| Tiền (số dư, đã thu/chi/nợ) | journal_* (ledger) |
| Quy trình (đang ở bước nào) | settlements.state (Doc 17) |
Rule: ledger và settlement state phải nhất quán qua event. Daily reconcile: mỗi settlement ở SETTLED phải có entry revenue tương ứng; lệch → freeze payout liên quan + alert Finance. Khi mâu thuẫn không giải được: ledger thắng về tiền, settlement re-derive.
7. Subledger thay 3 bảng tiền của¶
points_ledger, agent_earnings, wallet_transactions bỏ — thành subledger view trên journal_lines:
- Ví KH = filter customer_wallet + owner.
- Earnings thợ = filter agent_payable (available) / agent_debt (debt) / agent_cod_receivable (cod_liability) + owner → 4 bucket dashboard (Hit 7).
- Điểm = filter loyalty_liability + owner.
agent_earnings_mv, agent_profile_mv giữ (read model projection, refresh từ event).
8. Consumer-side idempotency (bug phụ)¶
Event không chỉ idempotent ở producer (outbox). Consumer phải có dedup store (Redis set processed:{consumer}:{event_id}, TTL 7d) → exactly-once xử lý. Chi tiết Event Catalog (Doc 18).
9. Changelog¶
| Version | Date | Changes |
|---|---|---|
| 1.2 | 2026-05-28 | Pattern 2 ledger; 3 CHECK + single-writer + deferred trigger; journal templates sửa đúng chiều (clawback 2-bước, refund matrix + VAT reversal, COD clearing); rounding rule; VAT từ config; ledger↔settlement SoT; COD-refund-before-remit; wallet cấm âm; rename insurance→service_guarantee_reserve |