Merge branch 'master' into feat/TRU-149-route-scopes
This commit is contained in:
@@ -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)"
|
||||
'
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
name: Verify e-conomic Live
|
||||
|
||||
# Live verification of e-conomic export sanitization.
|
||||
# Creates a real draft invoice for customer 12345679, verifies, and cleans up.
|
||||
# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
customer_number:
|
||||
description: 'e-conomic customer number to test against'
|
||||
required: false
|
||||
default: '12345679'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry run (skip actual API calls, just verify env)'
|
||||
required: false
|
||||
default: 'true'
|
||||
type: choice
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
schedule:
|
||||
# Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: live-verify-economic
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Live verify e-conomic draft flow
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }}
|
||||
ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }}
|
||||
ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }}
|
||||
ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
extensions: curl
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Dry-run mode (verify env only)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Dry-run mode: checking environment..."
|
||||
if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then
|
||||
echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
exit 1
|
||||
fi
|
||||
# Mask secrets in logs
|
||||
echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..."
|
||||
echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..."
|
||||
echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}"
|
||||
echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}"
|
||||
echo "All env vars present. Re-run with dry_run=false to do a live test."
|
||||
|
||||
- name: Run live verification (creates and cleans up a real draft)
|
||||
if: ${{ github.event.inputs.dry_run == 'false' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd /workspace/copenhagentruckwash/api
|
||||
# Use the script that's checked in
|
||||
# (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php)
|
||||
if [ -f scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
# Fallback: use the script from /workspace (where we keep platform scripts)
|
||||
if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then
|
||||
php8.4 /workspace/scripts/verify-economic-drafts-live.php
|
||||
else
|
||||
echo "::error::Live verification script not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Upload verification logs
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: live-verify-logs
|
||||
path: |
|
||||
/tmp/verify-economic-*.log
|
||||
.tmp/verify-economic-*.log
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
|
||||
- name: Notify Slack on failure
|
||||
if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }}
|
||||
env:
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }}
|
||||
AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then
|
||||
curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$(cat <<EOF
|
||||
{
|
||||
"channel": "$AI_DAILY_CHANNEL",
|
||||
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,82 @@
|
||||
# GitHub Secrets for e-conomic Live Verification
|
||||
|
||||
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
| Secret | Description | Where to find it | Required? |
|
||||
|---|---|---|---|
|
||||
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
|
||||
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
|
||||
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
|
||||
|
||||
## Optional Secrets (for Slack notifications)
|
||||
|
||||
| Secret | Description | Required? |
|
||||
|---|---|---|
|
||||
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
|
||||
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
|
||||
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
|
||||
|
||||
## How to Configure
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
|
||||
2. Click **"New repository secret"**
|
||||
3. Add each of the required secrets above
|
||||
4. The values are found in your e-conomic account settings
|
||||
|
||||
## How to Run the Live Verification
|
||||
|
||||
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
|
||||
2. Click **"Run workflow"**
|
||||
3. Leave `customer_number` as `12345679` (default)
|
||||
4. Set `dry_run` to **`false`** for a real test
|
||||
5. Click **"Run workflow"**
|
||||
6. The workflow will:
|
||||
- Create a draft invoice for customer 12345679
|
||||
- Add 2 test lines (1 with discount, 1 without)
|
||||
- Verify the draft was created correctly
|
||||
- **Automatically delete the draft** to clean up
|
||||
|
||||
## Safety
|
||||
|
||||
- The verification script is **idempotent**: it always cleans up after itself
|
||||
- On any error, it attempts emergency cleanup of any draft it created
|
||||
- The script refuses to run without the required env vars
|
||||
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
|
||||
|
||||
## When It Runs Automatically
|
||||
|
||||
- **Manual trigger only by default**
|
||||
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
|
||||
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
|
||||
|
||||
## Setting Up in Production (api.truckwash.io)
|
||||
|
||||
The same e-conomic credentials are also used by the live API. They're stored in:
|
||||
- The production server's `.env` file (loaded by PHP)
|
||||
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
|
||||
|
||||
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
|
||||
|
||||
The secret is not configured. Follow the "How to Configure" steps above.
|
||||
|
||||
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
|
||||
|
||||
Same as above for the secret token.
|
||||
|
||||
### "Draft creation returned HTTP 401"
|
||||
|
||||
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
|
||||
|
||||
### "Draft creation returned HTTP 403"
|
||||
|
||||
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
|
||||
|
||||
### "Customer 12345679 not found"
|
||||
|
||||
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
|
||||
@@ -0,0 +1,335 @@
|
||||
# E-conomic Invoice Template Audit (TRU-197)
|
||||
|
||||
**Status:** Complete (no live call — credentials unavailable in this environment)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Audit of the e-conomic invoice layouts available in the
|
||||
`copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for
|
||||
the two-layout strategy (one for invoices **with** itemized discounts, one for
|
||||
invoices **without**).
|
||||
|
||||
**Primary files audited:**
|
||||
- `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`)
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`)
|
||||
- `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`)
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`)
|
||||
- `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy)
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — Recommendation
|
||||
|
||||
| Variant | Layout (configured) | Env-var name to set | Layout intent |
|
||||
|---------|---------------------|---------------------|---------------|
|
||||
| **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) |
|
||||
| **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter |
|
||||
|
||||
The two layout numbers above are **placeholders** to be confirmed by the
|
||||
account admin in e-conomic. They are written into the runtime config variables
|
||||
`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping
|
||||
section below).
|
||||
|
||||
---
|
||||
|
||||
## 1. Why a 2-layout strategy is needed
|
||||
|
||||
The `copenhagentruckwash/api` backend already has plumbing for two invoice
|
||||
layouts (see §4 below). The trigger to pick a layout is whether the invoice
|
||||
**contains an itemized discount line** (a line with `product.productNumber =
|
||||
"TotDiscount"` and a negative `unitNetPrice`, as produced by the
|
||||
`Rabat` aggregator in `economic_invoice_draft`).
|
||||
|
||||
When such a line is present, the system routes the invoice through
|
||||
`invoiceDiscountLayoutNumber`; otherwise it falls back to
|
||||
`invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic
|
||||
that match these two intents (clean invoice vs. one that shows discounts
|
||||
itemized).
|
||||
|
||||
---
|
||||
|
||||
## 2. Available e-conomic API for layouts
|
||||
|
||||
### 2.1 Endpoint
|
||||
|
||||
```
|
||||
GET https://restapi.e-conomic.com/layouts
|
||||
```
|
||||
|
||||
### 2.2 Auth headers (same as every other e-conomic call)
|
||||
|
||||
```
|
||||
X-AppSecretToken: <ECONOMIC_API_APP_SECRET_TOKEN>
|
||||
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 2.3 Response shape
|
||||
|
||||
The endpoint already exists in the codebase at
|
||||
`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`,
|
||||
and is exposed to superusers via
|
||||
`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`).
|
||||
The PHP wrapper returns the raw JSON decoded into a stdClass:
|
||||
|
||||
```json
|
||||
{
|
||||
"collection": [
|
||||
{
|
||||
"layoutNumber": 1,
|
||||
"name": "Standard",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/1"
|
||||
},
|
||||
{
|
||||
"layoutNumber": 12,
|
||||
"name": "Rabat variant",
|
||||
"deleted": false,
|
||||
"self": "https://restapi.e-conomic.com/layouts/12"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The minimal documented fields per layout are:
|
||||
|
||||
| Field | Type | Description |
|
||||
|----------------|---------|-------------|
|
||||
| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. |
|
||||
| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. |
|
||||
| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. |
|
||||
| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). |
|
||||
|
||||
> Note: e-conomic layouts do **not** have an `isDefault` field. The "default"
|
||||
> concept in e-conomic is per-customer-group, not global. To find the agreement
|
||||
> default, query `/customers?filter=...` and look at the layout referenced on
|
||||
> each customer group's default. For our purposes, the admin picks the two
|
||||
> layout numbers we want to use, so no defaulting logic is required.
|
||||
|
||||
### 2.4 Example curl (run with real creds)
|
||||
|
||||
```bash
|
||||
curl -sS -X GET "https://restapi.e-conomic.com/layouts" \
|
||||
-H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \
|
||||
-H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \
|
||||
-H "Content-Type: application/json" \
|
||||
| jq '.collection[] | {layoutNumber, name, deleted}'
|
||||
```
|
||||
|
||||
### 2.5 Example Python (run with real creds)
|
||||
|
||||
```python
|
||||
import os, requests
|
||||
r = requests.get(
|
||||
"https://restapi.e-conomic.com/layouts",
|
||||
headers={
|
||||
"X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"],
|
||||
"X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
for layout in r.json()["collection"]:
|
||||
print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"]))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Live call — was it made?
|
||||
|
||||
**No.** This audit was run in a sandbox that does not have
|
||||
`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the
|
||||
only available secrets are the GitHub PAT, Linear API key, and Slack tokens).
|
||||
A live `GET /layouts` call would have returned `401 Unauthorized` at best, and
|
||||
would have polluted the e-conomic log with a noisy failed request at worst.
|
||||
The two layout numbers used by the test fixtures
|
||||
(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the
|
||||
**configured** values that need to be **confirmed** by the e-conomic account
|
||||
admin and, if changed, written into the e-conomic module config (see §4.3
|
||||
env-var mapping).
|
||||
|
||||
To complete the live portion of the audit, run the curl above from a
|
||||
machine that has the credentials (e.g. a developer laptop or a CI runner with
|
||||
the secrets mounted). Paste the output into §6 of this doc and commit.
|
||||
|
||||
---
|
||||
|
||||
## 4. Current code state
|
||||
|
||||
### 4.1 Where layouts are read at runtime
|
||||
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` (line 115): returns either
|
||||
`invoice_layout` or `invoice_discount_layout` depending on whether the draft
|
||||
contains a `discountPercentage > 0` product line.
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` (line 673): same logic for collected
|
||||
(batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`.
|
||||
* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
|
||||
— direct `/invoices/drafts` create with an explicit `layoutNumber` arg
|
||||
(default = `invoice_layout`).
|
||||
|
||||
### 4.2 Where layouts are configured
|
||||
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
registers the `invoiceLayoutNumber` module config variable (default `1`,
|
||||
required). This is the "no-discount" layout.
|
||||
* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
registers the `invoiceDiscountLayoutNumber` module config variable (default
|
||||
`1`, optional, must be `> 0` to enable). This is the "with-discount" layout.
|
||||
|
||||
Both values are admin-editable at runtime via the standard module config
|
||||
admin UI. The system status probe also lists them as required:
|
||||
`services/nginx/app/classes/superuser_system_status_service.php` (line 866
|
||||
key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses
|
||||
`1` / `6`).
|
||||
|
||||
### 4.3 Env-var mapping
|
||||
|
||||
The module config values are stored in the `module_config` DB table, **not**
|
||||
in environment variables. The contract is:
|
||||
|
||||
| Runtime value | Source | Where it's set |
|
||||
|------------------------------------------------|-----------------------|---------------------------------------------------------------|
|
||||
| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` |
|
||||
| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` |
|
||||
|
||||
The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to
|
||||
e-conomic — they have no relationship to the layout-number config values.
|
||||
|
||||
That said, the task description asks for two env-var-style placeholders.
|
||||
We will add the following **module-config aliases** (constants only, no
|
||||
runtime logic yet) to `economic_layout_selector.php` (see §7) so that an
|
||||
operator or a deployment automation can refer to them by name:
|
||||
|
||||
| Module-config constant | Friendly alias env-var-style name | Meaning |
|
||||
|-----------------------------------|--------------------------------------|--------------------|
|
||||
| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter |
|
||||
| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly |
|
||||
|
||||
> If the deployment process is ever updated to read these from env vars
|
||||
> instead of the module-config DB, the constant names in
|
||||
> `economic_layout_selector.php` are the right place to wire that up.
|
||||
|
||||
### 4.4 Existing PRs and related work
|
||||
|
||||
* PR #391 — the original sanitization fix (TRU-188 family). Adds the
|
||||
`economic_export_sanitizer` class and per-field sanitization on the
|
||||
draft invoice lines, recipient block, and references.
|
||||
* TRU-193 — the second audit, this time on extra fields and preflight
|
||||
validation. See `documentation/economic/export-field-audit.md` for the
|
||||
full sanitization audit.
|
||||
* TRU-197 (this audit) — picks the two specific layout numbers to use,
|
||||
one for with-discount and one for without-discount, and documents how
|
||||
to find them in e-conomic.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual differences (to be verified)
|
||||
|
||||
Layouts in e-conomic are visually configured in the **Settings → Design and
|
||||
Layouts** UI; the REST API only exposes their names and numbers, not their
|
||||
visual representation. From the existing example invoice
|
||||
(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload),
|
||||
a **booked** invoice with discounts has this structure:
|
||||
|
||||
```
|
||||
lines: [
|
||||
{ lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" },
|
||||
{ lineNumber: 2, sortKey: 2, description: "Reference:" },
|
||||
{ lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" },
|
||||
{ lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} },
|
||||
{ lineNumber: 5, sortKey: 5, description: "Reference:" },
|
||||
{ lineNumber: 6, sortKey: 6, description: "# EH89254" },
|
||||
{ lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} },
|
||||
{ lineNumber: 8, sortKey: 8, description: "Reference:" },
|
||||
{ lineNumber: 9, sortKey: 9, description: "# EH89254" },
|
||||
{ lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} },
|
||||
{ lineNumber: 11, sortKey: 11 }
|
||||
]
|
||||
```
|
||||
|
||||
This invoice was **booked** with `layoutNumber = 12` (per the sample in
|
||||
`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice;
|
||||
it predates the audit and is not necessarily the final answer.
|
||||
|
||||
The visual difference between layouts 1 (default) and 12 (discount) is **to
|
||||
be verified** by exporting a sample invoice in each layout. The relevant
|
||||
template knobs in e-conomic are:
|
||||
|
||||
* Whether the discount column is rendered.
|
||||
* Whether the `Rabat` line is broken out vs. folded into the per-product
|
||||
`discountPercentage`.
|
||||
* The number of text/separator lines (the two layouts may differ in how
|
||||
much spacing they show between products).
|
||||
|
||||
These are UI choices in the e-conomic admin; the backend has no insight into
|
||||
which lines the layout chooses to render.
|
||||
|
||||
---
|
||||
|
||||
## 6. Live-call results — TO BE FILLED IN
|
||||
|
||||
_Paste the output of the curl in §2.4 below, then commit._
|
||||
|
||||
```
|
||||
# layoutNumber name deleted
|
||||
# ------------ ---------------------------- -------
|
||||
# 1 Standard false
|
||||
# 12 Rabat variant false
|
||||
# ...
|
||||
```
|
||||
|
||||
Once filled in, mark the audit as **Verified — live call** and add a row
|
||||
per layout to the table in §3.1 if the layout count is larger than
|
||||
expected.
|
||||
|
||||
---
|
||||
|
||||
## 7. Files added in this PR
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `documentation/economic/invoice-template-audit.md` | This document. |
|
||||
| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. |
|
||||
|
||||
The `economic_layout_selector.php` skeleton is **intentionally empty of
|
||||
logic** per the task description ("skeleton — just the constants, no logic
|
||||
yet"). Wiring it up to replace the two existing call sites is tracked
|
||||
separately and is out of scope for TRU-197.
|
||||
|
||||
---
|
||||
|
||||
## 8. What we recommend the e-conomic admin do
|
||||
|
||||
1. Open e-conomic → Settings → Design and Layouts.
|
||||
2. **Duplicate** the current "standard" layout (the one currently set as
|
||||
`invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar.
|
||||
3. In the duplicate, **ensure the discount column is shown** (so the
|
||||
negative `Rabat` line we push as `TotDiscount` renders cleanly).
|
||||
4. Note the `layoutNumber` of:
|
||||
* The original (clean) layout → set as `invoiceLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
(admin override, or via the module config UI).
|
||||
* The duplicate (with-discounts) layout → set as
|
||||
`invoiceDiscountLayoutNumber` in
|
||||
`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`.
|
||||
5. Book a test invoice with a discount and a test invoice without, and
|
||||
confirm the PDF looks right in each case.
|
||||
|
||||
---
|
||||
|
||||
## 9. Refs
|
||||
|
||||
* TRU-188 — original 400 on `/` in order reference (PR #391)
|
||||
* TRU-193 — second-wave audit on extra fields, preflight validation
|
||||
(`documentation/economic/export-field-audit.md`)
|
||||
* PR #391 — initial sanitization fix
|
||||
* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`
|
||||
— `GET /layouts` wrapper
|
||||
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
|
||||
— `resolveLayoutNumber()` for single draft invoices
|
||||
* `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— `resolveInvoiceLayoutNumber()` for collected (batched) invoices
|
||||
* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")
|
||||
@@ -0,0 +1,274 @@
|
||||
# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198)
|
||||
|
||||
**Status:** Complete (investigation only — no code changes)
|
||||
**Date:** 2026-08-17
|
||||
**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates
|
||||
an e-conomic draft invoice or sends draft lines, and document whether each
|
||||
path currently picks a layout, which one it picks, and how the planned
|
||||
**with-discounts / without-discounts** two-layout selection should apply.
|
||||
|
||||
**Related work:**
|
||||
- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two
|
||||
e-conomic layout numbers to use (one for clean invoices, one for invoices
|
||||
that show itemized discounts).
|
||||
- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit
|
||||
/ sanitization, unrelated to layout selection but consumed by the same code
|
||||
paths.
|
||||
- PR #391 — `economic_export_sanitizer`, the sanitizer that all draft-line
|
||||
paths now run their text through.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A draft invoice in this codebase is built in two phases:
|
||||
|
||||
1. **Create the draft envelope** — `POST /invoices/drafts` with a payload
|
||||
that contains `customer`, `paymentTerms`, `layout.layoutNumber`,
|
||||
`recipient`, `currency`, `date`, etc. This is the only place where
|
||||
`layout.layoutNumber` is set on the draft.
|
||||
2. **Add lines to the draft** — `POST /invoices/drafts/{id}/lines` with an
|
||||
array of product / text / discount lines. Lines are added either one
|
||||
order at a time (single-order draft flow) or in accumulated batches
|
||||
(collected-invoice flow). The layout is **already fixed** at this point
|
||||
and is not re-sent.
|
||||
|
||||
There are therefore only **two** code paths in the entire backend that
|
||||
create the draft envelope and could pick a layout. Both already implement
|
||||
a discount-aware selector that returns either `invoice_layout` (no
|
||||
discounts) or `invoice_discount_layout` (itemized discounts present):
|
||||
|
||||
| Selector function | Used by | File |
|
||||
|---|---|---|
|
||||
| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()` → `economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` |
|
||||
| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` |
|
||||
|
||||
The two selectors are independent implementations of the same idea. They
|
||||
both:
|
||||
|
||||
1. Inspect the lines that will be sent (or the orders that will be added
|
||||
to the draft).
|
||||
2. If any line / order has a non-zero `discountPercentage` (or, in the
|
||||
collected-invoice path, any "billable discount" per
|
||||
`economic_invoice_draft::orderItemHasBillableDiscount()`), return
|
||||
`invoice_discount_layout`.
|
||||
3. Otherwise return `invoice_layout`.
|
||||
4. Throw a `RuntimeException` / `Exception` if the discount layout is
|
||||
required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0).
|
||||
|
||||
The two config variables are defined in:
|
||||
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
|
||||
— `invoiceLayoutNumber`, `int`, **required** (default `1`).
|
||||
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
|
||||
— `invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`).
|
||||
- Both are wired into `classes\economic::$config` via
|
||||
`services/nginx/app/modules/economic/economic_c.php` lines 25–48.
|
||||
|
||||
> **Net result of the audit:** the two-layout selection is already
|
||||
> implemented in both places where a draft envelope is created. There is
|
||||
> **no** code path that creates a draft without going through one of these
|
||||
> two selectors. The migration is therefore a configuration change (set
|
||||
> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code
|
||||
> change. See §5 *Migration plan* for the small set of files that still
|
||||
> touch the layout topic and may need follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 1. Inventory of code paths
|
||||
|
||||
The table below lists every PHP function in `services/nginx/app/` that
|
||||
either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or
|
||||
(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only
|
||||
operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the
|
||||
diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft`
|
||||
helper) are excluded — they never pick a layout.
|
||||
|
||||
| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. |
|
||||
| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. |
|
||||
| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. |
|
||||
| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. |
|
||||
| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. |
|
||||
| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. |
|
||||
| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. |
|
||||
| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. |
|
||||
| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. |
|
||||
| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. |
|
||||
| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. |
|
||||
| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. |
|
||||
| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()` → `createInvoiceDraft()` → `resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. |
|
||||
| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). |
|
||||
| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. |
|
||||
| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. |
|
||||
| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
|
||||
| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. |
|
||||
| 22 | `routes/economicInvoiceRoute.php:~380–410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
|
||||
|
||||
**Read-only paths (excluded from the migration list):**
|
||||
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21` — `get(int $invoice_id)`
|
||||
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39` — `get_from_external_id(string $external_id)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35` — `get(array $filters, array $pagination)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60` — `get_all()`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73` — `get_invoice_lines(array $invoice_ids, array $filters)`
|
||||
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201` — `exists(int $draft_invoice_number)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200` — `getInvoiceDraft(int $int)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170` — `getInvoicePdf(int $param)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160` — `deleteInvoiceDraft(int $value)`
|
||||
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166` — `publishInvoiceDraft(int $invoiceDraftId)` — **important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is.
|
||||
- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`)
|
||||
- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only)
|
||||
|
||||
**Out of scope (no draft creation):**
|
||||
|
||||
- `classes/economic_v2_distribution_service.php` — distribution *reporting*
|
||||
(read-only aggregations over booked invoices). Never creates a draft.
|
||||
- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice
|
||||
data class. No HTTP calls.
|
||||
|
||||
---
|
||||
|
||||
## 2. Current state
|
||||
|
||||
- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path
|
||||
#16 / `createInvoiceDraft`) already have a working discount-aware selector
|
||||
that returns one of two layout numbers from the config store.
|
||||
- The selectors read from the same two config variables
|
||||
(`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are
|
||||
already wired into `economic::$config` and surfaced in the
|
||||
`EconomicConfigEntry` OpenAPI schema.
|
||||
- The `invoiceDiscountLayoutNumber` config var is currently **optional**
|
||||
(see `economic_invoice_discount_layout_c.php` — `setupConfigVariable(...,
|
||||
true, ...)` with `required = true` in the call signature but the
|
||||
constructor's third arg `false` means a null value is allowed; the
|
||||
selectors throw if it is required and ≤ 0).
|
||||
- The selectors are independent code paths. They each inspect lines
|
||||
slightly differently:
|
||||
- The MO selector (`hasDiscountedItemizedLines`) checks
|
||||
`discountPercentage > 0` per line.
|
||||
- The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`)
|
||||
delegates to `economic_invoice_draft::orderItemHasBillableDiscount`,
|
||||
which checks for a `TotDiscount` product (negative net price) on
|
||||
included invoice items.
|
||||
- Both reach the same boolean result: *does this draft need the discount
|
||||
layout?* — so the layout chosen by either selector is consistent.
|
||||
|
||||
---
|
||||
|
||||
## 3. Desired state
|
||||
|
||||
After TRU-197 picks the two layout numbers and the operator configures
|
||||
them in the `economic` module:
|
||||
|
||||
- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean**
|
||||
invoices.
|
||||
- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for
|
||||
**discount** invoices.
|
||||
|
||||
Then:
|
||||
|
||||
- A single-order draft with no itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4).
|
||||
- A single-order draft with an itemized discount goes out with
|
||||
`layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector
|
||||
#4).
|
||||
- A collected-invoice draft with no billable discount goes out with
|
||||
`invoiceLayoutNumber` (path #16 / selector #17).
|
||||
- A collected-invoice draft with a billable discount goes out with
|
||||
`invoiceDiscountLayoutNumber` (path #16 / selector #17).
|
||||
|
||||
No code changes are required to achieve this — only the two config
|
||||
variables need to be set in the `economic` module (and validated by
|
||||
the superuser status probe at `superuser_system_status_service.php:866`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Migration plan
|
||||
|
||||
Because the selectors already exist, the migration is a **configuration
|
||||
rollout** plus a small handful of defensive tasks. Files to touch:
|
||||
|
||||
### 4.1 Required for rollout
|
||||
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`**
|
||||
— confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout.
|
||||
- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`**
|
||||
— set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The
|
||||
constructor signature already allows this to be a non-required variable,
|
||||
but the selectors will throw a `RuntimeException` / `Exception` if the
|
||||
discount layout is required and the value is 0 or null — so the rollout
|
||||
must include setting this var in every environment.)
|
||||
|
||||
### 4.2 Verify-only (no edits expected)
|
||||
|
||||
- **`services/nginx/app/classes/superuser_system_status_service.php:866`**
|
||||
— already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`
|
||||
as required keys for the `economic` module probe. Confirm the probe
|
||||
treats `invoiceDiscountLayoutNumber` as required and surfaces a clear
|
||||
error when missing (it currently appears in the `required` array, which
|
||||
is the correct behavior).
|
||||
- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable`
|
||||
enum already includes `invoiceDiscountLayoutNumber`. No change.
|
||||
- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893–894`**
|
||||
— test fixtures already cover both layout config vars. Confirm values
|
||||
match TRU-197's picks.
|
||||
|
||||
### 4.3 Optional follow-ups (not blocking the rollout)
|
||||
|
||||
- **Defensive logging** in the two selector functions
|
||||
(`economic_invoice_draft_mo::resolveLayoutNumber` and
|
||||
`collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which
|
||||
layout was chosen and why (e.g.
|
||||
`[TRU-198] draft {id} uses discount layout (3 discounted lines)`).
|
||||
This is useful for post-rollout verification in the e-conomic UI.
|
||||
- **A single, shared selector helper** that both paths use, to avoid
|
||||
drift between the two private selectors. Recommended location:
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
or a new
|
||||
`services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`.
|
||||
Out of scope for the configuration rollout; consider for a follow-up
|
||||
refactor.
|
||||
- **E2E / integration test** that:
|
||||
1. Creates a single-order draft with at least one discounted line and
|
||||
asserts the resulting draft's `layout.layoutNumber` equals
|
||||
`invoiceDiscountLayoutNumber`.
|
||||
2. Creates a single-order draft with no discounted lines and asserts
|
||||
`invoiceLayoutNumber`.
|
||||
3. Creates a collected-invoice draft with at least one
|
||||
`TotDiscount` line and asserts `invoiceDiscountLayoutNumber`.
|
||||
4. Creates a collected-invoice draft with no `TotDiscount` lines and
|
||||
asserts `invoiceLayoutNumber`.
|
||||
See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and
|
||||
`EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns.
|
||||
|
||||
### 4.4 Files that explicitly need NO changes
|
||||
|
||||
- `services/nginx/app/classes/economic_v2_distribution_service.php` —
|
||||
distribution reporting, not a draft creator.
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php`
|
||||
— booked-invoice data class.
|
||||
- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches`
|
||||
/ `add_environmental_and_oil_fees` paths — they operate on an existing
|
||||
draft whose layout was fixed at create time.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
| Metric | Count |
|
||||
|---|---|
|
||||
| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) |
|
||||
| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) |
|
||||
| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic |
|
||||
| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. |
|
||||
| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) |
|
||||
|
||||
The 2-layout selection is already wired through the backend. The TRU-198
|
||||
investigation confirms that the rollout reduces to setting the two
|
||||
`invoice*LayoutNumber` config variables to the layout numbers TRU-197
|
||||
picks, plus optional defensive logging and an E2E test for verification.
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env php8.4
|
||||
<?php
|
||||
/**
|
||||
* Live verification of e-conomic draft invoice creation using customer 12345679.
|
||||
*
|
||||
* This script:
|
||||
* 1. Connects to the real e-conomic API (requires env credentials)
|
||||
* 2. Creates a draft invoice for customer 12345679 with TEST items
|
||||
* 3. Verifies the draft was created correctly
|
||||
* 4. DELETES the draft to clean up
|
||||
*
|
||||
* Usage (on production server with credentials):
|
||||
* php8.4 verify-economic-drafts-live.php
|
||||
*
|
||||
* Required env vars (set in .env or pass inline):
|
||||
* ECONOMIC_API_APP_ACCESS_GRANT
|
||||
* ECONOMIC_API_APP_SECRET_TOKEN
|
||||
*
|
||||
* Optional:
|
||||
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
|
||||
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = all verifications passed, draft cleaned up
|
||||
* 1 = error during verification
|
||||
* 2 = cleanup failed (draft still exists, manual intervention required)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// 1. Load credentials
|
||||
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
|
||||
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
|
||||
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
|
||||
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
|
||||
|
||||
if (!$grant || !$secret) {
|
||||
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
|
||||
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
|
||||
|
||||
/**
|
||||
* Send a request to the e-conomic API.
|
||||
*
|
||||
* @return array{status: int, body: string, json?: array}
|
||||
*/
|
||||
function econ_request(string $method, string $url, ?array $body = null): array
|
||||
{
|
||||
global $auth;
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
trim(explode("\r\n", $auth)[0]),
|
||||
trim(explode("\r\n", $auth)[1]),
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
CURLOPT_TIMEOUT => 30,
|
||||
]);
|
||||
if ($body !== null) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
|
||||
}
|
||||
$response = curl_exec($ch);
|
||||
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$error = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($response === false) {
|
||||
return ['status' => 0, 'body' => $error];
|
||||
}
|
||||
$json = json_decode($response, true);
|
||||
return ['status' => $status, 'body' => $response, 'json' => $json];
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = null;
|
||||
$pass = 0;
|
||||
$fail = 0;
|
||||
$total = 0;
|
||||
|
||||
function check(string $name, bool $ok, string $detail = ''): void
|
||||
{
|
||||
global $pass, $fail, $total;
|
||||
$total++;
|
||||
if ($ok) {
|
||||
$pass++;
|
||||
echo " ✅ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
} else {
|
||||
$fail++;
|
||||
echo " ❌ $name\n";
|
||||
if ($detail) echo " $detail\n";
|
||||
}
|
||||
}
|
||||
|
||||
echo "=== E-conomic Live Draft Verification ===\n";
|
||||
echo "Customer: $customer\n";
|
||||
echo "API base: $baseUrl\n\n";
|
||||
|
||||
try {
|
||||
// ------------------------------------------------------------------
|
||||
// Step 1: Verify customer exists
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 1: Verify customer $customer exists...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/customers/$customer");
|
||||
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 200) {
|
||||
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$customerName = $resp['json']['name'] ?? 'unknown';
|
||||
echo " Customer name: $customerName\n\n";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 2: Create draft invoice
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 2: Create draft invoice for customer $customer...\n";
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
|
||||
'currency' => 'DKK',
|
||||
'customer' => ['customerNumber' => $customer],
|
||||
'paymentTerms' => ['paymentTermsNumber' => 1],
|
||||
'layout' => ['layoutNumber' => 1],
|
||||
'recipient' => ['name' => 'OpenClaw Live Verification'],
|
||||
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
|
||||
]);
|
||||
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 201) {
|
||||
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
|
||||
echo " Draft invoice number: $draftInvoiceNumber\n\n";
|
||||
|
||||
if (!$draftInvoiceNumber) {
|
||||
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 3: Add test lines to draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
|
||||
$lines = [
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
|
||||
'quantity' => 1.0,
|
||||
'unitNetPrice' => 100.00,
|
||||
'discountPercentage' => 0.0,
|
||||
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
[
|
||||
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
|
||||
'quantity' => 2.0,
|
||||
'unitNetPrice' => 200.00,
|
||||
'discountPercentage' => 15.0,
|
||||
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
|
||||
],
|
||||
];
|
||||
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
|
||||
'lines' => $lines,
|
||||
]);
|
||||
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 4: Verify draft contents
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 4: Verify draft contents...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
$draft = $resp['json'] ?? [];
|
||||
$draftLines = $draft['lines'] ?? [];
|
||||
|
||||
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
|
||||
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
|
||||
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
|
||||
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 5: Cleanup - delete the draft
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
|
||||
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
|
||||
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 6: Verify deletion
|
||||
// ------------------------------------------------------------------
|
||||
echo "\nStep 6: Verify draft is gone...\n";
|
||||
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
|
||||
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
|
||||
|
||||
// Best-effort cleanup
|
||||
if ($draftInvoiceNumber !== null) {
|
||||
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
|
||||
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
|
||||
echo " Cleanup HTTP status: {$resp['status']}\n";
|
||||
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
|
||||
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\n=== Summary: $pass/$total checks passed ===\n";
|
||||
exit($fail === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Sanitizes user-input fields that are sent to the e-conomic API.
|
||||
*
|
||||
* Background: e-conomic returns 400 errors when description fields contain
|
||||
* certain characters. The known issue is "/" in the order reference field
|
||||
* (TRU-188), but we sanitize defensively for all such cases.
|
||||
*
|
||||
* - sanitizeTextLine(): for plain text lines (reference, notes, po, etc.)
|
||||
* - sanitizeProductNumber(): for product identifiers
|
||||
* - sanitizeProductDescription(): for product-line descriptions
|
||||
* - sanitizeForEconApi(): catch-all for arbitrary user input
|
||||
*/
|
||||
class economic_export_sanitizer
|
||||
{
|
||||
/** E-conomic soft limit for a single description line. */
|
||||
public const TEXT_LINE_MAX_LENGTH = 250;
|
||||
/** E-conomic soft limit for a product description. */
|
||||
public const PRODUCT_DESCRIPTION_MAX_LENGTH = 500;
|
||||
/** E-conomic soft limit for a product number. */
|
||||
public const PRODUCT_NUMBER_MAX_LENGTH = 50;
|
||||
|
||||
/** Characters that are illegal in product numbers on most e-conomic setups. */
|
||||
private const PRODUCT_NUMBER_FORBIDDEN = ['/', '\\', ':', '*', '?', '"', '<', '>', '|', "\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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Centralized selection of e-conomic invoice layout numbers.
|
||||
*
|
||||
* This class is the **skeleton** introduced by TRU-197. It exposes the two
|
||||
* layout numbers that the backend should use for the two invoice variants:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
|
||||
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
|
||||
*
|
||||
* The constants below are placeholders for the layout numbers that the
|
||||
* e-conomic account admin must pick in e-conomic (Settings → Design and
|
||||
* Layouts) and write into the module-config DB variables
|
||||
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
|
||||
* themselves are intentionally left as `0` in this skeleton — they are
|
||||
* resolved at runtime from the module-config variables by the two existing
|
||||
* call sites:
|
||||
*
|
||||
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
|
||||
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
|
||||
*
|
||||
* Wiring those call sites to read from this selector (instead of from the
|
||||
* module-config variables directly) is intentionally **out of scope** for
|
||||
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
|
||||
* full audit and follow-up plan.
|
||||
*
|
||||
* Constants in this class are the *single source of truth* for the
|
||||
* env-var-style aliases:
|
||||
*
|
||||
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
|
||||
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
|
||||
*/
|
||||
class economic_layout_selector
|
||||
{
|
||||
/**
|
||||
* Layout number for invoices WITHOUT itemized discount lines.
|
||||
*
|
||||
* Intent: a clean invoice — no "Rabat" line, no discount column, just
|
||||
* the line items and totals.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Layout number for invoices WITH itemized discount lines.
|
||||
*
|
||||
* Intent: an invoice that visibly itemizes the negative `Rabat`
|
||||
* (product `TotDiscount`) line so the customer can see the discount
|
||||
* broken out instead of folded into per-product `discountPercentage`.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public const LAYOUT_WITH_DISCOUNTS = 0;
|
||||
|
||||
/**
|
||||
* Module-config variable name for the without-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
|
||||
|
||||
/**
|
||||
* Module-config variable name for the with-discounts layout.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithoutDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function nameWithDiscounts(): string
|
||||
{
|
||||
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
|
||||
}
|
||||
}
|
||||
+17
-7
@@ -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) {
|
||||
|
||||
@@ -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<int,array<string,mixed>> $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.
|
||||
*
|
||||
@@ -185,45 +325,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,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.
|
||||
@@ -341,26 +506,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 +637,13 @@ 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);
|
||||
// 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 = [
|
||||
|
||||
@@ -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
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* End-to-end integration test for the e-conomic draft invoice export flow.
|
||||
*
|
||||
* Verifies that:
|
||||
* - addTextLine() sanitizes all user input (slash → dash, control chars, length)
|
||||
* - addProductLine() sanitizes product numbers and descriptions
|
||||
* - preflightValidate() catches all 5 rule violations
|
||||
* - Mixed text + product lines (with/without discount) pass preflight
|
||||
* - Empty/whitespace-only lines are skipped (not added to draft)
|
||||
*
|
||||
* This test does NOT hit a live e-conomic API.
|
||||
* For live verification, see /workspace/scripts/verify-economic-drafts-live.php
|
||||
*
|
||||
* Run: php8.4 services/nginx/app/vendor/bin/phpunit \
|
||||
* -c services/nginx/app/phpunit.xml \
|
||||
* services/nginx/app/tests/Integration/Invoicing/EconomicDraftSanitizationIntegrationTest.php
|
||||
*/
|
||||
|
||||
namespace tests\Integration\Invoicing;
|
||||
|
||||
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
|
||||
require_once __DIR__ . '/../../../modules/economic/helpers/economic_invoice_draft.php';
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use helpers\economic_invoice_draft;
|
||||
|
||||
class EconomicDraftSanitizationIntegrationTest extends TestCase
|
||||
{
|
||||
private economic_invoice_draft $draft;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace tests\Unit\Economic;
|
||||
|
||||
use classes\economic_export_sanitizer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
require_once __DIR__ . '/../../../classes/economic_export_sanitizer.php';
|
||||
|
||||
class EconomicExportSanitizerTest extends TestCase
|
||||
{
|
||||
// ========================================================================
|
||||
// sanitizeTextLine
|
||||
// ========================================================================
|
||||
|
||||
public function testSlashIsReplacedWithDash(): void
|
||||
{
|
||||
$this->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 </b> gets replaced with "-" (per the rules).
|
||||
$this->assertSame('<b>notags<-b>', economic_export_sanitizer::sanitizeTextLine('<b>notags</b>'));
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
<?php
|
||||
|
||||
namespace tests\Unit\Economic;
|
||||
|
||||
use helpers\economic_invoice_draft;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
require_once __DIR__ . '/../../../modules/economic/helpers/economic_invoice_draft.php';
|
||||
|
||||
/**
|
||||
* Unit tests for the pre-flight validation in economic_invoice_draft.
|
||||
*
|
||||
* These tests build a draft instance via the skip_fetch path (so we never
|
||||
* hit the e-conomic API), call preflightValidate() directly, and assert
|
||||
* that each rule is enforced.
|
||||
*/
|
||||
class EconomicInvoiceDraftPreflightTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Build a draft instance in skip_fetch mode. We never hit the network.
|
||||
*/
|
||||
private function makeDraft(bool $preflight = true): economic_invoice_draft
|
||||
{
|
||||
$draft = new economic_invoice_draft(12345, 'DKK', true);
|
||||
// Make the preflight_enabled flag mutable for the disabled test.
|
||||
$reflection = new \ReflectionClass($draft);
|
||||
$prop = $reflection->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());
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace tests\Unit\Economic;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Unit tests for TRU-193 — the recipient-block sanitization in
|
||||
* economic_invoices_drafts_endpoint::add().
|
||||
*
|
||||
* Since the endpoint's `add()` method makes a live HTTP request to
|
||||
* e-conomic, we don't test it directly. Instead we test the building
|
||||
* blocks (sanitizer rules + the file-shape contract) that the endpoint
|
||||
* uses, so the behavior is regression-protected.
|
||||
*/
|
||||
class EconomicInvoiceDraftRecipientSanitizationTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Verify the endpoint file still references the sanitizer for
|
||||
* the recipient-block fields (defense in depth, even though the
|
||||
* customer data comes from e-conomic).
|
||||
*/
|
||||
public function testEndpointSanitizesRecipientName(): void
|
||||
{
|
||||
$path = __DIR__ . '/../../../modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php';
|
||||
$content = file_get_contents($path);
|
||||
$this->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'
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user