Files
api/documentation/economic/invoice-template-audit.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

15 KiB

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:

{
  "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)

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)

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.phpresolveLayoutNumber() (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.phpresolveInvoiceLayoutNumber() (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.

  • 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.phpGET /layouts wrapper
  • services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.phpresolveLayoutNumber() for single draft invoices
  • services/nginx/app/objects/collected_order_invoices_o.phpresolveInvoiceLayoutNumber() for collected (batched) invoices
  • E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")