Domain Model
Schemas + repositories are owned by
@nx/core. Source:packages/core/src/models/schemas/invoice/*and.../tax/tax-info/*. TheInvoiceschema isinvoice;TaxInfolives in the sharedtaxschema. No cross-schema FKs — onlyInvoiceIssuance/InvoiceAuditTracingdeclare FKs (both toInvoice).
1. Full ERD
2. Entities
Invoice
| Property | Value |
|---|---|
| Table | invoice.Invoice |
| Source | packages/core/src/models/schemas/invoice/invoice/schema.ts |
| Soft-delete | yes |
| Owner column | invoiceConfigMappingId (→ merchant via mapping) |
Fields (selected):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
id | text | ✓ | Snowflake | PK |
sourceType | text | ✓ | 001_SALE_ORDER | Origin doc type (cross-schema soft ref) |
sourceId / sourceNumber | text | ✓ | — | Order id / number |
origin | text | ✓ | 000_ORIGIN | ORIGIN/ADJUSTMENT/REPLACEMENT |
invoiceType | text | ✓ | — | Config snapshot (VAT/SALE/POS/…) |
invoiceSymbol / invoiceCategory / year | text/int/int | ✓ | — | Serial snapshot |
hasCqtCode | boolean | ✓ | true | Has tax-authority code |
taxMethod | text | ✓ | — | DEDUCTION/DIRECT/UNKNOWN |
autoRelease/autoSign/autoSendCqt | boolean | ✓ | — | Policy snapshot |
issuanceMode | text | ✓ | — | Snapshot of config mode |
invoiceConfigMappingId | text | ✓ | — | Routing mapping |
invoiceNumber | text | — | Assigned on success | |
issuanceStatus | text | ✓ | PENDING | See enum |
retryCount | int | ✓ | 0 | — |
parentId | text | — | Corrected invoice (adjust/replace) | |
invoiceRequestId | text | — | Buyer-info source | |
claimQrDataUrl | text | — | Buyer-claim QR data URL | |
metadata | jsonb | — | errorMessage, permanent, … |
Status enum (issuanceStatus):
| Value | Description |
|---|---|
PENDING | Created, awaiting issuance (start state) |
PROCESSING | Worker is calling provider |
SUCCESS | Issued; invoiceNumber assigned |
FAILED | Permanent error / retries exhausted / DLQ |
CANCELLED | Cancelled post-issuance |
Origin enum: 000_ORIGIN, 100_ADJUSTMENT, 200_REPLACEMENT.
Key indexes:
| Name | Columns | Type |
|---|---|---|
| UQ partial | (sourceType, sourceId) where origin=ORIGIN AND deletedAt IS NULL | Unique partial |
| UQ partial | (invoiceSymbol, invoiceNumber) where deletedAt IS NULL | Unique partial |
| partial | (invoiceConfigMappingId, createdAt) where PENDING AND issuanceMode=SCHEDULED | Cron pickup |
InvoiceRequest
| Property | Value |
|---|---|
| Table | invoice.InvoiceRequest |
| Source | .../invoice-request/schema.ts |
| Soft-delete | yes |
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
sourceType/sourceId/sourceNumber | text | ✓ | SALE_ORDER | Origin doc |
flowType | text | ✓ | 000_DIRECT | DIRECT / 100_BUYER_CLAIM |
status | text | ✓ | ACTIVATED | ACTIVATED/DEACTIVATED/CANCELLED |
requestedBy | text | ✓ | — | Captured-by |
buyerInfo | jsonb | ✓ | {} | TBuyerInfo (name, taxCode, address, …) |
claimToken | text | — | Self-service token (UQ partial) | |
claimDeadline | timestamptz | — | Claim window end | |
claimState | text | — | PENDING/CLAIMED/EXPIRED | |
claimSubmittedAt | timestamptz | — | — |
Invariant: one ACTIVATED request per (sourceType, sourceId) (UQ partial index).
InvoiceProvider
| Property | Value |
|---|---|
| Table | invoice.InvoiceProvider |
| Soft-delete | yes |
| Field | Type | Required | Notes |
|---|---|---|---|
merchantInvoiceProfileId | text | ✓ | Owner profile |
provider | text | ✓ | InvoiceProviders (VNPAY today) |
environment | text | ✓ | DEVELOPMENT/PRODUCTION |
username / password | text | ✓ | password AES-256-GCM encrypted |
webhookSecret | text | encrypted | |
webhookUUID / webhookEventTypes / webhookStatus | text/array/text | webhook registration |
Invariant: UQ (merchantInvoiceProfileId, provider) and UQ name (both partial on deletedAt IS NULL).
InvoiceProviderConfig
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
providerId | text | ✓ | — | → InvoiceProvider |
invoiceType/invoiceSymbol/invoiceCategory/year | mixed | ✓ | — | Serial |
issuanceMode | text | ✓ | 100_MANUAL | REAL_TIME/MANUAL/SCHEDULED/BUYER_SELF_SERVICE |
issuanceModeMetadata | jsonb | — | claimWindowMinutes, claimIssueTiming | |
autoRelease/autoSign/autoSendCqt/isSendMail | boolean | ✓ | false | Export policy |
retryMetadata | jsonb | ✓ | {max:3, delays:[5,15,60]} | Retry policy |
defaultBuyerInfo | jsonb | ✓ | "Người mua không lấy hoá đơn" | Fallback buyer |
deliveryChannels | jsonb | ✓ | [RECEIPT_QR] | RECEIPT_QR/EMAIL/SMS |
Invariant: UQ (providerId, invoiceSymbol, invoiceType) partial.
InvoiceConfigMapping
| Field | Type | Required | Notes |
|---|---|---|---|
principalType / principalId | text | ✓ | e.g. SALE_CHANNEL → channel id |
providerConfigId | text | ✓ | → InvoiceProviderConfig |
merchantId | text | ✓ | Owner |
status | text | ✓ | ACTIVATED/… |
Invariant: one active mapping per (principalType, principalId) (UQ partial).
InvoiceIssuance
| Field | Type | Required | Notes |
|---|---|---|---|
invoiceId | text | ✓ | FK → Invoice.id; UQ partial (one per invoice) |
invoiceRequestId | text | FK → InvoiceRequest.id | |
provider | text | ✓ | Issuing provider |
externalId/externalRef/taxAuthorityCode | text | Provider refs | |
providerStatus | text | VNPAY: new/released/signed/… | |
taxAuthorityStatus | int | VNPAY tvanStatus 1–8 | |
requestId | text | ${invoiceId}:${retryCount} | |
requestPayload/responsePayload/buyerInfoSnapshot | jsonb | Provider blobs |
InvoiceAuditTracing
| Field | Type | Required | Notes |
|---|---|---|---|
invoiceId | text | ✓ | FK → Invoice.id |
eventType | text | ✓ | e.g. ISSUE_ATTEMPT |
eventOutcome | text | ✓ | default SUCCESS |
issuanceStatusBefore/After | text | transition trace | |
message / details | text/jsonb | human + structured | |
triggeredBy | text | ✓ | actor / system:* |
occurredAt | timestamptz | ✓ | now() |
MerchantInvoiceProfile
| Field | Type | Required | Notes |
|---|---|---|---|
merchantId | text | ✓ | UQ partial (one profile per merchant) |
taxInfoId | text | ✓ | → tax.TaxInfo |
taxMethod | text | ✓ | DEDUCTION/DIRECT/UNKNOWN |
businessType | text | ✓ | HOUSEHOLD/BUSINESS |
sharingPolicy | text | ✓ | PRIVATE/ALL_BRANCHES/WHITELIST |
TaxInfo (shared tax schema)
| Field | Type | Required | Notes |
|---|---|---|---|
principalType | text | ✓ | Merchant / Organizer |
principalId | text | ✓ | — |
taxCode / fullName / addressLine | text | ✓ | Authoritative seller/buyer identity |
cityCode/districtCode/wardsCode/fullAddress | text | VN address | |
managingTaxAuthority/chapter/department | text | GDT registry fields |
Invariant: UQ (principalType, principalId) partial. Written by TaxInfoService.syncTaxInfo from merchant CDC — see Integration §3.
3. Cross-entity Invariants
| Invariant | Enforcement |
|---|---|
| One ORIGIN invoice per source order | UQ partial (sourceType, sourceId) where origin=ORIGIN AND deletedAt IS NULL |
Unique official (invoiceSymbol, invoiceNumber) once assigned | UQ partial index |
One InvoiceIssuance per invoice | UQ partial (invoiceId); upserted per retry (latest wins) |
One active InvoiceRequest per source | UQ partial (sourceType, sourceId) where ACTIVATED |
| One config mapping per principal | UQ partial (principalType, principalId) |
| TaxInfo is authoritative seller identity | syncTaxInfo upsert by (principalType, principalId); diff vs persisted row |
(businessType, taxMethod) → allowed invoiceType set | ALLOWED_INVOICE_TYPES map (service-layer guard) |
| Adjustment/replacement chain | Invoice.parentId self-reference (service-layer, not DB FK) |
4. Soft-delete Behavior
| Behavior | Detail |
|---|---|
| Read default | deletedAt IS NULL (SoftDeletableRepository) |
| Unique indexes | All partial on deletedAt IS NULL — soft-deleted rows free the slot |
| Hard-delete | Not used by default |
| Restore | Not implemented |