Domain Model
Schema source:
packages/core/src/models/schemas/ledger/- this package owns no Drizzle schemas; it re-exports repositories from@nx/core. All tables live in theledgerPostgres schema.
1. Full ERD
2. Entities
Ledger
| Property | Value |
|---|---|
| Table | ledger.Ledger |
| Source | core/src/models/schemas/ledger/ledger/schema.ts |
| Soft-delete | yes |
| Owner ID column | merchantId |
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
type | text | ✓ | - | TLedgerIdentifierCode (S1a-HKD..S2e-HKD) |
status | text | ✓ | PENDING | See enum below |
period | text | ✓ | - | YYYY-MN / YYYY-QN / YYYY-Y (e.g. 2026-M3, 2026-Q1, 2026-Y) |
periodStart / periodEnd | timestamptz | ✓ | - | Period bounds |
merchantId | text | ✓ | - | Owner merchant |
isCurrent | boolean | ✓ | true | Current version flag |
version | numeric(_,1) | ✓ | 1.0 | Revision version |
previousVersionId | text | null | Prior version (set on revise) | |
ledgerIdentifierId | text | ✓ | - | Soft ref to LedgerIdentifier |
summary | jsonb | null | TLedgerSummary (per-form totals) | |
note | jsonb | null | i18n { en, vi } revision note |
Status enum (LedgerStatuses - reuses IGNIS Statuses):
| Value | Description |
|---|---|
PENDING | Default; editable draft (record created without/awaiting a job) |
NEW | Record created alongside a job to be processed |
PROCESSING | Generation in progress |
COMPLETED | Generation finished |
FAIL | Generation failed |
SETTLED | Finalized; version locked - must revise to change |
ARCHIVED | Superseded by a re-generated/revised version; read-only |
Active user-driven flow uses
PENDING(draft) →SETTLED(finalize) →revise→ newPENDING. The worker mutatesLedgerJob.statusonly - theLedger.statuswrite is commented out inLedgerWorkerService.
Indexes & constraints:
| Name | Columns | Type |
|---|---|---|
PK_Ledger | id | Primary key |
UPQ_Ledger_* | merchantId, type, period, version | Unique partial (deleted_at IS NULL) |
UQ_Ledger_* | ledgerIdentifierId, merchantId, period, version | Unique |
IDX_Ledger_* | isCurrent · merchantId,period · merchantId,periodStart,periodEnd · merchantId,status · previousVersionId · status | Btree |
LedgerJob
| Property | Value |
|---|---|
| Table | ledger.LedgerJob |
| Source | core/src/models/schemas/ledger/ledger-job/schema.ts |
| Soft-delete | yes |
| Owner ID column | - (via ledgerId → Ledger) |
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
ledgerId | text | ✓ | - | Owning ledger (soft ref) |
status | text | ✓ | PENDING | See enum below |
attemptCount | integer | ✓ | 0 | Lifetime attempts; not reset on retry |
processStartAt | timestamptz | - | Stall-detection anchor | |
processCompletedAt | timestamptz | - | - | |
failureReason | jsonb | - | { default, en?, vi?, errorCode } | |
enqueuedAt | timestamptz | ✓ | - | First enqueue |
lastEnqueuedAt | timestamptz | - | Last re-enqueue |
Status enum (LedgerJobStatuses): DRAFT, PENDING, PROCESSING, COMPLETED, REJECTED (DRAFT = created but not yet queued; active flow runs PENDING → PROCESSING → COMPLETED|REJECTED).
Indexes: IDX_LedgerJob_ledgerId, IDX_LedgerJob_status, IDX_LedgerJob_status_processStartAt (stalled-job sweep), UPQ_LedgerJob_ledgerId (partial - one in-flight job per ledger where status IN (PENDING, PROCESSING, DRAFT)).
LedgerSnapshot
| Property | Value |
|---|---|
| Table | ledger.LedgerSnapshot |
| Source | core/src/models/schemas/ledger/ledger-snapshot/schema.ts |
| Soft-delete | yes (+ user-audit columns) |
| Owner ID column | - (via ledgerId) |
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
ledgerId | text | ✓ | - | Owning ledger; unique |
headerData | jsonb | - | TSnapshotHeaderData (businessName, taxCode, address…) | |
snapshotMeta | jsonb | - | Per-type staleness aggregate (count, maxUpdatedAt) | |
pulledAt | timestamptz | ✓ | - | Pull time |
hasUnrecordedChange | boolean | ✓ | false | Staleness flag (blocks finalize) |
lastChangeDetectedAt | timestamptz | - | - |
Indexes: UQ_LedgerSnapshot_ledgerId (one snapshot per ledger).
LedgerSnapshotEntry
| Property | Value |
|---|---|
| Table | ledger.LedgerSnapshotEntry |
| Source | core/src/models/schemas/ledger/ledger-snapshot-entry/schema.ts |
| Soft-delete | yes (+ user-audit columns) |
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
snapshotId | text | ✓ | - | Owning snapshot |
rowIndex | integer | ✓ | - | Row order |
originalData | jsonb | null | Source row; null for user-added entries | |
currentData | jsonb | ✓ | - | Edited/effective row |
Indexes: IDX_LedgerSnapshotEntry_snapshotId.
LedgerIdentifier
| Property | Value |
|---|---|
| Table | ledger.LedgerIdentifier |
| Source | core/src/models/schemas/ledger/ledger-identifier/schema.ts |
| Soft-delete | yes |
Catalogue of the 6 HKD form codes. Seeded once; referenced by Ledger.type and Ledger.ledgerIdentifierId.
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
identifier | text | ✓ | - | TLedgerIdentifierCode (S1a-HKD..S2e-HKD); unique |
name | jsonb | ✓ | - | i18n { en, vi } |
description | jsonb | - | i18n |
Indexes: unique on identifier.
TaxTier
| Property | Value |
|---|---|
| Table | ledger.TaxTier |
| Source | core/src/models/schemas/ledger/tax-tier/schema.ts |
| Soft-delete | yes (+ user-audit columns) |
Revenue-band group a merchant falls into (TIRE_1..TIRE_4). Seeded once.
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
code | text | ✓ | - | TTaxTierCode (100_TIRE_1..400_TIRE_4) |
name | jsonb | ✓ | - | i18n { en, vi } |
description | jsonb | - | i18n | |
status | text | ✓ | ACTIVATED | ACTIVATED / DEACTIVATED |
businessScale | text | ✓ | 000_HOUSEHOLD | TBusinessScaleType (000_HOUSEHOLD / 100_ENTERPRISE) |
revenueMin | decimal(15,4) | ✓ | 0 | Band lower bound (VND) |
revenueMax | decimal(15,4) | null | Band upper bound (VND); null = open-ended |
Indexes: IDX_TaxTier_status, UQ_TaxTier_code (unique partial, deleted_at IS NULL).
LedgerTaxConfig
| Property | Value |
|---|---|
| Table | ledger.LedgerTaxConfig |
| Source | core/src/models/schemas/ledger/ledger-tax-config/schema.ts |
| Soft-delete | yes (+ user-audit columns) |
A tax approach available within a tier, with the set of ledger forms it requires. Seeded once.
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
code | text | ✓ | - | TTaxApproachCode (100_EXEMPT, 201_PERCENTAGE_BOTH, 202_PERCENTAGE_VAT_INCOME_PIT) |
taxTierId | text | ✓ | - | Soft ref to TaxTier |
name | jsonb | ✓ | - | i18n { en, vi } |
description | jsonb | - | i18n | |
requiredLedgers | jsonb | ✓ | [] | TLedgerIdentifierCode[] required by this approach |
status | text | ✓ | ACTIVATED | ACTIVATED / DEACTIVATED |
metadata | jsonb | - | { isDefault, isSelectable } |
Indexes: IDX_LedgerTaxConfig_tax_tier_id, IDX_LedgerTaxConfig_status, UPQ_LedgerTaxConfig_tax_tier_id_code (unique partial, deleted_at IS NULL).
MerchantTaxConfig
| Property | Value |
|---|---|
| Table | ledger.MerchantTaxConfig |
| Source | core/src/models/schemas/ledger/merchant-tax-config/schema.ts |
| Soft-delete | yes (+ user-audit columns) |
| Owner ID column | merchantId |
Per-merchant per-year tax onboarding selection. Schema and model are defined; no repository/service/controller is wired in this package yet.
Fields:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
merchantId | text | ✓ | - | Owner merchant |
year | integer | ✓ | current year | Config year |
taxTierId | text | - | Soft ref to TaxTier | |
ledgerTaxConfigId | text | - | Soft ref to LedgerTaxConfig (selected approach) | |
filingPeriod | text | - | TPeriodType (MONTHLY / QUARTERLY / YEARLY) | |
declaredAnnualRevenue | decimal(15,4) | - | Self-declared annual revenue (VND) | |
onboardingStatus | text | ✓ | PENDING | PENDING / PARTIAL / COMPLETED |
disclaimerAcceptedAt | timestamptz | - | Disclaimer acceptance time | |
disclaimerAcceptedBy | text | - | User who accepted (DB column disclaimer_accepted_by) | |
disclaimerVersion | text | - | Accepted disclaimer version | |
metadata | jsonb | - | { disclaimerHistory: { acceptedAt, version }[] } |
Indexes: IDX_MerchantTaxConfig_year, IDX_MerchantTaxConfig_merchant_id_onboarding_status_year, UPQ_MerchantTaxConfig_merchant_id_year (unique partial, deleted_at IS NULL).
3. Cross-entity Invariants
| Invariant | Enforcement |
|---|---|
At most one current ledger per (merchantId, type, period, version) | Unique partial index + isCurrent flag |
revise always produces a new PENDING row (version+1, isCurrent=false, previousVersionId set, requires source SETTLED) | LedgerSnapshotService.revise |
| Exactly one snapshot per ledger | UQ_LedgerSnapshot_ledgerId |
| At most one in-flight job per ledger | UPQ_LedgerJob_ledgerId (partial on PENDING/PROCESSING/DRAFT) |
finalize blocked while snapshot hasUnrecordedChange = true; sets Ledger.status = SETTLED | LedgerSnapshotService.finalize guard |
One LedgerTaxConfig approach is unique per (taxTierId, code); its requiredLedgers drives the forms a merchant must keep | UPQ_LedgerTaxConfig_tax_tier_id_code + APPROACH_REQUIRED_LEDGERS seed |
One MerchantTaxConfig per (merchantId, year) | UPQ_MerchantTaxConfig_merchant_id_year |
Worker mutates LedgerJob.status only - never Ledger.status | LedgerWorkerService (Ledger-status write commented out) |
4. Soft-delete Behavior
| Behavior | Detail |
|---|---|
| Read default | deletedAt IS NULL (all repos via SoftDeletableRepository) |
| Hard-delete | Snapshot re-pull soft-deletes prior entries + snapshot before recreating |
| Unique indexes | Partial (WHERE deleted_at IS NULL) so soft-deleted rows don't block re-create |