Skip to content

Architecture

1. System Context (C4 L1)

2. Container View (C4 L2)

A single deployable that runs as api, worker, or both, selected by APP_ENV_APPLICATION_ROLES.

3. Component View (C4 L3) — Internal Layering

LayerResponsibility
ControllersAuth + assertMerchantAccess + DTO mapping
Queue/Snapshot/Config servicesLifecycle logic, idempotency, enqueue
Worker servicePipeline orchestration (fetch → render → encrypt → upload)
Fetchers / generators / crypto / metalinkSingle-responsibility pipeline stages
RepositoriesDrizzle queries, atomic job-status adjusters, soft-delete
ComponentsKafka producer/consumer, recovery sweep, WS emitter

4. State Machines Index

EntityStatesDiagram
LedgerDRAFT, FINALIZED, ARCHIVED, SUBMITTED*→ jump
LedgerJobPENDING, PROCESSING, COMPLETED, REJECTED (+DRAFT/PARTIAL reserved)→ jump

* SUBMITTED is reserved — transition logic not built yet.

Ledger (user-driven lifecycle)

Owned exclusively by LedgerSnapshotService. The worker never mutates Ledger.status.

FromEventToGuards
DRAFTfinalizeFINALIZEDsnapshot has no hasUnrecordedChange
FINALIZEDrevisenew DRAFT rownote.default required; version+1, previousVersionId set

LedgerJob (generation state)

Owned by LedgerJobService + LedgerWorkerService. Independent of Ledger.status.

FromEventToGuards
PENDINGsetProcessingPROCESSINGatomic UPDATE … WHERE status=PENDING; returns null if already claimed
PROCESSINGsetCompletedIfProcessingCOMPLETEDatomic WHERE status=PROCESSING; false if pre-empted
PROCESSINGerror/timeoutREJECTEDfailureReason recorded (errorCode + i18n)
REJECTEDhandleRetryPENDINGre-enqueues; attemptCount not reset
PROCESSINGstalled (processStartAt < cutoff)PENDINGRecoveryComponent sweep, then re-enqueue

5. Runtime Scenarios

5.1 Enqueue → generate → complete

StepDetail
3Idempotent on (merchantId, type, period); FINALIZED ledgers reject re-generation
8No PENDING job → returns (already claimed/done) unless APP_ENV_FORCE_GENERATE
11Parse failure → REJECTED (FETCH_DATA_ERROR), commit, no replay
14setCompletedIfProcessing=false → pre-empted by a concurrent worker; finalize skipped
16Commit happens only after upload + finalize; failures rethrow and the message is still committed (no auto-replay)

5.2 Stalled-job recovery

StepDetail
2Only PROCESSING jobs whose processStartAt is older than APP_ENV_STALL_THRESHOLD_MS (default 180s)
4RecoveryComponent is registered before KafkaConsumerComponent so re-enqueued messages exist before consumers poll

5.3 Finalize then revise

6. Crosscutting Concerns

ConcernHow this service handles it
AuthNJWT (issuer = identity), JWKS verified per request (VerifierApplication)
AuthZCasbin via PolicyDefinition; permissions cached in Redis; every endpoint calls assertMerchantAccess(merchantId)
i18njsonb { en, vi } (ledger note, tax-level name/description, failure-reason)
LoggingStructured key-value (key: %s); pipeline phases logged (FETCH/GENERATE/UPLOAD/COMPLETED)
IdempotencyEnqueue keyed on (merchantId, type, period); job claims via atomic conditional UPDATE
EncryptionAES-256-GCM on every uploaded file (APP_ENV_LEDGER_ENCRYPTION_KEY)
Soft-deleteSoftDeletableRepository (deletedAt); unique indexes are partial (WHERE deleted_at IS NULL)
IDsSnowflake via IdGenerator, worker 6

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