Architecture
1. System Context (C4 L1)
2. Container View (C4 L2)
3. Component View (C4 L3) — Internal Layering
| Layer | Responsibility |
|---|---|
| Components | Kafka producer/consumer, WebSocket emitter, VN bank assets |
| Controllers | Auth + Casbin gate + DTO mapping; voucher custom actions; account merchant-scoping |
| Worker | FinanceWorkerService — maps inbound events to issueDirect calls |
| Core services | FinanceVoucherService (posting engine), FinanceAccountService, PaymentIntegrationService |
| Repositories | Drizzle queries, soft-delete, balance/posting-sequence persistence (all from @nx/core) |
4. State Machines Index
| Entity | States | Diagram |
|---|---|---|
FinanceVoucher | DRAFT, ISSUED, VOIDED | → jump |
FinanceTransaction | PENDING, COMPLETED, CANCELLED | → jump |
FinanceAccount | ACTIVATED, DEACTIVATED, ARCHIVED | → jump |
FinanceVoucher
| From | Event | To | Guards |
|---|---|---|---|
— | createDraft | DRAFT | manual source; lines buffered in metadata.draftLines |
— | issueDirect | ISSUED | lines posted immediately; idempotent replay on dedup key |
DRAFT | approve (issueDraft) | ISSUED | draft must own ≥1 line; promotes draft, assigns number |
DRAFT | deleteDraft | removed | only while DRAFT |
ISSUED | void | VOIDED | not COGS/INVENTORY_ADJUSTMENT; opening-balance only if no later activity; issues counter voucher |
FinanceTransaction (ledger line)
In practice voucher lines are created directly
COMPLETED.PENDING/CANCELLEDexist in the enum for future flows.
FinanceAccount
5. Runtime Scenarios
5.1 Sale payment → RECEIPT voucher
| Step | Detail |
|---|---|
| 1-2 | Idempotency via sourceEventUid = attempt.uid (SALE_ORDER per-event dedup) |
| 3 | RECEIPT ⇒ line direction inferred DEBIT; balance increases |
| 4 | Whole posting is a single DB transaction with SELECT … FOR UPDATE on the account |
5.2 Purchase order received → PAYMENT voucher (with asset leg)
5.3 Inventory issued for sale → COGS posting
5.4 Merchant CDC → reconcile accounts + onboarding
6. Crosscutting Concerns
| Concern | How this service handles it |
|---|---|
| AuthN | JWT (Issuer = identity), JWKS verified per request (VerifierApplication) |
| AuthZ | Casbin; account list/count filtered by per-merchant GROUP policies (assertMerchantAccess on single-entity routes) |
| i18n | jsonb columns, { default, en, vi } shape (account/category name, voucher party/reason) |
| Logging | Structured key-value (key: %s); @logged() on worker handlers |
| Idempotency | Voucher dedup: per-source (merchantId, type, sourceType, sourceId) and per-event (sourceType, sourceEventUid) partial unique indexes; issueDirect replays existing voucher on hit |
| Posting integrity | postingSequenceLastValue strictly increases per account; balance computed in-tx with float(_, 4); account row FOR UPDATE locked |
| Soft-delete | SoftDeletableRepository (deletedAt) |
| IDs | Snowflake via IdGenerator, worker 4 |