# 12 · GENERAL LEDGER INTEGRATION — ERPCore ↔ External GL Service

> **Navigation:** you arrived from `00-CORE.md`. This doc is the **connection layer only** — the transport between ERPCore and the external General Ledger service. It is not the GL service's own API contract: every endpoint, request/response shape, and error code the GL service exposes is documented in that service's own repo (`04_API_Reference_And_Scenarios.md`, its `01_Full_Development_Plan.md`/`02_Phase_By_Phase_Development_Plan.md`/`03_Progress_Tracker.md` companions, and its Postman collection) — ERPCore does not duplicate that content here (`01-DOC-GUIDE.md` §5, single source of truth per topic).
> **Record work:** `Backend/PROGRESS.md`.

---

## 1. What this is

The General Ledger service is a **separate ASP.NET Core microservice** — its own repo, own database, no UI, no end-user login. It is called server-to-server, authenticated with a single shared secret (`X-Api-Key`), by "the core backend" — which, from this repo's side, is ERPCore.

This pass connects the two systems at the **transport level only**:

- A generic reverse-proxy endpoint on ERPCore (`/api/v1/gl/*`) that the frontend can call, which forwards the request to the GL service and returns its response unchanged.
- A service-layer function (`IGeneralLedgerService.ForwardAsync`) that does the actual forwarding — usable today by the controller, and **intended to be called directly by other ERPCore services later** (GRN confirm, adjustments, etc. posting real journal entries into GL instead of — or alongside — the local `JournalEntryStub`). That internal wiring is **explicitly deferred**; nothing in ERPCore calls GL yet except the proxy itself.

There is **no GL-specific business logic, no typed DTOs, and no per-endpoint validation** in ERPCore for this integration. Every request under `/api/v1/gl/*` is forwarded byte-for-byte — method, path, query string, request body, and `Content-Type` — and GL's response (status code, content type, body) comes back exactly as GL sent it, un-reshaped.

---

## 2. Architecture

```
Frontend ──► ERPCore  /api/v1/gl/{**path}   (ErpAccess door policy, same as every other v1 endpoint)
              │
              ▼
       GeneralLedgerController  (Controllers/GeneralLedgerController.cs)
              │  Request.Method, path, QueryString, ContentType, Body — untouched
              ▼
       IGeneralLedgerService.ForwardAsync   (Services/GeneralLedgerService.cs)
              │  the one function — controller and future internal callers both use this
              ▼
       IGeneralLedgerClient.SendAsync   (Infra/Gl/GeneralLedgerClient.cs)
              │  attaches X-Api-Key, streams body through, no parsing
              ▼
       External GL service   (GeneralLedgerService:BaseUrl)
```

**Why a generic proxy and not typed endpoints:** the GL service's own reference (`04_API_Reference_And_Scenarios.md`) documents ~15 modules and 40+ endpoint variations (Accounts, Fiscal Calendar, Currency, Cost Centers, Journal Entries, Recurring Entries, Tax, Fixed Assets, Bank, Budget, Reporting, System Config, Audit Log). Modelling all of that as ERPCore DTOs before any of it is actually consumed would be premature — this pass connects the pipe; the next pass (deferred, see §6) picks specific GL calls to wire into specific ERPCore workflows and can build typed request/response DTOs for exactly those calls at that point.

**Layering matches the existing AuthHex integration** (`Infra/Auth/AuthHex/IAuthHexClient` → `Services/Auth/*` → `AuthController`), except AuthHex's client unwraps a known envelope into typed results, while GL's client deliberately does not — see §4.

---

## 3. Configuration

`Backend/ERPCore/appsettings.json`:

```json
"GeneralLedgerService": {
  "BaseUrl": "https://localhost:7024/api/v1/",
  "ApiKey": ""
}
```

| Key | Purpose |
|---|---|
| `GeneralLedgerService:BaseUrl` | The GL service's base URL, **including** `/api/v1/` and a trailing slash (so relative paths combine correctly against `HttpClient.BaseAddress`). GL's own docs note it calls `UseHttpsRedirection()` before its API-key check runs — use the `https://` URL directly, per GL's own integration guide §3, to avoid every call taking a `307` round-trip. |
| `GeneralLedgerService:ApiKey` | The shared secret sent as `X-Api-Key` on every forwarded call. **Ships blank** — same pattern as this project's own `ApiKey`-style secrets (02-SECURITY AR-06): get a value from the GL service's own `POST /api/v1/dev-tools/generate-api-key` (Development only) and paste it in here *and* into the GL service's own `ApiKey:Value` config — it is one shared string on both sides, not a per-caller credential. |

