Skip to content

Ledger Service — Client Guide

Scope: This guide covers the REST API calls (list, trigger generation, view PDF, download files) and real-time WebSocket notifications for live job status updates. The DRAFT/FINALIZED lifecycle (finalize, revise, snapshot) is noted as a future feature and not required for this flow.

Base URL (dev): https://sgw.develop.bana.com.vn/v1/api/ledgerAuth: Authorization: Bearer <jwt> on every request.

UI Button States

For the current flow, only jobStatus matters for determining which buttons to show. ledgerStatus is not used in the current UI — see Status Reference for details.

jobStatusGenerate buttonView / DownloadRegenerate
nullShow
103_PENDING
203_PROCESSING
303_COMPLETEDShowShow
507_REJECTEDShow
300_PARTIALPartialShow

1. Ledger Book List

Batch Status (primary)

Returns every expected period for the year, including periods not yet generated.

http
GET /ledgers/status/batch
Query paramRequiredNotes
merchantId
yearDefault: current year
periodTypeMONTHLY | QUARTERLY | YEARLY. Default: MONTHLY
typesComma-separated, e.g. S1A-HKD,S2A-HKD. Derived from MerchantLedgerConfig.requiredLedgerTypes if omitted

Response:

json
{
  "warnings": [],
  "items": [
    {
      "type": "S1A-HKD",
      "period": "2026-M1",
      "periodType": "MONTHLY",
      "ledgerStatus": "001_DRAFT",
      "jobStatus": "303_COMPLETED",
      "ledgerId": "7601234567890002",
      "attemptCount": 1,
      "failureReason": null
    },
    {
      "type": "S1A-HKD",
      "period": "2026-M2",
      "periodType": "MONTHLY",
      "ledgerStatus": "001_DRAFT",
      "jobStatus": "507_REJECTED",
      "ledgerId": "7601234567890003",
      "attemptCount": 1,
      "failureReason": {
        "default": "Parse error: missing field 'totalRevenue'",
        "en": null,
        "vi": null,
        "errorCode": "FETCH_DATA_ERROR"
      }
    },
    {
      "type": "S1A-HKD",
      "period": "2026-M4",
      "periodType": "MONTHLY",
      "ledgerStatus": null,
      "jobStatus": null,
      "ledgerId": null,
      "attemptCount": null,
      "failureReason": null
    }
  ]
}

ledgerId: null means this period has not been generated yet.

failureReason — structured failure detail

When jobStatus is 507_REJECTED, failureReason is a non-null object:

FieldTypeNotes
defaultstringHuman-readable message; always present
enstring|nullEnglish override if set by the worker
vistring|nullVietnamese override if set by the worker
errorCodestringMachine-readable key — see Error Codes

Display priority: for a Vietnamese UI prefer vi, fall back to en, then default. For an English UI prefer en, fall back to default. errorCode can be used to look up a localised string on the client side when the server-side language field is null.

For all other job statuses failureReason is null. warnings lists types or period types the merchant is not configured to use:

json
{
  "warnings": [
    "Ledger type S2A-HKD is not in your configuration",
    "Period type YEARLY is not configured for ledger type S1A-HKD"
  ],
  "items": []
}

Period support by tier:

TierS1A-HKDS2B/S2C-HKDS2D-HKD
TIRE_1 (< 1B VND)MONTHLY, QUARTERLY, YEARLY
TIRE_2 (1B–10B VND)MONTHLY, QUARTERLYQUARTERLYYEARLY
TIRE_3 (> 10B VND)MONTHLY, QUARTERLYMONTHLY or QUARTERLYYEARLY

Search (paginated, existing records only)

http
GET /ledgers/search
Query paramRequiredNotes
merchantId
yearDefault: current year
typeSingle ledger type
pageDefault: 1
size5–50. Default: 5

Returns { data: Ledger[], count: number }. Only periods that have been generated at least once appear here.

2. Generate

Single period

http
POST /ledgers/{ledgerType}/generate

Path param: ledgerType — e.g. S1A-HKD.

Request body:

FieldRequiredNotes
merchantId
periodTypeMONTHLY | QUARTERLY | YEARLY
periodValueMonth 1–12 for MONTHLY; quarter 1–4 for QUARTERLY; omit for YEARLY
yearDefault: current year

Response:

FieldNotes
idLedger ID
typeLedger type
periodPeriod string — e.g. 2026-M3, 2026-Q1, 2026-Y
actioncreated | skipped | retried — see below
job.statusInitial job status

action values:

ValueMeaning
createdNew job enqueued
skippedA job is already 103_PENDING or 203_PROCESSING for this period
retriedPrevious 507_REJECTED job was re-enqueued

Pre-flight errors (HTTP 4xx/5xx, thrown before a job is created):

