# E-conomic Invoice Template Audit (TRU-197) **Status:** Complete (no live call — credentials unavailable in this environment) **Date:** 2026-08-17 **Scope:** Audit of the e-conomic invoice layouts available in the `copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for the two-layout strategy (one for invoices **with** itemized discounts, one for invoices **without**). **Primary files audited:** - `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`) - `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`) - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`) - `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`) - `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`) - `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`) - `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy) --- ## TL;DR — Recommendation | Variant | Layout (configured) | Env-var name to set | Layout intent | |---------|---------------------|---------------------|---------------| | **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) | | **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter | The two layout numbers above are **placeholders** to be confirmed by the account admin in e-conomic. They are written into the runtime config variables `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping section below). --- ## 1. Why a 2-layout strategy is needed The `copenhagentruckwash/api` backend already has plumbing for two invoice layouts (see §4 below). The trigger to pick a layout is whether the invoice **contains an itemized discount line** (a line with `product.productNumber = "TotDiscount"` and a negative `unitNetPrice`, as produced by the `Rabat` aggregator in `economic_invoice_draft`). When such a line is present, the system routes the invoice through `invoiceDiscountLayoutNumber`; otherwise it falls back to `invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic that match these two intents (clean invoice vs. one that shows discounts itemized). --- ## 2. Available e-conomic API for layouts ### 2.1 Endpoint ``` GET https://restapi.e-conomic.com/layouts ``` ### 2.2 Auth headers (same as every other e-conomic call) ``` X-AppSecretToken: X-AgreementGrantToken: Content-Type: application/json ``` ### 2.3 Response shape The endpoint already exists in the codebase at `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`, and is exposed to superusers via `services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`). The PHP wrapper returns the raw JSON decoded into a stdClass: ```json { "collection": [ { "layoutNumber": 1, "name": "Standard", "deleted": false, "self": "https://restapi.e-conomic.com/layouts/1" }, { "layoutNumber": 12, "name": "Rabat variant", "deleted": false, "self": "https://restapi.e-conomic.com/layouts/12" } ] } ``` The minimal documented fields per layout are: | Field | Type | Description | |----------------|---------|-------------| | `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. | | `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. | | `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. | | `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). | > Note: e-conomic layouts do **not** have an `isDefault` field. The "default" > concept in e-conomic is per-customer-group, not global. To find the agreement > default, query `/customers?filter=...` and look at the layout referenced on > each customer group's default. For our purposes, the admin picks the two > layout numbers we want to use, so no defaulting logic is required. ### 2.4 Example curl (run with real creds) ```bash curl -sS -X GET "https://restapi.e-conomic.com/layouts" \ -H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \ -H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \ -H "Content-Type: application/json" \ | jq '.collection[] | {layoutNumber, name, deleted}' ``` ### 2.5 Example Python (run with real creds) ```python import os, requests r = requests.get( "https://restapi.e-conomic.com/layouts", headers={ "X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"], "X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"], "Content-Type": "application/json", }, timeout=15, ) r.raise_for_status() for layout in r.json()["collection"]: print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"])) ``` --- ## 3. Live call — was it made? **No.** This audit was run in a sandbox that does not have `ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the only available secrets are the GitHub PAT, Linear API key, and Slack tokens). A live `GET /layouts` call would have returned `401 Unauthorized` at best, and would have polluted the e-conomic log with a noisy failed request at worst. The two layout numbers used by the test fixtures (`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the **configured** values that need to be **confirmed** by the e-conomic account admin and, if changed, written into the e-conomic module config (see §4.3 env-var mapping). To complete the live portion of the audit, run the curl above from a machine that has the credentials (e.g. a developer laptop or a CI runner with the secrets mounted). Paste the output into §6 of this doc and commit. --- ## 4. Current code state ### 4.1 Where layouts are read at runtime * `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` — `resolveLayoutNumber()` (line 115): returns either `invoice_layout` or `invoice_discount_layout` depending on whether the draft contains a `discountPercentage > 0` product line. * `services/nginx/app/objects/collected_order_invoices_o.php` — `resolveInvoiceLayoutNumber()` (line 673): same logic for collected (batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`. * `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` — direct `/invoices/drafts` create with an explicit `layoutNumber` arg (default = `invoice_layout`). ### 4.2 Where layouts are configured * `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` registers the `invoiceLayoutNumber` module config variable (default `1`, required). This is the "no-discount" layout. * `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` registers the `invoiceDiscountLayoutNumber` module config variable (default `1`, optional, must be `> 0` to enable). This is the "with-discount" layout. Both values are admin-editable at runtime via the standard module config admin UI. The system status probe also lists them as required: `services/nginx/app/classes/superuser_system_status_service.php` (line 866 key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses `1` / `6`). ### 4.3 Env-var mapping The module config values are stored in the `module_config` DB table, **not** in environment variables. The contract is: | Runtime value | Source | Where it's set | |------------------------------------------------|-----------------------|---------------------------------------------------------------| | `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` | | `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` | The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to e-conomic — they have no relationship to the layout-number config values. That said, the task description asks for two env-var-style placeholders. We will add the following **module-config aliases** (constants only, no runtime logic yet) to `economic_layout_selector.php` (see §7) so that an operator or a deployment automation can refer to them by name: | Module-config constant | Friendly alias env-var-style name | Meaning | |-----------------------------------|--------------------------------------|--------------------| | `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter | | `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly | > If the deployment process is ever updated to read these from env vars > instead of the module-config DB, the constant names in > `economic_layout_selector.php` are the right place to wire that up. ### 4.4 Existing PRs and related work * PR #391 — the original sanitization fix (TRU-188 family). Adds the `economic_export_sanitizer` class and per-field sanitization on the draft invoice lines, recipient block, and references. * TRU-193 — the second audit, this time on extra fields and preflight validation. See `documentation/economic/export-field-audit.md` for the full sanitization audit. * TRU-197 (this audit) — picks the two specific layout numbers to use, one for with-discount and one for without-discount, and documents how to find them in e-conomic. --- ## 5. Visual differences (to be verified) Layouts in e-conomic are visually configured in the **Settings → Design and Layouts** UI; the REST API only exposes their names and numbers, not their visual representation. From the existing example invoice (`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload), a **booked** invoice with discounts has this structure: ``` lines: [ { lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" }, { lineNumber: 2, sortKey: 2, description: "Reference:" }, { lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" }, { lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} }, { lineNumber: 5, sortKey: 5, description: "Reference:" }, { lineNumber: 6, sortKey: 6, description: "# EH89254" }, { lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} }, { lineNumber: 8, sortKey: 8, description: "Reference:" }, { lineNumber: 9, sortKey: 9, description: "# EH89254" }, { lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} }, { lineNumber: 11, sortKey: 11 } ] ``` This invoice was **booked** with `layoutNumber = 12` (per the sample in `orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice; it predates the audit and is not necessarily the final answer. The visual difference between layouts 1 (default) and 12 (discount) is **to be verified** by exporting a sample invoice in each layout. The relevant template knobs in e-conomic are: * Whether the discount column is rendered. * Whether the `Rabat` line is broken out vs. folded into the per-product `discountPercentage`. * The number of text/separator lines (the two layouts may differ in how much spacing they show between products). These are UI choices in the e-conomic admin; the backend has no insight into which lines the layout chooses to render. --- ## 6. Live-call results — TO BE FILLED IN _Paste the output of the curl in §2.4 below, then commit._ ``` # layoutNumber name deleted # ------------ ---------------------------- ------- # 1 Standard false # 12 Rabat variant false # ... ``` Once filled in, mark the audit as **Verified — live call** and add a row per layout to the table in §3.1 if the layout count is larger than expected. --- ## 7. Files added in this PR | File | Purpose | |------|---------| | `documentation/economic/invoice-template-audit.md` | This document. | | `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. | The `economic_layout_selector.php` skeleton is **intentionally empty of logic** per the task description ("skeleton — just the constants, no logic yet"). Wiring it up to replace the two existing call sites is tracked separately and is out of scope for TRU-197. --- ## 8. What we recommend the e-conomic admin do 1. Open e-conomic → Settings → Design and Layouts. 2. **Duplicate** the current "standard" layout (the one currently set as `invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar. 3. In the duplicate, **ensure the discount column is shown** (so the negative `Rabat` line we push as `TotDiscount` renders cleanly). 4. Note the `layoutNumber` of: * The original (clean) layout → set as `invoiceLayoutNumber` in `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (admin override, or via the module config UI). * The duplicate (with-discounts) layout → set as `invoiceDiscountLayoutNumber` in `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`. 5. Book a test invoice with a discount and a test invoice without, and confirm the PDF looks right in each case. --- ## 9. Refs * TRU-188 — original 400 on `/` in order reference (PR #391) * TRU-193 — second-wave audit on extra fields, preflight validation (`documentation/economic/export-field-audit.md`) * PR #391 — initial sanitization fix * `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` — `GET /layouts` wrapper * `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` — `resolveLayoutNumber()` for single draft invoices * `services/nginx/app/objects/collected_order_invoices_o.php` — `resolveInvoiceLayoutNumber()` for collected (batched) invoices * E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")