Skip to content

Architecture

1. System Context (C4 L1)

2. Container View (C4 L2)

3. Component View (C4 L3) — Internal Layering

LayerResponsibility
RoutesHTTP surface, declared in RestPaths (src/common/constants.ts)
ControllersAuth + permission gate + DTO mapping
WorkerKafka → decide issuance action by issuanceMode / taxMethod; TaxInfo resync
Queue serviceEnqueue (hash partition) + process jobs; retry/DLQ policy
Action servicesBuild adapter payload, call provider, record audit + WS
Connection componentsRegister provider clients with iiapi / t-van at boot
RepositoriesDrizzle queries (owned by @nx/core), soft-delete

4. State Machines Index

EntityStatesDiagram
Invoice.issuanceStatusPENDING, PROCESSING, SUCCESS, FAILED, CANCELLED→ jump
InvoiceRequest.claimStatePENDING, CLAIMED, EXPIRED→ jump

Invoice issuance

FromEventToGuards
(none)createPendingInvoicePENDINGone non-deleted ORIGIN invoice per source
PENDINGworker dequeuePROCESSINGactive provider config resolved
PROCESSINGprovider 2xxSUCCESSresponse carries invoice number
PROCESSINGtransient (5xx / 429 / network)PENDINGretryCount ≤ maxRetryCount → re-enqueue with backoff
PROCESSINGpermanent 4xx (≠429) or retries exhaustedFAILEDmetadata.permanent = true on 4xx
SUCCESScancelCANCELLEDInvoiceCancellationService

Source: Invoice.constants.ts (IssuanceStatuses), InvoiceIssuanceQueueService._handleIssuanceFailure. Default retry: maxRetryCount = 3, retryDelayMinutes = [5, 15, 60] (from InvoiceProviderConfig.retryMetadata).

Buyer claim

FromEventToGuards
(none)payment success, mode BUYER_SELF_SERVICEPENDINGclaim token + deadline created; expiry job delayed to deadline
PENDINGbuyer submitsCLAIMEDenqueues issuance
PENDINGexpiry jobEXPIREDapplies defaultBuyerInfo, enqueues issuance
CLAIMEDexpiry jobEXPIREDissuance already enqueued; no-op

5. Runtime Scenarios

5.1 Payment success → async issuance

StepDetail
validateorder.status === COMPLETED, saleChannelId present, no active (non-cancelled) invoice
resolvemapping by SALE_CHANNEL principal; merchant-id match guard
partitiongetPartitionByKey(orderId) (Java hashCode mod 3) — same order always same partition
modesAUTO/SCHEDULED leave PENDING; REAL_TIME enqueues now; BUYER_SELF_SERVICE opens claim window

5.2 Merchant CDC → authoritative TaxInfo

StepDetail
change detectiondiff against persisted TaxInfo, NOT Debezium before-image (correct regardless of REPLICA IDENTITY)
authoritative writeTaxInfo (principalType=Merchant) is source of truth; FE reads merchant.taxInfo relation, not metadata.tax
onboardingmarks TAX_INFO onboarding step complete (idempotent)

5.3 Issuance retry / DLQ

6. Crosscutting Concerns

ConcernHow this service handles it
AuthNJWT (ES256, JWKS from identity); VerifierApplication
AuthZPermission-gated controllers + merchant-level AuthorizationService
i18njsonb / label maps { en, vi } (e.g. INVOICE_TYPE_DISPLAY_NAMES)
LoggingStructured key-value (key: %s)
IdempotencyIssuance jobId = orderId; partial-unique index (sourceType, sourceId) for ORIGIN; one InvoiceIssuance per invoice (latest attempt wins)
Credential encryptionAES-256-GCM; 32-byte key (APP_ENV_INVOICE_CREDENTIALS_KEY)
Webhook trustTwo HMAC secrets (merchant→platform, iiapi→platform)
Soft-deleteSoftDeletableRepository (deletedAt); partial-unique indexes exclude deleted rows
IDsSnowflake via IdGenerator (worker id ⚠️ unset)

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.