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
| Layer | Responsibility |
|---|---|
| Controllers | Auth + assertMerchantAccess + DTO mapping |
| Queue/Snapshot/Config services | Lifecycle logic, idempotency, enqueue |
| Worker service | Pipeline orchestration (fetch → render → encrypt → upload) |
| Fetchers / generators / crypto / metalink | Single-responsibility pipeline stages |
| Repositories | Drizzle queries, atomic job-status adjusters, soft-delete |
| Components | Kafka producer/consumer, recovery sweep, WS emitter |
4. State Machines Index
| Entity | States | Diagram |
|---|---|---|
Ledger | DRAFT, FINALIZED, ARCHIVED, SUBMITTED* | → jump |
LedgerJob | PENDING, 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 mutatesLedger.status.
| From | Event | To | Guards |
|---|---|---|---|
DRAFT | finalize | FINALIZED | snapshot has no hasUnrecordedChange |
FINALIZED | revise | new DRAFT row | note.default required; version+1, previousVersionId set |
LedgerJob (generation state)
Owned by
LedgerJobService+LedgerWorkerService. Independent ofLedger.status.
| From | Event | To | Guards |
|---|---|---|---|
PENDING | setProcessing | PROCESSING | atomic UPDATE … WHERE status=PENDING; returns null if already claimed |
PROCESSING | setCompletedIfProcessing | COMPLETED | atomic WHERE status=PROCESSING; false if pre-empted |
PROCESSING | error/timeout | REJECTED | failureReason recorded (errorCode + i18n) |
REJECTED | handleRetry | PENDING | re-enqueues; attemptCount not reset |
PROCESSING | stalled (processStartAt < cutoff) | PENDING | RecoveryComponent sweep, then re-enqueue |
5. Runtime Scenarios
5.1 Enqueue → generate → complete
| Step | Detail |
|---|---|
| 3 | Idempotent on (merchantId, type, period); FINALIZED ledgers reject re-generation |
| 8 | No PENDING job → returns (already claimed/done) unless APP_ENV_FORCE_GENERATE |
| 11 | Parse failure → REJECTED (FETCH_DATA_ERROR), commit, no replay |
| 14 | setCompletedIfProcessing=false → pre-empted by a concurrent worker; finalize skipped |
| 16 | Commit happens only after upload + finalize; failures rethrow and the message is still committed (no auto-replay) |
5.2 Stalled-job recovery
| Step | Detail |
|---|---|
| 2 | Only PROCESSING jobs whose processStartAt is older than APP_ENV_STALL_THRESHOLD_MS (default 180s) |
| 4 | RecoveryComponent is registered before KafkaConsumerComponent so re-enqueued messages exist before consumers poll |
5.3 Finalize then revise
6. Crosscutting Concerns
| Concern | How this service handles it |
|---|---|
| AuthN | JWT (issuer = identity), JWKS verified per request (VerifierApplication) |
| AuthZ | Casbin via PolicyDefinition; permissions cached in Redis; every endpoint calls assertMerchantAccess(merchantId) |
| i18n | jsonb { en, vi } (ledger note, tax-level name/description, failure-reason) |
| Logging | Structured key-value (key: %s); pipeline phases logged (FETCH/GENERATE/UPLOAD/COMPLETED) |
| Idempotency | Enqueue keyed on (merchantId, type, period); job claims via atomic conditional UPDATE |
| Encryption | AES-256-GCM on every uploaded file (APP_ENV_LEDGER_ENCRYPTION_KEY) |
| Soft-delete | SoftDeletableRepository (deletedAt); unique indexes are partial (WHERE deleted_at IS NULL) |
| IDs | Snowflake via IdGenerator, worker 6 |