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.
jobStatus | Generate button | View / Download | Regenerate |
|---|---|---|---|
null | Show | — | — |
103_PENDING | — | — | — |
203_PROCESSING | — | — | — |
303_COMPLETED | — | Show | Show |
507_REJECTED | — | — | Show |
300_PARTIAL | — | Partial | Show |
1. Ledger Book List
Batch Status (primary)
Returns every expected period for the year, including periods not yet generated.
GET /ledgers/status/batch| Query param | Required | Notes |
|---|---|---|
merchantId | ✓ | |
year | — | Default: current year |
periodType | — | MONTHLY | QUARTERLY | YEARLY. Default: MONTHLY |
types | — | Comma-separated, e.g. S1A-HKD,S2A-HKD. Derived from MerchantLedgerConfig.requiredLedgerTypes if omitted |
Response:
{
"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:
| Field | Type | Notes |
|---|---|---|
default | string | Human-readable message; always present |
en | string|null | English override if set by the worker |
vi | string|null | Vietnamese override if set by the worker |
errorCode | string | Machine-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:
{
"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:
| Tier | S1A-HKD | S2B/S2C-HKD | S2D-HKD |
|---|---|---|---|
| TIRE_1 (< 1B VND) | MONTHLY, QUARTERLY, YEARLY | — | — |
| TIRE_2 (1B–10B VND) | MONTHLY, QUARTERLY | QUARTERLY | YEARLY |
| TIRE_3 (> 10B VND) | MONTHLY, QUARTERLY | MONTHLY or QUARTERLY | YEARLY |
Search (paginated, existing records only)
GET /ledgers/search| Query param | Required | Notes |
|---|---|---|
merchantId | ✓ | |
year | — | Default: current year |
type | ✓ | Single ledger type |
page | — | Default: 1 |
size | — | 5–50. Default: 5 |
Returns { data: Ledger[], count: number }. Only periods that have been generated at least once appear here.
2. Generate
Single period
POST /ledgers/{ledgerType}/generatePath param: ledgerType — e.g. S1A-HKD.
Request body:
| Field | Required | Notes |
|---|---|---|
merchantId | ✓ | |
periodType | ✓ | MONTHLY | QUARTERLY | YEARLY |
periodValue | — | Month 1–12 for MONTHLY; quarter 1–4 for QUARTERLY; omit for YEARLY |
year | — | Default: current year |
Response:
| Field | Notes |
|---|---|
id | Ledger ID |
type | Ledger type |
period | Period string — e.g. 2026-M3, 2026-Q1, 2026-Y |
action | created | skipped | retried — see below |
job.status | Initial job status |
action values:
| Value | Meaning |
|---|---|
created | New job enqueued |
skipped | A job is already 103_PENDING or 203_PROCESSING for this period |
retried | Previous 507_REJECTED job was re-enqueued |
Pre-flight errors (HTTP 4xx/5xx, thrown before a job is created):
messageCode | HTTP | When |
|---|---|---|
server.core.ledger.tax_info_not_found | 404 | Merchant has no tax declaration info configured |
server.core.ledger.failed_to_get_fetcher_service | 500 | Ledger type has no registered data fetcher (system error) |
These errors are returned synchronously — no ledger or job record is created.
Batch generate
POST /ledgers/generate/batchEnqueues 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:
| Field | Required | Notes |
|---|---|---|
merchantId | ✓ | |
year | — | Default: current year |
periodType | — | Default: MONTHLY |
types | — | Array of ledger types. Derived from merchant config if omitted |
Response:
{
"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
GET /ledgers/{id}/statusReturns the current state of the generation job for the given ledger ID.
Response fields:
| Field | Notes |
|---|---|
ledgerId | |
status | Current job status — see Job Status |
attemptCount | How many times generation has been attempted |
processStartAt | ISO timestamp when the worker started |
processCompletedAt | ISO timestamp when the worker finished |
failureReason | Non-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 ClientThe 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:
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
// After EncryptedWebSocketClient fires 'connected':
client.joinRooms({ rooms: [room] });
// On component unmount or when leaving the ledger screen:
client.leaveRooms({ rooms: [room] });Listening for events
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
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 errorEach 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(orpayload.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.
GET /ledgers/{id}/download/pdf?disposition=inlineResponse: binary PDF (Content-Type: application/pdf, Content-Disposition: inline; filename="..."). The browser renders it natively.
Note: the
disposition=inlinequery param is required to get inline rendering. Omitting it defaults toattachmentand triggers a download prompt instead.
Error when job is not complete (HTTP 400):
{
"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.
GET /ledgers/{id}/download/pdf
GET /ledgers/{id}/download/xlsxResponse: 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.
POST /ledgers/{id}/regenerateGuards — returns 400 if:
- Ledger is
200_FINALIZED— usePOST /{id}/reviseto create a revision first - Ledger is not
001_DRAFT - Job is
103_PENDINGor203_PROCESSING— wait for the current job to finish
Response: { ledgerId, status, attemptCount }
Retry vs Regenerate:
POST /{id}/retry | POST /{id}/regenerate | |
|---|---|---|
| Ledger requirement | Any status | Must be 001_DRAFT |
| Job requirement | Must be 507_REJECTED | Must NOT be 103_PENDING or 203_PROCESSING |
| Use case | Error recovery | Re-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.
| Value | Meaning |
|---|---|
null | No job created yet — period not generated |
103_PENDING | Enqueued, waiting for a worker |
203_PROCESSING | Worker is generating PDF + XLSX |
303_COMPLETED | Files ready |
300_PARTIAL | One format succeeded, one failed |
507_REJECTED | Generation failed — failureReason has details |
Error Codes (failureReason.errorCode)
These values appear in failureReason.errorCode when the generation worker captures a known error.
errorCode | Category | When it occurs | Suggested UI message |
|---|---|---|---|
FETCH_DATA_ERROR | System | Data fetch from source services failed, or the response did not match the expected schema | "Data fetch failed — please retry" |
MERCHANT_TAX_INFO_NOT_FOUND | Business | The merchant's tax declaration info has not been configured | "Tax information is missing — configure the merchant first" |
FAILED_TO_GET_DATA_FETCHER_SERVICE | System | Internal service wiring error; no data fetcher registered for this ledger type | "System error — contact support" |
ENQUEUE_FAILED | System | The 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_FAILED | System | Unexpected 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.
| Value | Meaning |
|---|---|
DRAFT | Editable — new files can be generated. Default state. |
200_FINALIZED | Locked by the user. Use POST /ledgers/{id}/revise to open a new revision. |
ARCHIVED | Superseded by a newer finalized revision |
400_SUBMITTED | Reserved — submitted to the tax authority (not implemented) |
Related Pages
- API Events — Kafka/WS payload reference
- Generation Pipeline — server-side flow
- REST endpoints — live OpenAPI at
/v1/api/ledger/doc/openapi.json