Bỏ qua

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_fundservice_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_clearing giữ 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ùng agent_debt (lỗi thợ) hoặc expense (lỗi SMP). KHÔNG bao giờ Cr agent_payable trong 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ưng agent_cod_receivable vẫ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_payable thay 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 < NOWtotal_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.statusexpired.

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 settlementSETTLED 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