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>
This commit is contained in:
Jeppe B
2026-08-17 13:05:13 +02:00
committed by GitHub
co-authored by openhands OpenClaw TRU-198 Subagent
parent ea9bdbe12c
commit ae4b7aef07
6 changed files with 1132 additions and 0 deletions
+127
View File
@@ -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 <<EOF
{
"channel": "$AI_DAILY_CHANNEL",
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
EOF
)"
fi
@@ -0,0 +1,82 @@
# GitHub Secrets for e-conomic Live Verification
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
## Required Secrets
| Secret | Description | Where to find it | Required? |
|---|---|---|---|
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
## Optional Secrets (for Slack notifications)
| Secret | Description | Required? |
|---|---|---|
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
## How to Configure
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
2. Click **"New repository secret"**
3. Add each of the required secrets above
4. The values are found in your e-conomic account settings
## How to Run the Live Verification
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
2. Click **"Run workflow"**
3. Leave `customer_number` as `12345679` (default)
4. Set `dry_run` to **`false`** for a real test
5. Click **"Run workflow"**
6. The workflow will:
- Create a draft invoice for customer 12345679
- Add 2 test lines (1 with discount, 1 without)
- Verify the draft was created correctly
- **Automatically delete the draft** to clean up
## Safety
- The verification script is **idempotent**: it always cleans up after itself
- On any error, it attempts emergency cleanup of any draft it created
- The script refuses to run without the required env vars
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
## When It Runs Automatically
- **Manual trigger only by default**
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
## Setting Up in Production (api.truckwash.io)
The same e-conomic credentials are also used by the live API. They're stored in:
- The production server's `.env` file (loaded by PHP)
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
## Troubleshooting
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
The secret is not configured. Follow the "How to Configure" steps above.
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
Same as above for the secret token.
### "Draft creation returned HTTP 401"
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
### "Draft creation returned HTTP 403"
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
### "Customer 12345679 not found"
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
@@ -0,0 +1,335 @@
# 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:
```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")
@@ -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 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.
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env php8.4
<?php
/**
* Live verification of e-conomic draft invoice creation using customer 12345679.
*
* This script:
* 1. Connects to the real e-conomic API (requires env credentials)
* 2. Creates a draft invoice for customer 12345679 with TEST items
* 3. Verifies the draft was created correctly
* 4. DELETES the draft to clean up
*
* Usage (on production server with credentials):
* php8.4 verify-economic-drafts-live.php
*
* Required env vars (set in .env or pass inline):
* ECONOMIC_API_APP_ACCESS_GRANT
* ECONOMIC_API_APP_SECRET_TOKEN
*
* Optional:
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
*
* Exit codes:
* 0 = all verifications passed, draft cleaned up
* 1 = error during verification
* 2 = cleanup failed (draft still exists, manual intervention required)
*/
declare(strict_types=1);
// 1. Load credentials
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
if (!$grant || !$secret) {
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
exit(1);
}
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
/**
* Send a request to the e-conomic API.
*
* @return array{status: int, body: string, json?: array}
*/
function econ_request(string $method, string $url, ?array $body = null): array
{
global $auth;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => 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);
@@ -0,0 +1,92 @@
<?php
namespace classes;
/**
* Centralized selection of e-conomic invoice layout numbers.
*
* This class is the **skeleton** introduced by TRU-197. It exposes the two
* layout numbers that the backend should use for the two invoice variants:
*
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
*
* The constants below are placeholders for the layout numbers that the
* e-conomic account admin must pick in e-conomic (Settings → Design and
* Layouts) and write into the module-config DB variables
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
* themselves are intentionally left as `0` in this skeleton — they are
* resolved at runtime from the module-config variables by the two existing
* call sites:
*
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
*
* Wiring those call sites to read from this selector (instead of from the
* module-config variables directly) is intentionally **out of scope** for
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
* full audit and follow-up plan.
*
* Constants in this class are the *single source of truth* for the
* env-var-style aliases:
*
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
*/
class economic_layout_selector
{
/**
* Layout number for invoices WITHOUT itemized discount lines.
*
* Intent: a clean invoice — no "Rabat" line, no discount column, just
* the line items and totals.
*
* @var int
*/
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
/**
* Layout number for invoices WITH itemized discount lines.
*
* Intent: an invoice that visibly itemizes the negative `Rabat`
* (product `TotDiscount`) line so the customer can see the discount
* broken out instead of folded into per-product `discountPercentage`.
*
* @var int
*/
public const LAYOUT_WITH_DISCOUNTS = 0;
/**
* Module-config variable name for the without-discounts layout.
*
* @var string
*/
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
/**
* Module-config variable name for the with-discounts layout.
*
* @var string
*/
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
/**
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
*
* @return string
*/
public static function nameWithoutDiscounts(): string
{
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
}
/**
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
*
* @return string
*/
public static function nameWithDiscounts(): string
{
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
}
}