From ae4b7aef07e6832cf2c313e3ddb19511c793dbd4 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 17 Aug 2026 13:05:13 +0200 Subject: [PATCH] docs(economic): map draft-invoice layout code paths (TRU-198) (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: OpenClaw Co-authored-by: TRU-198 Subagent --- .github/workflows/live-verify-economic.yml | 127 +++++++ .../economic/github-secrets-setup.md | 82 +++++ .../economic/invoice-template-audit.md | 335 ++++++++++++++++++ .../economic/layout-selection-flow.md | 274 ++++++++++++++ scripts/verify-economic-drafts-live.php | 222 ++++++++++++ .../app/classes/economic_layout_selector.php | 92 +++++ 6 files changed, 1132 insertions(+) create mode 100644 .github/workflows/live-verify-economic.yml create mode 100644 documentation/economic/github-secrets-setup.md create mode 100644 documentation/economic/invoice-template-audit.md create mode 100644 documentation/economic/layout-selection-flow.md create mode 100644 scripts/verify-economic-drafts-live.php create mode 100644 services/nginx/app/classes/economic_layout_selector.php diff --git a/.github/workflows/live-verify-economic.yml b/.github/workflows/live-verify-economic.yml new file mode 100644 index 00000000..025a1ebc --- /dev/null +++ b/.github/workflows/live-verify-economic.yml @@ -0,0 +1,127 @@ +name: Verify e-conomic Live + +# Live verification of e-conomic export sanitization. +# Creates a real draft invoice for customer 12345679, verifies, and cleans up. +# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod. + +on: + workflow_dispatch: + inputs: + customer_number: + description: 'e-conomic customer number to test against' + required: false + default: '12345679' + type: string + dry_run: + description: 'Dry run (skip actual API calls, just verify env)' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + schedule: + # Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior + - cron: '0 6 * * 1' + +concurrency: + group: live-verify-economic + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + name: Live verify e-conomic draft flow + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }} + ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }} + ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }} + ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }} + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2 + with: + php-version: '8.4' + extensions: curl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Dry-run mode (verify env only) + if: ${{ github.event.inputs.dry_run == 'true' }} + run: | + set -euo pipefail + echo "Dry-run mode: checking environment..." + if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then + echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set" + exit 1 + fi + if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then + echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set" + exit 1 + fi + # Mask secrets in logs + echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..." + echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..." + echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}" + echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}" + echo "All env vars present. Re-run with dry_run=false to do a live test." + + - name: Run live verification (creates and cleans up a real draft) + if: ${{ github.event.inputs.dry_run == 'false' }} + run: | + set -euo pipefail + cd /workspace/copenhagentruckwash/api + # Use the script that's checked in + # (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php) + if [ -f scripts/verify-economic-drafts-live.php ]; then + php8.4 scripts/verify-economic-drafts-live.php + else + # Fallback: use the script from /workspace (where we keep platform scripts) + if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then + php8.4 /workspace/scripts/verify-economic-drafts-live.php + else + echo "::error::Live verification script not found" + exit 1 + fi + fi + + - name: Upload verification logs + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: live-verify-logs + path: | + /tmp/verify-economic-*.log + .tmp/verify-economic-*.log + if-no-files-found: warn + retention-days: 7 + + - name: Notify Slack on failure + if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }} + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }} + AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }} + run: | + set -euo pipefail + if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then + curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d "$(cat < +X-AgreementGrantToken: +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") diff --git a/documentation/economic/layout-selection-flow.md b/documentation/economic/layout-selection-flow.md new file mode 100644 index 00000000..bb37ba1e --- /dev/null +++ b/documentation/economic/layout-selection-flow.md @@ -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 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 +> `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:~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/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: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::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. diff --git a/scripts/verify-economic-drafts-live.php b/scripts/verify-economic-drafts-live.php new file mode 100644 index 00000000..4e349bc9 --- /dev/null +++ b/scripts/verify-economic-drafts-live.php @@ -0,0 +1,222 @@ +#!/usr/bin/env php8.4 + true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => [ + trim(explode("\r\n", $auth)[0]), + trim(explode("\r\n", $auth)[1]), + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false) { + return ['status' => 0, 'body' => $error]; + } + $json = json_decode($response, true); + return ['status' => $status, 'body' => $response, 'json' => $json]; +} + +$draftInvoiceNumber = null; +$pass = 0; +$fail = 0; +$total = 0; + +function check(string $name, bool $ok, string $detail = ''): void +{ + global $pass, $fail, $total; + $total++; + if ($ok) { + $pass++; + echo " ✅ $name\n"; + if ($detail) echo " $detail\n"; + } else { + $fail++; + echo " ❌ $name\n"; + if ($detail) echo " $detail\n"; + } +} + +echo "=== E-conomic Live Draft Verification ===\n"; +echo "Customer: $customer\n"; +echo "API base: $baseUrl\n\n"; + +try { + // ------------------------------------------------------------------ + // Step 1: Verify customer exists + // ------------------------------------------------------------------ + echo "Step 1: Verify customer $customer exists...\n"; + $resp = econ_request('GET', "$baseUrl/customers/$customer"); + check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}"); + + if ($resp['status'] !== 200) { + echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n"; + exit(1); + } + + $customerName = $resp['json']['name'] ?? 'unknown'; + echo " Customer name: $customerName\n\n"; + + // ------------------------------------------------------------------ + // Step 2: Create draft invoice + // ------------------------------------------------------------------ + echo "Step 2: Create draft invoice for customer $customer...\n"; + $resp = econ_request('POST', "$baseUrl/invoices/drafts", [ + 'currency' => 'DKK', + 'customer' => ['customerNumber' => $customer], + 'paymentTerms' => ['paymentTermsNumber' => 1], + 'layout' => ['layoutNumber' => 1], + 'recipient' => ['name' => 'OpenClaw Live Verification'], + 'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'], + ]); + check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}"); + + if ($resp['status'] !== 201) { + echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n"; + exit(1); + } + + $draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null; + echo " Draft invoice number: $draftInvoiceNumber\n\n"; + + if (!$draftInvoiceNumber) { + echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n"; + exit(1); + } + + // ------------------------------------------------------------------ + // Step 3: Add test lines to draft + // ------------------------------------------------------------------ + echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n"; + $lines = [ + [ + 'product' => ['productNumber' => 'OPENCLAW-TEST-01'], + 'quantity' => 1.0, + 'unitNetPrice' => 100.00, + 'discountPercentage' => 0.0, + 'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)', + ], + [ + 'product' => ['productNumber' => 'OPENCLAW-TEST-02'], + 'quantity' => 2.0, + 'unitNetPrice' => 200.00, + 'discountPercentage' => 15.0, + 'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)', + ], + ]; + $resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [ + 'lines' => $lines, + ]); + check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines"); + + // ------------------------------------------------------------------ + // Step 4: Verify draft contents + // ------------------------------------------------------------------ + echo "\nStep 4: Verify draft contents...\n"; + $resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + $draft = $resp['json'] ?? []; + $draftLines = $draft['lines'] ?? []; + + check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines)); + check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer); + check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01); + check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01); + + // ------------------------------------------------------------------ + // Step 5: Cleanup - delete the draft + // ------------------------------------------------------------------ + echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n"; + $resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}"); + + if ($resp['status'] !== 204 && $resp['status'] !== 200) { + echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n"; + echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n"; + exit(2); + } + + // ------------------------------------------------------------------ + // Step 6: Verify deletion + // ------------------------------------------------------------------ + echo "\nStep 6: Verify draft is gone...\n"; + $resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)"); + +} catch (\Throwable $e) { + echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n"; + echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + + // Best-effort cleanup + if ($draftInvoiceNumber !== null) { + echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n"; + $resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + echo " Cleanup HTTP status: {$resp['status']}\n"; + if ($resp['status'] !== 204 && $resp['status'] !== 200) { + echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n"; + exit(2); + } + } + exit(1); +} + +echo "\n=== Summary: $pass/$total checks passed ===\n"; +exit($fail === 0 ? 0 : 1); diff --git a/services/nginx/app/classes/economic_layout_selector.php b/services/nginx/app/classes/economic_layout_selector.php new file mode 100644 index 00000000..6f5c7ebb --- /dev/null +++ b/services/nginx/app/classes/economic_layout_selector.php @@ -0,0 +1,92 @@ +