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:
co-authored by
openhands
OpenClaw
TRU-198 Subagent
parent
ea9bdbe12c
commit
ae4b7aef07
@@ -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 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.
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user