# 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: ` 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: ` 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: ```json { "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): ```bash 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: ```json { "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`: ```json { "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`: ```json { "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: ```csharp // 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 { 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> 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>(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: }` — `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 ''. 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: , 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` 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 ''. 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 ''. Must be one of: FullReversal, PartialAdjustment."` | | An `accountCode` in `lines` doesn't exist | 404 | `"Account not found"` | | An `accountCode` in `lines` isn't postable | 400 | `"Account is not postable"` | | A `costCenterCode` in `lines` doesn't exist | 404 | `"Cost center 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 ''. Must be one of: Monthly, Quarterly, Annual."` | | A line's account isn't postable | 400 | `"Account is not postable"` | | A line's account doesn't exist | 404 | `"Account 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 ''. 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 | `"