Not touched by this pass: `appsettings.Development.json` / `appsettings.Production.json` — fill in a real key locally the same way `AuthHex:BaseUrl` and other per-environment values are handled today (edit the file directly for local dev; environment-variable override for prod, per `00-CORE.md §5.4`'s secrets note).

---

## 4. The proxy contract

### `{GET|POST|PUT} /api/v1/gl/{**path}`

- **Auth:** requires a valid ERPCore session (AuthHex-issued token, `erp_at` cookie or Bearer header) satisfying the `ErpAccess` door policy — identical to every other `/api/v1/*` endpoint (`ApiControllerBase`). The GL `X-Api-Key` itself is attached **server-side only**; the frontend never sees it and cannot set it.
- **Method:** only `GET`/`POST`/`PUT` are exposed — the GL service's own API reference has no `PATCH` or `DELETE` endpoint anywhere in its surface, so those verbs aren't wired.
- **`{**path}`:** everything after `/api/v1/gl/` is forwarded as-is to the GL service's own `/api/v1/{path}` — e.g. a frontend call to `POST /api/v1/gl/journal-entries` reaches GL's `POST /api/v1/journal-entries`. Query string is forwarded unchanged.
- **Body:** for `POST`/`PUT`, the raw request body (and its `Content-Type` header) is streamed straight through — this is what makes the one `multipart/form-data` endpoint (bank statement import) work through the proxy without ERPCore needing to understand multipart at all.
- **Response:** GL's status code, `Content-Type`, and body are returned to the frontend **unchanged**. This deliberately preserves two documented GL quirks rather than papering over them:
  - GL's success bodies are camelCase (`{"statusCode":200,...}`), its error bodies (from its own exception/API-key middleware) are PascalCase (`{"StatusCode":400,...}`) — GL's own docs flag this as a known inconsistency, not fixed on their side yet. ERPCore does not normalize it; the frontend must handle both shapes (case-insensitive deserialization), same as GL's own integration guide recommends to *its* callers.
  - `outputFormat=Json` report amounts keep full decimal precision (no rounding) — since ERPCore never deserializes the body into its own model, nothing here can accidentally truncate it.
- **This proxy needs zero code changes whenever GL's own domain contract evolves — confirmed again as of GL's 2026-07-22 revision (§7).** Five reports were restructured, `outputFormat` gained a `Csv` value, Cash/Bank accounts split into two endpoints, and numerous fields were renamed from numeric IDs to business codes (`CLAUDE.md` Rule 8.2 on GL's side) — none of it touched this file. That's the entire point of a byte-for-byte proxy over typed per-endpoint DTOs (§2's rationale): a generic passthrough only ever needs a code change when the *transport* contract changes (new HTTP verb, new content type, new auth mechanism), never when GL's own request/response payloads gain, lose, or rename fields. §7 is a curated reference of what's actually being called today, kept for the frontend team's convenience — it is documentation, not something this proxy depends on or needs updated in lockstep with.

### Failure modes added by ERPCore itself

| Case | Status | `code` |
|---|---|---|
| GL service unreachable (connection refused/DNS/etc.) | 503 | `GL_SERVICE_UNAVAILABLE` |
| GL service timed out | 503 | `GL_SERVICE_UNAVAILABLE` |
| No/invalid ERPCore session | 401/403 | (standard ERPCore door-policy response — request never reaches the proxy) |

Everything else — `401` for a bad `X-Api-Key` (can't happen here since ERPCore always attaches the configured key, but a misconfigured/blank key on either side would surface as this), every domain `400`/`404`/`409`/`422` GL itself returns — passes straight through with GL's own body and message, untouched.

---

## 5. What is *not* done in this pass

- **No internal service calls into GL.** `IGeneralLedgerService.ForwardAsync` exists and is registered in DI, but no ERPCore service (GRN, Adjustment, Transfer, PO, etc.) calls it yet. The local `JournalEntryStub` (docs/10 §C.7, FR-STK-13) is untouched and remains the only GL-adjacent artifact those flows produce today.
- **No typed request/response DTOs** for any GL endpoint — the proxy is byte-for-byte, so nothing in ERPCore currently understands GL's schema.
- **No path allowlist.** The proxy forwards *any* path under `/api/v1/gl/` to GL — it trusts GL's own routing/auth to reject anything invalid. Acceptable for now because the proxy is still gated by ERPCore's own door policy (only ERP-admitted users reach it at all) and GL requires its own valid `X-Api-Key` regardless, but worth revisiting alongside per-endpoint RBAC (02-SECURITY Part D) if GL exposes anything sufficiently destructive to warrant narrowing.
- **No `appsettings.Development.json`/`appsettings.Production.json` changes** — only the base `appsettings.json` shape was added, per this pass's scope (connect the pipe, not configure every environment).
- **No CORS/rate-limiting review specific to this proxy** — it inherits whatever ERPCore already has (currently none, same accepted gap as `AuthController`, 02-SECURITY AR-08).

---

## 6. Progress

- [x] **Transport wired (2026-07-20).** `Infra/Gl/{IGeneralLedgerClient,GeneralLedgerClient,GeneralLedgerResponse}` — typed `HttpClient` (`GeneralLedgerService:BaseUrl`), attaches `X-Api-Key` (`GeneralLedgerService:ApiKey`), streams request/response bodies through unparsed. `Services/{Interfaces/IGeneralLedgerService,GeneralLedgerService}` — the shared forwarding function. `Controllers/GeneralLedgerController` — `GET|POST|PUT /api/v1/gl/{**path}`, `ErpAccess`-gated like every other v1 controller. Config keys added to `appsettings.json`. Build verified clean (`dotnet build`, 0 warnings/0 errors).
- [ ] **Live smoke test against a running GL instance** — not yet run in this pass (no GL instance available in this session). Before relying on this in anger: generate a dev API key from GL's `POST /api/v1/dev-tools/generate-api-key`, put it in both sides' config, and round-trip at least one call per HTTP verb (e.g. `GET /api/v1/gl/accounts`, `POST /api/v1/gl/accounts`, `PUT /api/v1/gl/accounts/{code}`) plus the multipart import call, to confirm the byte-for-byte passthrough holds in practice (headers, streaming, status codes).
- [ ] **Internal wiring** — pick the first real ERPCore→GL use case (most likely candidate: posting `FifoCostingService`/`StockMutator`-driven movements as real GL journal entries instead of `JournalEntryStub` rows) and build the typed request/response DTOs + service calls for that specific flow through `IGeneralLedgerService`. Deliberately deferred per this pass's scope.
- [ ] **Security pass once real traffic flows** — revisit the "no path allowlist" note in §5, decide whether `GeneralLedgerService:ApiKey` needs to move to a secret store before any shared/staging use (same trigger as `02-SECURITY.md` AR-06), and whether this proxy needs its own accepted-risk entry there.

---

## 7. Endpoint reference — GL endpoints currently used (or planned) by the frontend

**Purpose of this section:** a quick-reference summary of exactly which GL endpoints the frontend calls, for connecting new screens without re-reading GL's full `04_API_Reference_And_Scenarios.md` from scratch each time. **This is a curated summary, not the canonical contract** — for exact request/response shapes, every field, and every error case, GL's own reference stays authoritative (`01-DOC-GUIDE.md §5`). Every call below goes through this proxy at `/api/v1/gl/{path}` (drop the leading `/api/v1` from GL's own documented paths, prefix `/api/v1/gl` instead — e.g. GL's `GET /api/v1/reports` is called here as `GET /api/v1/gl/reports`).

As of GL's 2026-07-22 revision. Full detail for anything marked "see GL §X" is in GL's `04_API_Reference_And_Scenarios.md` at that section.

### Reporting (`docs/21` §3 — all seven Ledgers report screens)

| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/reports` | All seven report screens, one shared endpoint | `reportType` (`GeneralLedger`\|`TrialBalance`\|`BalanceSheet`\|`ProfitAndLoss`\|`CashFlow`\|`BudgetVsActual`\|`TaxSummary`), `outputFormat` (`Json`\|`Pdf`\|`Csv`), plus report-specific params — see GL §"GET /reports" for the full per-`reportType` table |

One endpoint, one row — every report screen and both download buttons (`DownloadPdfButton`/`DownloadCsvButton`) call this same path with different query parameters. Nothing else in this module hits a second reporting endpoint.

### Accounts (`docs/21` §3/§4 — GL-account pickers only, never a screen of its own here)

| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/accounts` | Populates the `glAccountCode` picker on the Bank/Cash create forms | none required — full list |

This module never creates, edits, or has a dedicated screen for GL's own Chart of Accounts — `GET /accounts` is called purely to feed a `code — name` dropdown elsewhere.

### Bank & Cash Accounts (`docs/21` §4)

| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/bank-accounts` | Unified Cash+Bank list page | `accountType` (optional — `Cash`\|`Bank`\|`Both`, default `Both`) |
| `POST` | `/bank-accounts` | Create form, "Bank" selected | `accountName`, `bankName`/`accountNumber` (optional), `glAccountCode`, `currencyCode` (optional) |
| `POST` | `/cash-accounts` | Create form, "Cash" selected | `accountName`, `cashAccountTypeName`, `accountNumber` (optional, auto-generated if omitted), `glAccountCode`, `currencyCode` (optional) |
| `GET` | `/cash-account-types` | Populates the Cash Account Type picker on the create form | none — full list |
| `POST` | `/bank-accounts/{id}/statement-lines/import` | Not yet built on the frontend — bank statement `.xlsx` import, `multipart/form-data` | field name `file` |
| `PUT` | `/bank-statement-lines/{id}/reconcile` | Not yet built on the frontend — reconcile a statement line | `matchedJournalNo` |

No `GET`/`PUT` by id exists on GL's side for either `bank_account` or `cash_account` — this is the entire reason edit can't be built yet (`docs/21` §4's known gap).

### Budgets (`docs/21` §3 — picker only)

| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/budgets` | Populates the `budgetId` picker on the Budget vs Actual report screen | none — full list |

### Not currently called by this frontend, listed for awareness

GL exposes a substantially larger surface than this module touches — Journal Entries, Fiscal Calendar, Cost Centers, Currency, Tax Codes, Fixed Assets, Recurring Templates, System Config, Audit Log. None of these have a screen in `docs/21` today. If a future pass adds one, add its row to the relevant table above rather than leaving this reference to drift out of sync with what's actually called — that's the whole value of keeping this section current.

---

*End of 12-GENERAL-LEDGER-INTEGRATION.md. Hub: `00-CORE.md`. Record work: `Backend/PROGRESS.md`.*