messageCodeHTTPWhen
server.core.ledger.tax_info_not_found404Merchant has no tax declaration info configured
server.core.ledger.failed_to_get_fetcher_service500Ledger type has no registered data fetcher (system error)

These errors are returned synchronously — no ledger or job record is created.

Batch generate

http
POST /ledgers/generate/batch

Enqueues all valid type+period combinations for the merchant. Types or period types not in the merchant's configuration are silently skipped with a warning.

Request body:

FieldRequiredNotes
merchantId
yearDefault: current year
periodTypeDefault: MONTHLY
typesArray of ledger types. Derived from merchant config if omitted

Response:

json
{
  "total": 5,
  "created": 3,
  "skipped": 0,
  "retried": 0,
  "failed": 0,
  "validationFailed": 2,
  "validationErrors": [
    {
      "type": "S1A-HKD",
      "period": "2026-M5",
      "errorCode": "server.core.ledger.tax_info_not_found",
      "message": "Merchant tax info not found"
    }
  ],
  "warnings": []
}

validationFailed counts items that failed pre-flight and were skipped entirely (no job created). failed counts items that passed validation but whose enqueue call threw an unexpected error. Items in validationErrors do not contribute to failed.

3. Generation Status

http
GET /ledgers/{id}/status

Returns the current state of the generation job for the given ledger ID.

Response fields:

FieldNotes
ledgerId
statusCurrent job status — see Job Status
attemptCountHow many times generation has been attempted
processStartAtISO timestamp when the worker started
processCompletedAtISO timestamp when the worker finished
failureReasonNon-null only when 507_REJECTED

For real-time updates, prefer WebSocket notifications over polling. If WebSocket is unavailable, poll this endpoint every 2–3 seconds and stop when status reaches 303_COMPLETED or 507_REJECTED.

4. Real-Time Status via WebSocket

The ledger service publishes job status transitions over WebSocket via the Signal service. This is the preferred alternative to polling — you get instant UI updates without repeated HTTP requests.

How it works

Ledger Worker
    │  publishes to Redis pub/sub

Signal Service
    │  broadcasts to connected clients in the room

Browser Client

The ledger service does not host its own WebSocket server. It publishes events to a shared Redis channel; the Signal service delivers them to all clients in the relevant room.

Connection

Connect to Signal's WebSocket using EncryptedWebSocketClient from @nx-app/core. See the Signal Web Browser Client Guide for the full connection setup (ECDH key exchange, authentication, reconnect, heartbeat).

Room and Topic

Use the constants from apps/core/src/socket/constants.ts:

typescript
import { WebSocketRooms, WebSocketTopics } from '@nx-app/core';

// One room per merchant — receives all ledger job events for that merchant
const room = WebSocketRooms.LEDGER_PROCESS.replace('{merchantId}', merchantId);
// → 'wr:ledger/760001234/process'

// Emitted on every job status transition
const topic = WebSocketTopics.LEDGER_JOB_STATUS;
// → 'ws:observation.ledger.job.status'

Joining and leaving the room

typescript
// After EncryptedWebSocketClient fires 'connected':
client.joinRooms({ rooms: [room] });

// On component unmount or when leaving the ledger screen:
client.leaveRooms({ rooms: [room] });

Listening for events

typescript
client.on({
  event: WebSocketTopics.LEDGER_JOB_STATUS,
  handler: (payload: TLedgerJobStatusPayload) => {
    // Match by ledgerId (or type + period if you don't have the ID yet)
    updateLedgerRow(payload.ledgerId, payload.jobStatus, payload.failureReason);
  },
});

Payload structure

typescript
interface TLedgerJobStatusPayload {
  ledgerId:      string;   // Ledger record ID
  merchantId:    string;   // Merchant the ledger belongs to
  type:          string;   // e.g. 'S1A-HKD'
  period:        string;   // e.g. '2026-M3'
  jobStatus:     string;   // '103_PENDING' | '203_PROCESSING' | '303_COMPLETED' | '507_REJECTED'
  attemptCount:  number;   // Number of generation attempts so far
  failureReason: {         // Non-null only when jobStatus === '507_REJECTED'
    default:   string;
    errorCode: string;
    en:        string | null;
    vi:        string | null;
  } | null;
}

Event sequence

POST /generate


'103_PENDING'       ← job enqueued, before worker picks it up


'203_PROCESSING'    ← worker started

    ├──▶ '303_COMPLETED'    files ready, download available
    └──▶ '507_REJECTED'     failureReason describes the error

Each status transition emits exactly one event. The 103_PENDING event may arrive before or after POST /generate returns — handle events in any order.

Room scope: All jobs for the same merchant share one room. Filter incoming events by payload.ledgerId (or payload.type + payload.period) to match the specific row in your UI.

Fallback to polling

