# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198) **Status:** Complete (investigation only — no code changes) **Date:** 2026-08-17 **Scope:** Inventory every code path in `copenhagentruckwash/api` that creates an e-conomic draft invoice or sends draft lines, and document whether each path currently picks a layout, which one it picks, and how the planned **with-discounts / without-discounts** two-layout selection should apply. **Related work:** - TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two e-conomic layout numbers to use (one for clean invoices, one for invoices that show itemized discounts). - TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit / sanitization, unrelated to layout selection but consumed by the same code paths. - PR #391 — `economic_export_sanitizer`, the sanitizer that all draft-line paths now run their text through. --- ## Overview A draft invoice in this codebase is built in two phases: 1. **Create the draft envelope** — `POST /invoices/drafts` with a payload that contains `customer`, `paymentTerms`, `layout.layoutNumber`, `recipient`, `currency`, `date`, etc. This is the only place where `layout.layoutNumber` is set on the draft. 2. **Add lines to the draft** — `POST /invoices/drafts/{id}/lines` with an array of product / text / discount lines. Lines are added either one order at a time (single-order draft flow) or in accumulated batches (collected-invoice flow). The layout is **already fixed** at this point and is not re-sent. There are therefore only **two** code paths in the entire backend that create the draft envelope and could pick a layout. Both already implement a discount-aware selector that returns either `invoice_layout` (no discounts) or `invoice_discount_layout` (itemized discounts present): | Selector function | Used by | File | |---|---|---| | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()` → `economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` | | `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | The two selectors are independent implementations of the same idea. They both: 1. Inspect the lines that will be sent (or the orders that will be added to the draft). 2. If any line / order has a non-zero `discountPercentage` (or, in the collected-invoice path, any "billable discount" per `economic_invoice_draft::orderItemHasBillableDiscount()`), return `invoice_discount_layout`. 3. Otherwise return `invoice_layout`. 4. Throw a `RuntimeException` / `Exception` if the discount layout is required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0). The two config variables are defined in: - `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` — `invoiceLayoutNumber`, `int`, **required** (default `1`). - `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` — `invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`). - Both are wired into `classes\economic::$config` via `services/nginx/app/modules/economic/economic_c.php` lines 25–48. > **Net result of the audit:** the two-layout selection is already > implemented in both places where a draft envelope is created. There is > **no** code path that creates a draft without going through one of these > two selectors. The migration is therefore a configuration change (set > `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code > change. See §5 *Migration plan* for the small set of files that still > touch the layout topic and may need follow-up. --- ## 1. Inventory of code paths The table below lists every PHP function in `services/nginx/app/` that either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or (b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft` helper) are excluded — they never pick a layout. | # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation | |---|---|---|---|---|---|---|---| | 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. | | 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. | | 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. | | 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. | | 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. | | 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. | | 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. | | 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. | | 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. | | 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. | | 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. | | 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. | | 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. | | 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()` → `createInvoiceDraft()` → `resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. | | 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). | | 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. | | 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. | | 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. | | 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. | | 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. | | 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. | | 22 | `routes/economicInvoiceRoute.php:~380–410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. | **Read-only paths (excluded from the migration list):** - `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21` — `get(int $invoice_id)` - `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39` — `get_from_external_id(string $external_id)` - `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35` — `get(array $filters, array $pagination)` - `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60` — `get_all()` - `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73` — `get_invoice_lines(array $invoice_ids, array $filters)` - `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201` — `exists(int $draft_invoice_number)` - `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200` — `getInvoiceDraft(int $int)` - `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170` — `getInvoicePdf(int $param)` - `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160` — `deleteInvoiceDraft(int $value)` - `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166` — `publishInvoiceDraft(int $invoiceDraftId)` — **important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is. - `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`) - `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only) **Out of scope (no draft creation):** - `classes/economic_v2_distribution_service.php` — distribution *reporting* (read-only aggregations over booked invoices). Never creates a draft. - `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice data class. No HTTP calls. --- ## 2. Current state - **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path #16 / `createInvoiceDraft`) already have a working discount-aware selector that returns one of two layout numbers from the config store. - The selectors read from the same two config variables (`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are already wired into `economic::$config` and surfaced in the `EconomicConfigEntry` OpenAPI schema. - The `invoiceDiscountLayoutNumber` config var is currently **optional** (see `economic_invoice_discount_layout_c.php` — `setupConfigVariable(..., true, ...)` with `required = true` in the call signature but the constructor's third arg `false` means a null value is allowed; the selectors throw if it is required and ≤ 0). - The selectors are independent code paths. They each inspect lines slightly differently: - The MO selector (`hasDiscountedItemizedLines`) checks `discountPercentage > 0` per line. - The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`) delegates to `economic_invoice_draft::orderItemHasBillableDiscount`, which checks for a `TotDiscount` product (negative net price) on included invoice items. - Both reach the same boolean result: *does this draft need the discount layout?* — so the layout chosen by either selector is consistent. --- ## 3. Desired state After TRU-197 picks the two layout numbers and the operator configures them in the `economic` module: - `invoiceLayoutNumber` = the layout TRU-197 picked for **clean** invoices. - `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for **discount** invoices. Then: - A single-order draft with no itemized discount goes out with `layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4). - A single-order draft with an itemized discount goes out with `layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector #4). - A collected-invoice draft with no billable discount goes out with `invoiceLayoutNumber` (path #16 / selector #17). - A collected-invoice draft with a billable discount goes out with `invoiceDiscountLayoutNumber` (path #16 / selector #17). No code changes are required to achieve this — only the two config variables need to be set in the `economic` module (and validated by the superuser status probe at `superuser_system_status_service.php:866`). --- ## 4. Migration plan Because the selectors already exist, the migration is a **configuration rollout** plus a small handful of defensive tasks. Files to touch: ### 4.1 Required for rollout - **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`** — confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout. - **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`** — set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The constructor signature already allows this to be a non-required variable, but the selectors will throw a `RuntimeException` / `Exception` if the discount layout is required and the value is 0 or null — so the rollout must include setting this var in every environment.) ### 4.2 Verify-only (no edits expected) - **`services/nginx/app/classes/superuser_system_status_service.php:866`** — already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` as required keys for the `economic` module probe. Confirm the probe treats `invoiceDiscountLayoutNumber` as required and surfaces a clear error when missing (it currently appears in the `required` array, which is the correct behavior). - **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable` enum already includes `invoiceDiscountLayoutNumber`. No change. - **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893–894`** — test fixtures already cover both layout config vars. Confirm values match TRU-197's picks. ### 4.3 Optional follow-ups (not blocking the rollout) - **Defensive logging** in the two selector functions (`economic_invoice_draft_mo::resolveLayoutNumber` and `collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which layout was chosen and why (e.g. `[TRU-198] draft {id} uses discount layout (3 discounted lines)`). This is useful for post-rollout verification in the e-conomic UI. - **A single, shared selector helper** that both paths use, to avoid drift between the two private selectors. Recommended location: `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php` or a new `services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`. Out of scope for the configuration rollout; consider for a follow-up refactor. - **E2E / integration test** that: 1. Creates a single-order draft with at least one discounted line and asserts the resulting draft's `layout.layoutNumber` equals `invoiceDiscountLayoutNumber`. 2. Creates a single-order draft with no discounted lines and asserts `invoiceLayoutNumber`. 3. Creates a collected-invoice draft with at least one `TotDiscount` line and asserts `invoiceDiscountLayoutNumber`. 4. Creates a collected-invoice draft with no `TotDiscount` lines and asserts `invoiceLayoutNumber`. See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and `EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns. ### 4.4 Files that explicitly need NO changes - `services/nginx/app/classes/economic_v2_distribution_service.php` — distribution reporting, not a draft creator. - `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php` — booked-invoice data class. - All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches` / `add_environmental_and_oil_fees` paths — they operate on an existing draft whose layout was fixed at create time. --- ## 5. Summary | Metric | Count | |---|---| | Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) | | Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) | | Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic | | Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. | | Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) | The 2-layout selection is already wired through the backend. The TRU-198 investigation confirms that the rollout reduces to setting the two `invoice*LayoutNumber` config variables to the layout numbers TRU-197 picks, plus optional defensive logging and an E2E test for verification.