69 KiB
General Ledger Service — Full Development Plan
Project: General-Ledgar-Service (ASP.NET Core Web API, .NET 10) Part of: ERPCore multi-service ERP suite Reference documents:
PROJECT_SPECIFICATION.md— current scaffolded state of this repo (source of truth for what already exists and must not be redesigned)Accounting_System_Design_LKAS.md— the accounting domain design (LKAS-compliant, recursive Chart of Accounts, core double-entry ledger)Accounting_System_ER_Diagram.drawio/er_diagram.mmd— the current (trimmed, core-ledger-only) entity relationship diagram, 25 tables includingaudit_log(Section 4.1/4.3). Sync status as of 2026-07-19:er_diagram.mmdis up to date —audit_logis present, and every column later converted fromvarcharto aninteger-backed enum (Rule 9 —accounting_period.status,period_lock_log.action,journal_entry.entry_status,journal_correction.correction_type,recurring_journal_template.recurrence_pattern,tax_code.calculation_basis/applies_to,fixed_asset.depreciation_method/status,audit_log.category/action) now shows its real stored type, andfixed_assetcarries its two later-added columns (gain_loss_account_id,depreciation_rate_percent).Accounting_System_ER_Diagram.drawiowas left as-is — it already hadaudit_log, but still shows the pre-conversionvarchartypes and is missingfixed_asset's two added columns; it's a single-line-per-entity HTML/XML blob (~190 lines, each one huge) that isn't safe to patch precisely without visually re-verifying every cell, so it's flagged here rather than edited blind — regenerate it from the now-current.mmdwhen convenient.02_Phase_By_Phase_Development_Plan.md— the execution plan this document feeds intoCLAUDE.md— working rules for Claude Code during implementation
1. Purpose of This Document
This is the full-scope plan — everything the General Ledger Service needs to become, end to end. It exists to give a complete picture of the domain, the architecture, the API surface, and the conventions, so that the phase-by-phase execution plan (Document 2) can slice this scope into small, independently testable, one-API-call-per-phase units without losing sight of the whole.
This document does not get executed phase-by-phase itself — Document 2 does. This document is the map; Document 2 is the route.
2. Current State (from PROJECT_SPECIFICATION.md)
The repository is a bootstrap/scaffold only. What already exists and is considered final / do-not-redesign:
| Component | File(s) | Status |
|---|---|---|
| Composition root | Program.cs |
Done — controllers, Swagger, EF Core + Pomelo (MySQL), health checks, conditional JWT bearer auth, authorization, HTTPS redirection |
| Unit of Work | Infra/UoW/IUnitOfWork.cs, EFUnitOfWork.cs |
Done — BeginAsync, CommitAsync, RollbackAsync, SaveChangesAsync |
| Custom exceptions | Exceptions/CustomExceptions.cs |
Done — NotFoundException, BadRequestException, UnauthorizedException, ForbiddenException |
| Auth response envelope | DTOs/Common/ApiResponse.cs |
Done — StatusCode, Success, Message, Data |
| Helpers | Helpers/DataExtractor.cs, DeserializePayload.cs, EncryptionHelper.cs |
Done |
| DbContext | AppDBContext.cs |
Empty shell — no DbSet<T> yet |
| Controllers | Controllers/GeneralLedgerController.cs |
Placeholder — only a GET /status liveness probe |
Interfaces/Repositories/, Interfaces/Services/ |
— | To be removed (see Section 3.2 and CLAUDE.md rule 4) |
Models/, Repositories/, Services/ |
— | Empty, to be populated phase by phase |
appsettings.json (all environments) |
— | New in this plan: gains an ApiKey:Value entry (same blank-in-Production, filled-via-secret-store pattern already used for Jwt:SigningKey) to support ApiKeyMiddleware (Section 3.3). New 2026-07-21: also gains a Company:Name entry — the reporting entity's name required on every LKAS-presented statement (LKAS 1.51), read by the shared PDF header component (05_LKAS_Report_PDF_Templates.md Section 1). This is a deployment-time constant, not a runtime-editable value, so it belongs in appsettings rather than system_config — every environment's file gets a real value (not blank-in-Production like the two secrets above; a company name isn't a secret, but it's still environment-specific if this service is ever deployed for more than one client). Expanded 2026-07-22: Company:Address, Company:Tin, Company:Brn added alongside Company:Name — needed only by the Income Tax Computation report's header (05_LKAS_Report_PDF_Templates.md §0/§8), a fuller identity block than the other six reports use |
Middleware/ |
— | New folder, new in this plan: houses ApiKeyMiddleware (Section 3.3), built in Phase 1 |
| Controller versioning | Controllers/GeneralLedgerController.cs and every controller going forward |
New in this plan: routes move to api/v{version}/[controller] via Asp.Versioning.Mvc (Section 3.4), starting at v1.0 |
Nothing in the "Done" row above is to be modified as part of this plan unless a phase explicitly identifies a genuine defect in one of those files (in which case, per CLAUDE.md rule 3, approval is asked first).
3. Architecture & Layering Convention
HTTP Request
│
▼
Controller — thin; parses request, calls one Service method, returns the ApiResponse the Service
│ already built via StatusCode(response.StatusCode, response). Single controller for
│ the whole service — see Section 3.2a
▼
Service — ALL business logic lives here; owns the Unit of Work; calls one or more Repositories;
│ throws CustomExceptions for domain-rule violations; uses Helpers where payloads are
│ loosely typed; builds and returns the success-path ApiResponse itself
▼
Repository — thin data-access only; no business rules; talks to AppDBContext; no UoW calls itself
│
▼
AppDBContext (EF Core / MySQL)
3.1 Why Unit of Work lives in the Service layer, not the Repository layer
A single service method often needs to touch more than one repository in one atomic operation — for example, posting a journal entry touches JournalEntryRepository (update status) and JournalEntryLineRepository (validate balance) in one transaction, or generating a depreciation entry touches both FixedAssetRepository and a repository that creates the journal_entry/journal_entry_line rows. If each repository managed its own transaction, a multi-repository operation could never be atomic. The Service layer is the only layer that knows the full scope of "what must succeed or fail together," so it is the only layer that calls BeginAsync / CommitAsync / RollbackAsync.
Repositories only ever call SaveChangesAsync indirectly through the Service's Unit of Work — they do not open their own transactions or commit independently.
3.2 No dedicated Interfaces/ folder
Per CLAUDE.md rule 4, the Interfaces/Repositories/ and Interfaces/Services/ folders are removed. Instead, each Repository and each Service declares its own interface in the same file, directly above the implementing class:
public interface IAccountRepository
{
Task<Account?> GetByIdAsync(long accountId);
Task<Account> AddAsync(Account account);
}
public class AccountRepository : IAccountRepository
{
private readonly AppDBContext _context;
public AccountRepository(AppDBContext context) => _context = context;
public async Task<Account?> GetByIdAsync(long accountId) =>
await _context.Accounts.FindAsync(accountId);
public async Task<Account> AddAsync(Account account)
{
await _context.Accounts.AddAsync(account);
return account;
}
}
This keeps every layer's contract next to its only implementation — there is exactly one implementation per interface in this codebase, so a separate interface file adds indirection without adding value (this is also why rule 2, "keep it simple," and rule 4 go together).
3.2a Single controller; ApiResponse built in the Service layer; payload parsing lives in the Service too
Every endpoint across every phase/module is an action on the one Controllers/GeneralLedgerController.cs — not a separate controller per module. The controller-level route carries only the version prefix ([Route("api/v{version:apiVersion}")], no [controller] token), so each action's own route template is its full path relative to api/v1/ with no prefix repetition, e.g. [HttpPost("accounts")] resolves to api/v1/accounts.
The Controller action never constructs new ApiResponse { ... } — the Service method's success path returns an already-built ApiResponse, and the Controller just relays it via StatusCode(response.StatusCode, response). Failure paths are untouched by this: a Service still throws a CustomException, and ExceptionHandlingMiddleware builds the ApiResponse for those.
The Controller also does no payload parsing. For any endpoint accepting a loosely-typed JSON body, the action's only parameter is [FromBody] Dictionary<string, JsonElement> payload, passed straight through to the Service method unexamined; DataExtractor calls happen inside the Service, which builds whatever internal request shape it needs before running its business logic. The Controller's job everywhere is exactly: bind the route/body, call one Service method, relay its ApiResponse.
Full rationale and worked examples: CLAUDE.md Rule 7 (controller/response) and Rule 1's Helpers row (payload parsing in the Service).
3.3 API Key Middleware — service-to-service authentication
Every request into this service is expected to come from the shared ERPCore backend, not directly from an end-user client. To guarantee that, a custom middleware validates a shared secret on every incoming request, before it reaches any controller:
HTTP Request
│
▼
UseHttpsRedirection
│
▼
ApiKeyMiddleware ← NEW: rejects the request here if the key is missing/wrong
│
▼
UseAuthentication (JWT bearer, existing)
│
▼
UseAuthorization (existing)
│
▼
MapControllers
How it works:
- The core backend sends a header on every call — e.g.
X-Api-Key: <shared-secret>. ApiKeyMiddlewarereads the expected value from configuration (ApiKey:Valueinappsettings.json, following the exact same pattern already used forJwt:SigningKey— blank inappsettings.Production.json, filled via environment variable/secret store at deploy time).- If the header is missing or doesn't match, the middleware short-circuits the pipeline and writes a
401response directly — it runs before MVC, so it builds the sameApiResponseJSON shape by hand (Success = false,Message = "Invalid or missing API key") rather than throwing aCustomException(those are only caught inside the MVC/Service pipeline, which this request never reaches). - Two paths are excluded from the check:
/health(infrastructure monitoring shouldn't need a secret) and/swagger(dev-only UI). - This check applies uniformly, once, in the middleware pipeline — individual controllers and endpoints do not each implement their own check. "Checked in every API call" is satisfied by the middleware sitting in front of all of them, not by per-endpoint code.
This is a new piece of shared foundation, built once in Phase 1, and — like the four components in Section 3.5 below — not to be modified per-phase once it exists; changes to it go through the same ask-first rule as everything else (CLAUDE.md Rule 3).
3.4 API Versioning
All controllers are versioned using Asp.Versioning.Mvc (package reference below). Routes take the shape api/v{version}/[controller], e.g. api/v1/accounts. Every controller in this plan starts at v1.0; a v2 only gets introduced later if a breaking change to an existing endpoint is ever needed — new, non-breaking endpoints are simply added to the existing controller under v1.
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiController]
public class AccountsController : ControllerBase { ... }
Program.cs registers versioning services (AddApiVersioning) alongside the existing MVC/Swagger/EF Core/health-check/JWT registration described in Section 2.
3.5 Where CustomException, Helpers, UnitOfWork, and ApiResponse are used
| Component | Layer | When |
|---|---|---|
NotFoundException |
Service | A requested entity (account, journal entry, fiscal year, etc.) doesn't exist |
BadRequestException |
Service | A domain rule is violated — unbalanced journal, posting to a non-postable account, posting into a locked period, exceeding configured COA depth, re-parenting that would create a cycle, editing a is_locked = TRUE system_config row |
UnauthorizedException / ForbiddenException |
Service | Reserved for when identity/permission checks are added later (this service currently only validates a JWT's signature/issuer/audience — no role checks exist yet, consistent with the "no authentication/role layer" decision in the accounting design; these exceptions exist for future use and for any endpoint that needs to confirm a caller identity claim is present via GetUserIdFromContext) |
DataExtractor |
Service (payload parsing) | Any endpoint accepting a loosely-typed JSON body instead of a strongly-typed DTO — e.g. a generic "post adjustment" endpoint where the line items are dynamic. The Controller accepts [FromBody] Dictionary<string, JsonElement> and passes it straight to the Service unexamined (Section 3.2a) |
DeserializePayload |
Service | Converting an incoming DTO into the Dictionary<string, JsonElement> shape DataExtractor expects, where applicable |
IUnitOfWork / EFUnitOfWork |
Service only | Every Service method that writes data wraps its repository calls in BeginAsync → (repository calls) → CommitAsync, with RollbackAsync on failure |
ApiResponse |
Service (constructed) / Controller (relayed) | The standard response envelope for every endpoint in this service — not just auth-related calls. StatusCode/Success/Message/Data wraps every response for consistency across the ERP suite. Built in the Service on the success path (Section 3.2a); the Controller only relays it |
ApiKeyMiddleware |
Pipeline (before MVC) | Validates the X-Api-Key header on every request against configuration; rejects with a hand-built ApiResponse-shaped 401 before the request ever reaches a controller. Applies globally — not per-endpoint code |
EncryptionHelper is available but not expected to be used by General Ledger endpoints unless a future phase needs to encrypt a stored value (e.g. a bank account number). Its hardcoded key is a known, flagged issue (Section 2 of PROJECT_SPECIFICATION.md) that is explicitly out of scope to fix as part of this plan — noted here so it isn't silently "fixed" mid-phase without approval (CLAUDE.md rule 3).
4. Domain Model Scope
The domain is the pure double-entry ledger core defined in Accounting_System_Design_LKAS.md and reflected in the current ER diagram — 25 tables (the original 24 plus audit_log, Section 4.3 — both diagram files now include it, see the reference-documents note above), no AR/AP party subledger, no inventory, no multi-branch (all removed by design decision in that document's revision history).
4.1 Entity groups
| Group | Tables |
|---|---|
| Core Ledger | account_type, accounts, fiscal_year, accounting_period, period_lock_log, journal_entry, journal_entry_line, journal_correction, cost_center |
| Fixed Assets | fixed_asset, depreciation_schedule, asset_disposal |
| Bank & Cash | bank_account, bank_statement_line |
| Tax Configuration | tax_type, tax_code |
| Currency Configuration | currency, exchange_rate, currency_rounding_rule |
| System Configuration | system_config |
| Budgeting | budget, budget_line |
| Manual Entry / Recurring | recurring_journal_template, recurring_journal_template_line |
| Audit & Change Tracking | audit_log (generic, category-tagged — Section 4.3), plus the two purpose-specific tables already listed above under Core Ledger (journal_correction, period_lock_log), which audit_log does not replace or duplicate |
Every table now also carries created_at (added in a later revision of the LKAS document), and system_config carries is_editable / is_locked for one-time-vs-always-editable settings. These are reflected in the Phase 1 model list in Document 2. Exception: audit_log has no separate created_at — its own occurred_at column already serves that role, and a duplicate timestamp column on a table that only ever gets inserted, never updated, would be redundant.
4.2 Two structurally recursive entities
accounts (self-referencing parent_account_id) and cost_center (self-referencing parent_cost_center_id) are the two trees the whole reporting engine is built around. Both need:
- A cycle-prevention check before any re-parent operation
- A configurable max-depth check driven by
system_config(COA_MAX_DEPTH,COST_CENTER_MAX_DEPTH,COA_ENFORCE_MAX_DEPTH) - Tree traversal and rollups in plain EF Core (no raw SQL, as of the 2026-10-06 MySQL migration — LINQ cannot express recursive CTEs, so repositories load the entity's rows as a
TreeRowprojection and walk them in memory). Implemented as a single shared helper,Helpers/RecursiveTreeQueries.cs, rather than duplicated per entity — seeCLAUDE.mdRule 11
4.3 Audit Log — schema and classification (LEDGER vs ADMINISTRATIVE)
CREATE TABLE audit_log (
audit_log_id BIGSERIAL PRIMARY KEY,
category VARCHAR(20) NOT NULL, -- 'LEDGER' | 'ADMINISTRATIVE' — representation flagged in CLAUDE.md Rule 10, not decided here
entity_name VARCHAR(50) NOT NULL, -- 'Account', 'JournalEntry', 'TaxCode', ...
entity_id VARCHAR(50) NOT NULL, -- affected row's PK, stored as text since PK types vary across tables
action VARCHAR(20) NOT NULL, -- 'CREATE', 'UPDATE', 'POST', 'REVERSE', 'ADJUST', 'LOCK', ...
description TEXT, -- human-readable summary, e.g. "Account 5401 created"
performed_by VARCHAR(100), -- free text, same pattern as JournalEntry.PreparedBy — no user table, no FK
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
No FK to any other table — entity_name + entity_id is a loose reference by design, the same way a generic log naturally spans every table without needing 25 separate nullable FK columns.
Classification rule: a write is LEDGER if it touches recorded balances, the double-entry ledger itself, or the structural integrity of something reports roll up through (the COA tree, tax rates, the fiscal calendar). Everything else is ADMINISTRATIVE. GET endpoints never write an audit row — nothing to log.
| Phase | Endpoint | Category | Why |
|---|---|---|---|
| 2 | POST /accounts |
LEDGER |
Creates a COA node every rollup depends on |
| 3 | GET /accounts/{accountCode} |
(none — read) | |
| 4 | PUT /accounts/{accountCode} |
LEDGER if parentAccountId changed, ADMINISTRATIVE otherwise |
Field-dependent — decided in the Service method itself, not fixed per-endpoint. Re-parenting moves the account in the tree every rollup depends on; renaming/deactivating doesn't |
| 5 | GET /accounts |
(none — read) | |
| 6 | POST /fiscal-years |
LEDGER |
Every journal entry references a period by FK |
| 7 | POST /fiscal-years/{id}/periods |
LEDGER |
Same reason as #6 |
| 8 | PUT /periods/{id}/lock |
LEDGER |
Already has its own richer period_lock_log row — the audit_log entry is a secondary, lightweight cross-reference for the unified feed, not a replacement |
| 9 | POST /cost-centers |
ADMINISTRATIVE |
A reporting dimension; doesn't affect the Balance Sheet/P&L/Trial Balance, which are account-based only |
| 10 | GET /cost-centers/{costCenterCode} |
(none — read) | |
| 11 | POST /journal-entries |
LEDGER |
The core of the ledger — plain entries and corrections alike |
| 12 | PUT /journal-entries/{id}/lines |
LEDGER |
Still editing a ledger entry's content, even pre-post |
| 13 | PUT /journal-entries/{id}/post |
LEDGER |
The moment an entry becomes real |
| 14 | GET /journal-entries/{id} |
(none — read) | |
| 15 | POST /recurring-templates |
ADMINISTRATIVE |
A saved recipe — posts nothing itself |
| 16 | POST /recurring-templates/{id}/generate |
LEDGER |
Produces a real draft entry (delegates to #11's classification) |
| 17 | GET /reports |
(none — read) | |
| 18 | (removed 2026-07-18) | (n/a) | No longer part of the API surface — see Section 6.1's FX Revaluation removal callout |
| 19 | POST /tax-codes |
LEDGER |
Rate applies to every future transaction's tax calculation |
| 20 | GET /tax-codes/calculate |
(none — read) | |
| 21 | POST /fixed-assets |
LEDGER |
Asset register underlies future depreciation/disposal postings |
| 22 | POST /fixed-assets/{assetCode}/transactions |
LEDGER |
Posts a journal entry (both Depreciate and Dispose) |
| 23 | POST /bank-accounts |
ADMINISTRATIVE |
Master data / cross-reference, not a posting |
| 24 | POST /bank-accounts/{id}/statement-lines/import |
ADMINISTRATIVE |
Same reasoning as #23 — the import itself doesn't post anything |
| 25 | PUT /bank-statement-lines/{id}/reconcile |
ADMINISTRATIVE |
Cross-references an existing entry, doesn't alter it |
| 26 | POST /budgets |
ADMINISTRATIVE |
Planning data, not recorded financial history |
| 27 | POST /budgets/{id}/lines |
ADMINISTRATIVE |
Same reasoning as #26 |
| 28 | PUT /system-config/{key} |
LEDGER for both current keys (COA_MAX_DEPTH, COST_CENTER_MAX_DEPTH, COA_ENFORCE_MAX_DEPTH all gate ledger structure) |
Classified per config key, not fixed for the endpoint — a future purely-cosmetic key wouldn't automatically inherit LEDGER |
| 29 | GET /system-config |
(none — read) | |
| 30–35 | All appended list endpoints (Section 6.3) | (none — read) | Every Phase 30–35 endpoint is a GET |
5. Default / Seed Data Strategy
Per the design document, the following tables must have default data present before the system is usable, because report queries and validation logic reference fixed values:
| Table | Why it needs default data | Seed content |
|---|---|---|
account_type |
Balance Sheet / P&L rollup queries filter by account_type_id IN (1,2,3) (Asset/Liability/Equity) and IN (4,5) (Income/Expense) — these IDs are structural, not business data |
5 fixed rows: 1=Asset(DR), 2=Liability(CR), 3=Equity(CR), 4=Income(CR), 5=Expense(DR) |
system_config |
The recursive-depth guard and other engine-level settings must exist before the first account is ever created | COA_MAX_DEPTH=6 (editable), COST_CENTER_MAX_DEPTH=4 (editable), COA_ENFORCE_MAX_DEPTH=TRUE (editable) |
currency |
Exactly one base/functional currency must exist before any account or journal line can be created | LKR, is_base_currency = TRUE, seeded alongside the full ISO 4217 currency list (142 currencies total) — now seeded via HasData directly, since the runtime setup endpoint was removed (Section 6.1). Confirmed 2026-07-18: this is a one-time initial-configuration choice — the client can never change the base currency afterward, and non-base currencies are never used at all (see the FX Revaluation removal callout in Section 6.1). The other 141 rows are seeded but functionally dead weight as a result — see Section 5.3 |
tax_type |
Reference categories that tax_code rows are classified under |
VAT, WHT, NBT, SSCL, INCOME_TAX |
Not seeded by default (business/company-specific, entered during setup, not hardcoded): accounts (the actual Chart of Accounts), tax_code (actual rates), fiscal_year/accounting_period, cost_center, budget.
5.1 Seeding mechanism
Seed data is applied via EF Core HasData in OnModelCreating for account_type, system_config, tax_type, and (as of the endpoint reduction in Section 6.1) currency — all four are now static, deterministic, and safe to bake into a migration, since there's no longer a runtime API call responsible for any of them.
This keeps every table in this section consistent: reference/foundational data that the engine depends on structurally lives in HasData, while genuinely business-specific data (the actual Chart of Accounts, tax rates, fiscal calendar, cost centers, budgets) is entered through the API once the system is running.
5.2 Exact seed values — copy-paste source for AppDBContext.OnModelCreating
This is the authoritative, exact specification for every HasData call needed before the application can be used — the same four tables from the summary table above, spelled out row by row rather than categorically. Implemented and verified live (Document 2, Phase 1 / 03_Progress_Tracker.md); this section exists so the values have one canonical source instead of being re-derived from scattered notes each time they're needed.
account_type — 5 rows, fixed IDs (referenced by report queries, Section 4.2):
id |
name |
normal_balance |
|---|---|---|
| 1 | Asset | DR |
| 2 | Liability | CR |
| 3 | Equity | CR |
| 4 | Income | CR |
| 5 | Expense | DR |
system_config — 3 rows. is_editable / is_locked follow the corrected semantics (Deviations log, 2026-07-18 — not the original design doc wording): the API rejects a write when !IsEditable || IsLocked, and every successful write sets IsLocked = TRUE afterward — so IsEditable = TRUE does not mean "always changeable," it means "eligible to be changed exactly once via the API." A key seeded with IsEditable = FALSE would never be writable via the API at all (fixed for the life of the system, seed-only). All three current keys start IsEditable = TRUE, IsLocked = FALSE — each can be set once (e.g. during initial deployment/configuration) and is then permanently locked:
config_key |
config_value |
description |
is_editable |
is_locked |
|---|---|---|---|---|
COA_MAX_DEPTH |
6 |
Maximum allowed levels in the recursive Chart of Accounts tree | TRUE |
FALSE |
COST_CENTER_MAX_DEPTH |
4 |
Maximum allowed levels in the recursive Cost Center tree | TRUE |
FALSE |
COA_ENFORCE_MAX_DEPTH |
TRUE |
If FALSE, COA depth is unlimited (COA_MAX_DEPTH ignored) |
TRUE |
FALSE |
tax_type — 5 rows:
id |
code |
name |
|---|---|---|
| 1 | VAT |
Value Added Tax |
| 2 | WHT |
Withholding Tax |
| 3 | NBT |
Nation Building Tax |
| 4 | SSCL |
Social Security Contribution Levy |
| 5 | INCOME_TAX |
Income Tax |
5.3 Currency seed data — full ISO 4217 list, not enumerated inline
142 rows, seeded via migration SeedIsoCurrencies (Deviations log, 2026-07-18) — too long to usefully list row-by-row in this document. The shape every row follows:
currency_code |
currency_name |
symbol |
decimal_places |
is_base_currency |
is_active |
|---|---|---|---|---|---|
LKR |
Sri Lankan Rupee | Rs |
2 | TRUE |
TRUE |
USD |
US Dollar | $ |
2 | FALSE |
TRUE |
JPY |
Japanese Yen | ¥ |
0 | FALSE |
TRUE |
KWD |
Kuwaiti Dinar | KD |
3 | FALSE |
TRUE |
| (...138 more, one per active ISO 4217 currency) |
- Exactly one row (
LKR) hasis_base_currency = TRUE— enforced by the partial unique index from the design document, not just seed-time discipline. decimal_placesvaries by currency: 16 currencies seed at0(JPY, KRW, VND, and 13 others), 7 at3(KWD and similar), the rest at the default2.- A real
HasDatabug was found and fixed while seeding this table, worth knowing before writing any futureHasDataseed with a similar shape: EF Core's change-tracking diff treats a seeded value equal to the CLR default for its type (0forshort) as "unset" when the column also hasHasDefaultValueconfigured in the model, and silently drops it from the generatedINSERT— everydecimal_places = 0row was seeding as2instead until the column's default value convention was removed entirely (migrationRemoveCurrencyDecimalPlacesDefault). If a futureHasDataseed needs a column where a meaningful value can coincide with0/false/null, don't also configureHasDefaultValueon that column. - Confirmed dead weight, not a bug: since Phase 18 (FX Revaluation) was permanently removed (Section 6.1) and the base currency can never change, the 141 non-base currency rows and the
exchange_rate/currency_rounding_ruletables are fully seeded/functional but structurally unreachable — every journal line always resolves to the single base currency at a rate of1.
6. Full API Surface
6.1 Endpoint consolidation strategy
The domain has ~40 distinct write/read operations, but that doesn't mean 40 endpoints. Several groups of operations are the same shape — one aggregate, one write, differing only in a small discriminator value — and are collapsed into a single endpoint that takes an enum/discriminator in the request body, rather than one endpoint per variant. Other groups look similar on the surface but actually differ in resource, HTTP semantics, or failure mode, and are kept separate. The rule applied throughout:
Merge when two or more operations write to the same aggregate, share the same validation shape, and differ only by "which kind" — expressed as one enum field. Keep separate when operations act on different resources, mix reads with writes, or have meaningfully different request/response shapes — merging those would blur REST semantics and make each phase's acceptance test ambiguous (Section 7.2).
Five consolidations applied, plus two endpoints removed and one method correction:
| Change | Old endpoints | New endpoint | Mechanism |
|---|---|---|---|
| Reporting | GET .../reports/general-ledger, .../trial-balance, .../balance-sheet, .../profit-and-loss, .../cash-flow, .../trial-balance/pdf, .../reports/budget-vs-actual (7 endpoints) |
GET /api/v1/reports |
ReportType and OutputFormat as query parameters (not a request body — see the method correction below), selecting which report logic runs and whether the response is JSON or PDF |
| Journal entry create + correction | POST .../journal-entries, POST .../journal-entries/{id}/reverse, POST .../journal-entries/{id}/adjust (3 endpoints, after an earlier round already merged reverse+adjust into 2) |
POST /api/v1/journal-entries |
A single creation endpoint now handles both a plain new entry and a correction of an existing one. An optional correction object in the request body ({ originalJournalId, correctionType }) is the discriminator — its absence means "just create a normal draft"; its presence means "create this new entry as a correction of that one." Both cases are fundamentally the same operation: insert a new journal_entry row. See the worked example in Document 2, Phase 11 |
| Journal entry lines (generalized) | PUT .../journal-entries/{id}/lines (manual drafts only) |
PUT /api/v1/journal-entries/{id}/lines (same route, broader purpose) |
No longer documented as "manual entries only" — it sets/replaces the lines of any entry still in DRAFT status, whether that draft originated as a plain manual entry or as a PartialAdjustment correction (Document 2, Phase 12). This isn't a new endpoint; it's the existing one doing the job it was already structurally capable of doing, now used consistently for both cases instead of only one |
| Fixed asset transactions | POST .../fixed-assets/{id}/depreciate, POST .../fixed-assets/{id}/dispose (2 endpoints) |
POST /api/v1/fixed-assets/{assetCode}/transactions |
TransactionType enum (Depreciate, Dispose); looked up by assetCode, not id (CLAUDE.md Rule 8) |
| Account read | GET .../accounts/{id}, GET .../accounts/{id}/tree (2 endpoints) |
GET /api/v1/accounts/{accountCode} |
Optional ?expand=tree query parameter. Looked up by accountCode, not the numeric id — see CLAUDE.md Rule 8 |
| Removed entirely | POST /api/v1/currencies, POST /api/v1/currencies/{code}/rates |
(none) | Multi-currency management is out of scope for the API surface — see the callout below |
| Removed entirely (2026-07-18) | POST /api/v1/fx-revaluation (was row 18) |
(none) | Confirmed out of scope — see the callout below |
| Method corrected | POST /api/v1/reports (an earlier round's choice) |
GET /api/v1/reports |
Reporting is read-only with no side effects — GET is the correct HTTP verb; there was never a strong reason it needed to be POST. See the callout below |
Net effect: 40 original endpoints → 27 endpoints (7 saved from reporting, 1 from merging journal-entry create+correction into one route, 1 from fixed asset transactions, 1 from account read, 2 removed outright for currency, 1 removed outright for FX revaluation = 13 fewer than the original 40, a 32.5% reduction).
Callout — why the currency endpoints were removed: POST /api/v1/currencies and POST /api/v1/currencies/{code}/rates are no longer part of the API surface. This is a scope decision, not a technical merge — the schema (currency, exchange_rate, currency_rounding_rule tables) is unaffected, and multi-currency data can still be seeded directly in the database if it's ever needed, the same way account_type/tax_type are seeded via HasData rather than an API call.
Callout — why POST /api/v1/fx-revaluation (Phase 18) was removed, resolving its earlier ⚠ Blocked status: this was originally kept in the plan as an open decision, flagged because removing the currency-management endpoints left it with no API-driven way to populate the non-base-currency balances and exchange rates it would revalue. Confirmed 2026-07-18: the base/functional currency is a one-time initial-configuration choice that can never be changed by the client afterward, and non-base currencies are never used in this system at all. Since FX revaluation only has meaning when an open balance exists in a non-base currency, and that can now never happen, the endpoint has no possible purpose — not "blocked pending data," but permanently out of scope. Removed outright, same treatment as the currency-management endpoints above, rather than left "blocked" indefinitely. Per the "no renumbering, ever" policy, Phase 18's slot is not reused — Document 2's Phase 18 section and 03_Progress_Tracker.md record it as removed, not silently dropped. This also leaves a chunk of already-built scaffolding as known, harmless dead weight: the exchange_rate/currency_rounding_rule tables, ~141 of the 142 seeded Currency rows, and JournalEntryLine's CurrencyCode/ExchangeRate/BaseDebit/BaseCredit columns — none of it is wrong, it just always resolves to the single base currency / rate of 1 and always will.
Callout — why reporting is GET, not POST: report parameters (reportType, outputFormat, a date or two, an optional id) are simple scalars that fit a query string perfectly well — GET /api/v1/reports?reportType=TrialBalance&asOfDate=2026-07-31&outputFormat=Json. A report has no side effects, is safely retryable, and is naturally cacheable — all textbook reasons to use GET over POST. The earlier choice of POST didn't have a strong justification behind it and has been corrected.
Considered and deliberately not merged, with the reasoning:
- Create fiscal year vs. generate periods (
POST /fiscal-yearsvsPOST /fiscal-years/{id}/periods) — left separate. A fiscal year can gain an optionalautoGeneratePeriodsflag on creation to cover the common case in one call, but the periods-generation endpoint stays independently callable, because periods sometimes need to be (re)generated or extended for a fiscal year that already exists. - Journal entry lines vs. post (
PUT .../linesvsPUT .../post) — left separate; editing lines is a repeatable step, posting is a one-way state transition with its own heavy validation (balance check, period-lock check) — collapsing these would make the balance check ambiguous about when it runs. - Recurring template generate (
POST /recurring-templates/{id}/generate) — kept as its own endpoint for on-demand/manual triggering and testing, in addition to (not instead of) a scheduled job that could call the same Service method internally. - Bank statement import vs. reconcile — left separate; import is a bulk file write with per-row failure handling, reconcile is a single-row interactive state change. Different payload shapes, different failure modes.
- Tax code create vs. calculate — left separate; one persists a new rate row, the other is a stateless computation against existing rows.
- Budget create vs. add lines — left separate; line items are added incrementally over time, not fixed at creation.
- System config update vs. list — left separate; standard single-resource-write vs. collection-read pair.
6.2 Full API Surface — 27 endpoints, each justified
| # | Module | Method | Endpoint | Purpose | Why this is its own endpoint |
|---|---|---|---|---|---|
| 1 | (Phase 1 — no API, models + DB + seed data only) | — | |||
| 2 | Accounts | POST |
/api/v1/accounts |
Create an account (validates parent depth, cycle-safe by construction on create) | Distinct write with its own validation (depth guard, duplicate code) — the entry point to the recursive COA |
| 3 | Accounts | GET |
/api/v1/accounts/{accountCode} |
Get one account, ancestor breadcrumb, and (via ?expand=tree) its recursive descendant tree |
Merged with the old tree-only endpoint — one read, two optional shapes of the same underlying resource. Looked up by accountCode, not id (CLAUDE.md Rule 8) |
| 4 | Accounts | PUT |
/api/v1/accounts/{accountCode} |
General account update, including re-parenting (cycle + depth check triggers only when parentAccountId is part of the payload and has changed) |
One write endpoint for "anything about this account changed," rather than a narrow reparent-only endpoint plus a separate future endpoint for renaming/deactivating. Looked up by accountCode, not id; parentAccountId in the body stays numeric (CLAUDE.md Rule 8) |
| 5 | Accounts | GET |
/api/v1/accounts |
List/search accounts (filter by type, active, postable) | Collection read is a different shape (pagination, filters) from a single-resource read |
| 6 | Fiscal Calendar | POST |
/api/v1/fiscal-years |
Create a fiscal year (optional autoGeneratePeriods flag covers the common case in one call) |
Distinct aggregate root — a real operational record with its own lock/close lifecycle, not expressible as a system_config key (Section 4.2 — periods are referenced by FK from every journal entry and individually lockable) |
| 7 | Fiscal Calendar | POST |
/api/v1/fiscal-years/{id}/periods |
Generate/add accounting periods for a fiscal year | Kept independently callable for regenerating or extending periods later — see Section 6.1 |
| 8 | Fiscal Calendar | PUT |
/api/v1/periods/{id}/lock |
Lock/soft-close/unlock a period (writes period_lock_log) |
A state-transition action with its own audit trail, not a field update |
| 9 | Cost Centers | POST |
/api/v1/cost-centers |
Create a cost center (recursive, same depth-guard pattern as accounts) | Separate aggregate/dimension from the COA — tags who spent, independent of which account was hit; own recursive tree, own depth guard against COST_CENTER_MAX_DEPTH |
| 10 | Cost Centers | GET |
/api/v1/cost-centers/{costCenterCode} |
Get one cost center, with ?expand=tree for its recursive descendant tree |
Same read-merge pattern as #3. Looked up by costCenterCode, not id — CostCenter.Code is UNIQUE in the design doc (CLAUDE.md Rule 8) |
| 11 | Journal Entries | POST |
/api/v1/journal-entries |
Create a new journal entry — a plain manual DRAFT (optionally with inline lines), or a correction of an existing posted entry (optional correction: { originalJournalId, correctionType }) |
Merged — see Section 6.1. One endpoint for "a new journal_entry row needs to be created," regardless of why |
| 12 | Journal Entries | PUT |
/api/v1/journal-entries/{id}/lines |
Add/replace lines on any entry still in DRAFT status — plain manual entries and PartialAdjustment corrections alike |
Generalized in purpose (not in route) — see Section 6.1. Kept separate from create/post since editing lines is a repeatable step before the one-way post transition |
| 13 | Journal Entries | PUT |
/api/v1/journal-entries/{id}/post |
Post a draft entry (balance check, period-open check, postable-account check). If the entry being posted is a FullReversal correction, this also flips the original entry to REVERSED in the same transaction |
The one moment an entry becomes real (is_posted = TRUE) — a distinct, heavily-validated state transition |
| 14 | Journal Entries | GET |
/api/v1/journal-entries/{id} |
Get one entry with its lines | Standard single-resource read, needed by every other journal-entry operation's "load it first" step |
| 15 | Recurring Entries | POST |
/api/v1/recurring-templates |
Create a recurring journal template | Distinct aggregate (its own table, its own line items) — a reusable recipe, not a transaction |
| 16 | Recurring Entries | POST |
/api/v1/recurring-templates/{id}/generate |
Generate the next draft entry from a template (internally calls #11's create-draft logic) | Kept as an on-demand action for manual/immediate triggering and testing — see Section 6.1 |
| 17 | Reporting | GET |
/api/v1/reports |
Unified reporting endpoint — reportType and outputFormat query parameters (GeneralLedger, TrialBalance, BalanceSheet, ProfitAndLoss, CashFlow, BudgetVsActual, TaxSummary × Json, Pdf, Csv) |
Merged from 7 endpoints, corrected from POST to GET — see Section 6.1. TaxSummary added 2026-07-21 as a 7th reportType on this same endpoint (not a new phase/endpoint — the whole point of the reportType discriminator is that a new report doesn't need a new route), then wholesale redesigned 2026-07-22 from a VAT/WHT/NBT summary into a full Income Tax Computation (04_API_Reference_And_Scenarios.md). Csv added 2026-07-22 as a third outputFormat, same reasoning — no new route needed for a new format either |
| 18 | Currency | (removed) | (removed) | Removed 2026-07-18 — base currency is a one-time initial-configuration choice, never changed, non-base currencies never used; FX revaluation has no possible purpose in this system (Section 6.1 callout) | Phase slot not reused — "no renumbering, ever" |
| 19 | Tax | POST |
/api/v1/tax-codes |
Create a tax code (rate, basis, accounts, effective dates) | Distinct, low-frequency setup write |
| 20 | Tax | GET |
/api/v1/tax-codes/calculate |
Calculate tax for a given amount/date/tax code (no posting) | Pure read/computation, not a write |
| 21 | Fixed Assets | POST |
/api/v1/fixed-assets |
Register a fixed asset | Distinct aggregate root for the asset register |
| 22 | Fixed Assets | POST |
/api/v1/fixed-assets/{assetCode}/transactions |
Unified depreciation/disposal (TransactionType: Depreciate | Dispose) |
Merged — see Section 6.1. Looked up by assetCode, not id (CLAUDE.md Rule 8) |
| 23 | Bank | POST |
/api/v1/bank-accounts |
Create a Bank account (unchanged original shape) | Distinct, low-frequency setup write |
| 23a | Bank | POST |
/api/v1/cash-accounts |
Create a Cash account — two separate tables, not a discriminator, retrofit 2026-07-22 (see Accounting_System_Design_LKAS.md §5.8): a cash account is a peer concept to a bank account, not a kind of one |
Same table/endpoint-naming precedent as TaxSummary keeping its name after a content redesign — bank_account/bank-accounts predate this split and stay as-is, cash gets its own new table/route instead of retrofitting a discriminator onto an already-shipped one |
| 23b | Bank | GET |
/api/v1/cash-account-types |
List cash account types (new, 2026-07-22) | Growable reference list (seeded Petty Cash/Till Cash/Safe Cash/Cash in Transit, gains a row automatically when POST /cash-accounts is called with an unrecognized cashAccountTypeName) needed to populate a frontend dropdown |
| 24 | Bank | POST |
/api/v1/bank-accounts/{id}/statement-lines/import |
Import a bank statement via .xlsx (ClosedXML) |
Bulk file-based write with per-row failure handling |
| 25 | Bank | PUT |
/api/v1/bank-statement-lines/{id}/reconcile |
Match a statement line to a journal entry | Single-row interactive state change |
| 26 | Budget | POST |
/api/v1/budgets |
Create a budget for a fiscal year | Distinct aggregate root |
| 27 | Budget | POST |
/api/v1/budgets/{id}/lines |
Add budget lines | Kept separate — lines are added incrementally over time, not fixed at creation |
| 28 | System Config | PUT |
/api/v1/system-config/{key} |
Update a config value (enforces is_editable/is_locked) |
Single-key write with its own one-time-lock enforcement |
| 29 | System Config | GET |
/api/v1/system-config |
List all config keys/values | Standard collection read, distinct from the single-key write above |
Each row above (2 through 29) becomes exactly one phase in Document 2, in this order, following Phase 1 (models/DB/seed), except Phase 18 (removed 2026-07-18 — see the FX Revaluation callout above; its slot is not reused). 27 API endpoints in the original numbered sequence — down from the original 40 — plus 6 appended list endpoints (Phases 30–35, Section 6.3) and 1 appended audit-log endpoint (Phase 36, Section 6.4): 34 API endpoints, 36 phases total (Phase 18's slot counts toward the phase total but has no endpoint).
6.3 Phases 30–35 — appended read/list endpoints (user-identified gap, 2026-07-18)
Reviewing the surface above turned up a real gap: Tax, Fixed Assets, Bank, and Budget have write-only API surfaces — every phase for those four modules is a POST/PUT, with no way to list or read back what's actually in those tables (the same problem Currency had, since its own create endpoints were removed entirely per Section 6.1). These six endpoints are appended as new phases rather than inserted into the existing numbering, per the phase-numbering policy in 03_Progress_Tracker.md ("no renumbering, ever").
| # | Module | Method | Endpoint | Purpose | Why this is its own endpoint |
|---|---|---|---|---|---|
| 30 | Currency | GET |
/api/v1/currencies |
List all currencies (seeded via HasData, ~140 rows) |
Currency has zero API read visibility today — the only way to see what's seeded is direct DB access |
| 31 | Tax | GET |
/api/v1/tax-types |
List all tax types (seeded via HasData: VAT, WHT, NBT, SSCL, INCOME_TAX) |
Same gap as Currency — pure reference data, no read endpoint |
| 32 | Tax | GET |
/api/v1/tax-codes |
List tax codes created via Phase 19 | Phase 19 is create-only; no way to list what's been created |
| 33 | Fixed Assets | GET |
/api/v1/fixed-assets |
List fixed assets created via Phase 21 | Phase 21 is create-only; no way to list the asset register |
| 34 | Bank | GET |
/api/v1/bank-accounts |
Unified, filterable list of Cash and Bank accounts together (?accountType=Cash|Bank|Both, default Both), retrofit 2026-07-22 — combines Phase 23's bank_account rows and Phase 23a's cash_account rows into one flattened CashAndBankAccountDto list, accountType a response-only discriminator (not a stored column) |
Phases 23/23a are create-only; no way to list configured accounts of either kind without two separate calls |
| 35 | Budget | GET |
/api/v1/budgets |
List budgets created via Phase 26 | Phase 26 is create-only; no way to list existing budgets |
Phase 30 is implemented immediately (Currency already has seed data with nothing depending on it). Phases 31–35 are implemented alongside (immediately after) their respective module's create phase, since list-reading a table that doesn't exist yet has nothing to test against.
6.4 Phase 36 — audit log list endpoint (appended, user request, filterable by category)
Same "append, never renumber" policy as Section 6.3.
| # | Module | Method | Endpoint | Purpose | Why this is its own endpoint |
|---|---|---|---|---|---|
| 36 | Audit | GET |
/api/v1/audit-log |
List audit_log rows, filterable by category (LEDGER, ADMINISTRATIVE, both if omitted, or repeated to select more than one), entityName, action, performedBy, and an occurredAt date range; paginated |
The one read surface for everything Section 4.3 writes — a standard collection read, same shape as every other list endpoint in Phases 30–35, just against a table with more filter dimensions |
category accepts zero, one, or multiple values via a repeated query parameter — ?category=LEDGER, ?category=LEDGER&category=ADMINISTRATIVE, or the parameter omitted entirely, which returns both. This is plain ASP.NET Core query-string array binding (List<string>? Category on the request DTO), not a custom parser — consistent with CLAUDE.md's "keep it simple" rule.
Requires the Phase 1 retrofit (audit_log model + migration) to exist first, and in practice returns nothing meaningful until at least one write-phase's own audit-insert retrofit (Section 4.3) is also in place — but as a GET, it can be implemented and smoke-tested (empty result set) independently of that sequencing.
7. Non-Functional Considerations
7.1 Global exception handling — a genuine gap, flagged for a decision
PROJECT_SPECIFICATION.md explicitly notes the four CustomExceptions aren't yet caught by any global handler, so today they'd surface as unhandled 500s. This plan proposes (subject to approval per CLAUDE.md rule 3) adding one small piece of exception-handling middleware early in Phase 2 that:
- Catches
NotFoundException→ 404,BadRequestException→ 400,UnauthorizedException→ 401,ForbiddenException→ 403 - Wraps the error in the same
ApiResponseenvelope (Success = false,Message= exception message) used for every successful response, so callers get one consistent response shape regardless of outcome
This is flagged rather than silently added because it touches shared composition-root code (Program.cs), which falls under "ask before editing" per CLAUDE.md rule 3.
7.2 Testing approach per phase
Each phase (from Phase 2 onward) is scoped to exactly one API call end-to-end, specifically so it can be manually verified via the existing General_Ledger_Service.http scratch file (updated with a real request for that phase's endpoint) or Swagger UI before moving to the next phase. No phase should be considered complete until its endpoint has been exercised at least once against a real local PostgreSQL database.
7.3 Logging
Serilog is already wired (Serilog.AspNetCore, Serilog.Sinks.File). Each Service method that performs a write (journal posting, period locking, reversal/adjustment) should log at Information level on success and the Service should let thrown CustomExceptions be logged by the (future) global handler rather than logging-and-rethrowing in multiple places.
7.4 Testing with the API Key
Since ApiKeyMiddleware sits in front of every controller once Phase 1 is complete, every manual test from Phase 2 onward must include the X-Api-Key header with the configured local development value, or every request will fail at the pipeline stage with a 401 before it ever reaches the endpoint being tested. The General_Ledger_Service.http scratch file should define this header once as a variable and reuse it across every request.
7.5 PDF / Excel output
QuestPDF (rendering, selected via outputFormat=Pdf on the unified /api/v1/reports endpoint) and ClosedXML (bank statement import) are already referenced in the .csproj. These are used starting at Phase 17 (Reporting, which handles both JSON and PDF output from the start) and Phase 24 (Excel import) — the core ledger and reporting logic is still proven correct with plain JSON responses first, since outputFormat=Json is the default and PDF is only exercised once the JSON path already works within that same phase.
8. Explicitly Out of Scope
Carried over from the accounting design document's own scope decisions — not part of this service's build-out:
- Authentication/role-based access control beyond the existing JWT bearer validation (no user/role tables)
- AR/AP party subledgers (
customer,supplier) — removed from the design - Inventory (
inventory_item,stock_movement) — removed from the design - Multi-branch (
branch,inter_branch_account_map) — removed from the design
If any of these are reintroduced later, they get their own new phases appended to Document 2 — they are not retrofitted into existing phases.
9. Tech Stack (fixed, from .csproj — not to be changed without approval)
.NET 10 / ASP.NET Core Web API · EF Core 9 + Pomelo (MySQL) · Serilog · Swashbuckle (Swagger) · QuestPDF · ClosedXML · JWT Bearer auth against ERPCore issuer · Asp.Versioning.Mvc 10.0.0 (controller/route versioning, api/v1/...) · custom ApiKeyMiddleware (service-to-service authentication, shared secret via ApiKey:Value config)
10. Post-Completion Architecture Refactor (2026-07-19)
With all 36 phase slots resolved (Section 6, 03_Progress_Tracker.md), the user directed a project-wide consolidation pass — not a new feature, no new endpoint, no schema change — across Models/, Repositories/, Services/, and Controllers/GeneralLedgerController.cs. Two things drove it, stated directly by the user: (1) the number of files per layer had grown roughly 1:1 with the number of tables, most of them trivial (10–40 lines), and (2) the same handful of CRUD/response-building shapes (GetByIdAsync, ExistsAsync, AddAsync, building an ApiResponse) were being hand-written over and over rather than shared, the same category of problem Rule 11 (CLAUDE.md) already solved once for recursive-tree logic.
This section is the design; 02_Phase_By_Phase_Development_Plan.md's Refactor Phases (R1–R6) is the ordered execution plan; 03_Progress_Tracker.md tracks completion the same way it does for Phases 1–36.
Ground rule for the whole refactor: zero behavior change. No public API contract, no request/response DTO shape, no database schema, no business rule changes. Every endpoint must return byte-for-byte the same response after the refactor that it did before. This is why the refactor is scoped to file organization + boilerplate extraction only, not touched interleaved with any new work.
10.1 Models/ — 25 files → 12 files, grouped by domain
One file per domain module (mirroring the Repository clusters in 10.2), not one class per file. Pure move — no property, attribute, or navigation changes.
| New file | Classes moved in |
|---|---|
Models/Account.cs |
Account, AccountType |
Models/CostCenter.cs |
CostCenter |
Models/Currency.cs |
Currency, CurrencyRoundingRule, ExchangeRate |
Models/FiscalCalendar.cs |
FiscalYear, AccountingPeriod, PeriodLockLog |
Models/JournalEntry.cs |
JournalEntry, JournalEntryLine, JournalCorrection |
Models/RecurringTemplate.cs |
RecurringJournalTemplate, RecurringJournalTemplateLine |
Models/Tax.cs |
TaxType, TaxCode |
Models/FixedAsset.cs |
FixedAsset, DepreciationSchedule, AssetDisposal |
Models/Bank.cs |
BankAccount, BankStatementLine |
Models/Budget.cs |
Budget, BudgetLine |
Models/SystemConfig.cs |
SystemConfig |
Models/AuditLog.cs |
AuditLog |
10.2 Repositories/ — 17 files → 14 files
Tax/FixedAsset/Bank/Budget are already-consolidated clusters (Rule 2) and are untouched here. Two new merges, both reversing/extending prior per-table decisions — see CLAUDE.md Rule 2's updated note for the full reasoning:
| New/changed file | Interface | Tables covered | Change |
|---|---|---|---|
AccountRepository.cs |
IAccountRepository |
accounts |
Unchanged — too large/central (237 lines, 4 embedded report queries) to fold into anything |
CostCenterRepository.cs |
ICostCenterRepository |
cost_center |
Unchanged — standalone recursive entity |
CurrencyRepository.cs |
ICurrencyRepository |
currency |
Unchanged |
FiscalCalendarRepository.cs |
IFiscalCalendarRepository |
fiscal_year, accounting_period, period_lock_log |
New merge — was 3 files (FiscalYearRepository, AccountingPeriodRepository, PeriodLockLogRepository); mirrors FiscalCalendarService, which already spans exactly these three tables |
JournalEntryRepository.cs |
IJournalEntryRepository |
journal_entry, journal_entry_line, journal_correction |
New merge, reverses the prior explicit decision to keep these separate — was 3 files. JournalEntryService is the sole consumer of all three tables, the same "only ever touched together" test already used for Tax/FixedAsset/Bank/Budget |
RecurringJournalTemplateRepository.cs |
IRecurringJournalTemplateRepository |
recurring_journal_template (+ line, via EF cascade on the parent's collection nav — no separate line repository ever existed) |
Unchanged |
TaxRepository.cs |
ITaxRepository |
tax_type, tax_code |
Unchanged (already consolidated) |
FixedAssetRepository.cs |
IFixedAssetRepository |
fixed_asset, depreciation_schedule, asset_disposal |
Unchanged (already consolidated) |
BankRepository.cs |
IBankRepository |
bank_account, bank_statement_line |
Unchanged (already consolidated) |
BudgetRepository.cs |
IBudgetRepository |
budget, budget_line |
Unchanged (already consolidated) |
SystemConfigRepository.cs |
ISystemConfigRepository |
system_config |
Unchanged |
AuditLogRepository.cs |
IAuditLogRepository |
audit_log |
Unchanged |
RecursiveTreeQueries.cs |
(none — shared helper, Rule 11) | — | Unchanged |
RepositoryHelpers.cs |
(none — shared helper) | — | New file — see 10.4 |
10.3 Services/ — already at the right granularity
14 files, one per domain module (the same modules as 10.2, plus DepthGuard.cs) — no file consolidation needed here; Services were already grouped by domain rather than by table (FiscalCalendarService already spans 3 tables, JournalEntryService already spans 3 tables). The only addition is Services/ResponseHelpers.cs (10.4) and the constructor updates flowing from 10.2's two repository merges (IFiscalCalendarRepository replaces 3 injected repositories in FiscalCalendarService; IJournalEntryRepository replaces 3 injected repositories in JournalEntryService, RecurringTemplateService, FixedAssetService, and BankService — wherever those services currently depend on IJournalEntryRepository/IJournalEntryLineRepository/IJournalCorrectionRepository individually).
10.4 Two new shared helpers (Rule 11 pattern, extended)
Repositories/RepositoryHelpers.cs— static helper, no base class/inheritance, for the boilerplate duplicated ~20+ times across repositories:GetByIdAsync<T>(DbSet<T> set, params object[] keys),ExistsAsync<T>(DbSet<T> set, Expression<Func<T,bool>> predicate),AddAsync<T>(DbSet<T> set, T entity). Each repository keeps its own public interface and method names — it just delegates the one-line body to the shared helper internally, exactly likeRecursiveTreeQueries.csalready does for tree logic.Services/ResponseHelpers.cs— static helper withApiResponse-building methods (e.g.ResponseHelpers.Ok(string message, object? data)) to remove the repeatednew ApiResponse { StatusCode = 200, Success = true, Message = ..., Data = ... }block duplicated in nearly every Service method's success path. Does not modifyApiResponseitself (Rule 1 foundation — still off-limits without asking); this is a separate file that constructs one.
10.5 Interfaces/ folder (Rule 4, reversed)
Interfaces/
Repositories/
IAccountRepository.cs
ICostCenterRepository.cs
ICurrencyRepository.cs
IFiscalCalendarRepository.cs
IJournalEntryRepository.cs
IRecurringJournalTemplateRepository.cs
ITaxRepository.cs
IFixedAssetRepository.cs
IBankRepository.cs
IBudgetRepository.cs
ISystemConfigRepository.cs
IAuditLogRepository.cs
Services/
IAccountService.cs
ICostCenterService.cs
ICurrencyService.cs
IFiscalCalendarService.cs
IJournalEntryService.cs
IRecurringTemplateService.cs
IReportService.cs
ITaxService.cs
IFixedAssetService.cs
IBankService.cs
IBudgetService.cs
ISystemConfigService.cs
IAuditLogService.cs
One interface file per repository/service implementation file (12 + 13 = 25 interface files), not per table — a merged file's interface covers every method for its whole cluster. Namespace matches the folder path (General_Ledger_Service.Interfaces.Repositories / .Interfaces.Services), not the implementation's own namespace — every consumer (the implementing class, any Service depending on a Repository, Program.cs, the Controller) needs the new using. RecursiveTreeQueries.cs and RepositoryHelpers.cs/ResponseHelpers.cs/DepthGuard.cs have no interface — they're internal static helpers, never injected via DI.
10.6 Controller
One private helper added to GeneralLedgerController:
private IActionResult Respond(DTOs.Common.ApiResponse response) => StatusCode(response.StatusCode, response);
Replacing every action's var response = await _xService.YAsync(...); return StatusCode(response.StatusCode, response); with return Respond(await _xService.YAsync(...));. No route, no method signature, no behavior changes.
10.7 Net file-count effect
| Layer | Before | After |
|---|---|---|
Models/ |
25 | 12 |
Repositories/ (incl. shared helpers) |
17 | 14 |
Services/ (incl. shared helpers) |
14 | 15 |
Interfaces/Repositories/ + Interfaces/Services/ |
0 | 25 (new) |
Controllers/ |
1 | 1 |
Total source files across these layers goes from 57 to 67 — more files overall once Interfaces/ is counted, but far fewer files carrying actual logic, and every remaining file groups a coherent domain rather than a single table.
11. Dev-Only API Key Generator (2026-07-19)
ApiKeyMiddleware (Section 3.3) checks every request against a pre-configured ApiKey:Value — but nothing in the original plan ever generated that value; it had to be invented by hand and typed into appsettings.*.json and the core backend's config separately. This adds one small endpoint to do that generation safely.
Endpoint: POST /api/v1/dev-tools/generate-api-key
What it does: generates a cryptographically random 256-bit key (RandomNumberGenerator.GetBytes(32), hex-encoded) and returns it in the response Data. It does not write the key anywhere — appsettings, the core backend's config, and (eventually) appsettings.Production.json/the deploy-time secret store are all still updated by hand, the same "generate once, paste into config, done" flow already established for Jwt:SigningKey.
Guarded two independent ways, not one:
DevToolsService.GenerateApiKey()checksIHostEnvironment.IsDevelopment()and throwsForbiddenException(403) otherwise — this is the real gate. It runs regardless ofASPNETCORE_ENVIRONMENT, is not a route-registration trick, and was verified live:Hosting environment: Production→403 Forbidden;Hosting environment: Development→ the key.ApiKeyMiddleware'sExcludedPathPrefixesgained/api/v1/dev-tools— a separate, narrower concern: once a key exists and the middleware is enforcing it (as of this change, it's enabled inProgram.cs— no longer commented out), this bootstrap endpoint has to stay reachable without a key, or there would be no way to ever generate the first one. This exclusion only removes the API-key requirement; it does not bypass the Development-only check above.
Architecture, following the existing convention exactly: thin Controller action (GeneralLedgerController.GenerateApiKey, no async work needed) → IDevToolsService/DevToolsService (new, Interfaces/Services/IDevToolsService.cs + Services/DevToolsService.cs) → returns ApiResponse via ResponseHelpers.Ok. No Repository, no Unit of Work — nothing is persisted or read from the database.
Not audited (CLAUDE.md Rule 10) — it doesn't touch any table; there's nothing for audit_log to record.
12. Unique Code Auto-Generation Retrofit (2026-07-20)
Account.AccountCode, CostCenter.Code, FixedAsset.AssetCode, and TaxCode.Code were originally plain fields on their Create*Request DTOs — client-supplied, and in Account's case editable afterward via PUT /accounts/{accountCode}. That's inconsistent with JournalEntry.JournalNo, which was already system-generated and immutable from the start (JournalEntryService.GenerateJournalNoAsync), and it's the wrong shape for a business-key column in general: a unique code should be assigned once, by the system, and never touched again. Full rule and generation scheme now live in CLAUDE.md Rule 8.1 — this section is the pointer plus the reasoning for why each scheme was chosen.
Why no scheme existed to just "apply from the docs": Accounting_System_Design_LKAS.md declares all four columns as VARCHAR UNIQUE NOT NULL with no generation algorithm specified anywhere — they were designed to be populated by something, but never said what. The closest real precedent in the actual codebase was JournalNo's JV-{year}-{seq:D6}.
Schemes chosen (see CLAUDE.md Rule 8.1 for full detail):
Account.AccountCode— LKAS-standard type-block root numbering (Asset=1xxx, Liability=2xxx, Equity=3xxx, Income=4xxx, Expense=5xxx) + a recursive child-suffix scheme ({parentCode}-{seq:D2}) that scales to arbitrary depth, consistent with Section 4.2's requirement that the COA tree support unlimited depth without a schema change.CostCenter.Code— same root/child shape as Account, minus the type blocks (cost centers have no type classification):CC-{seq:D3}roots,{parentCode}-{seq:D2}children.FixedAsset.AssetCode— flat global sequence,FA-{seq:D6}(no recursion, no natural grouping to key off).TaxCode.Code— prefixed by its parentTaxType.Code(fixed seed data:VAT,WHT,NBT,SSCL,INCOME_TAX), sequential per type:{TaxTypeCode}-{seq:D3}.
What changed in code: the four Create*Request DTOs lost their code field entirely; each Service generates the code internally before insert (new small CountXxxAsync-style repository methods replace the old CodeExistsAsync/TaxCodeExistsAsync uniqueness-conflict checks, which no longer have any user input to conflict with); AccountService.UpdateAccountAsync lost its accountCode-mutation branch entirely. No database schema change — same VARCHAR columns, just populated by the Service instead of the client.
13. Full-Precision Amounts, PDF-Only Rounding (2026-07-20)
Accounting_System_Design_LKAS.md specifies every monetary column as NUMERIC(18,2) (rate/FX columns as NUMERIC(18,6)). The user directed a deliberate deviation: the database no longer rounds or truncates any decimal value on storage, and no API response rounds one either — full precision flows all the way through from request body to stored row to JSON response. The only place a value is ever rounded to 2 decimal places is when rendering a PDF report, since that's the one output where a long decimal tail genuinely shouldn't be shown to a reader.
Database: every decimal/decimal? property across AppDBContext.cs — 16 columns total, both real monetary amounts and the rate/multiplier fields (ExchangeRate, TaxCode.RatePercent, FixedAsset.DepreciationRatePercent, ExchangeRate.RateToBase) — had its .HasPrecision(...) call removed entirely, so Npgsql maps the column to a bare Postgres numeric (unbounded precision/scale). Migration: WidenAmountAndRateColumnsToUnboundedNumeric.
Application layer: confirmed there was no Math.Round/decimal.Round call anywhere in the codebase before this change, so nothing needed removing there — the DB column precision was the only thing enforcing 2 decimal places.
PDF rendering — the sole exception: ReportService.RenderPdf's cell formatter rounds decimal/decimal? values only, via Math.Round(d, 2, MidpointRounding.AwayFromZero). AwayFromZero (not .NET's ToEven default) is required for the requested "number routing" rule — any value in [x.xx5, x.xx5+0.01) rounds up to the next cent (e.g. 1000.005 → 1000.01, 1000.0574536 → 1000.06), which is exactly round-half-up, not banker's rounding. This only produces correct results because decimal represents base-10 fractions like 1000.005 exactly — the same call on a double would be unreliable.
See CLAUDE.md Rule 13 for the full column list and the verification notes.