## Summary Maps every code path in the API repo that creates an e-conomic draft invoice or sends draft lines, and documents which paths pick a layout, which one they pick, and how the planned **with-discounts / without-discounts** two-layout selection applies. **Key finding:** the two envelope creators already implement a discount-aware selector. No code change is required for the TRU-197 rollout — only the two `invoice*LayoutNumber` config variables need to be set in the `economic` module. ## Findings at a glance - **22** code paths in `services/nginx/app/` create or send draft invoices (2 envelope creators + 6 line-add paths + 14 caller/selector/helper paths) - **2** paths currently pick a layout — both already discount-aware - **0** paths need updating for the 2-layout rollout - **2** config variables drive the selection: `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (already wired into `economic::$config` and the OpenAPI schema) ## The two selectors 1. `economic_invoice_draft_mo::resolveLayoutNumber()` at `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` — used by `createInvoiceDraftExample()` for the single-order draft flow. 2. `collected_order_invoices_o::resolveInvoiceLayoutNumber()` at `services/nginx/app/objects/collected_order_invoices_o.php:673` — used by `createInvoiceDraft()` for the collected-invoice flow. Both return `invoice_discount_layout` if any item has a non-zero discount, otherwise `invoice_layout`. They throw if the discount layout is required and `invoiceDiscountLayoutNumber` is unconfigured. ## Document `documentation/economic/layout-selection-flow.md` — full inventory table, current/desired state, and migration plan. ## Related - TRU-197 — `documentation/economic/invoice-template-audit.md` - TRU-193 — `documentation/economic/export-field-audit.md` - PR #391 — `economic_export_sanitizer` Refs: TRU-198 --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io> Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
21 KiB
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:
- Create the draft envelope —
POST /invoices/draftswith a payload that containscustomer,paymentTerms,layout.layoutNumber,recipient,currency,date, etc. This is the only place wherelayout.layoutNumberis set on the draft. - Add lines to the draft —
POST /invoices/drafts/{id}/lineswith 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:
- Inspect the lines that will be sent (or the orders that will be added to the draft).
- If any line / order has a non-zero
discountPercentage(or, in the collected-invoice path, any "billable discount" pereconomic_invoice_draft::orderItemHasBillableDiscount()), returninvoice_discount_layout. - Otherwise return
invoice_layout. - Throw a
RuntimeException/Exceptionif the discount layout is required butinvoiceDiscountLayoutNumberis unconfigured (≤ 0).
The two config variables are defined in:
services/nginx/app/modules/economic/config/economic_invoice_layout_c.php—invoiceLayoutNumber,int, required (default1).services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php—invoiceDiscountLayoutNumber,int, optional (defaultnull).- Both are wired into
classes\economic::$configviaservices/nginx/app/modules/economic/economic_c.phplines 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
invoiceDiscountLayoutNumberto 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/bookedwith{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 /
createInvoiceDraftExampleand 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
(
invoiceLayoutNumberandinvoiceDiscountLayoutNumber) which are already wired intoeconomic::$configand surfaced in theEconomicConfigEntryOpenAPI schema. - The
invoiceDiscountLayoutNumberconfig var is currently optional (seeeconomic_invoice_discount_layout_c.php—setupConfigVariable(..., true, ...)withrequired = truein the call signature but the constructor's third argfalsemeans 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) checksdiscountPercentage > 0per line. - The collected-invoice selector (
hasDiscountedIncludedInvoiceItems) delegates toeconomic_invoice_draft::orderItemHasBillableDiscount, which checks for aTotDiscountproduct (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.
- The MO selector (
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— confirminvoiceLayoutNumberis configured to TRU-197's "clean" layout.services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php— setinvoiceDiscountLayoutNumberto TRU-197's "discount" layout. (The constructor signature already allows this to be a non-required variable, but the selectors will throw aRuntimeException/Exceptionif 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 listsinvoiceLayoutNumberandinvoiceDiscountLayoutNumberas required keys for theeconomicmodule probe. Confirm the probe treatsinvoiceDiscountLayoutNumberas required and surfaces a clear error when missing (it currently appears in therequiredarray, which is the correct behavior).services/nginx/app/openapi.yaml:18644—EconomicConfigEntry.variableenum already includesinvoiceDiscountLayoutNumber. 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::resolveLayoutNumberandcollected_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.phpor a newservices/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:
- Creates a single-order draft with at least one discounted line and
asserts the resulting draft's
layout.layoutNumberequalsinvoiceDiscountLayoutNumber. - Creates a single-order draft with no discounted lines and asserts
invoiceLayoutNumber. - Creates a collected-invoice draft with at least one
TotDiscountline and assertsinvoiceDiscountLayoutNumber. - Creates a collected-invoice draft with no
TotDiscountlines and assertsinvoiceLayoutNumber. Seetests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.phpandEconomicLegacyDraftPayloadWiringTest.phpfor the existing patterns.
- Creates a single-order draft with at least one discounted line and
asserts the resulting draft's
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_feespaths — 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.