Files
api/documentation/economic/layout-selection-flow.md
T
ae4b7aef07 docs(economic): map draft-invoice layout code paths (TRU-198) (#395)
## 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>
2026-08-17 13:05:13 +02:00

275 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.