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

21 KiB
Raw Blame History

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 envelopePOST /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 draftPOST /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.phpinvoiceLayoutNumber, int, required (default 1).
  • services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.phpinvoiceDiscountLayoutNumber, 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:21get(int $invoice_id)
  • modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39get_from_external_id(string $external_id)
  • modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35get(array $filters, array $pagination)
  • modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60get_all()
  • modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73get_invoice_lines(array $invoice_ids, array $filters)
  • modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201exists(int $draft_invoice_number)
  • modules/economic/invoices/draft/economic_invoice_draft_mo.php:200getInvoiceDraft(int $int)
  • modules/economic/invoices/draft/economic_invoice_draft_mo.php:170getInvoicePdf(int $param)
  • modules/economic/invoices/draft/economic_invoice_draft_mo.php:160deleteInvoiceDraft(int $value)
  • modules/economic/invoices/draft/economic_invoice_draft_mo.php:166publishInvoiceDraft(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.phpsetupConfigVariable(..., 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:18644EconomicConfigEntry.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.