Merge branch 'master' into feat/TRU-149-route-scopes

This commit is contained in:
Jeppe B
2026-08-17 13:45:15 +02:00
committed by GitHub
16 changed files with 2905 additions and 32 deletions
@@ -0,0 +1,262 @@
# E-conomic Export Field Audit (TRU-193)
**Status:** Complete
**Date:** 2026-08-17
**Scope:** All user-input fields that flow into e-conomic API payloads from
the `copenhagentruckwash/api` backend.
**Primary files audited:**
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself)
## Summary
| Category | Count |
|----------|-------|
| User-input fields audited | 17 |
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) |
| Fields that are controlled input (no sanitization needed) | 4 |
| Fields not present in any e-conomic export path (out of scope) | 3 |
All user-input fields flowing to e-conomic are now either sanitized via
`economic_export_sanitizer` or verified to be controlled input.
## Sanitizer methods used
| Method | Purpose | Length cap |
|--------|---------|------------|
| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
| `sanitizeProductNumber($value)` | Product identifiers | 50 |
| `sanitizeProductDescription($value)` | Product-line descriptions | 500 |
| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 |
Rules applied:
- `/` replaced with `-` (the reported 400 trigger, TRU-188)
- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped
- Tab + newline characters collapse to a single space
- Whitespace normalized and trimmed
- Length capped with `...` suffix if too long
## Audit by field
### 1. `order.po` (purchase order)
- **Source:** `orders_o::po` (user input)
- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 2. `order.reference`
- **Source:** `orders_o::reference` (user input)
- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars
### 3. `order.notes`
- **Source:** `orders_o::notes` (user input)
- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 4. `order.reg_1`, `order.reg_2`, `order.reg_3`
- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers)
- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()`
- **Sensitive to:** `/`, special chars, length (capped at 50)
### 5. `department.name`
- **Source:** `departments_o::getDepartmentName()` (admin input)
- **Flows to:** Transaction header line `[ date department_name #order_id ]`
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine(..., 100)`
- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length
### 6. `order.created_at` (formatted date)
- **Source:** `orders_o::created_at` (server-generated timestamp)
- **Flows to:** Transaction header line date prefix
- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))`
- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format
but the sanitizer does not run on the formatted string — verified by inspection
that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern)
### 7. `order.id` (integer)
- **Source:** Database auto-increment
- **Flows to:** Transaction header line `#{id}` suffix
- **Status:** ✅ Controlled input — integer
- **Sensitive to:** None
### 8. `order_item.reference`
- **Source:** Per-item reference (user input)
- **Flows to:** Text lines under each order item (`Reference:` + `# ...`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 9. `order_item.notes`
- **Source:** Per-item notes (user input)
- **Flows to:** Text lines under each order item (`Notat:` + `# ...`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 10. `order_item.product.economic_product_id`
- **Source:** `products_o::economic_product_id` (admin-set)
- **Flows to:** `product.productNumber` in the e-conomic line payload
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeProductNumber()`
- **Sensitive to:** Path separators, illegal chars
### 11. `order_item.product.name`
- **Source:** `products_o::name` (admin-set product name)
- **Flows to:** `description` in the e-conomic line payload
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`)
- **Sensitive to:** `/`, newlines, control chars, length (capped at 500)
### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price`
- **Source:** Numeric fields (calculated or admin-set)
- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields
- **Status:** ✅ Controlled input — numeric types; cast to float/int before use
- **Sensitive to:** None
### 13. Currency (`DKK`, `EUR`, etc.)
- **Source:** Admin-set on the department / invoice
- **Flows to:** `'currency' => $currency` in the invoice payload
- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper`
- **Sensitive to:** None
### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input)
- **Flows to:** `recipient.name` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 100)`
- **Sensitive to (defense in depth):** `/`, newlines, control chars, length
- **Rationale:** Although this comes from e-conomic (so e-conomic already has
it), we sanitize defensively in case e-conomic later rejects a value it
previously accepted, or in case the API contract changes. Cap of 100 chars
matches the e-conomic recipient `name` field limit.
### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input)
- **Flows to:** `recipient.address` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 250)`
- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some
countries use `/` in street names), control chars, length
- **Rationale:** Same as `recipient.name` — defense in depth.
### 16. `recipient.zip`, `recipient.city`
- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data)
- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city
- **Sensitive to (defense in depth):** Special chars, length
- **Rationale:** Defense in depth — same as above.
### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getEan()` (e-conomic data)
- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'`
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits
- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec
- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely
rather than sending an empty string (which e-conomic may reject).
## Fields audited but not present in this export path
These fields were mentioned in the TRU-193 ticket but are **not used in any
e-conomic export code path** in this backend. Documenting them for
completeness:
| Field | Why not in scope |
|-------|------------------|
| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. |
| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
## Other controlled inputs (no sanitization needed)
| Field | Why safe |
|-------|----------|
| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` |
| `layout.layoutNumber` | Admin-set integer from e-conomic config |
| `paymentTerms.paymentTermsNumber` | Integer from e-conomic |
| `vatZone.vatZoneNumber` | Integer from e-conomic |
| `customer.customerNumber` | Integer from e-conomic |
| `attention` reference | E-conomic nested object (`customerContactNumber`) |
| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects |
| `departmentalDistributionNumber` / `dimension` | Integer IDs |
| `TotDiscount` (productNumber for discount line) | Literal string constant |
| `'Rabat'` (description for discount line) | Literal string constant |
## Defense in depth: preflight validation
In addition to the field-level sanitizers, `economic_invoice_draft::addLines()`
now runs a **preflight validation** before sending to e-conomic. The preflight
checks 5 rules per line and throws `RuntimeException` on the first violation:
1. `description` must be non-empty after `trim()`
2. `description` must be ≤ 250 chars
3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/`
4. `quantity` (if present) must be a positive number
5. `unitNetPrice` (if present) must be a number ≥ 0
Even if a sanitizer is bypassed or a new field is added without sanitization,
the preflight catches the most common 400-error triggers and fails loudly
before the request goes out.
## Test coverage
- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions
- Original 31: slash replacement, control chars, tab/newline handling,
whitespace collapse, length cap with ellipsis, multibyte safety,
null/empty input, integer/float input, product number rules
- New 14 (TRU-193): recipient name/address/zip/city length caps,
recipient address newlines + slashes, Danish/UK postal formats,
Danish special chars (København Ø), ampersand + quotes, CRLF
normalization, empty-field handling, EAN digit preservation
- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions
- Covers: all 5 preflight rules + the disabled-flag bypass path
- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests
- Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php`
(sanitize calls for name/address/zip/city, preg_replace for EAN,
empty-EAN unsets the key)
- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions
- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip,
preflight catches all 5 rules, mixed text + product flow works
Total: 94 tests, 171 assertions, all passing.
## What changed in TRU-193
1. **Pre-flight validation** added to `economic_invoice_draft.php`
(separate atomic commit) — defense in depth.
2. **Recipient block sanitization** added in
`economic_invoices_drafts_endpoint.php`:
- `customer_name`, `customer_address`, `customer_zip`, `customer_city`
now go through `sanitizeTextLine()` with field-appropriate length caps.
- `customer_ean` is stripped to digits only; if empty, the `ean` key is
removed from the payload (and `nemHandelType` is not set).
3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`:
- `addTextLine()` now sanitizes at insertion time (was: sanitization only
happened in the calling methods). Catches any new caller that forgets
to sanitize.
- `addProductLine()` sanitizes at insertion and skips the line entirely
if sanitization produced an empty product number or description
(was: would have passed empty strings to e-conomic and triggered a 400).
4. **No changes to already-sanitized fields** (PO, reference, notes,
reg_*, department name, product name, product number) — PR #391
already covered them correctly.
## Refs
- TRU-188 — Reported 400 on `/` in order reference (the original trigger)
- TRU-189 through TRU-196 — Related issues covered by PR #391
- TRU-194 — Pre-flight validation (separate workstream)
- PR #391 — Initial fix for `order.*` and `order_item.*` fields
- PR #392 — Pre-flight validation defense in depth
@@ -0,0 +1,82 @@
# GitHub Secrets for e-conomic Live Verification
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
## Required Secrets
| Secret | Description | Where to find it | Required? |
|---|---|---|---|
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
## Optional Secrets (for Slack notifications)
| Secret | Description | Required? |
|---|---|---|
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
## How to Configure
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
2. Click **"New repository secret"**
3. Add each of the required secrets above
4. The values are found in your e-conomic account settings
## How to Run the Live Verification
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
2. Click **"Run workflow"**
3. Leave `customer_number` as `12345679` (default)
4. Set `dry_run` to **`false`** for a real test
5. Click **"Run workflow"**
6. The workflow will:
- Create a draft invoice for customer 12345679
- Add 2 test lines (1 with discount, 1 without)
- Verify the draft was created correctly
- **Automatically delete the draft** to clean up
## Safety
- The verification script is **idempotent**: it always cleans up after itself
- On any error, it attempts emergency cleanup of any draft it created
- The script refuses to run without the required env vars
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
## When It Runs Automatically
- **Manual trigger only by default**
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
## Setting Up in Production (api.truckwash.io)
The same e-conomic credentials are also used by the live API. They're stored in:
- The production server's `.env` file (loaded by PHP)
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
## Troubleshooting
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
The secret is not configured. Follow the "How to Configure" steps above.
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
Same as above for the secret token.
### "Draft creation returned HTTP 401"
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
### "Draft creation returned HTTP 403"
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
### "Customer 12345679 not found"
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
@@ -0,0 +1,335 @@
# 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: <ECONOMIC_API_APP_SECRET_TOKEN>
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
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")
@@ -0,0 +1,274 @@
# 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 2548.
> **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:~380410` | `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:893894`**
— 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.