## Summary Audit and (where needed) fix additional fields in the e-conomic export path. PR #391 covered the main order.* and order_item.* fields; this PR covers the remaining fields that could carry special characters. ## Changes 1. Pre-flight validation (defense in depth): 5 rules per line throw on violation. 2. addTextLine() and addProductLine() now sanitize at insertion (defense in depth). 3. Recipient block sanitization in add(): name/address/zip/city via sanitizeTextLine, EAN via preg_replace. 4. Audit document: documentation/economic/export-field-audit.md. 5. Tests: 94 tests / 171 assertions (14 + 19 + 6 + 24 new tests). ## Refs - TRU-193, TRU-188, TRU-194, PR #391 --------- Co-authored-by: openhands <openhands@all-hands.dev> Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io> Co-authored-by: Bugfix Subagent <bugfix@subagent.local>
13 KiB
E-conomic Export Field Audit (TRU-193)
Status: Complete
Date: 2026-08-17
Scope: All user-input fields that flow into e-conomic API payloads from
the copenhagentruckwash/api backend.
Primary files audited:
services/nginx/app/modules/economic/helpers/economic_invoice_draft.phpservices/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.phpservices/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.phpservices/nginx/app/classes/economic_export_sanitizer.php(the sanitizer itself)
Summary
| Category | Count |
|---|---|
| User-input fields audited | 17 |
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
| Fields newly sanitized in TRU-193 | 3 (recipient.name, recipient.address, recipient.zip/city, recipient.ean) |
| Fields that are controlled input (no sanitization needed) | 4 |
| Fields not present in any e-conomic export path (out of scope) | 3 |
All user-input fields flowing to e-conomic are now either sanitized via
economic_export_sanitizer or verified to be controlled input.
Sanitizer methods used
| Method | Purpose | Length cap |
|---|---|---|
sanitizeTextLine($value, $maxLength=250) |
Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
sanitizeProductNumber($value) |
Product identifiers | 50 |
sanitizeProductDescription($value) |
Product-line descriptions | 500 |
sanitizeForEconApi($value) |
Catch-all alias of sanitizeTextLine |
250 |
Rules applied:
/replaced with-(the reported 400 trigger, TRU-188)- Control characters (
\x00-\x1Fexcept\tand\n, plus\x7F) stripped - Tab + newline characters collapse to a single space
- Whitespace normalized and trimmed
- Length capped with
...suffix if too long
Audit by field
1. order.po (purchase order)
- Source:
orders_o::po(user input) - Flows to: Text line in draft invoice (
addNewTransactionHeader) - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine() - Sensitive to:
/, newlines, control chars, length
2. order.reference
- Source:
orders_o::reference(user input) - Flows to: Text lines in draft invoice (multiple
Reference:lines) - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine() - Sensitive to:
/(PRIMARY TRU-188 trigger), newlines, control chars
3. order.notes
- Source:
orders_o::notes(user input) - Flows to: Text lines in draft invoice (multiple
Notat:lines) - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine() - Sensitive to:
/, newlines, control chars, length
4. order.reg_1, order.reg_2, order.reg_3
- Source:
orders_o::reg_1/2/3(user input — vehicle registration numbers) - Flows to: Concatenated
Reg 1: ... Reg 2: ... Reg 3: ...line - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine(..., 50)thenstrtoupper() - Sensitive to:
/, special chars, length (capped at 50)
5. department.name
- Source:
departments_o::getDepartmentName()(admin input) - Flows to: Transaction header line
[ date department_name #order_id ] - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine(..., 100) - Sensitive to:
/(e.g. "Roskilde/Ølstykke"), special chars, length
6. order.created_at (formatted date)
- Source:
orders_o::created_at(server-generated timestamp) - Flows to: Transaction header line date prefix
- Status: ✅ Controlled input — formatted by
date('d/m/Y H:i', strtotime(...)) - Sensitive to: None (formatted as digits + slashes;
/is added by date format but the sanitizer does not run on the formatted string — verified by inspection that the slashes indd/mm/YYYYare safe; this is a known, accepted pattern)
7. order.id (integer)
- Source: Database auto-increment
- Flows to: Transaction header line
#{id}suffix - Status: ✅ Controlled input — integer
- Sensitive to: None
8. order_item.reference
- Source: Per-item reference (user input)
- Flows to: Text lines under each order item (
Reference:+# ...) - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine() - Sensitive to:
/, newlines, control chars, length
9. order_item.notes
- Source: Per-item notes (user input)
- Flows to: Text lines under each order item (
Notat:+# ...) - Status: ✅ Already sanitized
- Sanitizer:
sanitizeTextLine() - Sensitive to:
/, newlines, control chars, length
10. order_item.product.economic_product_id
- Source:
products_o::economic_product_id(admin-set) - Flows to:
product.productNumberin the e-conomic line payload - Status: ✅ Already sanitized
- Sanitizer:
sanitizeProductNumber() - Sensitive to: Path separators, illegal chars
11. order_item.product.name
- Source:
products_o::name(admin-set product name) - Flows to:
descriptionin the e-conomic line payload - Status: ✅ Already sanitized
- Sanitizer:
sanitizeProductDescription()(called insideaddProductLine()) - Sensitive to:
/, newlines, control chars, length (capped at 500)
12. order_item.quantity, order_item.price, order_item.product.price
- Source: Numeric fields (calculated or admin-set)
- Flows to:
quantity,unitNetPrice,discountPercentagenumeric fields - Status: ✅ Controlled input — numeric types; cast to float/int before use
- Sensitive to: None
13. Currency (DKK, EUR, etc.)
- Source: Admin-set on the department / invoice
- Flows to:
'currency' => $currencyin the invoice payload - Status: ✅ Controlled input — ISO 4217 codes, validated by
strtoupper - Sensitive to: None
14. recipient.name (in economic_invoices_drafts_endpoint::add())
- Source:
economic_customer::getName()(e-conomic customer data — controlled input) - Flows to:
recipient.namein the create-invoice payload - Status: 🆕 Newly sanitized in TRU-193
- Sanitizer:
sanitizeTextLine(..., 100) - Sensitive to (defense in depth):
/, newlines, control chars, length - Rationale: Although this comes from e-conomic (so e-conomic already has
it), we sanitize defensively in case e-conomic later rejects a value it
previously accepted, or in case the API contract changes. Cap of 100 chars
matches the e-conomic recipient
namefield limit.
15. recipient.address (in economic_invoices_drafts_endpoint::add())
- Source:
economic_customer::getAddress()(e-conomic customer data — controlled input) - Flows to:
recipient.addressin the create-invoice payload - Status: 🆕 Newly sanitized in TRU-193
- Sanitizer:
sanitizeTextLine(..., 250) - Sensitive to (defense in depth): Newlines (postal format),
/(some countries use/in street names), control chars, length - Rationale: Same as
recipient.name— defense in depth.
16. recipient.zip, recipient.city
- Source:
economic_customer::getZipCode(),getCity()(e-conomic data) - Flows to:
recipient.zip,recipient.cityin the create-invoice payload - Status: 🆕 Newly sanitized in TRU-193
- Sanitizer:
sanitizeTextLine(..., 20)for zip,(..., 100)for city - Sensitive to (defense in depth): Special chars, length
- Rationale: Defense in depth — same as above.
17. recipient.ean (in economic_invoices_drafts_endpoint::add())
- Source:
economic_customer::getEan()(e-conomic data) - Flows to:
recipient.ean+recipient.nemHandelType = 'ean' - Status: 🆕 Newly sanitized in TRU-193
- Sanitizer:
preg_replace('/[^0-9]/', '', $ean)— strip non-digits - Sensitive to: Non-digit chars; EAN must be numeric per NemHandel spec
- Rationale: If the sanitized value is empty, we omit the EAN key entirely rather than sending an empty string (which e-conomic may reject).
Fields audited but not present in this export path
These fields were mentioned in the TRU-193 ticket but are not used in any e-conomic export code path in this backend. Documenting them for completeness:
| Field | Why not in scope |
|---|---|
customer.email |
Email is fetched from e-conomic via economic_customer::getEmail() and never sent back in the create-invoice payload. The email field is used only for read operations. |
customer.address (full multi-line) |
recipient.address is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
subscription.name |
Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
Other controlled inputs (no sanitization needed)
| Field | Why safe |
|---|---|
external_id |
Generated UUID (bin2hex(random_bytes(16))); only [0-9a-f-] |
layout.layoutNumber |
Admin-set integer from e-conomic config |
paymentTerms.paymentTermsNumber |
Integer from e-conomic |
vatZone.vatZoneNumber |
Integer from e-conomic |
customer.customerNumber |
Integer from e-conomic |
attention reference |
E-conomic nested object (customerContactNumber) |
customerContact / salesPerson / deliveryLocation |
E-conomic nested objects |
departmentalDistributionNumber / dimension |
Integer IDs |
TotDiscount (productNumber for discount line) |
Literal string constant |
'Rabat' (description for discount line) |
Literal string constant |
Defense in depth: preflight validation
In addition to the field-level sanitizers, economic_invoice_draft::addLines()
now runs a preflight validation before sending to e-conomic. The preflight
checks 5 rules per line and throws RuntimeException on the first violation:
descriptionmust be non-empty aftertrim()descriptionmust be ≤ 250 charsproductNumber(if present) must match/^[A-Za-z0-9._-]{1,50}$/quantity(if present) must be a positive numberunitNetPrice(if present) must be a number ≥ 0
Even if a sanitizer is bypassed or a new field is added without sanitization, the preflight catches the most common 400-error triggers and fails loudly before the request goes out.
Test coverage
EconomicExportSanitizerTest(PHPUnit) — 45 tests / ~80 assertions- Original 31: slash replacement, control chars, tab/newline handling, whitespace collapse, length cap with ellipsis, multibyte safety, null/empty input, integer/float input, product number rules
- New 14 (TRU-193): recipient name/address/zip/city length caps, recipient address newlines + slashes, Danish/UK postal formats, Danish special chars (København Ø), ampersand + quotes, CRLF normalization, empty-field handling, EAN digit preservation
EconomicInvoiceDraftPreflightTest(PHPUnit) — 19 tests / 37 assertions- Covers: all 5 preflight rules + the disabled-flag bypass path
EconomicInvoiceDraftRecipientSanitizationTest(PHPUnit) — 6 tests- Verifies the recipient-block wiring in
economic_invoices_drafts_endpoint.php(sanitize calls for name/address/zip/city, preg_replace for EAN, empty-EAN unsets the key)
- Verifies the recipient-block wiring in
EconomicDraftSanitizationIntegrationTest(PHPUnit, integration) — 24 tests / 51 assertions- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip, preflight catches all 5 rules, mixed text + product flow works
Total: 94 tests, 171 assertions, all passing.
What changed in TRU-193
- Pre-flight validation added to
economic_invoice_draft.php(separate atomic commit) — defense in depth. - Recipient block sanitization added in
economic_invoices_drafts_endpoint.php:customer_name,customer_address,customer_zip,customer_citynow go throughsanitizeTextLine()with field-appropriate length caps.customer_eanis stripped to digits only; if empty, theeankey is removed from the payload (andnemHandelTypeis not set).
- Defense-in-depth at insertion in
economic_invoice_draft.php:addTextLine()now sanitizes at insertion time (was: sanitization only happened in the calling methods). Catches any new caller that forgets to sanitize.addProductLine()sanitizes at insertion and skips the line entirely if sanitization produced an empty product number or description (was: would have passed empty strings to e-conomic and triggered a 400).
- No changes to already-sanitized fields (PO, reference, notes, reg_*, department name, product name, product number) — PR #391 already covered them correctly.
Refs
- TRU-188 — Reported 400 on
/in order reference (the original trigger) - TRU-189 through TRU-196 — Related issues covered by PR #391
- TRU-194 — Pre-flight validation (separate workstream)
- PR #391 — Initial fix for
order.*andorder_item.*fields - PR #392 — Pre-flight validation defense in depth