Files
Distribution_GL/Docs/04_API_Reference_And_Scenarios.md
T

108 KiB
Raw Blame History

General Ledger Service — API Reference & Scenarios

Companion to 01_Full_Development_Plan.md (design), 02_Phase_By_Phase_Development_Plan.md (build order), and 03_Progress_Tracker.md (what's actually done). This document is the usage reference: every endpoint's exact request/response shapes (success and failure), its internal Controller → Service → Repository flow, and full worked scenarios showing how the endpoints combine to do real accounting work.

Paired with General_Ledger_Service.postman_collection.json, which contains a runnable request for every variation catalogued here.


Conventions used throughout this document

  • Base URL: http://localhost:7024/api/v1 (local dev; appsettings.Development.json).
  • Auth: every request needs header X-Api-Key: <value from ApiKey:Value in appsettings> except GET /health, GET /swagger, and POST /dev-tools/generate-api-key. Missing/wrong key → 401 before the request reaches any controller code (Middleware/ApiKeyMiddleware.cs).
  • Envelope: every response (success or failure) is { statusCode, success, message, data } (DTOs/Common/ApiResponse.cs). Failure responses (success: false) are built by Middleware/ExceptionHandlingMiddleware.cs from whichever CustomException the Service threw — NotFoundException→404, BadRequestException→400, UnauthorizedException→401, ForbiddenException→403, anything unhandled→500 with a generic message.
  • "Flow" sections name the exact Service/Repository methods involved, in call order, so the internal path from HTTP request to database is traceable without reading the source.
  • IDs used in examples (accountId: 1, fiscalYearId: 1, etc.) reflect this project's own local dev database as seeded/built up over its development phases — substitute your own.

Connecting From Another Backend (Integration Guide)

This service has no UI and no end-user login — its only client is another backend (referred to throughout this repo as "the core backend"), calling it server-to-server over plain HTTP(S) with a shared secret. This section is everything that calling backend needs to actually reach it, in order.

1. What you need before the first call

  • This service reachable on the network — running locally via dotnet run, or however it's deployed in your environment. There is no separate "start-up" step beyond that; once the process is up and its database migrations are applied, every endpoint in this document is live.
  • A shared API key, provisioned on both sides — this service's ApiKey:Value config and the core backend's own config must hold the exact same string. Nothing else is required: there is no OAuth flow, no client registration, no per-caller credentials. One shared secret, checked on every request.

2. Getting the API key

In Development, this service can generate the key for you — that's the entire purpose of the one dev-only endpoint:

POST /api/v1/dev-tools/generate-api-key
(no headers required — this is the one endpoint excluded from the API-key check, since it's what produces the very first key)

→ 200
{
  "statusCode": 200,
  "success": true,
  "message": "API key generated. Copy this into ApiKey:Value in appsettings.Development.json ...",
  "data": { "apiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" }
}

Copy data.apiKey into both:

  1. This service's appsettings.Development.json → ApiKey:Value.
  2. The core backend's own configuration (whatever key/secret store it uses) — under the same conceptual name, e.g. GeneralLedgerService:ApiKey.

This call is not persisted anywhere — if you lose the generated value before pasting it into both places, just call it again and generate a new one; the old value simply stops matching once overwritten.

In Production, this endpoint is hard-disabled — it checks IHostEnvironment.IsDevelopment() inside the Service itself (not just route registration) and returns 403 Forbidden in any other environment. There is no API-driven way to generate a production key. Instead: generate a strong random value out-of-band (the same 256-bit-hex shape the dev endpoint produces is fine — e.g. openssl rand -hex 32, or reuse the dev endpoint's output pattern), then place it into appsettings.Production.json → ApiKey:Value (or your deployment's secret store / environment-variable override of that same config path) and into the core backend's production config, through whatever secure channel your deployment already uses for shared secrets. appsettings.Production.json ships with ApiKey:Value deliberately blank — the service will reject every request with 401 until this is filled in.

3. What every request needs

Requirement Detail
Header X-Api-Key: <the shared key> on every request, except GET /health, GET /swagger, and POST /dev-tools/generate-api-key. Missing or wrong → 401 before the request reaches any Controller/Service code (Middleware/ApiKeyMiddleware.cs) — no partial processing, no side effects.
Base path /api/v1/... — every business endpoint in this document sits under that prefix. /health is the one exception (outside /api/v1, ASP.NET Core's built-in health-check middleware).
Content-Type application/json for every request with a body, except POST /bank-accounts/{id}/statement-lines/import, which is multipart/form-data (see Module: Bank).
Transport This service calls UseHttpsRedirection() before the API-key check runs — a plain-HTTP request gets a 307 redirect to the HTTPS URL rather than a clean 401/response. Call the HTTPS URL directly rather than relying on the redirect, so your client doesn't need to follow a 307 on every call.
Jwt:* config — not relevant to this integration. appsettings.json also has a Jwt:Issuer/Jwt:Audience/Jwt:SigningKey section and Program.cs registers JWT bearer authentication, but no endpoint in this service is decorated [Authorize] — that config exists but is not enforced anywhere. Don't send a bearer token; it will be ignored. X-Api-Key is the only real gate.

4. Handling the response — including one real gotcha

Every response, success or failure, is the same envelope shape:

{ "statusCode": 200, "success": true, "message": "...", "data": { ... } }

But the JSON key casing differs between success and error responses — this is a known, documented inconsistency (see "Known Inconsistency Worth Flagging" at the end of this document), not something you're misreading:

  • Success (200/201, from a Controller action): camelCase — statusCode, success, message, data.
  • Error (401 from ApiKeyMiddleware, or any 4xx/5xx from ExceptionHandlingMiddleware): PascalCase — StatusCode, Success, Message, Data.

Practical fix on the calling side: deserialize with case-insensitive property matching (e.g. .NET's JsonSerializerOptions.PropertyNameCaseInsensitive = true, or the equivalent in whatever language/framework the core backend uses) so one model works for both shapes. Without that, a strict case-sensitive deserializer will silently produce a fully-null object on every error response.

5. Worked example — creating an account, end to end

Request (curl, works from any language/tool the core backend uses):

curl -X POST "https://localhost:7024/api/v1/accounts" \
  -H "X-Api-Key: 1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D" \
  -H "Content-Type: application/json" \
  -d '{
        "accountName": "Rent Expense",
        "accountTypeId": 5,
        "isControlAccount": false,
        "isPostable": true
      }'

Success response — 201, camelCase:

{
  "statusCode": 201,
  "success": true,
  "message": "Account created successfully",
  "data": { "accountId": 11, "accountCode": "5100", "accountName": "Rent Expense", "accountTypeId": 5, "isPostable": true, "isActive": true, ... }
}

Same call, wrong or missing X-Api-Key — 401, PascalCase, never reaches AccountService:

{ "StatusCode": 401, "Success": false, "Message": "Invalid or missing API key", "Data": null }

Same call, valid key but a domain-rule violation (e.g. accountTypeId: 9, which isn't 1-5) — 400, PascalCase, this time from ExceptionHandlingMiddleware after AccountService threw BadRequestException:

{ "StatusCode": 400, "Success": false, "Message": "Invalid accountTypeId '9'. Must be one of: 1 (Asset), 2 (Liability), 3 (Equity), 4 (Income), 5 (Expense).", "Data": null }

6. Worked example — a minimal typed client (C#/HttpClient)

If the core backend is also .NET, a small wrapper is enough to cover every endpoint in this document — one HttpClient with the base address and API-key header set once at startup, and one shared envelope type used for both success and error bodies:

// Registered once at startup (Program.cs / a DI extension method):
services.AddHttpClient("GeneralLedgerService", client =>
{
    client.BaseAddress = new Uri(configuration["GeneralLedgerService:BaseUrl"]!); // e.g. "https://localhost:7024/api/v1/"
    client.DefaultRequestHeaders.Add("X-Api-Key", configuration["GeneralLedgerService:ApiKey"]);
});

// One envelope type, used for every call — case-insensitive so it deserializes
// both the camelCase success shape and the PascalCase error shape (Section 4 above).
public class GlApiResponse<T>
{
    public int StatusCode { get; set; }
    public bool Success { get; set; }
    public string? Message { get; set; }
    public T? Data { get; set; }
}

private static readonly JsonSerializerOptions JsonOptions = new()
{
    PropertyNameCaseInsensitive = true
};

public async Task<GlApiResponse<CreatedAccount>> CreateAccountAsync(string accountName, short accountTypeId)
{
    var client = _httpClientFactory.CreateClient("GeneralLedgerService");

    var httpResponse = await client.PostAsJsonAsync("accounts", new
    {
        accountName,
        accountTypeId,
        isControlAccount = false,
        isPostable = true
    });

    // Read the envelope regardless of status code - both success and error bodies
    // deserialize into the same shape (case-insensitive options handle the casing gotcha).
    var envelope = await httpResponse.Content.ReadFromJsonAsync<GlApiResponse<CreatedAccount>>(JsonOptions);

    if (!httpResponse.IsSuccessStatusCode)
    {
        // envelope!.Message is human-readable and safe to log/surface as-is -
        // it's the exact CustomException message the Service threw (Section 4 above).
        throw new GeneralLedgerServiceException(envelope!.Message, httpResponse.StatusCode);
    }

    return envelope!;
}

7. Environment summary

Development Production
Base URL https://localhost:7024/api/v1 (appsettings.Development.json) Whatever your deployment assigns — not fixed by this repo; read it from your own deployment config, same as any other externally-hosted service
API key source POST /api/v1/dev-tools/generate-api-key (Section 2) Generated out-of-band, placed in appsettings.Production.json / secret store manually — the generator endpoint is hard-disabled here
X-Api-Key still required? Yes, on everything except /health, /swagger, /dev-tools/generate-api-key Yes, on everything except /health, /swagger — /dev-tools/generate-api-key still exists as a route but always returns 403 outside Development
/swagger available? Yes (app.Environment.IsDevelopment() gate in Program.cs) No — not registered outside Development at all

8. Where to go next

  • Every endpoint's full request/response contract: the module sections below.
  • A runnable request for every case in this document: General_Ledger_Service.postman_collection.json (collection-level {{baseUrl}}/{{apiKey}} variables — set those once and every request in the collection is ready to run against a live instance).
  • Full end-to-end business flows (not just single calls): "Example Scenarios" near the end of this document, especially Scenario 11 for the API-key bootstrap flow specifically.

Module: Accounts

POST /accounts — Create Account

Request body (retrofit 2026-07-20: accountCode is no longer accepted here — it's system-generated; see CLAUDE.md Rule 8.1):

Field Type Required Notes
accountName string yes
parentAccountId long no Omit for a root account
accountTypeId short yes 1=Asset,2=Liability,3=Equity,4=Income,5=Expense (account_type seed rows). Also drives the generated code's block (1000/2000/3000/4000/5000) — an id outside 1-5 now fails cleanly with 400, not a DB FK 500
isControlAccount bool yes
isPostable bool yes
currencyCode string no Defaults to "LKR" if omitted/blank

Responses:

Case Status Body
Success (root) 201 { statusCode:201, success:true, message:"Account created successfully", data: <Account> } — accountCode is generated, e.g. 1000, 1100 for the 1st/2nd root Asset account
Success (child) 201 Same shape; depth-guard check runs first (see Flow); accountCode = {parentCode}-{seq:D2}, e.g. 1100-01
Invalid accountTypeId (not 1-5) 400 Message: "Invalid accountTypeId '<n>'. Must be one of: 1 (Asset), 2 (Liability), 3 (Equity), 4 (Income), 5 (Expense)."
10 root accounts already exist for this type 400 Message: "Maximum number of root-level accounts for this account type has been reached."
parentAccountId doesn't exist 404 Message: "Parent account not found"
Resulting depth > COA_MAX_DEPTH (only when COA_ENFORCE_MAX_DEPTH=TRUE) 400 Message: "Maximum Chart of Accounts depth exceeded"
Generated child code would exceed the 20-char column (only reachable with COA_ENFORCE_MAX_DEPTH=FALSE and unusually deep nesting) 400 Message: "Cannot generate an account code: nesting is too deep for the 20-character account code column."

Flow: GeneralLedgerController.CreateAccount → AccountService.CreateAccountAsync — (1) if parentAccountId given, GetByIdAsync (404 if missing) then GetAncestorDepthAsync (recursive CTE via RecursiveTreeQueries.GetAncestorDepthAsync) then DepthGuard.EnsureWithinMaxDepthAsync against system_config.COA_MAX_DEPTH/COA_ENFORCE_MAX_DEPTH; (2) GenerateAccountCodeAsync — root: look up accountTypeId's LKAS block start, CountRootAccountsByTypeAsync, {blockStart + count*100}; child: CountChildAccountsAsync, {parentCode}-{count+1:D2}; (3) IUnitOfWork.BeginAsync → AccountRepository.AddAsync → IAuditLogRepository.AddAsync (category Ledger, action Create) → CommitAsync.


GET /accounts/{accountCode} — Get Account

Responses:

Case Status Body
Success 200 data: { account, ancestorBreadcrumb, descendantTree: null }
Success, ?expand=tree 200 Same, descendantTree populated (full recursive subtree, each node { accountId, accountCode, accountName, parentAccountId, depth })
Not found 404 Message: "Account not found"

Flow: AccountService.GetAccountByCodeAsync — GetByCodeAsync (404 if null) → GetAncestorBreadcrumbAsync (always) → GetDescendantTreeAsync only if expand=="tree" (case-insensitive). No UoW — read-only.


GET /accounts — List Accounts

Query params (all optional, all independent): accountTypeId, isActive, isPostable, page, pageSize.

Responses:

Case Status Body
No params 200 data: { items: <all accounts>, totalCount, page: null, pageSize: null } — no default paging; omitting page/pageSize returns everything
Filters only 200 items filtered, still unpaginated unless page+pageSize both given
page+pageSize both given 200 items sliced, page/pageSize echoed back
pageSize given without page (or vice versa) 200 Treated as "not enough to paginate" — full list, page/pageSize both null in the response even though one was sent

Flow: AccountService.ListAccountsAsync — normalizes non-positive page/pageSize to null, calls AccountRepository.SearchAsync(accountTypeId, isActive, isPostable, page, pageSize), which builds the Where chain conditionally then applies RepositoryHelpers.ApplyPaging (only slices when both are non-null). No UoW.


PUT /accounts/{accountCode} — Update Account

Request body (all fields optional — only supplied ones change): accountName, accountTypeId, isControlAccount, isPostable, currencyCode, cashFlowCategory, incomeStatementSection, balanceSheetClassification (retrofit 2026-07-31 — NON_CURRENT_ASSET/CURRENT_ASSET/NON_CURRENT_LIABILITY/CURRENT_LIABILITY, CLAUDE.md-equivalent tag pattern to cashFlowCategory/incomeStatementSection; see the BalanceSheet report note below), isTaxDeductible, isActive, parentAccountId. accountCode is not accepted here (retrofit 2026-07-20) — codes are immutable once generated; see CLAUDE.md Rule 8.1.

parentAccountId has three distinct states, not two: omitted (field absent from the JSON body → unchanged), explicit null (clears the parent, becomes a root account), a value (re-parents, subject to the same cycle+depth checks as create). Distinguished server-side via payload.ContainsKey("parentAccountId"), not just null-checking the parsed value.

Responses:

Case Status Body Audit category
Rename / field update, no parentAccountId 200 Updated Account Administrative
parentAccountId present and changed 200 Updated Account Ledger
parentAccountId present but equal to current value 200 No cycle/depth check runs (nothing actually changed) Administrative
parentAccountId equals the account's own id 400 Message: "An account cannot be its own parent" —
New parent doesn't exist 404 Message: "Parent account not found" —
New parent is a descendant of this account (would create a cycle) 400 Message: "Cannot re-parent: the new parent is a descendant of this account" —
Resulting depth exceeds COA_MAX_DEPTH 400 Message: "Maximum Chart of Accounts depth exceeded" —
accountCode in the route doesn't exist 404 Message: "Account not found" —

Flow: AccountService.UpdateAccountAsync — parses the raw Dictionary<string,JsonElement> payload via Helpers/DataExtractor into an internal UpdateAccountRequest (Controller passes the dictionary through untouched, per CLAUDE.md Rule 1); loads by code (404); applies each supplied field; if parentAccountId changed, runs the identical self-parent/cycle/depth-guard sequence as create; wraps the audit insert (category computed here, not fixed) in BeginAsync/CommitAsync.


Module: Fiscal Calendar

POST /fiscal-years — Create Fiscal Year

Request body: yearName (string, required), startDate/endDate (date, required), autoGeneratePeriods (bool, optional), periodCount (int, optional, defaults to 12 when autoGeneratePeriods=true).

Case Status Notes
Success, autoGeneratePeriods omitted/false 201 data.generatedPeriods = []
Success, autoGeneratePeriods=true 201 data.generatedPeriods populated, internally reuses the same period-building logic as the standalone endpoint below
endDate <= startDate 400 "End date must be after start date"
Date range overlaps an existing fiscal year 400 "Fiscal year date range overlaps with an existing fiscal year"

Flow: FiscalCalendarService.CreateFiscalYearAsync — validates dates, IFiscalCalendarRepository.HasOverlappingFiscalYearRangeAsync, BeginAsync → AddFiscalYearAsync → SaveChangesAsync → (if autoGeneratePeriods) private BuildPeriodsAsync → AddPeriodRangeAsync → audit insert (Ledger/Create) → CommitAsync.

POST /fiscal-years/{id}/periods — Generate Periods

Case Status Notes
Success 201 Extends/adds periods; numbering continues from the last existing period for that fiscal year
Fiscal year id doesn't exist 404 "Fiscal year not found"
Requested periods would extend past the fiscal year's endDate 400 "Requested period count exceeds the fiscal year's date range"

Flow: GeneratePeriodsAsync — GetFiscalYearByIdAsync (404), BuildPeriodsAsync (shared with create), AddPeriodRangeAsync, audit (Ledger/Create).

PUT /periods/{id}/lock — Lock/Unlock/Close a Period

Request body: action (string, one of LOCK/UNLOCK/SOFT_CLOSE/CLOSE, case-insensitive), reason (string, required).

Case Status Notes
Success 200 Writes both a period_lock_log row and an audit_log row (Ledger)
Action other than UNLOCK sent to an already-CLOSED period 400 "Period is closed; only Unlock is allowed to reopen it"
Invalid action string 400 "Invalid action '<value>'. Must be one of: Lock, Unlock, SoftClose, Close."
id doesn't exist 404 "Period not found"

Flow: LockPeriodAsync — parses action/reason from the loose payload via DataExtractor, loads the period (404), rejects non-UNLOCK on a CLOSED period, maps action to (PeriodStatus, LockGl) and to an AuditAction, BeginAsync → AddPeriodLockLogAsync → audit insert → CommitAsync.

GET /fiscal-years/periods/by-date?date=YYYY-MM-DD — Get Period Covering a Date

Added Phase 38 (2026-08-12), closing the gap noted below for callers (e.g. ERPCore's GRN module) that need to resolve a periodId for POST /journal-entries but have no other way to discover one. Query params: date (date, required).

Case Status Notes
A period covers date 200 Returns the AccountingPeriod (periodId, fiscalYearId, periodNumber, startDate, endDate, status, lockGl)
No period covers date 404 "Period not found"

Flow: FiscalCalendarService.GetPeriodByDateAsync — IFiscalCalendarRepository.GetPeriodByDateAsync (the same repository method RecurringTemplateService.GenerateAsync already used internally to resolve "today's" period). Read-only, no UoW, no audit row.

Previously true, now narrowed: there is still no GET for fiscal years themselves, and no lookup by periodId alone — only this date-based lookup, POST /fiscal-years, POST /fiscal-years/{id}/periods, and PUT /periods/{id}/lock.


Module: Currency

GET /currencies — List Currencies

Read-only, no filters, no paging. Returns all 142 seeded ISO 4217 rows. Exactly one has isBaseCurrency: true (LKR) — enforced by a partial unique index, not just seed discipline. There is no create/update endpoint for this table at all (Section 6.1 of the full plan) — currency is a one-time HasData seed, and the base currency can never change after initial setup.

Flow: CurrencyService.ListCurrenciesAsync → ICurrencyRepository.GetAllAsync (plain OrderBy(CurrencyCode)). No UoW.


Module: Cost Centers

POST /cost-centers — Create Cost Center

Same shape as accounts (Rule 2's shared recursive-tree pattern), simpler: no isPostable/isControlAccount/currency, and the depth guard is unconditional — there's no COST_CENTER_ENFORCE_MAX_DEPTH flag counterpart to accounts' COA_ENFORCE_MAX_DEPTH, so COST_CENTER_MAX_DEPTH is always checked whenever a parentCostCenterId is given. Retrofit 2026-07-20: code is no longer accepted in the request body — it's system-generated (CLAUDE.md Rule 8.1): CC-{seq:D3} for a root (e.g. CC-001, CC-002), {parentCode}-{seq:D2} for a child (e.g. CC-001-01).

Case Status Notes
Success (root or child) 201 Audit category always Administrative — cost centers are a reporting dimension, not part of the ledger structure. code is generated, not echoed from the request
parentCostCenterId doesn't exist 404 "Parent cost center not found"
Resulting depth > COST_CENTER_MAX_DEPTH 400 "Maximum Cost Center depth exceeded"

Flow: CostCenterService.CreateCostCenterAsync — (if parent given) GetByIdAsync + GetAncestorDepthAsync + DepthGuard.EnsureWithinMaxDepthAsync(..., enforceConfigKey: null, ...) (note the null — no enforce-flag lookup, unlike accounts); GenerateCostCenterCodeAsync — root: CountRootCostCentersAsync; child: CountChildCostCentersAsync; BeginAsync → AddAsync → audit (Administrative/Create) → CommitAsync.

GET /cost-centers/{costCenterCode} — Get Cost Center

Identical read-merge pattern to GET /accounts/{accountCode}: data: { costCenter, ancestorBreadcrumb, descendantTree }, descendantTree only populated with ?expand=tree. 404 "Cost center not found" if the code doesn't exist. No cycle check and no update/re-parent endpoint exists for cost centers at all — unlike accounts, there is no PUT /cost-centers/{code}, so WouldCreateCycleAsync was never needed for this entity.

Flow: GetCostCenterByCodeAsync — GetByCodeAsync (404), GetAncestorBreadcrumbAsync, conditionally GetDescendantTreeAsync. No UoW.


Module: Journal Entries (the core double-entry action)

This is the module everything else in the system ultimately feeds into or reads back out of — Fixed Asset depreciation/disposal, Recurring Templates, and Bank reconciliation all either post a journal entry through this same code path or reference one that was already posted.

POST /journal-entries — Create (and post, in one call)

Retrofit 2026-07-22 (CLAUDE.md Rule 14) — there is no client-facing DRAFT state anymore. The client always sends a complete, ready-to-post entry; PUT .../lines and PUT .../post (formerly Phases 12/13) are removed — this is now the only write endpoint for journal entries. CreateAsync validates, inserts, balance-checks, period-lock-checks, and posts, all in one transaction.

Request body: periodId (int, required), entryDate (date, required), journalType/sourceModule/reference/narration/preparedBy (strings, all optional/free text), plus two independent fields that drive branching:

  • lines — array of { accountCode, debitAmount, creditAmount, costCenterCode?, memo? } (retrofit 2026-07-22: accountCode/costCenterCode replace accountId/costCenterId, CLAUDE.md Rule 8.2). Required for a plain create and for a PartialAdjustment correction — both previously deferred lines to the now-removed Set Lines call; both now supply them inline in this same body. Ignored (not required) for a FullReversal correction, whose lines are auto-built by mirroring the original.
  • correction — { originalJournalNo, correctionType, reason, correctedBy } (retrofit 2026-07-22: originalJournalNo replaces originalJournalId), correctionType one of FullReversal/PartialAdjustment. If present, this call creates a correction of an existing entry instead of a plain new one.
Case Status Notes
Plain, with lines, balanced 201 Created and posted in the same call — isPosted:true, entryStatus:2 (Posted)
Plain, lines missing or empty 400 "lines is required"
Plain, with deliberately unbalanced lines 400 "Journal entry does not balance" — checked after insert, inside the same transaction (nothing is left half-inserted; the whole call rolls back)
periodId doesn't exist 404 "Period not found"
periodId's period is Locked/Closed/LockGl=true 400 "Period is locked"
correction.correctionType = FullReversal 201 Lines auto-generated by mirroring the original's lines with debit/credit swapped; reversalOfId set to the original; auto-posted; the original is flipped to entryStatus:3 (Reversed), isReversed:true, in the same transaction
correction.correctionType = PartialAdjustment 201 lines now required and taken inline (previously always empty, added afterward via the now-removed Set Lines) — auto-posted; original entry left untouched
correction.originalJournalNo isn't Posted 400 "Original journal entry must be posted before it can be corrected" — also the case if the original was already reversed (its status is Reversed, not Posted)
Invalid correctionType 400 "Invalid correctionType '<value>'. Must be one of: FullReversal, PartialAdjustment."
An accountCode in lines doesn't exist 404 "Account <code> not found"
An accountCode in lines isn't postable 400 "Account <code> is not postable"
A costCenterCode in lines doesn't exist 404 "Cost center <code> not found"

Flow: JournalEntryService.CreateAsync — IFiscalCalendarRepository.GetPeriodByIdAsync (404); build the JournalEntry shell with an auto-generated JournalNo; branch on correction: if FullReversal, load+validate the original by JournalNo (GetJournalEntryByJournalNoAsync, status check) and mirror its lines in memory; if PartialAdjustment or plain, request.Lines is required and each line is built via BuildLineAsync (resolves accountCode→Account via GetByCodeAsync, checks IsPostable; resolves costCenterCode→CostCenter via GetByCodeAsync if supplied). BeginAsync → AddJournalEntryAsync → AddJournalEntryLinesAsync → (if correction) AddJournalCorrectionAsync → audit insert (Ledger; Create/Reverse/Adjust) → balance check → period-lock check → flip to Posted (and flip the original to Reversed for FullReversal) → audit insert(s) for the post (Ledger/Post, plus Ledger/Reverse against the original when applicable) → CommitAsync. A balance or lock failure at this point throws inside the same transaction the insert happened in, so the whole call rolls back — create-and-post is atomic, not two separable steps.

Every other Service that generates a journal entry internally calls this once and uses the already-posted result directly — there is no more separate PostAsync call to make afterward. See FixedAssetService (Depreciate/Dispose) and RecurringTemplateService.GenerateAsync below.

GET /journal-entries/{journalNo} — Get

200 with lines[] included, or 404 "Journal entry not found". Looked up by journalNo (CLAUDE.md Rule 8), not the numeric journalId. Read-only, no UoW.


Module: Recurring Entries

POST /recurring-templates — Create Template

Request body: templateName, recurrencePattern (Monthly/Quarterly/Annual), nextRunDate, endDate (optional), lines: [{ accountCode, debitAmount, creditAmount }] (retrofit 2026-07-22: accountCode replaces accountId, CLAUDE.md Rule 8.2).

Lines must balance at creation time — unlike journal entries, there's no separate post-time balance check for a template (a template is never itself posted; only the entries it generates are, and those are validated at generation time by the same CreateAsync a manual entry uses).

Case Status Notes
Success 201
Lines don't balance 400 "Recurring template lines do not balance"
Invalid recurrencePattern 400 "Invalid recurrencePattern '<value>'. Must be one of: Monthly, Quarterly, Annual."
A line's account isn't postable 400 "Account <code> is not postable"
A line's account doesn't exist 404 "Account <code> not found"

Flow: RecurringTemplateService.CreateAsync — parse/validate recurrencePattern, sum check, per-line IAccountRepository.GetByCodeAsync + IsPostable check, BeginAsync → AddAsync (cascades the line collection via EF, storing each line's resolved numeric AccountId internally — the template's own persisted storage stays numeric, CLAUDE.md Rule 8.2) → audit (Administrative/Create) → CommitAsync.

POST /recurring-templates/{id}/generate — Generate Next Entry

Retrofit 2026-07-22 — the generated entry is now created and posted in the same call (Rule 14's create+post merge applies here too, since this delegates to IJournalEntryService.CreateAsync). Previously left as a DRAFT for a separate Post call that no longer exists.

Case Status Notes
Success (nextRunDate <= today) 201 Produces an already-posted journal entry (journalType: RECURRING)
nextRunDate > today 400 "Recurring template is not yet due to run"
id doesn't exist 404 "Recurring template not found"

Flow: GenerateAsync — load template (404), active + due checks, resolve today's period via IFiscalCalendarRepository.GetPeriodByDateAsync, resolve each line's stored numeric AccountId back to its AccountCode (IAccountRepository.GetByIdAsync(...).AccountCode — the template stores ids internally, but CreateJournalEntryRequest.Lines now needs codes, CLAUDE.md Rule 8.2), build the request and delegate to IJournalEntryService.CreateAsync (reuses the exact same code path as a manual plain create — the audit rows it needs come from that reused call, not a second insert here), then advance NextRunDate by the pattern (+1 month/+3 months/+1 year) so a same-day repeat call doesn't generate a duplicate — this advancement isn't in the original design doc; it was a gap the team identified and confirmed needed fixing (an unadvanced NextRunDate would let every subsequent /generate call on the same due date create another entry, forever).


Module: Tax

POST /tax-codes — Create Tax Code

Request body (retrofit 2026-07-20: code is no longer accepted — it's system-generated, {TaxTypeCode}-{seq:D3}, e.g. VAT-001; see CLAUDE.md Rule 8.1): taxTypeCode, name, ratePercent, calculationBasis (Exclusive/Inclusive), appliesTo (Sales/Purchase/Both), outputTaxAccountCode/inputTaxAccountCode (optional), effectiveFrom (required), effectiveTo (optional). (Retrofit 2026-07-22: taxTypeId/outputTaxAccountId/inputTaxAccountId replaced with the ...Code fields above, CLAUDE.md Rule 8.2.)

Case Status Notes
Success 201 code is generated from the tax type's own code + a per-type sequence, not echoed from the request
effectiveFrom missing 400 "effectiveFrom is required"
taxTypeCode doesn't exist 404 "Tax type not found"
outputTaxAccountCode/inputTaxAccountCode doesn't exist 404 "Output tax account not found" / "Input tax account not found"
Invalid calculationBasis/appliesTo 400 Named per field, e.g. "Invalid calculationBasis '<value>'. Must be one of: Exclusive, Inclusive."

Flow: TaxService.CreateTaxCodeAsync — effectiveFrom presence check, enum parses, ITaxRepository.GetTaxTypeByCodeAsync (404, and the returned TaxType is kept for code generation), IAccountRepository.GetByCodeAsync for outputTaxAccountCode/inputTaxAccountCode if supplied (404 each), GenerateTaxCodeAsync (CountTaxCodesByTypeAsync scoped to the tax type, {taxType.Code}-{count+1:D3}), BeginAsync → AddTaxCodeAsync → audit (Ledger/Create — a tax rate applies to every future transaction's calculation) → CommitAsync.

No longer relevant post-retrofit: the design doc originally called for rejecting overlapping effectiveFrom/effectiveTo ranges for the same code — since code is now system-generated per creation (never resubmitted by the client), and TaxCode.Code still has a hard unique index, two rows can never collide on a code at all; there was never a duplicate-code scenario to reach.

GET /tax-codes/calculate — Calculate Tax

Query params: taxCode, amount, txnDate. (Retrofit 2026-07-22: taxCodeId replaced with taxCode, CLAUDE.md Rule 8.2.)

Case Status Notes
Success, Exclusive basis 200 taxAmount = amount * (ratePercent / 100)
Success, Inclusive basis 200 taxAmount = amount - (amount / (1 + ratePercent/100)) — amount is treated as already tax-inclusive
taxCode doesn't exist, or exists but txnDate falls outside [effectiveFrom, effectiveTo] 404 Same message either way: "Tax code not found or not effective for the given date"

Flow: CalculateTaxAsync — GetEffectiveTaxCodeByCodeAsync(taxCode, txnDate) (single query combining existence + date-range check, hence the shared 404 message), then a pure in-memory calculation. No persistence, no UoW.

GET /tax-types, GET /tax-codes — List

Both flat, unfiltered, unpaginated lists. tax-types is pure HasData reference data (5 rows, never changes); tax-codes reflects everything created via the endpoint above.


Module: Fixed Assets

POST /fixed-assets — Register Asset

Request body (retrofit 2026-07-20: assetCode is no longer accepted — it's system-generated, FA-{seq:D6}; see CLAUDE.md Rule 8.1): assetName, assetAccountCode, accumDeprAccountCode, deprExpenseAccountCode, gainLossAccountCode (all four account fields required — gainLossAccountCode was a schema gap found and filled during the build, needed to post a real disposal entry), acquisitionDate, acquisitionCost, residualValue, usefulLifeMonths, depreciationMethod (StraightLine/ReducingBalance), depreciationRatePercent (required only for ReducingBalance — the other schema-gap fix). (Retrofit 2026-07-22: the four ...Id fields replaced with ...Code, CLAUDE.md Rule 8.2.)

Case Status Notes
Success 201 assetCode is generated (e.g. FA-000001), not echoed from the request
ReducingBalance without depreciationRatePercent 400 "depreciationRatePercent is required when depreciationMethod is ReducingBalance"
residualValue > acquisitionCost 400 "residualValue cannot exceed acquisitionCost"
Any of the four account ids isn't postable 400 "<label> account (<code>) is not postable" — checked in order Asset → AccumDepr → DeprExpense → GainLoss, first failure wins

Flow: FixedAssetService.CreateAsync — enum parse, ReducingBalance+rate check, UsefulLifeMonths > 0 check, residualValue check, four sequential EnsurePostableAsync calls (retrofit 2026-07-22: now resolves by accountCode via GetByCodeAsync, returning the Account so its numeric AccountId can be stored on the FixedAsset row — 404 if missing, 400 if not postable), GenerateAssetCodeAsync (CountAssetsAsync, FA-{count+1:D6}), BeginAsync → AddAsync → audit (Ledger/Create) → CommitAsync.

POST /fixed-assets/{assetCode}/transactions — Depreciate or Dispose

One endpoint, transactionType (Depreciate/Dispose) discriminates. Both build and post a real journal entry internally via IJournalEntryService — this endpoint's own Ledger audit requirement is satisfied by that reused call, not a second insert.

Depreciate body: transactionType: "Depreciate", periodId (required).

  • StraightLine formula: (acquisitionCost - residualValue) / usefulLifeMonths, flat every period.
  • ReducingBalance formula: (acquisitionCost - priorAccumulatedDepreciation) * (depreciationRatePercent / 100) — declining each period as accumulated depreciation grows. Both methods clamp so accumulated depreciation never exceeds acquisitionCost - residualValue; if the clamped amount is ≤ 0, the asset is already fully depreciated.
  • Posts Debit DeprExpenseAccount / Credit AccumDeprAccount, dated the period's endDate.
  • If this depreciation run reaches the fully-depreciated point, the asset's status flips to FullyDepreciated.

Dispose body: transactionType: "Dispose", disposalDate, proceeds, proceedsAccountCode (all required; retrofit 2026-07-22: proceedsAccountId → proceedsAccountCode, CLAUDE.md Rule 8.2).

  • netBookValue = acquisitionCost - accumulatedDepreciation (from the latest schedule row, 0 if never depreciated); gainLoss = proceeds - netBookValue.
  • Journal lines, zero-value lines omitted entirely (the journal_entry_line table has a check constraint rejecting a zero-amount line on either side): Debit proceedsAccount (if proceeds > 0), Debit accumDeprAccount (if any accumulated depreciation), Credit assetAccount at full acquisitionCost (always present), Debit gainLossAccount if a loss / Credit gainLossAccount if a gain (omitted entirely if gainLoss == 0).
  • Verified live with a real loss scenario: asset acquired at 12,000, depreciated 1,000 (NBV 11,000), disposed for proceeds of 9,000 → gainLoss = 9000 - 11000 = -2000. Journal: Debit Cash 9,000 / Debit AccumDepr 1,000 / Credit Asset 12,000 / Debit Gain-Loss 2,000 — both sides sum to 12,000, balanced.
  • Asset status flips to Disposed.
Case Status Notes
Depreciate success 201 Returns the new DepreciationSchedule row, embeds the posted journal
Depreciate, period locked/closed 400 "Period is locked"
Depreciate, asset already Disposed 400 "Asset has been disposed"
Depreciate, asset already FullyDepreciated 400 "Asset is already fully depreciated"
Dispose success 201 Returns the new AssetDisposal row, embeds the posted journal
Dispose, asset already Disposed 400 "Asset has already been disposed"
assetCode doesn't exist 404 "Fixed asset not found"
Invalid transactionType 400 "Invalid transactionType '<value>'. Must be one of: Depreciate, Dispose."

Flow: CreateTransactionAsync — GetByCodeAsync (404), parse transactionType, dispatch to private DepreciateAsync/DisposeAsync. Each: status guards, compute amounts, resolve the asset's own persisted numeric account-id fields (asset.DeprExpenseAccountId, etc.) back into codes via ResolveAccountCodeAsync (retrofit 2026-07-22 — JournalEntryLineRequest now needs codes, CLAUDE.md Rule 8.2), build a CreateJournalEntryRequest with the computed lines, call IJournalEntryService.CreateAsync once — it now creates and posts in the same call (Rule 14), so the separate .PostAsync call this flow used to make afterward is gone — BeginAsync → AddScheduleAsync/AddDisposalAsync → CommitAsync (no separate audit insert — see above).

GET /fixed-assets — List

Flat list, no filters/paging, every registered asset.


Module: Bank

Retrofit 2026-07-22, superseding an earlier same-day design. A bank_account.accountType (CASH/BANK) discriminator was drafted and specified (see 03_Progress_Tracker.md's "follow-up #2" entry) but never shipped — reviewing it surfaced that a cash account isn't a kind of bank account, it's a peer concept, and cramming both into one table with conditionally-meaningless bankName/accountNumber fields was judged confusing rather than clarifying. Built and verified live instead: two separate tables, bank_account (unchanged from the original Phase 23 design) and a new cash_account, plus a small growable cash_account_type reference table. Full design: Accounting_System_Design_LKAS.md §5.8.

POST /bank-accounts — Create Bank Account

Retrofit 2026-07-31 — glAccountCode removed entirely; the backing GL account is now always auto-created, never caller-selected. Previously the caller had to already know/select an existing postable GL account (glAccountCode, CLAUDE.md Rule 8.2 retrofit 2026-07-22) — the user explicitly asked for that step to go away: creating a bank account should never require picking a GL account, since a bank account is definitionally a bank account, not an arbitrary ledger line. Request body now: accountName (required), bankName/accountNumber (both optional, nullable), currencyCode (optional, defaults "LKR") — no GL field at all.

What happens instead: BankService.CreateBankAccountAsync finds-or-creates a root Asset account named exactly Bank (non-postable, auto-created the first time any bank account is ever created — see Accounting_System_Design_LKAS.md §5.8), then creates a brand-new postable leaf Asset account as its child, named after this bank account's own accountName, system-generated code (CLAUDE.md Rule 8.1 — e.g. 1500 for the Bank root the first time it's created, then 1500-01, 1500-02, ... per bank account). That leaf account's id becomes BankAccount.GlAccountId — the caller never sees or supplies it as an id, but the created Account object is returned nested under data.glAccount (with its own parentAccount showing the Bank header) so the caller can see exactly what was generated.

Case Status Notes
Success 201 data.glAccount shows the newly auto-created GL account (and its Bank parent)

Flow: BankService.CreateBankAccountAsync — BeginAsync → GetOrCreateSystemRootAsync("Bank") (finds by exact name + null parent, or generates a root Asset code and inserts, with its own Ledger/Create audit row) → CreateSystemChildAccountAsync (generates a child code via the same AccountCodeGenerator helper AccountService.CreateAccountAsync uses, inserts a postable leaf, its own Ledger/Create audit row) → AddAsync (the BankAccount itself) → audit (Administrative/Create) → CommitAsync. No DepthGuard check — this structure is fixed at 2 levels (Bank → leaf), well under COA_MAX_DEPTH's default of 6, so the guard (meant for unbounded user-driven nesting) isn't invoked.

POST /cash-accounts — Create Cash Account (new, 2026-07-22; glAccountCode removed 2026-07-31)

Retrofit 2026-07-31 — glAccountCode removed entirely, same reasoning as POST /bank-accounts above, plus one more level of auto-structure for the cash account's type. Request body now: accountName (required), cashAccountTypeName (required — matched case-insensitively against cash_account_type.name; a name with no match creates a new row on the fly, so a frontend "Other, please specify" field becomes a permanent selectable option from then on, unchanged from 2026-07-22), accountNumber (optional — if omitted, BankService.CreateCashAccountAsync auto-generates a sequential CASH-000001-style reference, CLAUDE.md Rule 8.1), currencyCode (optional, defaults "LKR") — no GL field at all.

What happens instead: three GL levels, auto-created/reused as needed:

  1. A root Asset account named exactly Cash (non-postable), found-or-created the first time any cash account is ever created — the sibling of Bank above, same mechanism.
  2. A child of Cash named after the cash account's type (e.g. Petty Cash, Till Cash — whichever cash_account_type row this account uses), non-postable. Created once per type, then reused — cash_account_type gained a new nullable glAccountId column (retrofit 2026-07-31) that persists this link; the very first cash account of a given type triggers its creation, every subsequent one of the same type reuses the same node. Pre-existing seeded types (Petty Cash, Till Cash, Safe Cash, Cash in Transit) start with glAccountId: null and get backfilled lazily on first use, not by a migration data-fix.
  3. A brand-new postable leaf Asset account as a child of that type node, named after this cash account's own accountName — one per CashAccount, never reused. That leaf's id becomes CashAccount.GlAccountId.

Verified live: two cash accounts created with cashAccountTypeName: "Petty Cash" produced Cash (root) → Petty Cash (type node, created once) → two distinct leaf accounts, one per cash account — confirmed via GET /cash-account-types showing Petty Cash's glAccountId populated after the first call and unchanged after the second.

Case Status Notes
Success 201 data.glAccount shows the newly auto-created leaf GL account, data.glAccount.parentAccount the type node
cashAccountTypeName missing/blank 400 "cashAccountTypeName is required"

Flow: BankService.CreateCashAccountAsync — IBankRepository.GetCashAccountTypeByNameAsync (case-insensitive; AddCashAccountTypeAsync if no match), auto-generate accountNumber if omitted, BeginAsync → GetOrCreateSystemRootAsync("Cash") → create-or-reuse the type's GL node (CreateSystemChildAccountAsync + persist CashAccountType.GlAccountId only the first time) → CreateSystemChildAccountAsync for the leaf → AddCashAccountAsync → audit (Administrative/Create) → CommitAsync.

GET /cash-account-types — List Cash Account Types (new, 2026-07-22)

Flat, unfiltered list — seeded with Petty Cash / Till Cash / Safe Cash / Cash in Transit, growing over time as POST /cash-accounts is called with new cashAccountTypeName values. Used to populate a frontend dropdown (plus its own "Other" free-text option).

POST /bank-accounts/{id}/statement-lines/import — Import Statement (.xlsx)

The only file-upload endpoint in the API — multipart/form-data, not JSON, field name file. The Controller extracts a Stream from the bound IFormFile and hands that to the Service, keeping the ASP.NET Core-specific IFormFile type out of the Service layer (same boundary principle as passing a loose JSON dictionary elsewhere). Column layout, header row required (row 1 skipped): TxnDate | Description | Amount.

Case Status Notes
Success, all rows valid 201 data: { imported: [...], failedRows: [] }
Success, some rows malformed 201 Still 201, not partial failure — imported has the valid rows, failedRows has { rowNumber, reason } per bad row. Verified live: a 2-row file with one non-numeric Amount → 1 imported, 1 in failedRows with reason "amount is missing or not a valid number"
File isn't a valid .xlsx at all (wrong format/corrupt) 400 "Uploaded file is not a valid .xlsx spreadsheet" — this one does fail outright; there's nothing to partially salvage from an unparseable file
id doesn't exist 404 "Bank account not found"

Flow: ImportStatementLinesAsync — GetByIdAsync (404), tightly-scoped try/catch around only new XLWorkbook(fileStream) / .Worksheets.First() (so a genuine DB error later can't be mislabeled as "bad file"), then private ParseAndSaveAsync: per row, TryGetValue<DateTime>/TryGetValue<decimal>, skip+record failures, BeginAsync → AddStatementLinesAsync (only the valid ones) → one audit row summarizing both counts (Administrative/Create) → CommitAsync — a single audit row regardless of how many rows succeeded/failed.

PUT /bank-statement-lines/{id}/reconcile — Reconcile

Request body: matchedJournalNo (retrofit 2026-07-22: replaces matchedJournalId, CLAUDE.md Rule 8.2).

"Amounts must match" resolved as: the matched journal entry must be Posted; sum its lines that post to the bank account's own glAccountId; compare that net debit - credit movement to the statement line's Amount.

Case Status Notes
Success (net movement == statement amount) 200 isReconciled: true, matchedJournalId set (the resolved numeric id is still what's stored internally)
Already reconciled 400 "Statement line is already reconciled"
Matched journal entry isn't Posted 400 "Journal entry must be posted before it can be used for reconciliation"
Amounts don't match 400 "Statement line amount (<x>) does not match the journal entry's movement on the bank's GL account (<y>)"
Statement line id doesn't exist 404 "Bank statement line not found"
matchedJournalNo doesn't exist 404 "Journal entry not found" — checked before the posted-status check

Flow: ReconcileStatementLineAsync — GetStatementLineByIdAsync (404), already-reconciled check, IJournalEntryRepository.GetJournalEntryByJournalNoAsync (404) + IsPosted check, GetByIdAsync for the bank account itself, sum matching lines, compare, BeginAsync → mutate (statementLine.MatchedJournalId = journalEntry.JournalId — internal storage stays numeric) + SaveChangesAsync → audit (Administrative/Update) → CommitAsync.

GET /bank-accounts — List Cash and Bank Accounts, unified (retrofit 2026-07-22)

Query params: accountType (optional — Cash | Bank | Both, defaults Both).

Combines both tables into one flat, filterable list — { accountType, accountId, accountName, bankName, cashAccountTypeName, accountNumber, glAccountId, glAccountCode, currencyCode, createdAt } per row (accountType here is a response-only discriminator telling you which table the row came from, not a stored column; bankName is null on Cash rows and cashAccountTypeName is null on Bank rows). Unfiltered, unpaginated, ordered by accountName. glAccountCode added Phase 38 (2026-08-12): the underlying GL account's business code (e.g. "1010"), alongside the pre-existing numeric glAccountId — added because this API has no GET /accounts lookup by numeric id (only by accountCode, Rule 8), so a caller picking a bank/cash account from this list had no way to resolve which accountCode to post a journal-entries line against. Additive field; every existing consumer of this endpoint is unaffected.

Case Status Notes
No accountType, or Both 200 Every bank account and every cash account, combined
accountType=Cash 200 Cash accounts only
accountType=Bank 200 Bank accounts only
accountType set to anything else 400 "Invalid accountType '<value>'. Must be one of: Cash, Bank, Both."

Flow: BankService.ListCashAndBankAccountsAsync — validates accountType, conditionally calls IBankRepository.GetAllAsync/GetAllCashAccountsAsync, maps both into the shared CashAndBankAccountDto, combines and sorts. Verified live: default call returned both pre-existing bank accounts and newly-created cash accounts together; each single-type filter correctly excluded the other.


Module: Budget

POST /budgets — Create Budget

Request body: fiscalYearId, name. 200/404 as expected ("Fiscal year not found" if fiscalYearId doesn't exist). lines: [] on the response — always added afterward.

POST /budgets/{id}/lines — Add Lines

Request body is a raw JSON array, not wrapped in an object: [{ accountCode, periodId, costCenterCode?, budgetedAmount }, ...] (retrofit 2026-07-22: accountId/costCenterId → accountCode/costCenterCode, CLAUDE.md Rule 8.2 — periodId stays numeric, AccountingPeriod has no unique code column).

Appends, does not replace — deliberately different from the old Journal Entries Set Lines (which replaced wholesale — that endpoint is now removed entirely, CLAUDE.md Rule 14). Calling this twice adds to the existing line set; nothing is deleted. No IsPostable check on the account, unlike journal entry lines — budgeting against a summary/control account is allowed here.

Case Status Notes
Success 201
An accountCode doesn't exist 404 "Account <code> not found"
A periodId doesn't exist 404 "Period <id> not found"
A costCenterCode doesn't exist 404 "Cost center <code> not found"
id (the budget) doesn't exist 404 "Budget not found"

Flow: BudgetService.AddLinesAsync — GetByIdAsync (404), per-line resolution against IAccountRepository.GetByCodeAsync/IFiscalCalendarRepository.GetPeriodByIdAsync/ICostCenterRepository.GetByCodeAsync, BeginAsync → AddLinesAsync (append, storing each line's resolved numeric ids — internal storage stays numeric, CLAUDE.md Rule 8.2) → audit (Administrative/Update) → CommitAsync.

GET /budgets — List

Known quirk, verified live: every budget's lines comes back as [] here, even for budgets with real lines — BudgetRepository.GetAllAsync doesn't eager-load the Lines navigation (unlike the Add Lines response, which does return the lines it just inserted). To see a budget's lines alongside actuals, use GET /reports?reportType=BudgetVsActual.


Module: Cheque Management

New module, added 2026-07-30 — beyond the original 36-phase plan (03_Progress_Tracker.md Phase 37). Purely operational tracking: no endpoint here ever creates or touches a journal entry. ChequePage/ReceivedCheque only store caller-supplied references (voucherId/referenceNo, referenceType/referenceId) — the caller posts the actual journal entry separately via POST /journal-entries and links back via those fields, the same relationship PUT /bank-statement-lines/{id}/reconcile already has with matchedJournalNo.

Two independent sub-areas, per the client's own spec: Cheque Books/Pages (cheques issued from this company's own cheque books) and Received Cheques (cheques received from others — deliberately no link to a ChequeBook).

POST /cheque-books — Create Cheque Book

Request body: branchId (required — no companyId on this table, per explicit direction; no Company/Branch table exists anywhere in this system, so this is a plain unvalidated long), bankAccountId (required, numeric — BankAccount has no unique code column, CLAUDE.md Rule 8.2's "no code" carve-out), chequeBookNo (required, unique — caller-supplied, printed on the physical book, not system-generated), startChequeNo/endChequeNo (required, numeric strings — leading zeros preserved), totalLeaves (required — must equal endChequeNo - startChequeNo + 1), receivedDate, description (optional), createdBy (optional, free text).

Auto-generates every leaf immediately — on success, totalLeaves ChequePage rows are created in the same transaction, status Unused, numbered sequentially from startChequeNo to endChequeNo (zero-padded to startChequeNo's digit width). The create response includes the full pages array.

Case Status Notes
Success 201 data.pages has exactly totalLeaves rows, all Unused
bankAccountId doesn't exist 404 "Bank account not found"
chequeBookNo already exists 400 "Cheque book number '<no>' already exists"
totalLeaves doesn't match the numeric range 400 "totalLeaves (<n>) does not match the range startChequeNo-endChequeNo (<m> cheques)"
startChequeNo/endChequeNo not numeric 400 "startChequeNo and endChequeNo must be numeric"
endChequeNo less than startChequeNo 400 "endChequeNo must not be less than startChequeNo"

Flow: ChequeService.CreateChequeBookAsync — IBankRepository.GetByIdAsync (404), chequeBookNo uniqueness check, BuildChequeNoRange validation, BeginAsync → AddChequeBookAsync → AddChequePagesAsync (the generated range) → audit (Administrative/Create) → CommitAsync.

GET /cheque-books/{chequeBookNo} — Get Cheque Book

Query params: expand (optional — pages to include the book's ChequePage rows; omitted/anything else returns pages: []). Read-only — no audit row.

GET /cheque-books — List Cheque Books

Query params (all optional): bankAccountId, branchId, status (Active/Completed/Cancelled), page/pageSize. Response: { items, totalCount, page, pageSize } — paging only applied when both page and pageSize are supplied. Ordered by receivedDate descending, then chequeBookNo.

PUT /cheque-pages/{chequeNo}/issue — Issue a Cheque

Request body: payeeType (required — Supplier/Customer/Employee/Other, sent as a string and parsed server-side via Enum.TryParse, not bound directly as an enum — this project has no global JsonStringEnumConverter, CLAUDE.md-established pattern also used by recurrencePattern), payeeId (optional, loose reference — no Supplier/Customer/Employee table exists in this service), payeeName (required), issueDate, amount (required, must be > 0), currencyCode (optional, defaults "LKR"), voucherId (optional, loose reference — no Voucher table exists), referenceNo, purpose, isCrossCheque/isAccountPayee/isPostDated (booleans), notes, printedBy.

Only a page currently Unused can be issued.

Case Status Notes
Success 200 issueStatus → Issued, printedAt set to now
Page isn't Unused 400 "Cheque page must be Unused to be issued (currently <status>)"
payeeName missing 400 "payeeName is required"
amount not > 0 400 "amount must be greater than 0"
Invalid payeeType 400 "Invalid payeeType '<value>'. Must be one of: Supplier, Customer, Employee, Other."
chequeNo doesn't exist 404 "Cheque page not found"

Flow: ChequeService.IssueChequePageAsync — lookup by chequeNo (404), status check, payeeType parse, field assignment, BeginAsync → SaveChangesAsync → audit (Administrative/Update) → CommitAsync.

PUT /cheque-pages/{chequeNo}/status — Clear / Bounce / Cancel / Void

Action-based, same pattern as PUT /periods/{id}/lock. Request body is a raw JSON object (Dictionary<string, JsonElement>, parsed via DataExtractor): action (required — Clear/Bounce/Cancel/Void), plus action-specific fields below, and an optional performedBy.

Action Required from Unused/Issued Valid from Resulting status
Clear clearedDate Issued only Cleared (clearedByBank set true)
Bounce — Issued only Bounced
Cancel cancelReason Unused or Issued Cancelled
Void — Unused only Void
Case Status Notes
Success 200
Invalid action 400 "Invalid action '<value>'. Must be one of: Clear, Bounce, Cancel, Void."
Action not valid from the page's current status 400 e.g. "Cheque must be Issued to be cleared (currently Unused)"
chequeNo doesn't exist 404 "Cheque page not found"

Flow: ChequeService.UpdateChequePageStatusAsync — parse action, lookup by chequeNo (404), per-action status-guard + field assignment, BeginAsync → SaveChangesAsync → audit (Administrative/Update) → CommitAsync.

GET /cheque-pages/{chequeNo} — Get Cheque Page

Read-only — no audit row. Includes the parent chequeBook.

GET /cheque-pages — List Cheque Pages

Query params (all optional): chequeBookNo, issueStatus (Unused/Issued/Cleared/Bounced/Cancelled/Void), payeeType, payeeId, page/pageSize. Response: { items, totalCount, page, pageSize }. Ordered by chequeNo.

POST /received-cheques — Record a Received Cheque

No ChequeBook link, by design — this is a cheque received from the other side, not one of this company's own. Request body: companyId (required — no branchId requirement on this table's own terms, but branchId is accepted as nullable per explicit direction), branchId (optional), receivedFromType (required — Customer/Supplier/Other, string, parsed server-side same as payeeType above), receivedFromId (optional, loose reference), receivedFromName (required), drawerBankName/drawerBankBranch (optional, plain free text, not a FK — renamed from the spec's bankId/bankBranch; no bank-master table exists in this service, same precedent as BankAccount.BankName), accountHolderName, chequeNo (required), chequeDate, amount (required, must be > 0), receivedDate, referenceType (free text — open-ended, e.g. Invoice/Receipt/Advance), referenceId (optional, loose reference), notes, createdBy.

Status always defaults to Received.

Case Status Notes
Success 201
chequeNo missing 400 "chequeNo is required"
receivedFromName missing 400 "receivedFromName is required"
amount not > 0 400 "amount must be greater than 0"
Invalid receivedFromType 400 "Invalid receivedFromType '<value>'. Must be one of: Customer, Supplier, Other."

Flow: ChequeService.CreateReceivedChequeAsync — validation, receivedFromType parse, BeginAsync → AddReceivedChequeAsync → audit (Administrative/Create) → CommitAsync.

PUT /received-cheques/{id}/status — Deposit / Clear / Return / Cancel

Action-based, same shape as the cheque-page status endpoint. Request body: action (required — Deposit/Clear/Return/Cancel), plus action-specific fields, and optional notes/performedBy.

Action Required Valid from Resulting status
Deposit depositBankAccountId (validated against a real bank account), depositDate Received only Deposited
Clear — Deposited only Cleared
Return — Deposited only Returned
Cancel — Received only Cancelled
Case Status Notes
Success 200
Invalid action 400 "Invalid action '<value>'. Must be one of: Deposit, Clear, Return, Cancel."
Action not valid from the current status 400 e.g. "Received cheque must be in Received status to be deposited (currently Deposited)"
depositBankAccountId missing (on Deposit) 400 "Missing required field 'depositBankAccountId'."
depositBankAccountId doesn't exist 404 "Deposit bank account not found"
id doesn't exist 404 "Received cheque not found"

Flow: ChequeService.UpdateReceivedChequeStatusAsync — parse action, lookup by id (404), per-action status-guard (+ bank account validation on Deposit), BeginAsync → SaveChangesAsync → audit (Administrative/Update) → CommitAsync.

GET /received-cheques/{id} — Get Received Cheque

Read-only — no audit row.

GET /received-cheques — List Received Cheques

Query params (all optional): companyId, branchId, status (Received/Deposited/Cleared/Returned/Cancelled), receivedFromType, page/pageSize. Response: { items, totalCount, page, pageSize }. Ordered by receivedDate descending.


Module: Reporting

GET /reports — Unified Reporting Endpoint

One endpoint for seven reports, merged from seven original endpoints (Section 6.1 — unrelated coincidence that both counts are seven). reportType and outputFormat are always required; every other query parameter is required or not depending on which reportType was chosen, validated inside the Service (not via model binding), so the 400 message always names the specific missing parameter. outputFormat now has three valid values, not two (retrofit 2026-07-22): Json, Pdf, Csv.

reportType Required params (beyond reportType/outputFormat) Response shape
TrialBalance asOfDate array of { accountCode, accountName, debit, credit } — flat, retrofit 2026-07-22: no depth/indentedCode/hierarchy in the response or the PDF/CSV layout, matching the reference template's plain list (see below)
BalanceSheet asOfDate nested object, retrofit 2026-07-31 (supersedes the flat {depth, lineItem, accountType, balance} recursive-rollup array): { asOfDate, nonCurrentAssets: { lines[], total }, currentAssets: { lines[], total }, unclassifiedAssets: { lines[], total }, totalAssets, equity: { lines[], total }, nonCurrentLiabilities: { lines[], total }, currentLiabilities: { lines[], total }, unclassifiedLiabilities: { lines[], total }, totalEquityAndLiabilities } — each lines[] entry is { accountCode, accountName, balance }. Driven by the new accounts.balance_sheet_classification tag (Accounting_System_Design_LKAS.md §11.3); untagged Asset/Liability leaf rows land in their own unclassifiedAssets/unclassifiedLiabilities buckets rather than being silently dropped. equity.lines[] always includes the synthetic { accountCode: "", accountName: "Current Year Earnings", balance } line (see retrofit note below)
GeneralLedger accountCode (optional — retrofit 2026-07-22: replaces accountId, CLAUDE.md Rule 8.2, closing the gap this report used to be the one exception to), periodStart, periodEnd array of { entryDate, journalNo, accountCode, accountName, narration, debitAmount, creditAmount, runningBalance } — see below for the two modes
ProfitAndLoss periodStart, periodEnd nested object, retrofit 2026-07-22 (supersedes the flat {depth, lineItem, accountType, amount} array): { periodStart, periodEnd, sales: { lines[], total }, costOfSales: { lines[], total }, grossProfit, otherIncome: { lines[], total }, distributionExpenses: { lines[], total }, administrationExpenses: { lines[], total }, otherExpenses: { lines[], total }, financialExpenses: { lines[], total }, netProfitForPeriod } — each lines[] entry is { accountCode, accountName, amount }. Driven by the new accounts.income_statement_section tag (Accounting_System_Design_LKAS.md §11.4); untagged Income/Expense rows land in an unclassified: { lines[], total } bucket rather than being silently dropped
CashFlow periodStart, periodEnd nested object (retrofit 2026-07-21, gained direction per line retrofit 2026-07-22): { periodStart, periodEnd, operatingActivities: { profitForPeriod, nonCashAdjustments[], workingCapitalChanges[ { accountCode, accountName, direction, changeAmount } ], netCashFromOperatingActivities }, investingActivities: { lines[], netCashFromInvestingActivities }, financingActivities: { lines[], netCashFromFinancingActivities }, netIncreaseDecreaseInCash, openingCashBalance, closingCashBalance } — openingCashBalance/closingCashBalance remain in the Json/Csv response for validation but are not printed in the Pdf/Csv display layout (retrofit 2026-07-22, see below)
BudgetVsActual budgetId array of { budgetLineId, accountCode, accountName, periodId, budgetedAmount, actualAmount, variance }
TaxSummary periodStart, periodEnd; optional allowableDeductions, otherTaxableIncome, qualifyingPaymentsReliefs, surchargeAmount, taxRateOverride (all decimal, default 0 except qualifyingPaymentsReliefs/taxRateOverride which default from system_config) Redesigned 2026-07-22, replaces the 2026-07-21 shape entirely — single object, the full Income Tax Computation: { periodStart, periodEnd, profitBeforeTax, nonDeductibleExpenses, allowableDeductions, adjustedBusinessProfit, otherTaxableIncome, assessableIncome, qualifyingPaymentsReliefs, taxableIncome, taxRatePercent, corporateIncomeTax, surchargeAmount, grossTaxLiability, apitCredit, whtCredit, quarterlyTaxPayments, balanceTaxPayable } — see below for the full redesign rationale, Accounting_System_Design_LKAS.md §11.7 for the query design

Retrofit 2026-07-31 — BalanceSheet restructured into a classified statement (Non-Current/Current Assets and Liabilities), implemented and verified live. Previously a flat recursive-rollup array ({depth, lineItem, accountType, balance}), with the response's own hierarchy-as-presented being the only classification signal — the schema had no is_current/is_non_current flag at all (Accounting_System_Design_LKAS.md §11.3's original note). A user-supplied reference template (a classified LKAS 1 Statement of Financial Position, Non-Current Assets and Current Assets each subtotaled, Non-Current Liabilities and Current Liabilities each subtotaled) needs that split independent of however deep the company's own COA tree happens to be. New accounts.balance_sheet_classification column (NON_CURRENT_ASSET/CURRENT_ASSET/NON_CURRENT_LIABILITY/CURRENT_LIABILITY, same recursive-tag-and-inherit pattern as cash_flow_category/income_statement_section, settable only via PUT /accounts/{accountCode}) drives the new nested response shape above; Equity accounts need no such tag since account_type_id alone already puts them in one equity section. Untagged Asset/Liability leaf rows land in unclassifiedAssets/unclassifiedLiabilities rather than being silently dropped — same rule as ProfitAndLoss's unclassified bucket. The old GetBalanceSheetAsync recursive-rollup query is replaced by GetBalanceSheetLeafRowsAsync (leaf/postable rows only, same shape as GetProfitAndLossLeafRowsAsync). Verified live against the seeded dataset: totalAssets (5,363,714) = totalEquityAndLiabilities (5,363,714) exactly, after tagging 1400 (Fixed Assets header) NON_CURRENT_ASSET, 1000/1100/1200/1300 CURRENT_ASSET, 2300 (Bank Loan) NON_CURRENT_LIABILITY, and 2000/2100/2200 CURRENT_LIABILITY — both Pdf and Csv outputs confirmed rendering correctly with the new sections. Full design: Accounting_System_Design_LKAS.md §11.3.

Retrofit 2026-07-22 — outputFormat=Csv added alongside Json/Pdf. Same ApiResponse envelope, same base64-carrying convention as Pdf: data: { fileName, contentType: "text/csv", contentBase64 }. Every report gets a real CSV — nested/hierarchical shapes (ProfitAndLoss's sections, CashFlow's three activities, BalanceSheet's classified sections) are flattened into a single row-per-line-item sheet with a Section/Category column carrying what the PDF would otherwise show via headers/indentation, rather than trying to preserve nesting in a flat format. Full per-report flattening rules: 05_LKAS_Report_PDF_Templates.md Section 10. Implemented and verified live 2026-07-22 — Services/CsvDocuments/ (CsvBuilder shared helper + one writer per report), valid CSV bytes confirmed for all seven report types.

Retrofit 2026-07-21 — bespoke per-report PDF layouts, replacing the old generic reflection-based renderer. outputFormat=Pdf works for every reportType, each via its own Services/PdfDocuments/*PdfDocument class (05_LKAS_Report_PDF_Templates.md) sharing one header/footer component (ReportPageHeader): company name (Company:Name config), report title, period line, presentation currency, page-numbered footer. A4 page size throughout; landscape for TrialBalance and GeneralLedger (wider tables), portrait for the rest as of 2026-07-21 — both since moved to portrait, see the retrofit notes for each below. The response stays inside the normal ApiResponse envelope rather than a raw binary body (CLAUDE.md Rule 7): data: { fileName, contentType: "application/pdf", contentBase64 } — the client base64-decodes contentBase64 to get real PDF bytes. Templates for TrialBalance, ProfitAndLoss, CashFlow, and TaxSummary are being reworked again as of 2026-07-22 (see the four retrofit notes above and below) — the "verified live" PDF bytes from 2026-07-21 reflect the previous content for those four reports, not the templates now specified.

Retrofit 2026-07-31 — GeneralLedger PDF moved to portrait A4, replacing the 2026-07-21 landscape choice. Landscape was originally justified by the report's six columns (Date, Particulars, Description/Reference (J.R.), Debit, Credit, Balance) plus the group-header account-name row being the tightest column-count fit of any report — but the user explicitly wants every report PDF on a standard portrait A4 page, consistent with the rest. GeneralLedgerPdfDocument's page.Size(...) changed from PageSizes.A4.Landscape() to plain PageSizes.A4 — no column/layout changes beyond the page dimensions themselves. Verified live: the rendered PDF's /MediaBox is [0 0 595 842] (595pt wide × 842pt tall — A4 portrait; landscape would be the reverse). TrialBalance had already moved to portrait earlier (2026-07-22, see below) for an unrelated reason (its own presentation simplification); this retrofit is unrelated to that one, just applies the same end state to the one report that hadn't moved yet.

Retrofit 2026-07-22 — TrialBalance presentation simplified to a flat list, implemented and verified live. A user-supplied reference template shows a plain Account name | Debit | Credit table with no hierarchy or indentation at all — the response/PDF/CSV all drop depth/indentedCode accordingly. The underlying query is otherwise unchanged (still leaf/postable accounts only, still the same balance computation; the recursive tree wasn't even needed anymore, so GetTrialBalanceAsync dropped it) — this was a presentation-only simplification, not a data change.

Retrofit 2026-07-22 — ProfitAndLoss restructured around income_statement_section, implemented and verified live. Previously a flat {depth, lineItem, accountType, amount} array (Income/Expense only, no further grouping). A user-supplied reference template needs Sales separated from Other Income, and Expenses split into Cost of Sales (subtracted for Gross Profit) plus Distribution/Administration/Other/Financial groups (subtracted for Net Profit) — none of which account_type_id alone can express. New accounts.income_statement_section column (same recursive-tag pattern as cash_flow_category) drives the new nested response shape above. Full design: Accounting_System_Design_LKAS.md §11.4. Real sign bug found and fixed during this retrofit: the pre-existing GetProfitAndLossAsync summed Expense accounts as credit − debit with no flip — negative for a normal debit-balance expense — so totalExpenses (and profitForPeriod, reused by BalanceSheet's Current Year Earnings and by CashFlow) was silently wrong. ReportService.BuildProfitAndLossReportAsync now flips every Expense-tagged section's total to positive; verified live: Sales(730,000) − CostOfSales(80,000) = GrossProfit(650,000) exactly, and netProfitForPeriod stayed identical (-1,875,000) before and after retagging four accounts out of "Unclassified" into named sections — confirming the regrouping is presentation-only, not a second data change.

Retrofit 2026-07-22 — CashFlow display simplified, direction added, implemented and verified live. The reference template for this statement has no opening/closing cash balance section — it ends at the combined Operating+Investing+Financing total. Rather than remove that reconciliation (a genuine correctness check, not decoration), both fields stay in outputFormat=Json/Csv for validation but are no longer printed in outputFormat=Pdf/the CSV's rendered table. Separately, workingCapitalChanges[] entries gained a direction field ("Increase"/"Decrease") so the PDF/CSV can render "Increase in Trade Receivables" wording — this is not derivable from the existing signed changeAmount alone, since changeAmount reflects cash impact (which flips sign between asset and liability accounts) while direction reflects the account balance's own movement (which doesn't). Full design: Accounting_System_Design_LKAS.md §11.5.

Retrofit 2026-07-22 — TaxSummary redesigned from a VAT/WHT/NBT per-tax_code summary into a full Income Tax Computation, implemented and verified live. The originally-built report (2026-07-21, one row per tax_code — output tax, input tax, net payable) doesn't match what a "Tax Report" actually needed to be, per a user-supplied reference template: a single Profit-Before-Tax-to-Balance-Tax-Payable computation, the standard corporate income tax return shape. This is a wholesale content replacement under the same reportType name, not a new report type — TaxCodeRepository.GetTaxSummaryAsync was removed entirely, not kept alongside — see Accounting_System_Design_LKAS.md §11.7 for the full redesign, including the superseded design (kept there for reference, marked §11.7†, since the VAT/WHT/NBT summary it produced is still a real, useful report — just not what this endpoint's TaxSummary name turned out to mean). Built: new accounts.is_tax_deductible column, five new system_config keys (CORPORATE_INCOME_TAX_RATE/STATUTORY_RELIEF_AMOUNT seeded via HasData; APIT_CREDIT_ACCOUNT_ID/WHT_CREDIT_ACCOUNT_ID/QUARTERLY_TAX_PAYMENT_ACCOUNT_ID seeded empty, same can't-be-HasData-seeded constraint as CASH_ACCOUNT_ROOT_ID), and four new optional request parameters for the figures that genuinely aren't GL-derived (allowableDeductions, otherTaxableIncome, qualifyingPaymentsReliefs, surchargeAmount). Verified live with both default config values and full parameter overrides; a negative balanceTaxPayable renders as "BALANCE TAX REFUNDABLE" on the PDF, confirmed on the seeded loss-making test dataset.

Retrofit 2026-07-21 — amount precision: outputFormat=Json returns every decimal field at full, unrounded precision (the database itself no longer truncates to 2 decimal places — see CLAUDE.md Rule 13). outputFormat=Pdf/Csv are the places any rounding happens: every decimal/decimal? cell is rounded to 2 decimal places with round-half-up (1000.005 → 1000.01, not banker's-rounding's 1000.00) purely for display. Verified live: a journal entry posted with debitAmount: 1000.1234567 came back as 1000.1234567 unrounded from outputFormat=Json.

Bugfix 2026-07-20 — every SUM()-based report was throwing 500 once realistic transaction volume existed. Discovered while seeding a full test dataset: straight-line depreciation frequently doesn't divide evenly (e.g. 800000 / 96 = 8333.333333333333333333333333, already 28 significant digits — right at C#'s decimal ceiling for one value), and the unbounded-precision columns from the Rule 13 retrofit let Postgres's SUM() aggregate several such values into something exceeding what decimal can hold at all, regardless of any application-level rounding policy — every report doing SUM() (TrialBalance, BalanceSheet, ProfitAndLoss, CashFlow, GeneralLedger's running balance, BudgetVsActual) failed with a bare 500. Fixed by wrapping only the SQL-side SUM(...) expressions with ROUND(..., 10) in each affected repository query — bounding just the aggregate at the point it's read into a decimal, not storage or any single-row response, which stay exactly as unrounded as Rule 13 requires. 10 decimal places is far more precision than any report needs; it just keeps the aggregate clear of the ceiling.

Bugfix 2026-07-20 — BalanceSheet had a sign-convention bug for Liability/Equity, independent of the two fixes above. GetBalanceSheetAsync computed every account's balance as debit - credit uniformly, which is correct for Asset (debit-normal) but backwards for Liability/Equity (credit-normal) — a real credit balance (e.g. a bank loan received) showed as negative. This was never caught before because no prior test exercised the Assets = Liabilities + Equity check against a dataset with a nonzero Liability balance. Fixed by flipping the sign for non-Asset types (CASE WHEN account_type_id = 1 THEN debit - credit ELSE credit - debit END), mirroring the flip GetProfitAndLossAsync already correctly applies for Income/Expense. Verified live against a full seeded dataset with real Liability/Equity balances: Assets = 3036000, Liabilities + Equity = 2054000 + 982000 = 3036000 — exact match.

Retrofit 2026-07-20 — GeneralLedger is now two modes, not one. accountId (now accountCode, retrofit 2026-07-22) was originally required, making this report really an "Account Ledger" (one account + its descendants, one running balance) rather than a true General Ledger. It's now optional:

  • accountCode supplied: unchanged from before — that account and every descendant, one running balance, exactly as it always worked.
  • accountCode omitted: every account with is_postable = true that has at least one posted line in [periodStart, periodEnd], each account's own transactions with its own running balance (the balance resets whenever the account changes), sorted by accountCode then entryDate. Non-postable header/summary accounts never appear in this mode — they structurally can't hold a posting (the posting engine already enforces is_postable at journal-entry time), so there's nothing of theirs to show, not an empty placeholder row.

Retrofit 2026-07-20 — BalanceSheet now actually balances. Previously it summed only Asset/Liability/Equity account balances, which never included the current period's net profit/loss (sitting in Income/Expense accounts, excluded from this report), so Assets ≠ Liabilities + Equity for any asOfDate after the fiscal year started — there is no closing-entry mechanism anywhere in this codebase that ever moves a fiscal year's P&L into an Equity account. Fixed by appending one synthetic row: { depth: 1, lineItem: "Current Year Earnings", accountType: "Equity", balance: <cumulative net income> }, always present (even at 0.00, for a predictable response shape) as the last row in the array. The net income figure is cumulative since inception (ProfitAndLossAsync(DateOnly.MinValue, asOfDate)), not scoped to just the fiscal year containing asOfDate — deliberately, since with no closing mechanism a fiscal-year-scoped figure would leave any prior unclosed year's profit unaccounted for, breaking the equation again the moment a second fiscal year has any posted activity. Reuses AccountRepository.GetProfitAndLossAsync exactly as BuildCashFlowAsync.netProfit already does (same Where(r => r.Depth == 1).Sum(r => r.Amount) reduction) — no new repository method.

The three BalanceSheet entries directly above (sign-convention bugfix, and this "now actually balances" note) describe the pre-2026-07-31 flat-array shape — superseded by the classified-statement retrofit note earlier in this section. Kept here rather than deleted, same as the CashFlow/TaxSummary superseded-design notes elsewhere in this file — an accurate record of what was actually built and verified at the time, even though the response shape has since changed again. The underlying fixes (sign convention, cumulative Current Year Earnings) are unchanged in substance; only the container shape around them (flat array → classified sections) is different now.

Case Status Notes
Success, any reportType + Json 200 Shape per the table above
Success, any reportType + Pdf 200 data.contentBase64
reportType missing or invalid 400 "Invalid reportType '<value>'. Must be one of: GeneralLedger, TrialBalance, BalanceSheet, ProfitAndLoss, CashFlow, BudgetVsActual, TaxSummary."
outputFormat missing or invalid 400 "Invalid outputFormat '<value>'. Must be one of: Json, Pdf, Csv."
A reportType-specific required parameter is missing 400 Names the parameter, e.g. "periodStart is required for the GeneralLedger report" — accountCode is never in this category, since it's optional
GeneralLedger with accountCode supplied but not found 404 "Account not found" — only checked when accountCode is present at all
BudgetVsActual with a non-existent budgetId 404 "Budget not found" — the only other reportType with an explicit existence check; the rest just return an empty result if nothing matches

Flow: ReportService.GenerateAsync — parse+validate reportType/outputFormat, switch dispatch to one private builder per report (BuildTrialBalanceAsync, BuildGeneralLedgerAsync, etc. — each validates its own required params first), then if Pdf/Csv, pass the result through the matching renderer (RenderPdfAsync/RenderCsv, one bespoke IDocument/writer per report). No UoW anywhere — every branch is read-only. BuildGeneralLedgerAsync branches on whether accountCode was supplied: present → AccountRepository.GetByCodeAsync (404) then JournalEntryRepository.GetGeneralLedgerLinesAsync(account.AccountId, ...); absent → JournalEntryRepository.GetGeneralLedgerLinesForAllAccountsAsync(periodStart, periodEnd) (retrofit 2026-07-20) — same SQL shape, no descendants CTE, filters is_postable = TRUE instead of a specific account subtree.

Retrofit 2026-07-21 — CashFlow upgraded to full LKAS 7, verified live. Previously a documented simplification (netProfitForPeriod standing in for Net Cash from Operating Activities, no non-cash add-backs, no working-capital changes, no opening/closing cash reconciliation). Both gaps closed: non-cash adjustments reuse the existing depreciation_schedule table (already populated by the Fixed Assets module, never read by this report before); "cash" accounts are identified via a new system_config key, CASH_ACCOUNT_ROOT_ID, pointing at the company's designated "Cash and Cash Equivalents" COA header — every recursive descendant is cash-like, same pattern as every other recursive rollup in this system. Response shape is now the full nested object (operatingActivities/investingActivities/financingActivities/opening+closing cash) shown above. ReportService.BuildCashFlowAsync resolves and validates CASH_ACCOUNT_ROOT_ID first, throwing a clear 400 if it's unset/invalid rather than silently producing an empty cash-accounts set. Verified live: openingCashBalance + netIncreaseDecreaseInCash = closingCashBalance exactly, against a cleanly-tagged test dataset. Important COA-design caveat found during verification: cash_flow_category must be tagged on individual leaf accounts with a genuine cash-relevant movement (e.g. a specific asset-cost or liability/equity account), never on a broad header — tagging a header cascades to all descendants including Accumulated Depreciation contra-accounts and (for a disposed asset) the disposal's cost-removal entry, neither of which is a real cash movement, and there's no way to "un-tag" a descendant from an ancestor's classification once inherited. See 03_Progress_Tracker.md's 2026-07-21 follow-up entry for the full diagnosis.

Spec 2026-07-21 — TaxSummary, a new 7th reportType. Not an LKAS 12 disclosure note (this system doesn't track deferred tax or an effective-tax-rate reconciliation — no schema support for either) — a practical VAT/WHT/NBT-style summary, the kind needed to actually prepare a periodic Inland Revenue return, grouped by tax_code. For each active tax_code, sums journal activity in that code's own output_tax_account_id (tax collected on sales — a credit-normal liability) and input_tax_account_id (tax paid on purchases, reclaimable — a debit-normal asset) for [periodStart, periodEnd], giving netTaxPayable = outputTaxAmount − inputTaxAmount per code. Relies on each tax_code having its own distinct output/input accounts (not shared across codes) to attribute activity correctly — there's no tax_code_id on journal_entry_line itself to attribute a line directly, so this is an account-level join, not a line-level one; full query design at Accounting_System_Design_LKAS.md §11.7. Not yet implemented — new repository method TaxCodeRepository.GetTaxSummaryAsync(periodStart, periodEnd), new ReportService.BuildTaxSummaryAsync dispatch branch. 03_Progress_Tracker.md Phase 17 tracks this as outstanding.

Spec 2026-07-21 — every PDF report renders on A4. RenderPdf (or its six/seven bespoke successors per 05_LKAS_Report_PDF_Templates.md) sets page.Size(PageSizes.A4) — QuestPDF's built-in constant, no manual millimeter math needed — with standard margins (2cm all sides, per 05_LKAS_Report_PDF_Templates.md Section 1). Portrait by default for every report; landscape is available per-report if a table's column count doesn't comfortably fit portrait width (Trial Balance, General Ledger, and Budget vs Actual are the most likely candidates, given their column counts — see 05_LKAS_Report_PDF_Templates.md Section 1 for the orientation decision per report). Not yet implemented — the current generic reflection-based renderer's page size wasn't explicitly set before this spec (QuestPDF's own default, not confirmed to already be A4).

The three entries directly above (CashFlow, TaxSummary, A4) describe the 2026-07-21 state — superseded 2026-07-22 by the retrofit notes earlier in this section (TrialBalance flattened, ProfitAndLoss restructured around income_statement_section, CashFlow gained direction/dropped its printed opening-closing lines, TaxSummary redesigned wholesale into an Income Tax Computation, Csv added as a third outputFormat). Kept here rather than deleted, same as Accounting_System_Design_LKAS.md §11.7†'s superseded design — an accurate record of what was actually built and verified on 2026-07-21, even though the report's shape has since changed again.


Module: System Config

PUT /system-config/{key} — Update

Request body: configValue. Edit-once semantics: rejects if !IsEditable || IsLocked; every successful write sets IsLocked = true afterward — each key can be changed exactly once via this endpoint, ever, for the life of the system.

Case Status Notes
Success 200 Response's isLocked is now true
Key already locked (or IsEditable=false) 400 "System config key '<key>' cannot be edited"
key doesn't exist 404 "System config key not found"

Flow: SystemConfigService.UpdateAsync — GetByKeyAsync (404), editable/locked check, mutate + lock, look up audit category from a static per-key dictionary (COA_MAX_DEPTH/COST_CENTER_MAX_DEPTH/COA_ENFORCE_MAX_DEPTH → Ledger; anything unmapped defaults to Administrative, not Ledger, so a future cosmetic key doesn't silently inherit the wrong category), BeginAsync → SaveChangesAsync → audit insert → CommitAsync.

GET /system-config — List

Flat list, all seeded keys (COA_MAX_DEPTH, COST_CENTER_MAX_DEPTH, COA_ENFORCE_MAX_DEPTH).


Module: Audit Log

GET /audit-log — List

Query params, all optional: category (repeatable — ?category=LEDGER&category=ADMINISTRATIVE, or omitted for both), entityName, action, performedBy (exact match), from/to (date range), page/pageSize.

from/to filter occurredAt — when the audit row was actually written (real wall-clock time) — not any business date on the underlying record. A journal entry post-dated far in the future (as most of this document's test data is) still writes its audit row with occurredAt = the moment the API call happened.

An unparseable category or action value is a read-side no-match, not a validation error — deliberate, documented design choice (a typo'd filter returns nothing, not a 400).

Case Status Notes
No filters 200 Full result set (both categories, no default paging — same optional-paging pattern as Accounts)
category=LEDGER 200 Only Ledger rows
entityName+action 200 Narrowed
Invalid category 200 { items: [], totalCount: 0, page: null, pageSize: null } — 200, not 400
Invalid action 200 Same — empty result, not an error
page+pageSize 200 Ordered occurredAt descending (newest first)

Flow: AuditLogService.ListAsync — resolve category list (default both, or parse+drop invalid entries — short-circuits to empty if the whole list becomes invalid), parse action (short-circuits to empty if unparseable), normalize page/pageSize, IAuditLogRepository.ListAsync (dynamic WHERE, RepositoryHelpers.ApplyPaging). No UoW.


Module: Dev Tools

POST /dev-tools/generate-api-key — Generate API Key (Development only)

See 01_Full_Development_Plan.md Section 11 for the full design. No request body. No X-Api-Key header needed — this path is excluded from ApiKeyMiddleware (it's the bootstrap tool that produces the very first key). Separately guarded by IHostEnvironment.IsDevelopment() inside the Service, not by route registration — verified live under ASPNETCORE_ENVIRONMENT=Production.

Case Status Notes
Success (Development) 200 data: { apiKey: "<64-char hex>" }. Not persisted anywhere by this call
Any other environment 403 "This endpoint is only available in the Development environment"

Module: Status & Health

  • GET /status — { service, status, timestampUtc }. The one endpoint that predates the ApiResponse convention and still returns Ok(new {...}) directly from the Controller. Still requires X-Api-Key.
  • GET /health (outside /api/v1, ASP.NET Core's built-in health-check middleware, not a Controller action) — plain text Healthy/Unhealthy. Excluded from ApiKeyMiddleware, no key needed.

Example Scenarios

Every request/response pair below was actually run against this project's local dev database while writing this document — not hypothetical. IDs reflect that real run; substitute your own.

Scenario 1 — Recording a double-entry transaction (the system's core action)

The whole point of this service: every financial event becomes one balanced journal_entry with two or more journal_entry_line rows where total debits equal total credits. Retrofit 2026-07-22 (CLAUDE.md Rule 14): there is now exactly one way to get there — lines is required, and the entry is created and posted in the same call. The old two-step "draft, then separately set lines, then separately post" flow (and its PUT .../lines/PUT .../post endpoints) is gone — the client always sends a complete entry.

POST /api/v1/journal-entries
{
  "periodId": 73,
  "entryDate": "2028-01-20",
  "journalType": "MANUAL",
  "reference": "DOC-TEST-2",
  "narration": "Inline lines",
  "preparedBy": "Doc Tester",
  "lines": [
    { "accountCode": "5400",     "debitAmount": 1000, "creditAmount": 0 },
    { "accountCode": "1700-02", "debitAmount": 0,    "creditAmount": 1000 }
  ]
}
→ 201, JV-2028-000004, isPosted: true, entryStatus: 2 (Posted) - already posted, no follow-up call needed

Why the balance/period-lock checks now run inside the one POST call, not a separate step: the whole point of a two-step create-then-post flow was to let an entry sit incomplete while it was being built up. That workflow doesn't exist for this client — every entry arrives complete — so there's nothing gained by splitting "insert the rows" from "validate and flip the status" into two calls; both happen in one transaction, and a balance/lock failure rolls back the whole thing (nothing is left half-inserted).

Scenario 2 — Correcting a posted entry: Full Reversal

Say JV-2028-000004 above was posted to the wrong accounts. Once posted, an entry is never edited or deleted directly — it's corrected by posting a new entry that reverses it. Retrofit 2026-07-22: originalJournalId → originalJournalNo, and the reversal is created and posted (and the original flipped) all in the one call.

POST /api/v1/journal-entries
{
  "periodId": 73,
  "entryDate": "2028-01-21",
  "journalType": "CORRECTION",
  "reference": "REV-JE2",
  "correction": {
    "originalJournalNo": "JV-2028-000004",
    "correctionType": "FullReversal",
    "reason": "Wrong account used",
    "correctedBy": "Doc Tester"
  }
}
→ 201, JV-2028-000005, isPosted: true, reversalOfId: 99, lines automatically mirror the original with debit/credit swapped
  AND, in the same transaction, JV-2028-000004 (the original) flips to entryStatus: 3 (Reversed), isReversed: true

Two audit rows come out of that one call: one Post for the reversal, one Reverse against the original.

Scenario 3 — Correcting a posted entry: Partial Adjustment

Different shape when only part of an entry is wrong — retrofit 2026-07-22: the adjustment lines are now supplied inline in the same call (previously deferred to the now-removed Set Lines endpoint):

POST /api/v1/journal-entries
{ "periodId": 73, "entryDate": "2028-01-22", "journalType": "CORRECTION",
  "correction": { "originalJournalNo": "JV-2028-000005", "correctionType": "PartialAdjustment", "reason": "Add missing accrual", "correctedBy": "Doc Tester" },
  "lines": [
    { "accountCode": "5400",    "debitAmount": 50, "creditAmount": 0 },
    { "accountCode": "1700-02", "debitAmount": 0,  "creditAmount": 50 }
  ] }
→ 201, JV-2028-000006, isPosted: true. Unlike FullReversal, the ORIGINAL entry is untouched - still Posted, not Reversed.

Scenario 4 — Setting up a new fiscal year and locking a period at month-end

POST /api/v1/fiscal-years
{ "yearName": "FY2036-Doc", "startDate": "2036-01-01", "endDate": "2036-12-31", "autoGeneratePeriods": true }
→ 201, fiscalYearId + 12 monthly periods generated in one call

At month-end, close January so nothing more can post to it:

PUT /api/v1/periods/{januaryPeriodId}/lock
{ "action": "CLOSE", "reason": "January close" }
→ 200, period.status: 4 (Closed), period.lockGl: true

# Any further attempt to create+post a journal entry dated into that period now fails:
POST /api/v1/journal-entries { "periodId": {januaryPeriodId}, ... } → 400 "Period is locked"

# The only way back in is UNLOCK, explicitly:
PUT /api/v1/periods/{januaryPeriodId}/lock  { "action": "UNLOCK", "reason": "reopen for correction" }
→ 200, period.status: 1 (Open) again

Scenario 5 — Recurring journal template lifecycle (e.g. monthly rent)

POST /api/v1/recurring-templates
{ "templateName": "Monthly Rent", "recurrencePattern": "Monthly", "nextRunDate": "2026-07-01",
  "lines": [ { "accountCode": "5300", "debitAmount": 30, "creditAmount": 0 }, { "accountCode": "1700-02", "debitAmount": 0, "creditAmount": 30 } ] }
→ 201, templateId: 3 (lines must balance here, unlike a journal entry's post-time check)

# Each month, trigger it (manually, or from an external scheduler calling the same endpoint):
POST /api/v1/recurring-templates/3/generate
→ 201, an already-posted journal entry (JV-2026-000003) built from the template's lines, journalType: RECURRING
  Internally this reuses JournalEntryService.CreateAsync - it's the same code path as a manual create,
  so it's created AND posted in this one call (retrofit 2026-07-22, CLAUDE.md Rule 14 - no separate
  Post call exists anymore for any journal entry, recurring or otherwise).
  nextRunDate auto-advances to 2026-08-01 so calling /generate again today won't duplicate it.

# Calling /generate again before 2026-08-01:
POST /api/v1/recurring-templates/3/generate → 400 "Recurring template is not yet due to run"

Scenario 6 — Fixed asset lifecycle: register → depreciate → dispose

POST /api/v1/fixed-assets
{ "assetName": "Laptop", "assetAccountCode": "1200", "accumDeprAccountCode": "1700-02",
  "deprExpenseAccountCode": "5400", "gainLossAccountCode": "1700-02", "acquisitionDate": "2026-01-01",
  "acquisitionCost": 100000, "residualValue": 10000, "usefulLifeMonths": 36, "depreciationMethod": "StraightLine" }
→ 201, assetId: 9, assetCode: "FA-000003" (system-generated - retrofit 2026-07-20), status: 1 (Active)
  (retrofit 2026-07-22: the 4 account fields are now codes, not ids - CLAUDE.md Rule 8.2)

# Each period, run depreciation - this posts a real journal entry automatically:
POST /api/v1/fixed-assets/FA-000003/transactions
{ "transactionType": "Depreciate", "periodId": 73 }
→ 201. Amount = (100000-10000)/36 = 2500. Posts Debit Depreciation Expense 2500 / Credit Accum. Depreciation 2500,
  already Posted (not a draft you post separately - the asset transaction endpoint posts it for you, and has
  since Phase 22 - unaffected by the 2026-07-22 create+post merge except that it now makes one internal call
  instead of two).

# Later, dispose of it:
POST /api/v1/fixed-assets/FA-000003/transactions
{ "transactionType": "Dispose", "disposalDate": "2028-06-20", "proceeds": 9000, "proceedsAccountCode": "1700-02" }
→ 201. NBV = 100000 - accumulated. gainLoss = proceeds - NBV (retrofit 2026-07-22: proceedsAccountId -> proceedsAccountCode).
  Posts a balanced 4-line entry: Debit Cash / Debit AccumDepr / Credit Asset (full acquisitionCost) / Debit-or-Credit Gain-Loss.
  asset.status -> 2 (Disposed). Any further Depreciate or Dispose call on this asset now returns 400.

Scenario 7 — Importing and reconciling a bank statement

POST /api/v1/bank-accounts
{ "accountName": "Main Account", "bankName": "Commercial Bank", "accountNumber": "8001234567" }
→ 201, bankAccountId: 2, glAccount auto-created under "Bank"   (retrofit 2026-07-31: glAccountCode removed, GL account now always auto-created)

POST /api/v1/bank-accounts/2/statement-lines/import   (multipart/form-data, field "file", .xlsx: TxnDate | Description | Amount)
→ 201, { imported: [ { stmtLineId: 3, amount: 500, isReconciled: false, ... } ], failedRows: [] }

# Meanwhile the 500 was already recorded as a journal entry touching the bank's own GL account (1700-02) -
# created AND posted in the one call (retrofit 2026-07-22, CLAUDE.md Rule 14):
POST /api/v1/journal-entries { ..., "lines": [ { "accountCode": "1700-02", "debitAmount": 500, "creditAmount": 0 }, ... ] }
→ 201, JV-2028-000026, isPosted: true

# Match the two:
PUT /api/v1/bank-statement-lines/3/reconcile   { "matchedJournalNo": "JV-2028-000026" }
→ 200, isReconciled: true, matchedJournalId: 26   (retrofit 2026-07-22: matchedJournalId -> matchedJournalNo in the request)
  (this only succeeds because the journal entry is Posted AND its net movement on the bank's GL account is exactly 500 -
   a mismatch on either condition returns 400 with a message naming both numbers)

Scenario 8 — Calculating tax on a sale, without posting anything

Tax calculation is a pure read — useful for showing a customer their total before a sale is even recorded:

GET /api/v1/tax-codes/calculate?taxCode=VAT-001&amount=1000&txnDate=2026-07-19
→ 200, { code: "VAT-001", ratePercent: 15, lineTotal: 1000, taxAmount: 150 }   (Exclusive basis: 1000 * 15% = 150)
  (retrofit 2026-07-22: taxCodeId -> taxCode, CLAUDE.md Rule 8.2)

If the tax code's calculationBasis is Inclusive instead, the same amount is treated as already including tax, and taxAmount is backed out of it (amount - amount/(1+rate/100)) — the caller doesn't need to know which basis a code uses; the endpoint applies the right formula either way.

Scenario 9 — Budget vs Actual, end to end

POST /api/v1/budgets { "fiscalYearId": 5, "name": "2035 Operating Budget" } → 201, budgetId: 2

POST /api/v1/budgets/2/lines
[ { "accountCode": "5400", "periodId": 73, "budgetedAmount": 1000 }, { "accountCode": "1700-02", "periodId": 73, "budgetedAmount": 500 } ]
→ 201 (appends - call this again later in the year to add more lines without disturbing existing ones)
  (retrofit 2026-07-22: accountId/costCenterId -> accountCode/costCenterCode, CLAUDE.md Rule 8.2; periodId stays numeric)

# After real transactions have posted against those accounts/period:
GET /api/v1/reports?reportType=BudgetVsActual&outputFormat=Json&budgetId=2
→ 200, one row per budget line: { accountCode, budgetedAmount, actualAmount, variance }
  (GET /budgets itself never shows lines - this report is the only place budget lines appear with real figures)

Scenario 10 — Locking down initial system configuration

Config keys like COA_MAX_DEPTH are meant to be set once during deployment/setup and never touched again:

GET /api/v1/system-config → 200, shows COA_MAX_DEPTH=8 (isLocked: false - still open)

PUT /api/v1/system-config/COA_MAX_DEPTH { "configValue": "10" }
→ 200, isLocked now true

# From this point on, forever:
PUT /api/v1/system-config/COA_MAX_DEPTH { "configValue": "12" } → 400 "System config key 'COA_MAX_DEPTH' cannot be edited"

Scenario 11 — Bootstrapping the API key (once, in Development, before anything else works)

Every scenario above assumes X-Api-Key is already configured and being sent. The very first time this service is stood up, that key doesn't exist yet:

POST /api/v1/dev-tools/generate-api-key      (no X-Api-Key header needed for this one call)
→ 200, { apiKey: "89F5C4983078A3D9..." }

# Copy that value into appsettings.Development.json's ApiKey:Value (and, when deploying, into
# appsettings.Production.json / the deploy-time secret store), and into the core backend's matching
# config. From then on every other endpoint in this document requires that exact header.

Known Inconsistency Worth Flagging

Error response JSON keys are PascalCase; success response JSON keys are camelCase. Verified live throughout every module above: a 200/201 body always looks like {"statusCode":200,"success":true,...}, while any 4xx/5xx body looks like {"StatusCode":400,"Success":false,...}. Cause: success responses are serialized by ASP.NET Core's normal MVC pipeline (camelCase by default), but Middleware/ExceptionHandlingMiddleware.cs and Middleware/ApiKeyMiddleware.cs both call JsonSerializer.Serialize(response) directly with no options, which falls back to System.Text.Json's true default (PascalCase). A client that deserializes both cases into the same model, case-sensitively, will silently get nulls out of every error response's fields. Not fixed as part of this documentation pass — flagged for a decision, since fixing it means touching two of the four Rule-1 foundation components.