Architecture
1. System Context (C4 L1)
2. Container View (C4 L2)
3. Component View (C4 L3) — Internal Layering
| Layer | Responsibility |
|---|---|
| Routes | HTTP surface, declared in RestPaths (src/common/constants.ts) |
| Controllers | Auth + permission gate + DTO mapping |
| Worker | Kafka → decide issuance action by issuanceMode / taxMethod; TaxInfo resync |
| Queue service | Enqueue (hash partition) + process jobs; retry/DLQ policy |
| Action services | Build adapter payload, call provider, record audit + WS |
| Connection components | Register provider clients with iiapi / t-van at boot |
| Repositories | Drizzle queries (owned by @nx/core), soft-delete |
4. State Machines Index
| Entity | States | Diagram |
|---|---|---|
Invoice.issuanceStatus | PENDING, PROCESSING, SUCCESS, FAILED, CANCELLED | → jump |
InvoiceRequest.claimState | PENDING, CLAIMED, EXPIRED | → jump |
Invoice issuance
| From | Event | To | Guards |
|---|---|---|---|
| (none) | createPendingInvoice | PENDING | one non-deleted ORIGIN invoice per source |
PENDING | worker dequeue | PROCESSING | active provider config resolved |
PROCESSING | provider 2xx | SUCCESS | response carries invoice number |
PROCESSING | transient (5xx / 429 / network) | PENDING | retryCount ≤ maxRetryCount → re-enqueue with backoff |
PROCESSING | permanent 4xx (≠429) or retries exhausted | FAILED | metadata.permanent = true on 4xx |
SUCCESS | cancel | CANCELLED | InvoiceCancellationService |
Source:
Invoice.constants.ts(IssuanceStatuses),InvoiceIssuanceQueueService._handleIssuanceFailure. Default retry:maxRetryCount = 3,retryDelayMinutes = [5, 15, 60](fromInvoiceProviderConfig.retryMetadata).
Buyer claim
| From | Event | To | Guards |
|---|---|---|---|
| (none) | payment success, mode BUYER_SELF_SERVICE | PENDING | claim token + deadline created; expiry job delayed to deadline |
PENDING | buyer submits | CLAIMED | enqueues issuance |
PENDING | expiry job | EXPIRED | applies defaultBuyerInfo, enqueues issuance |
CLAIMED | expiry job | EXPIRED | issuance already enqueued; no-op |
5. Runtime Scenarios
5.1 Payment success → async issuance
| Step | Detail |
|---|---|
| validate | order.status === COMPLETED, saleChannelId present, no active (non-cancelled) invoice |
| resolve | mapping by SALE_CHANNEL principal; merchant-id match guard |
| partition | getPartitionByKey(orderId) (Java hashCode mod 3) — same order always same partition |
| modes | AUTO/SCHEDULED leave PENDING; REAL_TIME enqueues now; BUYER_SELF_SERVICE opens claim window |
5.2 Merchant CDC → authoritative TaxInfo
| Step | Detail |
|---|---|
| change detection | diff against persisted TaxInfo, NOT Debezium before-image (correct regardless of REPLICA IDENTITY) |
| authoritative write | TaxInfo (principalType=Merchant) is source of truth; FE reads merchant.taxInfo relation, not metadata.tax |
| onboarding | marks TAX_INFO onboarding step complete (idempotent) |
5.3 Issuance retry / DLQ
6. Crosscutting Concerns
| Concern | How this service handles it |
|---|---|
| AuthN | JWT (ES256, JWKS from identity); VerifierApplication |
| AuthZ | Permission-gated controllers + merchant-level AuthorizationService |
| i18n | jsonb / label maps { en, vi } (e.g. INVOICE_TYPE_DISPLAY_NAMES) |
| Logging | Structured key-value (key: %s) |
| Idempotency | Issuance jobId = orderId; partial-unique index (sourceType, sourceId) for ORIGIN; one InvoiceIssuance per invoice (latest attempt wins) |
| Credential encryption | AES-256-GCM; 32-byte key (APP_ENV_INVOICE_CREDENTIALS_KEY) |
| Webhook trust | Two HMAC secrets (merchant→platform, iiapi→platform) |
| Soft-delete | SoftDeletableRepository (deletedAt); partial-unique indexes exclude deleted rows |
| IDs | Snowflake via IdGenerator (worker id ⚠️ unset) |