If the WebSocket connection is unavailable, fall back to the GET /ledgers/{id}/status endpoint. Poll every 2–3 seconds and stop when jobStatus reaches 303_COMPLETED or 507_REJECTED.

UI integration

Apply the same logic as the UI Button States table at the top of this guide — use payload.jobStatus as the jobStatus value and payload.failureReason as the failure context.

5. View PDF in Browser

Requires jobStatus = 303_COMPLETED.

http
GET /ledgers/{id}/download/pdf?disposition=inline

Response: binary PDF (Content-Type: application/pdf, Content-Disposition: inline; filename="..."). The browser renders it natively.

Note: the disposition=inline query param is required to get inline rendering. Omitting it defaults to attachment and triggers a download prompt instead.

Error when job is not complete (HTTP 400):

json
{
  "messageCode": "server.core.ledger.fetch_data_error",
  "message": "Parse error: missing field 'totalRevenue'",
  "extra": {
    "failureReason": {
      "default": "Parse error: missing field 'totalRevenue'",
      "errorCode": "FETCH_DATA_ERROR"
    }
  }
}

messageCode reflects the specific failure (e.g. server.core.ledger.fetch_data_error) rather than the generic server.core.ledger.job_not_ready. Both endpoints require a JWT in the Authorization header — a direct <a href> or <iframe src> will not work. Clients must fetch the binary via JS and handle the response (Blob URL).

6. Download PDF or XLSX

Requires jobStatus = 303_COMPLETED.

http
GET /ledgers/{id}/download/pdf
GET /ledgers/{id}/download/xlsx

Response: binary file (Content-Disposition: attachment; filename="...").

Filename format: {type}_{period}_v{version}.{format} — e.g. S1A-HKD_2026-M3_v1.pdf.

Both endpoints require a JWT in the Authorization header. A direct <a href> or <iframe src> will not work — clients must fetch the binary via JS and handle the response (Blob URL or file save).

7. Regenerate

Forcefully re-generates the file for a 001_DRAFT ledger.

http
POST /ledgers/{id}/regenerate

Guards — returns 400 if:

  • Ledger is 200_FINALIZED — use POST /{id}/revise to create a revision first
  • Ledger is not 001_DRAFT
  • Job is 103_PENDING or 203_PROCESSING — wait for the current job to finish

Response: { ledgerId, status, attemptCount }

Retry vs Regenerate:

POST /{id}/retryPOST /{id}/regenerate
Ledger requirementAny statusMust be 001_DRAFT
Job requirementMust be 507_REJECTEDMust NOT be 103_PENDING or 203_PROCESSING
Use caseError recoveryRe-generate after source data changed

8. Status Reference

Job Status (jobStatus)

This is the only status the current UI needs. Use it to control which buttons are shown.

ValueMeaning
nullNo job created yet — period not generated
103_PENDINGEnqueued, waiting for a worker
203_PROCESSINGWorker is generating PDF + XLSX
303_COMPLETEDFiles ready
300_PARTIALOne format succeeded, one failed
507_REJECTEDGeneration failed — failureReason has details

Error Codes (failureReason.errorCode)

These values appear in failureReason.errorCode when the generation worker captures a known error.

errorCodeCategoryWhen it occursSuggested UI message
FETCH_DATA_ERRORSystemData fetch from source services failed, or the response did not match the expected schema"Data fetch failed — please retry"
MERCHANT_TAX_INFO_NOT_FOUNDBusinessThe merchant's tax declaration info has not been configured"Tax information is missing — configure the merchant first"
FAILED_TO_GET_DATA_FETCHER_SERVICESystemInternal service wiring error; no data fetcher registered for this ledger type"System error — contact support"
ENQUEUE_FAILEDSystemThe Kafka producer could not publish the ledger.generate message; the job was rejected before reaching a worker"Generation could not be started — please retry"
JOB_EXECUTION_FAILEDSystemUnexpected error during worker execution not covered by a more specific code"Generation failed unexpectedly — please retry or contact support"

Ledger Status (ledgerStatus)

ledgerStatus reflects the lifecycle of the ledger record itself (not the generation job). It is returned in batch status and search responses. The generate/view/download flow only requires the job status above; the finalize/revise lifecycle is documented as reference material — see Domain Model and Architecture — State Machines.

ValueMeaning
DRAFTEditable — new files can be generated. Default state.
200_FINALIZEDLocked by the user. Use POST /ledgers/{id}/revise to open a new revision.
ARCHIVEDSuperseded by a newer finalized revision
400_SUBMITTEDReserved — submitted to the tax authority (not implemented)
  • API Events — Kafka/WS payload reference
  • Generation Pipeline — server-side flow
  • REST endpoints — live OpenAPI at /v1/api/ledger/doc/openapi.json

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