Distribution_GL Initialization
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
# 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 including `audit_log` (Section 4.1/4.3). **Sync status as of 2026-07-19:** `er_diagram.mmd` is up to date — `audit_log` is present, and every column later converted from `varchar` to an `integer`-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, and `fixed_asset` carries its two later-added columns (`gain_loss_account_id`, `depreciation_rate_percent`). **`Accounting_System_ER_Diagram.drawio` was left as-is** — it already had `audit_log`, but still shows the pre-conversion `varchar` types and is missing `fixed_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 `.mmd` when convenient.
|
||||
- `02_Phase_By_Phase_Development_Plan.md` — the execution plan this document feeds into
|
||||
- `CLAUDE.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:
|
||||
|
||||
```csharp
|
||||
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>`.
|
||||
- `ApiKeyMiddleware` reads the expected value from configuration (`ApiKey:Value` in `appsettings.json`, following the exact same pattern already used for `Jwt:SigningKey` — blank in `appsettings.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 `401` response directly — it runs before MVC, so it builds the same `ApiResponse` JSON shape by hand (`Success = false`, `Message = "Invalid or missing API key"`) rather than throwing a `CustomException` (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`.
|
||||
|
||||
```csharp
|
||||
[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 `TreeRow` projection and walk them in memory). Implemented as a single shared helper, `Helpers/RecursiveTreeQueries.cs`, rather than duplicated per entity — see `CLAUDE.md` Rule 11
|
||||
|
||||
### 4.3 Audit Log — schema and classification (`LEDGER` vs `ADMINISTRATIVE`)
|
||||
|
||||
```sql
|
||||
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`) has `is_base_currency = TRUE` — enforced by the partial unique index from the design document, not just seed-time discipline.
|
||||
- `decimal_places` varies by currency: 16 currencies seed at `0` (JPY, KRW, VND, and 13 others), 7 at `3` (KWD and similar), the rest at the default `2`.
|
||||
- **A real `HasData` bug was found and fixed while seeding this table**, worth knowing before writing any future `HasData` seed with a similar shape: EF Core's change-tracking diff treats a seeded value equal to the CLR default for its type (`0` for `short`) as "unset" when the column also has `HasDefaultValue` configured in the model, and silently drops it from the generated `INSERT` — every `decimal_places = 0` row was seeding as `2` instead until the column's default value convention was removed entirely (migration `RemoveCurrencyDecimalPlacesDefault`). If a future `HasData` seed needs a column where a meaningful value can coincide with `0`/`false`/`null`, don't also configure `HasDefaultValue` on 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_rule` tables are fully seeded/functional but structurally unreachable — every journal line always resolves to the single base currency at a rate of `1`.
|
||||
|
||||
---
|
||||
|
||||
## 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-years` vs `POST /fiscal-years/{id}/periods`) — left separate. A fiscal year can gain an optional `autoGeneratePeriods` flag 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 .../lines` vs `PUT .../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 `ApiResponse` envelope (`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 like `RecursiveTreeQueries.cs` already does for tree logic.
|
||||
- **`Services/ResponseHelpers.cs`** — static helper with `ApiResponse`-building methods (e.g. `ResponseHelpers.Ok(string message, object? data)`) to remove the repeated `new ApiResponse { StatusCode = 200, Success = true, Message = ..., Data = ... }` block duplicated in nearly every Service method's success path. Does not modify `ApiResponse` itself (Rule 1 foundation — still off-limits without asking); this is a separate file that constructs one.
|
||||
|
||||
### 10.5 `Interfaces/` folder (Rule 4, reversed)
|
||||
|
||||
```text
|
||||
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`:
|
||||
|
||||
```csharp
|
||||
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()` checks `IHostEnvironment.IsDevelopment()` and throws `ForbiddenException` (403) otherwise** — this is the real gate. It runs regardless of `ASPNETCORE_ENVIRONMENT`, is not a route-registration trick, and was verified live: `Hosting environment: Production` → `403 Forbidden`; `Hosting environment: Development` → the key.
|
||||
- **`ApiKeyMiddleware`'s `ExcludedPathPrefixes` gained `/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 in `Program.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 parent `TaxType.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.
|
||||
Reference in New Issue
Block a user