From 339b6eb7a505b98636a20e2b4eb5762a8ee47faf Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 17 Aug 2026 11:00:23 +0200 Subject: [PATCH 1/5] fix(api): install + start cron-worker systemd service on deploy (#390) Brings cron-worker online in production via systemd. Closes verify-api-cron.py liveness alert. --- .github/workflows/deploy.yml | 8 +++++ .../nginx/app/resources/cron-worker.service | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 services/nginx/app/resources/cron-worker.service diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7cee1b2e..a7c25b9a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -86,6 +86,14 @@ jobs: fi # Restart generic services sudo systemctl reload nginx || true + # Install and start the cron-worker systemd service (long-running scheduler) + if [ -f services/nginx/app/resources/cron-worker.service ]; then + sudo install -m 0644 services/nginx/app/resources/cron-worker.service /etc/systemd/system/cron-worker.service + sudo systemctl daemon-reload + sudo systemctl enable cron-worker || true + sudo systemctl restart cron-worker || true + echo "cron-worker status: $(sudo systemctl is-active cron-worker || echo unknown)" + fi echo "Deploy complete: $(git rev-parse --short HEAD)" ' diff --git a/services/nginx/app/resources/cron-worker.service b/services/nginx/app/resources/cron-worker.service new file mode 100644 index 00000000..2b87dfda --- /dev/null +++ b/services/nginx/app/resources/cron-worker.service @@ -0,0 +1,32 @@ +[Unit] +Description=Truck Wash API cron worker (long-running scheduler) +After=network-online.target php8.2-fpm.service redis.service +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/copenhagentruckwash-api/services/nginx/app +ExecStart=/usr/bin/php /opt/copenhagentruckwash-api/services/nginx/app/index.php run cron-worker +ExecReload=/bin/kill -HUP $MAINPID +Restart=always +RestartSec=10 +TimeoutStopSec=30 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=cron-worker + +# Hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/opt/copenhagentruckwash-api/services/php/logs + +# Resource limits +LimitNOFILE=65536 +MemoryMax=512M + +[Install] +WantedBy=multi-user.target From b16b2cfe448f31a67bc31a3c50bfc7d17b79acaf Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 17 Aug 2026 12:24:04 +0200 Subject: [PATCH 2/5] fix(economic): sanitize user-input fields to prevent 400 errors (#391) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem E-conomic API returns HTTP 400 when text-line descriptions contain certain characters. The most common case is `/` in the order reference field, which causes the entire draft-invoice export to fail. ## Root cause When `order.reference` (or notes, reg_*, po) contains `/`, e-conomic's text-line validation rejects the entire draft with HTTP 400. Same for control characters and very long strings. ## Fix Adds `economic_export_sanitizer` class that sanitizes all user-input fields flowing into e-conomic: - `/` → `-` (the reported 400 trigger) - Control chars stripped (\x00-\x1F except \t and \n) - Tab and newline → single space - Whitespace normalized and trimmed - Lengths capped (text 250, product 50, description 500) with `...` suffix - Multibyte safe (æ, ø, å, emoji, Chinese) ## Applied to In `economic_invoice_draft.php`: - `order.po` - `order.reference` (PRIMARY FIX for the reported issue) - `order.notes` - `order.reg_1/2/3` - `order_item.reference` - `order_item.notes` - `product.description` - `product.productNumber` - `department_name` ## Test coverage - 31 unit tests with 45 assertions - All edge cases (null, empty, control chars, multibyte, very long, HTML, control chars in every position) - Lint and test suite both pass ## Linear Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196 Co-authored-by: OpenClaw --- .../app/classes/economic_export_sanitizer.php | 111 +++++++++ .../helpers/economic_invoice_draft.php | 63 +++-- .../Economic/EconomicExportSanitizerTest.php | 224 ++++++++++++++++++ 3 files changed, 374 insertions(+), 24 deletions(-) create mode 100644 services/nginx/app/classes/economic_export_sanitizer.php create mode 100644 services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php diff --git a/services/nginx/app/classes/economic_export_sanitizer.php b/services/nginx/app/classes/economic_export_sanitizer.php new file mode 100644 index 00000000..b103ba0c --- /dev/null +++ b/services/nginx/app/classes/economic_export_sanitizer.php @@ -0,0 +1,111 @@ +', '|', "\0"]; + + /** + * Sanitize a value for use in a single-line text description. + * + * Transformations (in order): + * 1. Replaces "/" with "-" (the reported 400 trigger) + * 2. Strips control characters (\x00-\x1F) except \t and \n + * 3. Replaces tab with single space + * 4. Collapses newlines into spaces (text lines are single-line) + * 5. Collapses runs of spaces to a single space + * 6. Trims leading/trailing whitespace + * 7. Truncates to $maxLength with "..." suffix if needed + */ + public static function sanitizeTextLine(mixed $value, int $maxLength = self::TEXT_LINE_MAX_LENGTH): string + { + if ($value === null) { + return ''; + } + $text = (string)$value; + if ($text === '') { + return ''; + } + // 1. Strip control characters except \t and \n + $text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text); + // 2. Replace tab with single space + $text = str_replace("\t", ' ', $text); + // 3. Collapse newlines to single space (text lines are single-line) + $text = preg_replace('/[\r\n]+/u', ' ', $text); + // 4. Replace forward slashes (the reported 400 trigger) + $text = str_replace('/', '-', $text); + // 5. Collapse runs of spaces + $text = preg_replace('/\s+/u', ' ', $text); + // 6. Trim + $text = trim($text); + // 7. Truncate with ellipsis if too long + if ($maxLength > 3 && mb_strlen($text) > $maxLength) { + $text = mb_substr($text, 0, $maxLength - 3) . '...'; + } elseif (mb_strlen($text) > $maxLength) { + $text = mb_substr($text, 0, $maxLength); + } + return $text; + } + + /** + * Sanitize a product number/identifier. + * + * Removes characters that are illegal in product numbers on most + * e-conomic setups (filesystem-unsafe + path separators). + */ + public static function sanitizeProductNumber(mixed $value): string + { + if ($value === null) { + return ''; + } + $text = (string)$value; + if ($text === '') { + return ''; + } + $text = str_replace(self::PRODUCT_NUMBER_FORBIDDEN, '', $text); + $text = preg_replace('/[\x00-\x1F\x7F]/u', '', $text); + $text = trim($text); + if (mb_strlen($text) > self::PRODUCT_NUMBER_MAX_LENGTH) { + $text = mb_substr($text, 0, self::PRODUCT_NUMBER_MAX_LENGTH); + } + return $text; + } + + /** + * Sanitize a longer product description. + */ + public static function sanitizeProductDescription(mixed $value): string + { + return self::sanitizeTextLine($value, self::PRODUCT_DESCRIPTION_MAX_LENGTH); + } + + /** + * Catch-all sanitizer for any user-input value going to e-conomic. + * Defaults to text-line rules. + */ + public static function sanitizeForEconApi(mixed $value): string + { + return self::sanitizeTextLine($value); + } +} diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index 24fb6853..380090fa 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -185,45 +185,55 @@ class economic_invoice_draft $department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value()); // Parse the date of the transaction. $parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value())); + // Sanitize the department name (could contain "/" or other chars) + $department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100); // Add the text line to the draft invoice self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]"); // If there's a PO number, add it to the invoice if ($order->po->value() !== '') { - self::addTextLine('PO: ' . $order->po->value()); + self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value())); } // If there's a reference, add it to the invoice - if ($order->reference->value() !== '') { + $reference_value = $order->reference->value(); + if ($reference_value !== '') { self::addTextLine('Reference:'); + // Sanitize the whole reference (handles "/" → "-" per TRU-188) + $reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value); // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order->reference->value(), "\n")) { - foreach ( explode("\n", $order->reference->value()) as $line ) { + if (str_contains($reference_sanitized, "\n")) { + foreach ( explode("\n", $reference_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order->reference->value()); + self::addTextLine('# ' . $reference_sanitized); } } // Add the registration numbers (if any) $line_reg = ''; - if ($order->reg_1->value() !== '') - $line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value()); - if ($order->reg_2->value() !== '') - $line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value()); - if ($order->reg_3->value() !== '') - $line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value()); + if ($order->reg_1->value() !== '') { + $line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50)); + } + if ($order->reg_2->value() !== '') { + $line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50)); + } + if ($order->reg_3->value() !== '') { + $line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50)); + } // Add the line to the invoice (If there's any registration numbers) if ($line_reg !== '') self::addTextLine($line_reg); // If there's a note, add it to the invoice - if ($order->notes->value() !== '') { + $notes_value = $order->notes->value(); + if ($notes_value !== '') { self::addTextLine('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order->notes->value(), "\n")) { - foreach ( explode("\n", $order->notes->value()) as $line ) { + // Sanitize notes (could contain "/", newlines, special chars) + $notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value); + if (str_contains($notes_sanitized, "\n")) { + foreach ( explode("\n", $notes_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order->notes->value()); + self::addTextLine('# ' . $notes_sanitized); } } } @@ -341,26 +351,28 @@ class economic_invoice_draft // If there's a reference, add it to the line if ($order_item['reference'] !== '') { self::addTextLine('Reference:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order_item['reference'], "\n")) { - foreach ( explode("\n", $order_item['reference']) as $line ) { + // Sanitize the reference (handles "/" → "-" per TRU-188) + $item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']); + if (str_contains($item_reference_sanitized, "\n")) { + foreach ( explode("\n", $item_reference_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order_item['reference']); + self::addTextLine('# ' . $item_reference_sanitized); } } // If there's a note, add it to the line if (!empty($order_item['notes'])) { self::addTextLine('Notat:'); - // Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once) - if (str_contains($order_item['notes'], "\n")) { - foreach ( explode("\n", $order_item['notes']) as $line ) { + // Sanitize notes (could contain "/", newlines, special chars) + $item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']); + if (str_contains($item_notes_sanitized, "\n")) { + foreach ( explode("\n", $item_notes_sanitized) as $line ) { self::addTextLine('# ' . $line); } } else { - self::addTextLine('# ' . $order_item['notes']); + self::addTextLine('# ' . $item_notes_sanitized); } } @@ -470,6 +482,9 @@ class economic_invoice_draft */ public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void { + // Sanitize product identifier and description (defense in depth — also done at addLines()) + $productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber); + $description = \classes\economic_export_sanitizer::sanitizeProductDescription($description); // We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS. // Add a line to the invoice $line = [ diff --git a/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php new file mode 100644 index 00000000..cd2bb52b --- /dev/null +++ b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php @@ -0,0 +1,224 @@ +assertSame('ABC-123-XYZ', economic_export_sanitizer::sanitizeTextLine('ABC/123/XYZ')); + $this->assertSame('Order 1 - 2 - 3', economic_export_sanitizer::sanitizeTextLine('Order 1 / 2 / 3')); + $this->assertSame('-leading and trailing-', economic_export_sanitizer::sanitizeTextLine('/leading and trailing/')); + } + + public function testControlCharactersAreStripped(): void + { + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x00lo")); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x01lo")); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x1Flo")); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("hel\x7F\x7Flo")); + } + + public function testTabIsReplacedWithSpace(): void + { + $this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine("a\tb\tc")); + } + + public function testNewlinesCollapsedToSpace(): void + { + $this->assertSame('line1 line2 line3', economic_export_sanitizer::sanitizeTextLine("line1\nline2\nline3")); + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2")); + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\n\n\nline2")); + } + + public function testMultipleSpacesCollapsed(): void + { + $this->assertSame('a b c', economic_export_sanitizer::sanitizeTextLine('a b c')); + } + + public function testTrimsLeadingAndTrailingWhitespace(): void + { + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine(' hello ')); + $this->assertSame('hello', economic_export_sanitizer::sanitizeTextLine("\n\thello\n\t")); + } + + public function testTruncatesAtMaxLengthWithEllipsis(): void + { + $text = str_repeat('a', 300); + $result = economic_export_sanitizer::sanitizeTextLine($text, 250); + $this->assertSame(250, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testTruncatesAtMaxLengthWithoutEllipsisWhenTooShort(): void + { + // When maxLength is 3, no room for ellipsis + $text = str_repeat('a', 100); + $result = economic_export_sanitizer::sanitizeTextLine($text, 3); + $this->assertSame(3, mb_strlen($result)); + $this->assertSame('aaa', $result); + } + + public function testDoesNotTruncateWhenShorterThanMaxLength(): void + { + $this->assertSame('short text', economic_export_sanitizer::sanitizeTextLine('short text', 250)); + } + + public function testNullReturnsEmptyString(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine(null)); + } + + public function testEmptyReturnsEmptyString(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('')); + } + + public function testWhitespaceOnlyReturnsEmptyString(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine(" \t\n ")); + } + + public function testWhitespaceOnlyWithSlashesReturnsEmptyString(): void + { + // After all transformations, "///" becomes "---" + // After trim of whitespace-only, " / " becomes "" (since / is replaced but space was there) + // Actually let's see: " / " -> " " stays; " - " -> "-"; then trim -> "-" + // So it doesn't become empty in this case. Let me re-test: + $result = economic_export_sanitizer::sanitizeTextLine(' / '); + $this->assertSame('-', $result); + } + + public function testHandlesMultibyteChars(): void + { + $this->assertSame('æøå', economic_export_sanitizer::sanitizeTextLine('æøå')); + $this->assertSame('中文', economic_export_sanitizer::sanitizeTextLine('中文')); + $this->assertSame('🚗 car', economic_export_sanitizer::sanitizeTextLine('🚗 car')); + } + + public function testTruncationRespectsMultibyteBoundaries(): void + { + $text = str_repeat('æ', 300); + $result = economic_export_sanitizer::sanitizeTextLine($text, 10); + $this->assertSame(10, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testHtmlTagsAreNotStripped(): void + { + // We don't strip HTML — that's a different concern (XSS). We just sanitize for e-conomic. + // The "/" in gets replaced with "-" (per the rules). + $this->assertSame('notags<-b>', economic_export_sanitizer::sanitizeTextLine('notags')); + } + + public function testSlashesInTheMiddleOfValueAreReplaced(): void + { + $this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeTextLine('foo/bar/baz')); + } + + public function testMultipleProblemCharsCombined(): void + { + $input = "AB/\nC\t\rD\x00E "; + $result = economic_export_sanitizer::sanitizeTextLine($input); + // After: strip control -> "AB/\nC\tDE ", tab->space -> "AB/\nC DE ", + // newline->space -> "AB/ C DE ", slash->dash -> "AB- C DE ", + // collapse spaces -> "AB- C DE ", trim -> "AB- C DE" + $this->assertSame('AB- C DE', $result); + } + + public function testIntegerIsConvertedToString(): void + { + $this->assertSame('42', economic_export_sanitizer::sanitizeTextLine(42)); + } + + public function testFloatIsConvertedToString(): void + { + $this->assertSame('3.14', economic_export_sanitizer::sanitizeTextLine(3.14)); + } + + // ======================================================================== + // sanitizeProductNumber + // ======================================================================== + + public function testProductNumberRemovesPathSeparators(): void + { + $this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC/DEF')); + $this->assertSame('ABCDEF', economic_export_sanitizer::sanitizeProductNumber('ABC\\DEF')); + } + + public function testProductNumberRemovesForbiddenChars(): void + { + $input = "PROD:01?*<>|\""; + $result = economic_export_sanitizer::sanitizeProductNumber($input); + $this->assertSame('PROD01', $result); + } + + public function testProductNumberTruncatesAt50Chars(): void + { + $text = str_repeat('a', 100); + $result = economic_export_sanitizer::sanitizeProductNumber($text); + $this->assertSame(50, mb_strlen($result)); + } + + public function testProductNumberTrimsWhitespace(): void + { + $this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber(' PROD01 ')); + } + + public function testProductNumberStripsControlChars(): void + { + $this->assertSame('PROD01', economic_export_sanitizer::sanitizeProductNumber("PROD\x0001")); + } + + public function testProductNumberNullReturnsEmpty(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeProductNumber(null)); + } + + public function testProductNumberAllForbiddenReturnsEmpty(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeProductNumber('///\\\\::')); + } + + public function testProductNumberKeepsDotsAndDashes(): void + { + $this->assertSame('PROD-01.0', economic_export_sanitizer::sanitizeProductNumber('PROD-01.0')); + } + + // ======================================================================== + // sanitizeProductDescription + // ======================================================================== + + public function testProductDescriptionTruncatesAt500(): void + { + $text = str_repeat('a', 1000); + $result = economic_export_sanitizer::sanitizeProductDescription($text); + $this->assertSame(500, mb_strlen($result)); + } + + public function testProductDescriptionReplacesSlashes(): void + { + $this->assertSame('foo-bar-baz', economic_export_sanitizer::sanitizeProductDescription('foo/bar/baz')); + } + + // ======================================================================== + // sanitizeForEconApi + // ======================================================================== + + public function testSanitizeForEconApiIsAliasForTextLine(): void + { + $this->assertSame( + economic_export_sanitizer::sanitizeTextLine('foo/bar'), + economic_export_sanitizer::sanitizeForEconApi('foo/bar') + ); + } +} From ea9bdbe12c202c7f91759a727887380f8ac41351 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 17 Aug 2026 12:52:04 +0200 Subject: [PATCH 3/5] fix(economic): audit and sanitize additional export fields (TRU-193) (#393) ## 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 Co-authored-by: OpenClaw Co-authored-by: Bugfix Subagent --- documentation/economic/export-field-audit.md | 262 ++++++++++++ .../economic_invoices_drafts_endpoint.php | 24 +- .../helpers/economic_invoice_draft.php | 161 +++++++- ...onomicDraftSanitizationIntegrationTest.php | 323 +++++++++++++++ .../Economic/EconomicExportSanitizerTest.php | 117 ++++++ .../EconomicInvoiceDraftPreflightTest.php | 379 ++++++++++++++++++ ...cInvoiceDraftRecipientSanitizationTest.php | 101 +++++ 7 files changed, 1359 insertions(+), 8 deletions(-) create mode 100644 documentation/economic/export-field-audit.md create mode 100644 services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php create mode 100644 services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php create mode 100644 services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php diff --git a/documentation/economic/export-field-audit.md b/documentation/economic/export-field-audit.md new file mode 100644 index 00000000..f9f174f8 --- /dev/null +++ b/documentation/economic/export-field-audit.md @@ -0,0 +1,262 @@ +# 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.php` +- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` +- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php` +- `services/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-\x1F` except `\t` and `\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)` then `strtoupper()` +- **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 in `dd/mm/YYYY` are 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.productNumber` in 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:** `description` in the e-conomic line payload +- **Status:** ✅ Already sanitized +- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`) +- **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`, `discountPercentage` numeric 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' => $currency` in 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.name` in 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 `name` field limit. + +### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`) +- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input) +- **Flows to:** `recipient.address` in 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.city` in 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: + +1. `description` must be non-empty after `trim()` +2. `description` must be ≤ 250 chars +3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/` +4. `quantity` (if present) must be a positive number +5. `unitNetPrice` (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) +- `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 + +1. **Pre-flight validation** added to `economic_invoice_draft.php` + (separate atomic commit) — defense in depth. +2. **Recipient block sanitization** added in + `economic_invoices_drafts_endpoint.php`: + - `customer_name`, `customer_address`, `customer_zip`, `customer_city` + now go through `sanitizeTextLine()` with field-appropriate length caps. + - `customer_ean` is stripped to digits only; if empty, the `ean` key is + removed from the payload (and `nemHandelType` is not set). +3. **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). +4. **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.*` and `order_item.*` fields +- PR #392 — Pre-flight validation defense in depth diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php index 3467e0c6..1d781f0c 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php @@ -125,10 +125,15 @@ class economic_invoices_drafts_endpoint $customer = (new economic())->getCustomer($customer_number); // Set the recipient details - $customer_name = $customer->getName() ?? 'Ukendt'; - $customer_address = $customer->getAddress() ?? 'Ukendt'; - $customer_zip = $customer->getZipCode() ?? 'Ukendt'; - $customer_city = $customer->getCity() ?? 'Ukendt'; + // Note: customer_* fields come from e-conomic itself (controlled input), + // but we sanitize them defensively to avoid 400s if e-conomic ever stores + // a value with chars e-conomic later rejects in the recipient block. + // Each field uses an appropriate length cap to match the corresponding + // e-conomic recipient field limits. + $customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100); + $customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250); + $customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20); + $customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100); $recipient = [ 'name' => $customer_name, 'address' => $customer_address, @@ -139,9 +144,14 @@ class economic_invoices_drafts_endpoint ], ]; $customer_ean = $customer->getEan(); - if ($customer_ean !== null) { - $recipient['ean'] = $customer_ean; - $recipient['nemHandelType'] = 'ean'; + if ($customer_ean !== null && $customer_ean !== '') { + // EAN should be digits only; sanitize to strip anything that slipped through + $recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean); + if ($recipient['ean'] !== '') { + $recipient['nemHandelType'] = 'ean'; + } else { + unset($recipient['ean']); + } } $public_entry_number = $customer->getPublicEntryNumber(); if ($public_entry_number !== null) { diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index 380090fa..42c101f1 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -42,6 +42,13 @@ class economic_invoice_draft */ protected float $conversion_rate = 1.0; + /** + * Whether pre-flight validation runs inside addLines() before sending to e-conomic. + * Defense in depth — even after sanitization, a final check catches anything that slips through. + * @var bool $preflight_enabled + */ + protected bool $preflight_enabled = true; + /** * Construct a new Economic draft invoice object @@ -116,9 +123,142 @@ class economic_invoice_draft */ public function addLines(): void { + if ($this->preflight_enabled) { + $this->preflightValidate($this->draft_lines, null); + } $this->flushLinesInBatches(); } + /** + * Pre-flight validation: defense in depth before sending to e-conomic. + * Validates each line against 5 rules and throws RuntimeException on the first violation. + * + * Rules (in order, per line): + * 1. description — must be non-empty after trim() + * 2. description — must be <= 250 chars + * 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/ + * 4. quantity — must be a positive number (> 0) + * 5. unitNetPrice — must be a number (>= 0) + * + * @param array> $lines + * @param int|null $orderId Optional order id for log context + * @throws \RuntimeException on any rule violation + */ + public function preflightValidate(array $lines, ?int $orderId = null): void + { + foreach ($lines as $i => $line) { + if (!is_array($line)) { + $this->logAndThrow( + $i, + $orderId, + 'line is not an array', + $line + ); + } + + // Rule 1 + 2: description + $description = $line['description'] ?? null; + if ($description === null) { + $description = ''; + } + if (!is_scalar($description)) { + $description = (string)$description; + } else { + $description = (string)$description; + } + $descriptionTrimmed = trim($description); + if ($descriptionTrimmed === '') { + $this->logAndThrow( + $i, + $orderId, + 'description is empty', + $description + ); + } + if (mb_strlen($descriptionTrimmed) > 250) { + $this->logAndThrow( + $i, + $orderId, + 'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')', + $description + ); + } + + // Rule 3: productNumber (only if present in product.productNumber) + if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) { + $productNumber = $line['product']['productNumber']; + if ($productNumber === null) { + $productNumber = ''; + } else { + $productNumber = (string)$productNumber; + } + if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) { + $this->logAndThrow( + $i, + $orderId, + 'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/', + $productNumber + ); + } + } + + // Rule 4: quantity — only required if present in the line (text lines omit it) + if (array_key_exists('quantity', $line)) { + $quantity = $line['quantity']; + if (!is_numeric($quantity) || (float)$quantity <= 0) { + $this->logAndThrow( + $i, + $orderId, + 'quantity is not a positive number', + $quantity + ); + } + } + + // Rule 5: unitNetPrice — only required if present in the line + if (array_key_exists('unitNetPrice', $line)) { + $unitNetPrice = $line['unitNetPrice']; + if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) { + $this->logAndThrow( + $i, + $orderId, + 'unitNetPrice is not a number >= 0', + $unitNetPrice + ); + } + } + } + } + + /** + * Log the offending line and throw a RuntimeException. + */ + private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never + { + $valueTruncated = is_scalar($value) ? (string)$value : json_encode($value); + if ($valueTruncated === false) { + $valueTruncated = '[unserializable]'; + } + if (mb_strlen($valueTruncated) > 200) { + $valueTruncated = mb_substr($valueTruncated, 0, 200) . '...'; + } + $orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId; + error_log(sprintf( + '[preflight] validation failed: %s | line=%d | %s | value=%s', + $rule, + $lineIndex, + $orderContext, + $valueTruncated + )); + $orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')'; + throw new \RuntimeException(sprintf( + 'Preflight validation failed for line %d: %s%s', + $lineIndex, + $rule, + $orderPart + )); + } + /** * Add queued draft lines using chunked requests. * @@ -246,11 +386,26 @@ class economic_invoice_draft */ public function addTextLine(string $text): void { + // Defense in depth: sanitize ALL text lines at insertion time. + // This catches anything that wasn't pre-sanitized at the call site. + $sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text); + if ($sanitized === '') { + return; // Skip empty/whitespace-only lines + } $this->draft_lines[] = [ - 'description' => $text + 'description' => $sanitized ]; } + /** + * Get the current draft lines (read-only view). + * Used by integration tests; production code uses addLines() to send. + */ + public function getDraftLines(): array + { + return $this->draft_lines; + } + /** * Add an order to the draft invoice * @note The lines won't be saved until the addLines() method is called. @@ -485,6 +640,10 @@ class economic_invoice_draft // Sanitize product identifier and description (defense in depth — also done at addLines()) $productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber); $description = \classes\economic_export_sanitizer::sanitizeProductDescription($description); + // Skip if sanitization removed everything + if ($productNumber === '' || $description === '') { + return; + } // We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS. // Add a line to the invoice $line = [ diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php new file mode 100644 index 00000000..ebcce58b --- /dev/null +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php @@ -0,0 +1,323 @@ +draft = new economic_invoice_draft(12345, 'DKK', true); // skip_fetch=true: no live API call + } + + // ======================================================================== + // addTextLine — sanitization + // ======================================================================== + + public function testAddTextLineSanitizesSlashToDash(): void + { + $this->draft->addTextLine('Reference: Order/123/ABC'); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame('Reference: Order-123-ABC', $lines[0]['description']); + } + + public function testAddTextLineStripsControlChars(): void + { + $this->draft->addTextLine("Line 1\nLine 2\twith tab"); + $lines = $this->draft->getDraftLines(); + $this->assertSame('Line 1 Line 2 with tab', $lines[0]['description']); + } + + public function testAddTextLineTruncatesVeryLongString(): void + { + $long = str_repeat('A', 5000); + $this->draft->addTextLine($long); + $lines = $this->draft->getDraftLines(); + $this->assertLessThanOrEqual(250, mb_strlen($lines[0]['description'])); + $this->assertStringEndsWith('...', $lines[0]['description']); + } + + public function testMultipleTextLinesAllSanitized(): void + { + $this->draft->addTextLine('Reference: A/B'); + $this->draft->addTextLine('PO: C/D'); + $this->draft->addTextLine('Reg 1: E/F'); + $lines = $this->draft->getDraftLines(); + $this->assertCount(3, $lines); + $this->assertSame('Reference: A-B', $lines[0]['description']); + $this->assertSame('PO: C-D', $lines[1]['description']); + $this->assertSame('Reg 1: E-F', $lines[2]['description']); + } + + public function testEmptyAndWhitespaceOnlyLinesAreSkipped(): void + { + $this->draft->addTextLine(''); + $this->draft->addTextLine(' '); + $this->draft->addTextLine("\t\n "); + $this->draft->addTextLine('///'); // All slashes become dashes, then trim leaves '-', not empty + $lines = $this->draft->getDraftLines(); + // '///' becomes '---' which is not empty after trim + $this->assertCount(1, $lines); + $this->assertSame('---', $lines[0]['description']); + } + + public function testPurelyWhitespaceAfterSanitizationIsSkipped(): void + { + $this->draft->addTextLine("\x00\x01\x02"); // All control chars, no actual text + $lines = $this->draft->getDraftLines(); + $this->assertCount(0, $lines); + } + + public function testMultibyteTextPreservedCorrectly(): void + { + $this->draft->addTextLine('Kunde: ÆØÅ / 中文 / 🚗'); + $lines = $this->draft->getDraftLines(); + $this->assertSame('Kunde: ÆØÅ - 中文 - 🚗', $lines[0]['description']); + } + + // ======================================================================== + // addProductLine — sanitization + // ======================================================================== + + public function testProductLineWithoutDiscount(): void + { + $this->draft->addProductLine( + 'PROD-001', + 'Bilvask Standard', + 1.0, + 150.0, + 1, + 1, + 0.0 + ); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame('Bilvask Standard', $lines[0]['description']); + $this->assertSame(0.0, $lines[0]['discountPercentage']); + $this->assertSame('PROD-001', $lines[0]['product']['productNumber']); + } + + public function testProductLineWithDiscount(): void + { + $this->draft->addProductLine( + 'PROD-002', + 'Storvask Premium', + 1.0, + 250.0, + 1, + 1, + 20.0 + ); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame(20.0, $lines[0]['discountPercentage']); + } + + public function testProductLineWithSlashInNumberSanitized(): void + { + $this->draft->addProductLine( + 'PROD/003', + 'Premium/Service', + 1.0, + 100.0, + 1, + 1 + ); + $lines = $this->draft->getDraftLines(); + // Product numbers REMOVE the slash (sanitizeProductNumber), text lines REPLACE with dash + $this->assertSame('PROD003', $lines[0]['product']['productNumber']); + $this->assertSame('Premium-Service', $lines[0]['description']); + } + + public function testProductLineWithEmptyDescriptionSkipped(): void + { + $this->draft->addProductLine('PROD-001', '', 1.0, 100.0, 1, 1); + $lines = $this->draft->getDraftLines(); + $this->assertCount(0, $lines); + } + + // ======================================================================== + // preflightValidate — via addLines (with transport stub would be ideal, + // but for unit-style integration we exercise preflight directly) + // ======================================================================== + + public function testPreflightCatchesEmptyDescription(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('description is empty'); + $this->draft->preflightValidate([ + ['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightCatchesTooLongDescription(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('exceeds 250 chars'); + $this->draft->preflightValidate([ + ['description' => str_repeat('A', 251), 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightCatchesInvalidProductNumber(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('productNumber does not match'); + $this->draft->preflightValidate([ + [ + 'description' => 'Valid line', + 'product' => ['productNumber' => 'PROD/01'], + 'quantity' => 1, + 'unitNetPrice' => 100.0, + ], + ]); + } + + public function testPreflightCatchesZeroQuantity(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('quantity is not a positive number'); + $this->draft->preflightValidate([ + ['description' => 'Valid line', 'quantity' => 0, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightCatchesNegativeUnitPrice(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('unitNetPrice is not a number'); + $this->draft->preflightValidate([ + ['description' => 'Valid line', 'quantity' => 1, 'unitNetPrice' => -10.0], + ]); + } + + public function testPreflightIncludesOrderIdInMessage(): void + { + try { + $this->draft->preflightValidate( + [['description' => '', 'quantity' => 1, 'unitNetPrice' => 100.0]], + 300 + ); + $this->fail('Expected RuntimeException'); + } catch (\RuntimeException $e) { + $this->assertStringContainsString('order 300', $e->getMessage()); + } + } + + public function testPreflightPassesValidLines(): void + { + // Should not throw + $this->draft->preflightValidate([ + ['description' => 'Line 1', 'quantity' => 2, 'unitNetPrice' => 100.0], + [ + 'description' => 'Line 2 with product', + 'product' => ['productNumber' => 'PROD-01'], + 'quantity' => 1, + 'unitNetPrice' => 50.0, + 'discountPercentage' => 10, + ], + ]); + $this->assertTrue(true); + } + + public function testPreflightPassesExactly250Chars(): void + { + $exactlyMax = str_repeat('B', 250); + // Should not throw + $this->draft->preflightValidate([ + ['description' => $exactlyMax, 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + $this->assertTrue(true); + } + + public function testPreflightFailsAt251Chars(): void + { + $this->expectException(\RuntimeException::class); + $this->draft->preflightValidate([ + ['description' => str_repeat('B', 251), 'quantity' => 1, 'unitNetPrice' => 100.0], + ]); + } + + public function testPreflightAcceptsProductNumberWithDotsAndDashes(): void + { + // Should not throw + $this->draft->preflightValidate([ + [ + 'description' => 'Valid', + 'product' => ['productNumber' => 'PROD-01.0_test'], + 'quantity' => 1, + 'unitNetPrice' => 100.0, + ], + ]); + $this->assertTrue(true); + } + + // ======================================================================== + // End-to-end: mixed flow + // ======================================================================== + + public function testMixedLinesAllTogetherAndPassPreflight(): void + { + $this->draft->addTextLine('Reference: Order/2024/Q1'); + $this->draft->addProductLine('PROD-001', 'Bilvask', 2.0, 100.0, 1, 1, 10.0); + $this->draft->addProductLine('PROD-002', 'Storvask', 1.0, 200.0, 1, 1, 0.0); + $this->draft->addTextLine('Note: paid/in/full'); + + $lines = $this->draft->getDraftLines(); + $this->assertCount(4, $lines); + + $this->assertSame('Reference: Order-2024-Q1', $lines[0]['description']); + $this->assertSame('Bilvask', $lines[1]['description']); + $this->assertSame(10.0, $lines[1]['discountPercentage']); + $this->assertSame('Storvask', $lines[2]['description']); + $this->assertSame(0.0, $lines[2]['discountPercentage']); + $this->assertSame('Note: paid-in-full', $lines[3]['description']); + + // All sanitized lines pass preflight + $this->draft->preflightValidate($lines, 12345); + } + + public function testDiscountPathProducesSingleProductLineWithDiscountPct(): void + { + $this->draft->addProductLine('DISC-01', 'Rabatservice', 1.0, 100.0, 1, 1, 25.0); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame(25.0, $lines[0]['discountPercentage']); + $this->assertSame('Rabatservice', $lines[0]['description']); + } + + public function testNoDiscountPathProducesSingleProductLineWithZeroDiscount(): void + { + $this->draft->addProductLine('NODISC-01', 'Standardservice', 1.0, 100.0, 1, 1, 0.0); + $lines = $this->draft->getDraftLines(); + $this->assertCount(1, $lines); + $this->assertSame(0.0, $lines[0]['discountPercentage']); + $this->assertSame('Standardservice', $lines[0]['description']); + } +} diff --git a/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php index cd2bb52b..49f6e78f 100644 --- a/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php +++ b/services/nginx/app/tests/Unit/Economic/EconomicExportSanitizerTest.php @@ -221,4 +221,121 @@ class EconomicExportSanitizerTest extends TestCase economic_export_sanitizer::sanitizeForEconApi('foo/bar') ); } + + // ======================================================================== + // Recipient-block fields (TRU-193) + // + // The recipient block in the create-invoice payload is built from + // e-conomic customer data (name, address, zip, city). We sanitize + // defensively with field-appropriate length caps. + // ======================================================================== + + public function testRecipientNameCapsAt100Chars(): void + { + $text = str_repeat('A', 200); + $result = economic_export_sanitizer::sanitizeTextLine($text, 100); + $this->assertSame(100, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testRecipientAddressCapsAt250Chars(): void + { + $text = str_repeat('B', 500); + $result = economic_export_sanitizer::sanitizeTextLine($text, 250); + $this->assertSame(250, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testRecipientAddressNewlinesAndSlashesReplaced(): void + { + // Address with embedded newlines and a slash — both common in EU street formats + $input = "Main Street 1\nFloor 2/3\n1234 City"; + $result = economic_export_sanitizer::sanitizeTextLine($input, 250); + $this->assertStringNotContainsString("\n", $result); + $this->assertStringNotContainsString('/', $result); + $this->assertSame('Main Street 1 Floor 2-3 1234 City', $result); + } + + public function testRecipientZipCapsAt20Chars(): void + { + $text = str_repeat('9', 50); + $result = economic_export_sanitizer::sanitizeTextLine($text, 20); + $this->assertSame(20, mb_strlen($result)); + $this->assertStringEndsWith('...', $result); + } + + public function testRecipientZipPreservesDanishFormat(): void + { + // Danish postal codes: "1234" — should pass through unchanged + $this->assertSame('1234', economic_export_sanitizer::sanitizeTextLine('1234', 20)); + } + + public function testRecipientZipHandlesUkFormatWithSlash(): void + { + // UK postcodes contain no slashes in practice but include spaces + $this->assertSame('SW1A 1AA', economic_export_sanitizer::sanitizeTextLine('SW1A 1AA', 20)); + } + + public function testRecipientCityCapsAt100Chars(): void + { + $text = str_repeat('C', 200); + $result = economic_export_sanitizer::sanitizeTextLine($text, 100); + $this->assertSame(100, mb_strlen($result)); + } + + public function testRecipientCityHandlesDanishSpecialChars(): void + { + $this->assertSame('København Ø', economic_export_sanitizer::sanitizeTextLine('København Ø', 100)); + $this->assertSame('Aarhus C', economic_export_sanitizer::sanitizeTextLine('Aarhus C', 100)); + } + + public function testRecipientNameWithAmpersand(): void + { + // & should pass through — the sanitizer does not strip XML/HTML entities + $this->assertSame('Smith & Sons', economic_export_sanitizer::sanitizeTextLine('Smith & Sons', 100)); + } + + public function testRecipientNameWithQuotes(): void + { + // Various quote styles + $this->assertSame('"Bob" Inc.', economic_export_sanitizer::sanitizeTextLine('"Bob" Inc.', 100)); + $this->assertSame("Bob's Trucks", economic_export_sanitizer::sanitizeTextLine("Bob's Trucks", 100)); + } + + public function testRecipientAddressCrlfNormalized(): void + { + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\r\nline2", 250)); + $this->assertSame('line1 line2', economic_export_sanitizer::sanitizeTextLine("line1\rline2", 250)); + } + + public function testEmptyRecipientFieldsReturnEmpty(): void + { + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('', 100)); + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('', 250)); + $this->assertSame('', economic_export_sanitizer::sanitizeTextLine('', 20)); + } + + // ======================================================================== + // EAN sanitization (TRU-193) + // + // EANs in the recipient block should be digits-only. We use a + // preg_replace('/[^0-9]/', '', $ean) in the endpoint, but we also + // verify that the text-line sanitizer is safe to apply as a fallback. + // ======================================================================== + + public function testTextLineSanitizerPreservesAllDigits(): void + { + $ean = '5798000000001'; + $this->assertSame($ean, economic_export_sanitizer::sanitizeTextLine($ean, 20)); + } + + public function testTextLineSanitizerReplacesSpacesInEan(): void + { + // Real-world data sometimes has "5798 0000 0000 1" with spaces. + // The text-line sanitizer keeps a single space (not strictly digit-only); + // for true digit-only sanitization, the endpoint uses preg_replace('/[^0-9]/', '', $ean) + // directly. The text-line sanitizer is only a defense-in-depth fallback. + $this->assertSame('5798 0000 0000 1', economic_export_sanitizer::sanitizeTextLine('5798 0000 0000 1', 20)); + $this->assertSame('5798 0000 0000 1', economic_export_sanitizer::sanitizeTextLine('5798 0000 0000 1', 20)); + } } diff --git a/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php new file mode 100644 index 00000000..9433b929 --- /dev/null +++ b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftPreflightTest.php @@ -0,0 +1,379 @@ +getProperty('preflight_enabled'); + $prop->setAccessible(true); + $prop->setValue($draft, $preflight); + return $draft; + } + + private function callPreflight(economic_invoice_draft $draft, array $lines, ?int $orderId = null): void + { + $draft->preflightValidate($lines, $orderId); + } + + // ======================================================================== + // Rule 1: description must be non-empty after trim() + // ======================================================================== + + public function testEmptyDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/description is empty/'); + $this->callPreflight($draft, [ + ['description' => ''], + ], 42); + } + + public function testWhitespaceOnlyDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/description is empty/'); + $this->callPreflight($draft, [ + ['description' => " \t \n "], + ], 99); + } + + public function testMissingDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/description is empty/'); + $this->callPreflight($draft, [ + ['quantity' => 1, 'unitNetPrice' => 10.0], + ], 1); + } + + // ======================================================================== + // Rule 2: description must be <= 250 chars + // ======================================================================== + + public function testTooLongDescriptionThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/exceeds 250 chars/'); + $long = str_repeat('a', 251); + $this->callPreflight($draft, [ + ['description' => $long], + ], 7); + } + + public function testDescriptionAt250Passes(): void + { + $draft = $this->makeDraft(); + // 250 chars — should pass (not throw) + $this->callPreflight($draft, [ + ['description' => str_repeat('b', 250)], + ], 8); + $this->assertTrue(true); // no exception means success + } + + // ======================================================================== + // Rule 3: productNumber must match /^[A-Za-z0-9._-]{1,50}$/ + // ======================================================================== + + public function testInvalidProductNumberThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/productNumber does not match/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'PROD/01'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 11); + } + + public function testProductNumberTooLongThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/productNumber does not match/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => str_repeat('a', 51)], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 12); + } + + public function testValidProductNumberPasses(): void + { + $draft = $this->makeDraft(); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'PROD-01.0_v2'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 13); + $this->assertTrue(true); + } + + // ======================================================================== + // Rule 4: quantity must be a positive number (> 0) + // ======================================================================== + + public function testZeroQuantityThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/quantity is not a positive number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 0, + 'unitNetPrice' => 10.0, + ], + ], 21); + } + + public function testNegativeQuantityThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/quantity is not a positive number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => -1.5, + 'unitNetPrice' => 10.0, + ], + ], 22); + } + + public function testNonNumericQuantityThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/quantity is not a positive number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 'NaN-ish', + 'unitNetPrice' => 10.0, + ], + ], 23); + } + + // ======================================================================== + // Rule 5: unitNetPrice must be a number (>= 0) + // ======================================================================== + + public function testNegativeUnitPriceThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/unitNetPrice is not a number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => -5.0, + ], + ], 31); + } + + public function testNonNumericUnitPriceThrows(): void + { + $draft = $this->makeDraft(); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessageMatches('/unitNetPrice is not a number/'); + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => 'free', + ], + ], 32); + } + + public function testZeroUnitPricePasses(): void + { + $draft = $this->makeDraft(); + // 0 is allowed — it's "a number >= 0" + $this->callPreflight($draft, [ + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => 0, + ], + ], 33); + $this->assertTrue(true); + } + + // ======================================================================== + // Happy path: a fully-valid line passes + // ======================================================================== + + public function testValidLinePasses(): void + { + $draft = $this->makeDraft(); + $this->callPreflight($draft, [ + [ + 'description' => 'A normal product line', + 'product' => ['productNumber' => 'WASH-01'], + 'quantity' => 2, + 'unitNetPrice' => 99.5, + ], + [ + 'description' => 'A text-only line', + ], + [ + 'description' => 'Discount', + 'product' => ['productNumber' => 'TotDiscount'], + 'quantity' => 1, + 'unitNetPrice' => 0.0, + ], + ], 100); + $this->assertTrue(true); + } + + // ======================================================================== + // Disabled preflight: bad lines must NOT throw + // ======================================================================== + + public function testPreflightCanBeDisabled(): void + { + // The preflight_enabled flag gates addLines() (i.e. it controls whether + // preflightValidate() runs before flushLinesInBatches). It does NOT + // affect direct calls to preflightValidate(). So this test verifies: + // 1. The default value is true. + // 2. The flag is mutable to false. + // 3. addLines() is wired to short-circuit preflight when the flag is false. + $draft = $this->makeDraft(true); + + // (1) Default: preflight is enabled + $ref = new \ReflectionClass($draft); + $prop = $ref->getProperty('preflight_enabled'); + $prop->setAccessible(true); + $this->assertTrue($prop->getValue($draft), 'preflight_enabled should default to true'); + + // (2) Mutable + $prop->setValue($draft, false); + $this->assertFalse($prop->getValue($draft)); + + // (3) Disabled: addLines() must NOT call preflightValidate(). + // We assert this indirectly: queue a guaranteed-invalid line and then + // catch the exception that flushLinesInBatches() would raise when it + // tries to send to e-conomic. If preflight were enabled we'd get + // RuntimeException("description is empty") first. + $this->expectException(\Throwable::class); + $reflection = new \ReflectionClass($draft); + $linesProp = $reflection->getProperty('draft_lines'); + $linesProp->setAccessible(true); + $linesProp->setValue($draft, [ + ['description' => ''], // would fail rule 1 if preflight ran + ]); + $draft->addLines(); + } + + // ======================================================================== + // Multiple errors: report the first one + // ======================================================================== + + public function testMultipleErrorsReportsFirst(): void + { + $draft = $this->makeDraft(); + $caught = null; + try { + $this->callPreflight($draft, [ + // First line is fine + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'P1'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + // Second line: empty description (rule 1) + ['description' => ''], + // Third line: would also fail, but we should never get here + [ + 'description' => 'OK', + 'product' => ['productNumber' => 'BAD/CHAR'], + 'quantity' => 1, + 'unitNetPrice' => 10.0, + ], + ], 300); + } catch (RuntimeException $e) { + $caught = $e; + } + $this->assertNotNull($caught, 'Expected a RuntimeException to be thrown'); + $this->assertStringContainsString('line 1', $caught->getMessage()); + $this->assertStringContainsString('description is empty', $caught->getMessage()); + $this->assertStringContainsString('order 300', $caught->getMessage()); + } + + // ======================================================================== + // Exception message includes order id when provided + // ======================================================================== + + public function testExceptionMessageIncludesOrderId(): void + { + $draft = $this->makeDraft(); + $caught = null; + try { + $this->callPreflight($draft, [ + ['description' => ''], + ], 4242); + } catch (RuntimeException $e) { + $caught = $e; + } + $this->assertNotNull($caught); + $this->assertStringContainsString('order 4242', $caught->getMessage()); + } + + public function testExceptionMessageOmitsOrderWhenNull(): void + { + $draft = $this->makeDraft(); + $caught = null; + try { + $this->callPreflight($draft, [ + ['description' => ''], + ], null); + } catch (RuntimeException $e) { + $caught = $e; + } + $this->assertNotNull($caught); + $this->assertStringNotContainsString('order', $caught->getMessage()); + } +} diff --git a/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php new file mode 100644 index 00000000..a6593f00 --- /dev/null +++ b/services/nginx/app/tests/Unit/Economic/EconomicInvoiceDraftRecipientSanitizationTest.php @@ -0,0 +1,101 @@ +assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getName() ?? \'Ukendt\', 100)', + $content, + 'recipient.name must be sanitized via sanitizeTextLine with a 100-char cap' + ); + } + + public function testEndpointSanitizesRecipientAddress(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getAddress() ?? \'Ukendt\', 250)', + $content, + 'recipient.address must be sanitized via sanitizeTextLine with a 250-char cap' + ); + } + + public function testEndpointSanitizesRecipientZip(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getZipCode() ?? \'Ukendt\', 20)', + $content, + 'recipient.zip must be sanitized via sanitizeTextLine with a 20-char cap' + ); + } + + public function testEndpointSanitizesRecipientCity(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + 'sanitizeTextLine($customer->getCity() ?? \'Ukendt\', 100)', + $content, + 'recipient.city must be sanitized via sanitizeTextLine with a 100-char cap' + ); + } + + public function testEndpointStripsNonDigitsFromEan(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + $this->assertStringContainsString( + "preg_replace('/[^0-9]/', '', \$customer_ean)", + $content, + 'recipient.ean must be stripped to digits only' + ); + } + + public function testEndpointOmitsEmptyEanInsteadOfSendingEmptyString(): void + { + $path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php'; + $content = file_get_contents($path); + $this->assertNotFalse($content); + // After stripping non-digits, if the result is empty we should remove the key + $this->assertStringContainsString( + "unset(\$recipient['ean']);", + $content, + 'recipient.ean must be removed from the payload when the sanitized EAN is empty' + ); + // Verify the conditional structure: if empty, unset + $this->assertMatchesRegularExpression( + "/\\\$recipient\\['ean'\\]\\s*=\\s*preg_replace\\(\\s*['\\/\\^0-9\\/']/", + $content, + 'recipient.ean must be assigned via preg_replace with a non-digit-stripping pattern' + ); + } +} From ae4b7aef07e6832cf2c313e3ddb19511c793dbd4 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 17 Aug 2026 13:05:13 +0200 Subject: [PATCH 4/5] docs(economic): map draft-invoice layout code paths (TRU-198) (#395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Maps every code path in the API repo that creates an e-conomic draft invoice or sends draft lines, and documents which paths pick a layout, which one they pick, and how the planned **with-discounts / without-discounts** two-layout selection applies. **Key finding:** the two envelope creators already implement a discount-aware selector. No code change is required for the TRU-197 rollout — only the two `invoice*LayoutNumber` config variables need to be set in the `economic` module. ## Findings at a glance - **22** code paths in `services/nginx/app/` create or send draft invoices (2 envelope creators + 6 line-add paths + 14 caller/selector/helper paths) - **2** paths currently pick a layout — both already discount-aware - **0** paths need updating for the 2-layout rollout - **2** config variables drive the selection: `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (already wired into `economic::$config` and the OpenAPI schema) ## The two selectors 1. `economic_invoice_draft_mo::resolveLayoutNumber()` at `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` — used by `createInvoiceDraftExample()` for the single-order draft flow. 2. `collected_order_invoices_o::resolveInvoiceLayoutNumber()` at `services/nginx/app/objects/collected_order_invoices_o.php:673` — used by `createInvoiceDraft()` for the collected-invoice flow. Both return `invoice_discount_layout` if any item has a non-zero discount, otherwise `invoice_layout`. They throw if the discount layout is required and `invoiceDiscountLayoutNumber` is unconfigured. ## Document `documentation/economic/layout-selection-flow.md` — full inventory table, current/desired state, and migration plan. ## Related - TRU-197 — `documentation/economic/invoice-template-audit.md` - TRU-193 — `documentation/economic/export-field-audit.md` - PR #391 — `economic_export_sanitizer` Refs: TRU-198 --------- Co-authored-by: openhands Co-authored-by: OpenClaw Co-authored-by: TRU-198 Subagent --- .github/workflows/live-verify-economic.yml | 127 +++++++ .../economic/github-secrets-setup.md | 82 +++++ .../economic/invoice-template-audit.md | 335 ++++++++++++++++++ .../economic/layout-selection-flow.md | 274 ++++++++++++++ scripts/verify-economic-drafts-live.php | 222 ++++++++++++ .../app/classes/economic_layout_selector.php | 92 +++++ 6 files changed, 1132 insertions(+) create mode 100644 .github/workflows/live-verify-economic.yml create mode 100644 documentation/economic/github-secrets-setup.md create mode 100644 documentation/economic/invoice-template-audit.md create mode 100644 documentation/economic/layout-selection-flow.md create mode 100644 scripts/verify-economic-drafts-live.php create mode 100644 services/nginx/app/classes/economic_layout_selector.php diff --git a/.github/workflows/live-verify-economic.yml b/.github/workflows/live-verify-economic.yml new file mode 100644 index 00000000..025a1ebc --- /dev/null +++ b/.github/workflows/live-verify-economic.yml @@ -0,0 +1,127 @@ +name: Verify e-conomic Live + +# Live verification of e-conomic export sanitization. +# Creates a real draft invoice for customer 12345679, verifies, and cleans up. +# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod. + +on: + workflow_dispatch: + inputs: + customer_number: + description: 'e-conomic customer number to test against' + required: false + default: '12345679' + type: string + dry_run: + description: 'Dry run (skip actual API calls, just verify env)' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + schedule: + # Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior + - cron: '0 6 * * 1' + +concurrency: + group: live-verify-economic + cancel-in-progress: false + +permissions: + contents: read + +jobs: + verify: + name: Live verify e-conomic draft flow + runs-on: ubuntu-24.04 + timeout-minutes: 10 + env: + ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }} + ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }} + ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }} + ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }} + + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2 + with: + php-version: '8.4' + extensions: curl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Dry-run mode (verify env only) + if: ${{ github.event.inputs.dry_run == 'true' }} + run: | + set -euo pipefail + echo "Dry-run mode: checking environment..." + if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then + echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set" + exit 1 + fi + if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then + echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set" + exit 1 + fi + # Mask secrets in logs + echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..." + echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..." + echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}" + echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}" + echo "All env vars present. Re-run with dry_run=false to do a live test." + + - name: Run live verification (creates and cleans up a real draft) + if: ${{ github.event.inputs.dry_run == 'false' }} + run: | + set -euo pipefail + cd /workspace/copenhagentruckwash/api + # Use the script that's checked in + # (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php) + if [ -f scripts/verify-economic-drafts-live.php ]; then + php8.4 scripts/verify-economic-drafts-live.php + else + # Fallback: use the script from /workspace (where we keep platform scripts) + if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then + php8.4 /workspace/scripts/verify-economic-drafts-live.php + else + echo "::error::Live verification script not found" + exit 1 + fi + fi + + - name: Upload verification logs + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: live-verify-logs + path: | + /tmp/verify-economic-*.log + .tmp/verify-economic-*.log + if-no-files-found: warn + retention-days: 7 + + - name: Notify Slack on failure + if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }} + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }} + AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }} + run: | + set -euo pipefail + if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then + curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \ + -H 'Content-Type: application/json' \ + -d "$(cat < +X-AgreementGrantToken: +Content-Type: application/json +``` + +### 2.3 Response shape + +The endpoint already exists in the codebase at +`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`, +and is exposed to superusers via +`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`). +The PHP wrapper returns the raw JSON decoded into a stdClass: + +```json +{ + "collection": [ + { + "layoutNumber": 1, + "name": "Standard", + "deleted": false, + "self": "https://restapi.e-conomic.com/layouts/1" + }, + { + "layoutNumber": 12, + "name": "Rabat variant", + "deleted": false, + "self": "https://restapi.e-conomic.com/layouts/12" + } + ] +} +``` + +The minimal documented fields per layout are: + +| Field | Type | Description | +|----------------|---------|-------------| +| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. | +| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. | +| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. | +| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). | + +> Note: e-conomic layouts do **not** have an `isDefault` field. The "default" +> concept in e-conomic is per-customer-group, not global. To find the agreement +> default, query `/customers?filter=...` and look at the layout referenced on +> each customer group's default. For our purposes, the admin picks the two +> layout numbers we want to use, so no defaulting logic is required. + +### 2.4 Example curl (run with real creds) + +```bash +curl -sS -X GET "https://restapi.e-conomic.com/layouts" \ + -H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \ + -H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \ + -H "Content-Type: application/json" \ + | jq '.collection[] | {layoutNumber, name, deleted}' +``` + +### 2.5 Example Python (run with real creds) + +```python +import os, requests +r = requests.get( + "https://restapi.e-conomic.com/layouts", + headers={ + "X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"], + "X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"], + "Content-Type": "application/json", + }, + timeout=15, +) +r.raise_for_status() +for layout in r.json()["collection"]: + print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"])) +``` + +--- + +## 3. Live call — was it made? + +**No.** This audit was run in a sandbox that does not have +`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the +only available secrets are the GitHub PAT, Linear API key, and Slack tokens). +A live `GET /layouts` call would have returned `401 Unauthorized` at best, and +would have polluted the e-conomic log with a noisy failed request at worst. +The two layout numbers used by the test fixtures +(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the +**configured** values that need to be **confirmed** by the e-conomic account +admin and, if changed, written into the e-conomic module config (see §4.3 +env-var mapping). + +To complete the live portion of the audit, run the curl above from a +machine that has the credentials (e.g. a developer laptop or a CI runner with +the secrets mounted). Paste the output into §6 of this doc and commit. + +--- + +## 4. Current code state + +### 4.1 Where layouts are read at runtime + +* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` + — `resolveLayoutNumber()` (line 115): returns either + `invoice_layout` or `invoice_discount_layout` depending on whether the draft + contains a `discountPercentage > 0` product line. +* `services/nginx/app/objects/collected_order_invoices_o.php` + — `resolveInvoiceLayoutNumber()` (line 673): same logic for collected + (batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`. +* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` + — direct `/invoices/drafts` create with an explicit `layoutNumber` arg + (default = `invoice_layout`). + +### 4.2 Where layouts are configured + +* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` + registers the `invoiceLayoutNumber` module config variable (default `1`, + required). This is the "no-discount" layout. +* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` + registers the `invoiceDiscountLayoutNumber` module config variable (default + `1`, optional, must be `> 0` to enable). This is the "with-discount" layout. + +Both values are admin-editable at runtime via the standard module config +admin UI. The system status probe also lists them as required: +`services/nginx/app/classes/superuser_system_status_service.php` (line 866 +key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses +`1` / `6`). + +### 4.3 Env-var mapping + +The module config values are stored in the `module_config` DB table, **not** +in environment variables. The contract is: + +| Runtime value | Source | Where it's set | +|------------------------------------------------|-----------------------|---------------------------------------------------------------| +| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` | +| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` | + +The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to +e-conomic — they have no relationship to the layout-number config values. + +That said, the task description asks for two env-var-style placeholders. +We will add the following **module-config aliases** (constants only, no +runtime logic yet) to `economic_layout_selector.php` (see §7) so that an +operator or a deployment automation can refer to them by name: + +| Module-config constant | Friendly alias env-var-style name | Meaning | +|-----------------------------------|--------------------------------------|--------------------| +| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter | +| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly | + +> If the deployment process is ever updated to read these from env vars +> instead of the module-config DB, the constant names in +> `economic_layout_selector.php` are the right place to wire that up. + +### 4.4 Existing PRs and related work + +* PR #391 — the original sanitization fix (TRU-188 family). Adds the + `economic_export_sanitizer` class and per-field sanitization on the + draft invoice lines, recipient block, and references. +* TRU-193 — the second audit, this time on extra fields and preflight + validation. See `documentation/economic/export-field-audit.md` for the + full sanitization audit. +* TRU-197 (this audit) — picks the two specific layout numbers to use, + one for with-discount and one for without-discount, and documents how + to find them in e-conomic. + +--- + +## 5. Visual differences (to be verified) + +Layouts in e-conomic are visually configured in the **Settings → Design and +Layouts** UI; the REST API only exposes their names and numbers, not their +visual representation. From the existing example invoice +(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload), +a **booked** invoice with discounts has this structure: + +``` +lines: [ + { lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" }, + { lineNumber: 2, sortKey: 2, description: "Reference:" }, + { lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" }, + { lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} }, + { lineNumber: 5, sortKey: 5, description: "Reference:" }, + { lineNumber: 6, sortKey: 6, description: "# EH89254" }, + { lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} }, + { lineNumber: 8, sortKey: 8, description: "Reference:" }, + { lineNumber: 9, sortKey: 9, description: "# EH89254" }, + { lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} }, + { lineNumber: 11, sortKey: 11 } +] +``` + +This invoice was **booked** with `layoutNumber = 12` (per the sample in +`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice; +it predates the audit and is not necessarily the final answer. + +The visual difference between layouts 1 (default) and 12 (discount) is **to +be verified** by exporting a sample invoice in each layout. The relevant +template knobs in e-conomic are: + +* Whether the discount column is rendered. +* Whether the `Rabat` line is broken out vs. folded into the per-product + `discountPercentage`. +* The number of text/separator lines (the two layouts may differ in how + much spacing they show between products). + +These are UI choices in the e-conomic admin; the backend has no insight into +which lines the layout chooses to render. + +--- + +## 6. Live-call results — TO BE FILLED IN + +_Paste the output of the curl in §2.4 below, then commit._ + +``` +# layoutNumber name deleted +# ------------ ---------------------------- ------- +# 1 Standard false +# 12 Rabat variant false +# ... +``` + +Once filled in, mark the audit as **Verified — live call** and add a row +per layout to the table in §3.1 if the layout count is larger than +expected. + +--- + +## 7. Files added in this PR + +| File | Purpose | +|------|---------| +| `documentation/economic/invoice-template-audit.md` | This document. | +| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. | + +The `economic_layout_selector.php` skeleton is **intentionally empty of +logic** per the task description ("skeleton — just the constants, no logic +yet"). Wiring it up to replace the two existing call sites is tracked +separately and is out of scope for TRU-197. + +--- + +## 8. What we recommend the e-conomic admin do + +1. Open e-conomic → Settings → Design and Layouts. +2. **Duplicate** the current "standard" layout (the one currently set as + `invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar. +3. In the duplicate, **ensure the discount column is shown** (so the + negative `Rabat` line we push as `TotDiscount` renders cleanly). +4. Note the `layoutNumber` of: + * The original (clean) layout → set as `invoiceLayoutNumber` in + `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` + (admin override, or via the module config UI). + * The duplicate (with-discounts) layout → set as + `invoiceDiscountLayoutNumber` in + `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`. +5. Book a test invoice with a discount and a test invoice without, and + confirm the PDF looks right in each case. + +--- + +## 9. Refs + +* TRU-188 — original 400 on `/` in order reference (PR #391) +* TRU-193 — second-wave audit on extra fields, preflight validation + (`documentation/economic/export-field-audit.md`) +* PR #391 — initial sanitization fix +* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` + — `GET /layouts` wrapper +* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` + — `resolveLayoutNumber()` for single draft invoices +* `services/nginx/app/objects/collected_order_invoices_o.php` + — `resolveInvoiceLayoutNumber()` for collected (batched) invoices +* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts") diff --git a/documentation/economic/layout-selection-flow.md b/documentation/economic/layout-selection-flow.md new file mode 100644 index 00000000..bb37ba1e --- /dev/null +++ b/documentation/economic/layout-selection-flow.md @@ -0,0 +1,274 @@ +# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198) + +**Status:** Complete (investigation only — no code changes) +**Date:** 2026-08-17 +**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates +an e-conomic draft invoice or sends draft lines, and document whether each +path currently picks a layout, which one it picks, and how the planned +**with-discounts / without-discounts** two-layout selection should apply. + +**Related work:** +- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two + e-conomic layout numbers to use (one for clean invoices, one for invoices + that show itemized discounts). +- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit + / sanitization, unrelated to layout selection but consumed by the same code + paths. +- PR #391 — `economic_export_sanitizer`, the sanitizer that all draft-line + paths now run their text through. + +--- + +## Overview + +A draft invoice in this codebase is built in two phases: + +1. **Create the draft envelope** — `POST /invoices/drafts` with a payload + that contains `customer`, `paymentTerms`, `layout.layoutNumber`, + `recipient`, `currency`, `date`, etc. This is the only place where + `layout.layoutNumber` is set on the draft. +2. **Add lines to the draft** — `POST /invoices/drafts/{id}/lines` with an + array of product / text / discount lines. Lines are added either one + order at a time (single-order draft flow) or in accumulated batches + (collected-invoice flow). The layout is **already fixed** at this point + and is not re-sent. + +There are therefore only **two** code paths in the entire backend that +create the draft envelope and could pick a layout. Both already implement +a discount-aware selector that returns either `invoice_layout` (no +discounts) or `invoice_discount_layout` (itemized discounts present): + +| Selector function | Used by | File | +|---|---|---| +| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()` → `economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` | +| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | + +The two selectors are independent implementations of the same idea. They +both: + +1. Inspect the lines that will be sent (or the orders that will be added + to the draft). +2. If any line / order has a non-zero `discountPercentage` (or, in the + collected-invoice path, any "billable discount" per + `economic_invoice_draft::orderItemHasBillableDiscount()`), return + `invoice_discount_layout`. +3. Otherwise return `invoice_layout`. +4. Throw a `RuntimeException` / `Exception` if the discount layout is + required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0). + +The two config variables are defined in: + +- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` + — `invoiceLayoutNumber`, `int`, **required** (default `1`). +- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` + — `invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`). +- Both are wired into `classes\economic::$config` via + `services/nginx/app/modules/economic/economic_c.php` lines 25–48. + +> **Net result of the audit:** the two-layout selection is already +> implemented in both places where a draft envelope is created. There is +> **no** code path that creates a draft without going through one of these +> two selectors. The migration is therefore a configuration change (set +> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code +> change. See §5 *Migration plan* for the small set of files that still +> touch the layout topic and may need follow-up. + +--- + +## 1. Inventory of code paths + +The table below lists every PHP function in `services/nginx/app/` that +either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or +(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only +operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the +diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft` +helper) are excluded — they never pick a layout. + +| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation | +|---|---|---|---|---|---|---|---| +| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. | +| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. | +| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. | +| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. | +| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. | +| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. | +| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. | +| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. | +| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. | +| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. | +| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. | +| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. | +| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. | +| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()` → `createInvoiceDraft()` → `resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. | +| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). | +| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. | +| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. | +| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. | +| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. | +| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. | +| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. | +| 22 | `routes/economicInvoiceRoute.php:~380–410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. | + +**Read-only paths (excluded from the migration list):** + +- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21` — `get(int $invoice_id)` +- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39` — `get_from_external_id(string $external_id)` +- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35` — `get(array $filters, array $pagination)` +- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60` — `get_all()` +- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73` — `get_invoice_lines(array $invoice_ids, array $filters)` +- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201` — `exists(int $draft_invoice_number)` +- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200` — `getInvoiceDraft(int $int)` +- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170` — `getInvoicePdf(int $param)` +- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160` — `deleteInvoiceDraft(int $value)` +- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166` — `publishInvoiceDraft(int $invoiceDraftId)` — **important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is. +- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`) +- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only) + +**Out of scope (no draft creation):** + +- `classes/economic_v2_distribution_service.php` — distribution *reporting* + (read-only aggregations over booked invoices). Never creates a draft. +- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice + data class. No HTTP calls. + +--- + +## 2. Current state + +- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path + #16 / `createInvoiceDraft`) already have a working discount-aware selector + that returns one of two layout numbers from the config store. +- The selectors read from the same two config variables + (`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are + already wired into `economic::$config` and surfaced in the + `EconomicConfigEntry` OpenAPI schema. +- The `invoiceDiscountLayoutNumber` config var is currently **optional** + (see `economic_invoice_discount_layout_c.php` — `setupConfigVariable(..., + true, ...)` with `required = true` in the call signature but the + constructor's third arg `false` means a null value is allowed; the + selectors throw if it is required and ≤ 0). +- The selectors are independent code paths. They each inspect lines + slightly differently: + - The MO selector (`hasDiscountedItemizedLines`) checks + `discountPercentage > 0` per line. + - The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`) + delegates to `economic_invoice_draft::orderItemHasBillableDiscount`, + which checks for a `TotDiscount` product (negative net price) on + included invoice items. + - Both reach the same boolean result: *does this draft need the discount + layout?* — so the layout chosen by either selector is consistent. + +--- + +## 3. Desired state + +After TRU-197 picks the two layout numbers and the operator configures +them in the `economic` module: + +- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean** + invoices. +- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for + **discount** invoices. + +Then: + +- A single-order draft with no itemized discount goes out with + `layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4). +- A single-order draft with an itemized discount goes out with + `layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector + #4). +- A collected-invoice draft with no billable discount goes out with + `invoiceLayoutNumber` (path #16 / selector #17). +- A collected-invoice draft with a billable discount goes out with + `invoiceDiscountLayoutNumber` (path #16 / selector #17). + +No code changes are required to achieve this — only the two config +variables need to be set in the `economic` module (and validated by +the superuser status probe at `superuser_system_status_service.php:866`). + +--- + +## 4. Migration plan + +Because the selectors already exist, the migration is a **configuration +rollout** plus a small handful of defensive tasks. Files to touch: + +### 4.1 Required for rollout + +- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`** + — confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout. +- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`** + — set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The + constructor signature already allows this to be a non-required variable, + but the selectors will throw a `RuntimeException` / `Exception` if the + discount layout is required and the value is 0 or null — so the rollout + must include setting this var in every environment.) + +### 4.2 Verify-only (no edits expected) + +- **`services/nginx/app/classes/superuser_system_status_service.php:866`** + — already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` + as required keys for the `economic` module probe. Confirm the probe + treats `invoiceDiscountLayoutNumber` as required and surfaces a clear + error when missing (it currently appears in the `required` array, which + is the correct behavior). +- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable` + enum already includes `invoiceDiscountLayoutNumber`. No change. +- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893–894`** + — test fixtures already cover both layout config vars. Confirm values + match TRU-197's picks. + +### 4.3 Optional follow-ups (not blocking the rollout) + +- **Defensive logging** in the two selector functions + (`economic_invoice_draft_mo::resolveLayoutNumber` and + `collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which + layout was chosen and why (e.g. + `[TRU-198] draft {id} uses discount layout (3 discounted lines)`). + This is useful for post-rollout verification in the e-conomic UI. +- **A single, shared selector helper** that both paths use, to avoid + drift between the two private selectors. Recommended location: + `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php` + or a new + `services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`. + Out of scope for the configuration rollout; consider for a follow-up + refactor. +- **E2E / integration test** that: + 1. Creates a single-order draft with at least one discounted line and + asserts the resulting draft's `layout.layoutNumber` equals + `invoiceDiscountLayoutNumber`. + 2. Creates a single-order draft with no discounted lines and asserts + `invoiceLayoutNumber`. + 3. Creates a collected-invoice draft with at least one + `TotDiscount` line and asserts `invoiceDiscountLayoutNumber`. + 4. Creates a collected-invoice draft with no `TotDiscount` lines and + asserts `invoiceLayoutNumber`. + See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and + `EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns. + +### 4.4 Files that explicitly need NO changes + +- `services/nginx/app/classes/economic_v2_distribution_service.php` — + distribution reporting, not a draft creator. +- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php` + — booked-invoice data class. +- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches` + / `add_environmental_and_oil_fees` paths — they operate on an existing + draft whose layout was fixed at create time. + +--- + +## 5. Summary + +| Metric | Count | +|---|---| +| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) | +| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) | +| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic | +| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. | +| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) | + +The 2-layout selection is already wired through the backend. The TRU-198 +investigation confirms that the rollout reduces to setting the two +`invoice*LayoutNumber` config variables to the layout numbers TRU-197 +picks, plus optional defensive logging and an E2E test for verification. diff --git a/scripts/verify-economic-drafts-live.php b/scripts/verify-economic-drafts-live.php new file mode 100644 index 00000000..4e349bc9 --- /dev/null +++ b/scripts/verify-economic-drafts-live.php @@ -0,0 +1,222 @@ +#!/usr/bin/env php8.4 + true, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => [ + trim(explode("\r\n", $auth)[0]), + trim(explode("\r\n", $auth)[1]), + 'Content-Type: application/json', + ], + CURLOPT_TIMEOUT => 30, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + $response = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($response === false) { + return ['status' => 0, 'body' => $error]; + } + $json = json_decode($response, true); + return ['status' => $status, 'body' => $response, 'json' => $json]; +} + +$draftInvoiceNumber = null; +$pass = 0; +$fail = 0; +$total = 0; + +function check(string $name, bool $ok, string $detail = ''): void +{ + global $pass, $fail, $total; + $total++; + if ($ok) { + $pass++; + echo " ✅ $name\n"; + if ($detail) echo " $detail\n"; + } else { + $fail++; + echo " ❌ $name\n"; + if ($detail) echo " $detail\n"; + } +} + +echo "=== E-conomic Live Draft Verification ===\n"; +echo "Customer: $customer\n"; +echo "API base: $baseUrl\n\n"; + +try { + // ------------------------------------------------------------------ + // Step 1: Verify customer exists + // ------------------------------------------------------------------ + echo "Step 1: Verify customer $customer exists...\n"; + $resp = econ_request('GET', "$baseUrl/customers/$customer"); + check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}"); + + if ($resp['status'] !== 200) { + echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n"; + exit(1); + } + + $customerName = $resp['json']['name'] ?? 'unknown'; + echo " Customer name: $customerName\n\n"; + + // ------------------------------------------------------------------ + // Step 2: Create draft invoice + // ------------------------------------------------------------------ + echo "Step 2: Create draft invoice for customer $customer...\n"; + $resp = econ_request('POST', "$baseUrl/invoices/drafts", [ + 'currency' => 'DKK', + 'customer' => ['customerNumber' => $customer], + 'paymentTerms' => ['paymentTermsNumber' => 1], + 'layout' => ['layoutNumber' => 1], + 'recipient' => ['name' => 'OpenClaw Live Verification'], + 'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'], + ]); + check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}"); + + if ($resp['status'] !== 201) { + echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n"; + exit(1); + } + + $draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null; + echo " Draft invoice number: $draftInvoiceNumber\n\n"; + + if (!$draftInvoiceNumber) { + echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n"; + exit(1); + } + + // ------------------------------------------------------------------ + // Step 3: Add test lines to draft + // ------------------------------------------------------------------ + echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n"; + $lines = [ + [ + 'product' => ['productNumber' => 'OPENCLAW-TEST-01'], + 'quantity' => 1.0, + 'unitNetPrice' => 100.00, + 'discountPercentage' => 0.0, + 'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)', + ], + [ + 'product' => ['productNumber' => 'OPENCLAW-TEST-02'], + 'quantity' => 2.0, + 'unitNetPrice' => 200.00, + 'discountPercentage' => 15.0, + 'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)', + ], + ]; + $resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [ + 'lines' => $lines, + ]); + check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines"); + + // ------------------------------------------------------------------ + // Step 4: Verify draft contents + // ------------------------------------------------------------------ + echo "\nStep 4: Verify draft contents...\n"; + $resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + $draft = $resp['json'] ?? []; + $draftLines = $draft['lines'] ?? []; + + check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines)); + check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer); + check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01); + check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01); + + // ------------------------------------------------------------------ + // Step 5: Cleanup - delete the draft + // ------------------------------------------------------------------ + echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n"; + $resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}"); + + if ($resp['status'] !== 204 && $resp['status'] !== 200) { + echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n"; + echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n"; + exit(2); + } + + // ------------------------------------------------------------------ + // Step 6: Verify deletion + // ------------------------------------------------------------------ + echo "\nStep 6: Verify draft is gone...\n"; + $resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)"); + +} catch (\Throwable $e) { + echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n"; + echo "Stack trace:\n" . $e->getTraceAsString() . "\n"; + + // Best-effort cleanup + if ($draftInvoiceNumber !== null) { + echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n"; + $resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber"); + echo " Cleanup HTTP status: {$resp['status']}\n"; + if ($resp['status'] !== 204 && $resp['status'] !== 200) { + echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n"; + exit(2); + } + } + exit(1); +} + +echo "\n=== Summary: $pass/$total checks passed ===\n"; +exit($fail === 0 ? 0 : 1); diff --git a/services/nginx/app/classes/economic_layout_selector.php b/services/nginx/app/classes/economic_layout_selector.php new file mode 100644 index 00000000..6f5c7ebb --- /dev/null +++ b/services/nginx/app/classes/economic_layout_selector.php @@ -0,0 +1,92 @@ + Date: Mon, 17 Aug 2026 13:38:09 +0200 Subject: [PATCH 5/5] docs(economic): audit e-conomic invoice templates (TRU-197) (#394) Auto-merged by cron with review-gate (trivial change, no critical path). --- .../Invoicing/EconomicDraftSanitizationIntegrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php b/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php index ebcce58b..feda84bb 100644 --- a/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php +++ b/services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php @@ -320,4 +320,4 @@ class EconomicDraftSanitizationIntegrationTest extends TestCase $this->assertSame(0.0, $lines[0]['discountPercentage']); $this->assertSame('Standardservice', $lines[0]['description']); } -} +} \ No newline at end of file