Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feb95a5375 | ||
|
|
7ac90f70c9 | ||
|
|
52d43fc16c | ||
|
|
d2528c5ed7 | ||
|
|
3e39a50a4f | ||
|
|
18dc8e6e9c | ||
|
|
4b08453ee2 | ||
|
|
ae4b7aef07 | ||
|
|
ea9bdbe12c | ||
|
|
b16b2cfe44 | ||
|
|
339b6eb7a5 |
@@ -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,98 @@
|
||||
# Invoice Discount Format — DRIFT 12 (TRU-73)
|
||||
|
||||
## What changed
|
||||
|
||||
The e-conomic draft invoice now applies the **customer-level discount
|
||||
percentage at the line level** on every line item, so the discount is
|
||||
clearly visible on each service line on the customer's invoice.
|
||||
|
||||
Before this fix, a customer with a global e-conomic discount (e.g. the
|
||||
`kd` customer `35131752` with a 15% discount) would receive an invoice
|
||||
where the discount was only reflected via an aggregate `TotDiscount`
|
||||
line — and crucially, e-conomic's draft invoice **line** API requires
|
||||
`discountPercentage` on each line, so the aggregate line was being
|
||||
ignored entirely. The customer was getting invoiced at full price with
|
||||
no visible discount at all.
|
||||
|
||||
## Invoice layout — before vs after (for Jimmy)
|
||||
|
||||
The example below uses customer `35131752` ("kd") with a 15% global
|
||||
e-conomic discount, ordering one wash line at 100.00 DKK.
|
||||
|
||||
### Before the fix (DRIFT 12 — discount silently dropped)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask 1 × 100,00 DKK 100,00
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat (15%) 0,00 DKK ← never applied
|
||||
Total 100,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The `Rabat` line was never actually created on the e-conomic side
|
||||
because the customer has a per-line discount configured, not an
|
||||
aggregate one. The customer saw 100,00 DKK with no discount displayed.
|
||||
|
||||
### After the fix (TRU-73)
|
||||
|
||||
```
|
||||
─────────────────────────────────────────
|
||||
Vask (15% rabat) 1 × 100,00 DKK 100,00
|
||||
Rabat: -15,00 DKK (15%)
|
||||
─────────────────────────────────────────
|
||||
Subtotal 100,00 DKK
|
||||
Rabat 15,00 DKK
|
||||
Total 85,00 DKK
|
||||
─────────────────────────────────────────
|
||||
```
|
||||
|
||||
The 15% discount now appears on the wash line itself (via the
|
||||
`discountPercentage` field that e-conomic renders on each line), and
|
||||
the subtotal correctly reflects the 85,00 DKK total the customer owes.
|
||||
|
||||
## How the fix works
|
||||
|
||||
1. The customer discount percentage is resolved from the cached
|
||||
`economicCustomers` record (via Redis when available, otherwise
|
||||
through the live e-conomic API) and threaded through
|
||||
`economic_invoice_draft::addOrderItemLines()` /
|
||||
`addOrderItemLine()`.
|
||||
2. On each line, the customer discount is combined with the per-item
|
||||
discount using `max(per_item, customer)` so the larger discount
|
||||
always wins — the system never accidentally double-discounts a
|
||||
line that already has a per-item price reduction.
|
||||
3. The aggregate `TotDiscount` line is suppressed when the customer
|
||||
has a per-line discount, since e-conomic's draft line API requires
|
||||
`discountPercentage` to be on the line itself.
|
||||
4. The customer discount is clamped to 0..100 to guard against bad
|
||||
data from the e-conomic API.
|
||||
|
||||
## Code paths
|
||||
|
||||
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
|
||||
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
|
||||
`customer_discount_percentage` argument and combine it with the
|
||||
per-item discount at the line level.
|
||||
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
|
||||
— logs swallowed missing-currency-price errors so silently-missing
|
||||
discounts become visible in the application log.
|
||||
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
|
||||
— forwards the customer discount percentage to the draft builder.
|
||||
- `services/nginx/app/objects/collected_order_invoices_o.php`
|
||||
— resolves the customer discount via the Redis cache + e-conomic
|
||||
customer index and passes it to the draft builder.
|
||||
|
||||
## Tests
|
||||
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
|
||||
— new tests covering the customer 35131752 case (15% global discount,
|
||||
applied at line level) plus edge cases: per-item + customer discount
|
||||
combined, clamping to 0..100, zero-discount baseline.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
|
||||
— updated to account for the new parameter and the customer-discount
|
||||
guard on the aggregate `TotDiscount` line.
|
||||
- `services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
|
||||
— updated to thread the new parameter through the batch transfer
|
||||
pipeline.
|
||||
@@ -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,323 @@
|
||||
# TRU-62 — Customer search / transaction history slow (~10s)
|
||||
|
||||
**Investigation date:** 2026-08-17
|
||||
**Branch:** `feat/TRU-62-perf-customer-search`
|
||||
**Investigator:** automated perf-investigation agent
|
||||
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
|
||||
|
||||
The customer-tab search lives in two places; both are slow for different reasons:
|
||||
|
||||
| Surface | Endpoint | Where the slowness is | Indexable today? |
|
||||
| --- | --- | --- | --- |
|
||||
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
|
||||
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
|
||||
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Root causes (ranked)
|
||||
|
||||
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
|
||||
|
||||
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510–600).
|
||||
|
||||
```php
|
||||
// system_search_service.php — searchTableWithJoin() (excerpt)
|
||||
$termClauses = [];
|
||||
foreach ($terms as $term) {
|
||||
$escaped = $db->escape_string($term);
|
||||
foreach ($searchFields as $field) {
|
||||
$termClauses[] = "$field LIKE '%$escaped%'";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// db_object_t.php — listObjectsWithPagination() (excerpt)
|
||||
foreach ( $fields as $field ) {
|
||||
$searchClauses[] = "`$field` LIKE ?";
|
||||
$params[] = "%$search%";
|
||||
}
|
||||
```
|
||||
|
||||
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
|
||||
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
|
||||
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
|
||||
|
||||
**Symptom → data size (estimate).**
|
||||
|
||||
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
|
||||
| --- | --- | --- | --- |
|
||||
| 1k | 100k | ~50ms | ~300ms |
|
||||
| 10k | 1M | ~500ms | ~3s |
|
||||
| 50k+ | 5M+ | ~3–10s ❌ | ~10s+ ❌ |
|
||||
|
||||
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
|
||||
|
||||
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
|
||||
|
||||
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
|
||||
|
||||
```php
|
||||
// system_search_service.php — executeLexicalSearch() (excerpt)
|
||||
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
|
||||
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
|
||||
} else {
|
||||
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
|
||||
}
|
||||
```
|
||||
|
||||
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
|
||||
|
||||
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
|
||||
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
|
||||
|
||||
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
|
||||
|
||||
`services/nginx/app/classes/system_search_service.php` lines 713–820:
|
||||
|
||||
```php
|
||||
$fromClause = 'users u';
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
|
||||
}
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields, // 13 fields
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
```
|
||||
|
||||
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
|
||||
|
||||
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
|
||||
|
||||
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
|
||||
|
||||
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
|
||||
|
||||
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
|
||||
|
||||
### RC6 — `users.display_name` has no index at all
|
||||
|
||||
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
...
|
||||
KEY `idx_users_customer_number` (`customer_number`),
|
||||
KEY `idx_users_group_id` (`group_id`)
|
||||
);
|
||||
```
|
||||
|
||||
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
|
||||
|
||||
---
|
||||
|
||||
## 3. SQL queries involved (verbatim paths)
|
||||
|
||||
### 3.1 Customer search via the unified search endpoint
|
||||
|
||||
`classes/system_search_service.php` lines 713–820 produce something like:
|
||||
|
||||
```sql
|
||||
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
|
||||
sci.economic_name, sci.economic_address, ..., sci.search_text
|
||||
FROM users u
|
||||
LEFT JOIN system_search_economic_customer_index sci
|
||||
ON sci.customer_number = u.customer_number
|
||||
WHERE 1=1
|
||||
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
|
||||
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
|
||||
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
|
||||
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
|
||||
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
|
||||
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
|
||||
OR sci.search_text LIKE '%foo%' )
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
|
||||
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
|
||||
|
||||
### 3.2 Order list / transaction history
|
||||
|
||||
`traits/db_object_t.php` lines ~547–556 produce, for a search of `foo` and a filter `customer_id:123`:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM orders_with_invoice_collections
|
||||
WHERE customer_id = 123
|
||||
AND deleted_at IS NULL
|
||||
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
|
||||
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
|
||||
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
|
||||
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
|
||||
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
|
||||
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
|
||||
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
|
||||
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
|
||||
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
|
||||
ORDER BY id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
```
|
||||
|
||||
* 22 ORed LIKE clauses against the view, all un-indexable.
|
||||
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
|
||||
|
||||
---
|
||||
|
||||
## 4. Schema snapshots
|
||||
|
||||
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_users_customer_number (customer_number)
|
||||
KEY idx_users_group_id (group_id)
|
||||
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
|
||||
```
|
||||
|
||||
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (id)
|
||||
KEY idx_orders_customer_id (customer_id)
|
||||
KEY idx_orders_department_id (department_id)
|
||||
KEY idx_orders_invoice_collection_id (invoice_collection_id)
|
||||
KEY idx_orders_reg_1 (reg_1)
|
||||
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
|
||||
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
|
||||
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
|
||||
```
|
||||
|
||||
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (customer_number)
|
||||
INDEX idx_system_search_econ_customer_user (user_id)
|
||||
INDEX idx_system_search_econ_customer_name (economic_name)
|
||||
INDEX idx_system_search_econ_customer_email (economic_email)
|
||||
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
|
||||
-- Missing: FULLTEXT on (search_text)
|
||||
```
|
||||
|
||||
### `system_search_documents` (from `classes/system_search_document_index.php`)
|
||||
|
||||
```sql
|
||||
PRIMARY KEY (entity_type, entity_id)
|
||||
INDEX idx_ssd_customer (customer_number)
|
||||
INDEX idx_ssd_department (department_id)
|
||||
INDEX idx_ssd_entity (entity_type)
|
||||
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
|
||||
```
|
||||
|
||||
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
|
||||
|
||||
---
|
||||
|
||||
## 5. EXPLAIN (expected)
|
||||
|
||||
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
|
||||
|
||||
```
|
||||
type: ALL -- full table scan
|
||||
key: NULL
|
||||
rows: N (all users)
|
||||
Extra: Using where
|
||||
```
|
||||
|
||||
For the order list query the expected plan against the view is:
|
||||
|
||||
```
|
||||
type: ALL
|
||||
key: NULL
|
||||
rows: N
|
||||
Extra: Using where; Using filesort
|
||||
```
|
||||
|
||||
Once a FULLTEXT index is added the same queries should become:
|
||||
|
||||
```
|
||||
type: fulltext
|
||||
key: ft_xxx
|
||||
rows: O(log N)
|
||||
Extra: Using where; Ft_hints: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Recommended fixes (ordered by ROI)
|
||||
|
||||
| # | Fix | Estimated effort | Estimated impact | Risk |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
|
||||
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
|
||||
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
|
||||
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
|
||||
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
|
||||
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
|
||||
|
||||
### Recommended sequencing
|
||||
|
||||
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation plan (this PR)
|
||||
|
||||
This PR ships **F1 only**, as a low-risk drop-in:
|
||||
|
||||
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
|
||||
```sql
|
||||
ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
|
||||
```
|
||||
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
|
||||
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
|
||||
```sql
|
||||
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
|
||||
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
|
||||
```
|
||||
and only fall back to the 13-clause OR if MATCH returns zero rows.
|
||||
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
|
||||
* Stubs `$db` to record the last query.
|
||||
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
|
||||
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
|
||||
|
||||
### What this PR does **not** do
|
||||
|
||||
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
|
||||
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
|
||||
* No schema changes to `users`.
|
||||
* No changes to the cron / dirty-table logic (F2).
|
||||
|
||||
These are tracked as follow-up issues.
|
||||
|
||||
---
|
||||
|
||||
## 8. Test impact
|
||||
|
||||
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
|
||||
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
|
||||
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
|
||||
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
|
||||
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
|
||||
`master` before this change.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / follow-ups
|
||||
|
||||
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
|
||||
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
|
||||
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
|
||||
@@ -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,227 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Static utility for generating, formatting, hashing, and parsing
|
||||
* API keys.
|
||||
*
|
||||
* Key format: <prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
|
||||
* e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
|
||||
*
|
||||
* The key_id (everything before the dot) is stored in plain text in
|
||||
* the database as the lookup key. The secret is NEVER stored in plain
|
||||
* text — only the argon2id hash is persisted. The full key is shown
|
||||
* to the user exactly once at creation time.
|
||||
*/
|
||||
class api_key_generator
|
||||
{
|
||||
/** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */
|
||||
public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** Characters permitted in the public key_id portion. */
|
||||
public const KEY_ID_RANDOM_LENGTH = 22;
|
||||
|
||||
/** Characters in the secret portion. */
|
||||
public const SECRET_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Build the public key_id portion: <prefix>_<env>_<random>.
|
||||
*/
|
||||
public static function generateKeyId(string $env = 'live'): string
|
||||
{
|
||||
$env = self::normaliseEnv($env);
|
||||
$prefix = self::prefix();
|
||||
$random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH);
|
||||
return $prefix . '_' . $env . '_' . $random;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the secret portion (32-char base62).
|
||||
*/
|
||||
public static function generateSecret(): string
|
||||
{
|
||||
return self::randomBase62(self::SECRET_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join key_id and secret with a single dot.
|
||||
*/
|
||||
public static function formatKey(string $keyId, string $secret): string
|
||||
{
|
||||
if ($keyId === '' || strpos($keyId, '.') !== false) {
|
||||
throw new \InvalidArgumentException('key_id must not contain a dot');
|
||||
}
|
||||
if ($secret === '' || strpos($secret, '.') !== false) {
|
||||
throw new \InvalidArgumentException('secret must not contain a dot');
|
||||
}
|
||||
return $keyId . '.' . $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash the full key (or just the secret) using argon2id.
|
||||
*/
|
||||
public static function hash(string $plain): string
|
||||
{
|
||||
if ($plain === '') {
|
||||
throw new \InvalidArgumentException('Cannot hash an empty value');
|
||||
}
|
||||
$hash = password_hash($plain, PASSWORD_ARGON2ID);
|
||||
if ($hash === false) {
|
||||
throw new \RuntimeException('Failed to hash with argon2id');
|
||||
}
|
||||
return $hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plaintext key against a stored argon2id hash.
|
||||
*/
|
||||
public static function verify(string $plain, string $hash): bool
|
||||
{
|
||||
if ($plain === '' || $hash === '') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return password_verify($plain, $hash);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a full "key_id.secret" string back into its parts.
|
||||
*
|
||||
* The key_id may contain underscores (as separators between
|
||||
* prefix/env/random) and must be base62 + underscores. The
|
||||
* secret must be strictly base62 with no separators.
|
||||
*
|
||||
* @return array{key_id:string, secret:string}|null
|
||||
* null if the input is malformed.
|
||||
*/
|
||||
public static function parseKey(string $full): ?array
|
||||
{
|
||||
$full = trim($full);
|
||||
if ($full === '' || strpos($full, '.') === false) {
|
||||
return null;
|
||||
}
|
||||
// Split on the FIRST dot only — secrets are base62 and contain
|
||||
// no dots, so there's exactly one separator.
|
||||
$parts = explode('.', $full, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
[$keyId, $secret] = $parts;
|
||||
$keyId = trim($keyId);
|
||||
$secret = trim($secret);
|
||||
if ($keyId === '' || $secret === '') {
|
||||
return null;
|
||||
}
|
||||
// The key_id is "<prefix>_<env>_<random>" — base62 with
|
||||
// underscore separators. The secret is pure base62.
|
||||
if (!self::isKeyId($keyId) || !self::isBase62($secret)) {
|
||||
return null;
|
||||
}
|
||||
return ['key_id' => $keyId, 'secret' => $secret];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a key_id string: base62 with optional underscore
|
||||
* separators. Exposed for testing.
|
||||
*/
|
||||
public static function isKeyId(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurable prefix (default: "truck"). Reads from
|
||||
* `config('api_key.prefix', 'truck')` if available, otherwise the
|
||||
* default. Always lowercased and stripped of separators.
|
||||
*/
|
||||
public static function prefix(): string
|
||||
{
|
||||
$default = 'truck';
|
||||
$value = $default;
|
||||
if (function_exists('config')) {
|
||||
try {
|
||||
$candidate = config('api_key.prefix', $default);
|
||||
if (is_string($candidate) && $candidate !== '') {
|
||||
$value = $candidate;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$value = $default;
|
||||
}
|
||||
}
|
||||
$value = strtolower(trim((string)$value));
|
||||
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
|
||||
if ($value === '') {
|
||||
$value = $default;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function randomBase62(int $length): string
|
||||
{
|
||||
if ($length < 1) {
|
||||
throw new \InvalidArgumentException('Length must be positive');
|
||||
}
|
||||
|
||||
$alphabet = self::ALPHABET;
|
||||
$alphabetMax = strlen($alphabet) - 1; // 61
|
||||
|
||||
$out = '';
|
||||
$bytesNeeded = (int)ceil($length * 1.3) + 8;
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
|
||||
while (strlen($out) < $length) {
|
||||
if (!isset($bytes[$byteIndex])) {
|
||||
$bytes = random_bytes($bytesNeeded);
|
||||
$byteIndex = 0;
|
||||
}
|
||||
// Mask off 0xC0 to get a value 0-63, then reject > 61 to
|
||||
// avoid modulo bias.
|
||||
$byte = ord($bytes[$byteIndex]);
|
||||
$byteIndex++;
|
||||
$value = $byte & 0x3F;
|
||||
if ($value > $alphabetMax) {
|
||||
continue;
|
||||
}
|
||||
$out .= $alphabet[$value];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — exposed for testing.
|
||||
*/
|
||||
public static function isBase62(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
return preg_match('/^[0-9A-Za-z]+$/', $value) === 1;
|
||||
}
|
||||
|
||||
private static function normaliseEnv(string $env): string
|
||||
{
|
||||
$trimmed = strtolower(trim($env));
|
||||
$sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? '';
|
||||
// If the input contained characters outside the allowed
|
||||
// set, the sanitised result will differ from the trimmed
|
||||
// input — in that case fall back to "live" rather than
|
||||
// echoing a mangled version. Empty / whitespace-only input
|
||||
// also falls back to "live".
|
||||
if ($sanitised === '' || $sanitised !== $trimmed) {
|
||||
return 'live';
|
||||
}
|
||||
return $sanitised;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Repository for the `api_keys` table.
|
||||
*
|
||||
* This is a thin procedural wrapper that uses the project's existing
|
||||
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
|
||||
* other repositories in this codebase (see `classes/orders_o.php`,
|
||||
* `classes/invoice_store.php`, etc.).
|
||||
*
|
||||
* Records are returned as associative arrays. The caller is expected
|
||||
* to interact with them as plain dicts; there is no dedicated model
|
||||
* class for api keys.
|
||||
*/
|
||||
class api_key_repository
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static function db()
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
throw new Exception('Database connection ($db) is not available');
|
||||
}
|
||||
// Lazy-create the table on first use so callers don't have to
|
||||
// remember to call ensureTables().
|
||||
if (class_exists(api_key_schema_bootstrap::class)) {
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
}
|
||||
return $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the input data for create(). Exposed so test doubles
|
||||
* can exercise the same validation without touching a real DB.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public static function validate(array $data): void
|
||||
{
|
||||
$required = ['key_id', 'key_hash', 'name', 'role'];
|
||||
foreach ($required as $field) {
|
||||
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
|
||||
throw new \InvalidArgumentException("Missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
|
||||
if (!in_array($data['role'], $allowedRoles, true)) {
|
||||
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return int inserted id
|
||||
*/
|
||||
public static function create(array $data): int
|
||||
{
|
||||
self::validate($data);
|
||||
|
||||
$db = self::db();
|
||||
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
|
||||
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
|
||||
: null;
|
||||
|
||||
$stmt = $db->conn()->prepare(
|
||||
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
|
||||
}
|
||||
|
||||
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
|
||||
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
|
||||
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
|
||||
? (string)$data['expires_at']
|
||||
: null;
|
||||
|
||||
$stmt->bind_param(
|
||||
'sssssiss',
|
||||
$data['key_id'],
|
||||
$data['key_hash'],
|
||||
$data['name'],
|
||||
$data['role'],
|
||||
$scopesJson,
|
||||
$customerId,
|
||||
$createdBy,
|
||||
$expiresAt
|
||||
);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to insert api_key: ' . $err);
|
||||
}
|
||||
$id = $stmt->insert_id;
|
||||
$stmt->close();
|
||||
return (int)$id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a non-revoked key by its public key_id.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findActiveByKeyId(string $keyId): ?array
|
||||
{
|
||||
if ($keyId === '') {
|
||||
return null;
|
||||
}
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('s', $keyId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any key by id (including revoked).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute select: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$row = $result ? $result->fetch_assoc() : null;
|
||||
$stmt->close();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
|
||||
*/
|
||||
public static function revoke(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump last_used_at for a key. Best-effort: failures are swallowed
|
||||
* because this is a hot-path observability hook and must not
|
||||
* break the request.
|
||||
*/
|
||||
public static function touchLastUsed(int $id): void
|
||||
{
|
||||
try {
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare(
|
||||
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||
);
|
||||
if ($stmt === false) {
|
||||
return;
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
} catch (Throwable) {
|
||||
// intentionally ignored
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keys for a customer, newest first.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
||||
{
|
||||
$db = self::db();
|
||||
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
|
||||
if (!$includeRevoked) {
|
||||
$sql .= ' AND revoked_at IS NULL';
|
||||
}
|
||||
$sql .= ' ORDER BY id DESC';
|
||||
$stmt = $db->conn()->prepare($sql);
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $customerId);
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
throw new Exception('Failed to execute list: ' . $err);
|
||||
}
|
||||
$result = $stmt->get_result();
|
||||
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
|
||||
$stmt->close();
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key by id. Returns true if a row was removed.
|
||||
* Generally prefer `revoke()` over `delete()` so audit trails
|
||||
* stay intact.
|
||||
*/
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
$db = self::db();
|
||||
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
|
||||
if ($stmt === false) {
|
||||
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
|
||||
}
|
||||
$stmt->bind_param('i', $id);
|
||||
$ok = $stmt->execute();
|
||||
$affected = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
return $ok && $affected > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
/**
|
||||
* Schema bootstrap for the api_keys table.
|
||||
*
|
||||
* This codebase does NOT use a migration framework; new tables are
|
||||
* added via `*_schema_bootstrap.php` files that run idempotent
|
||||
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
|
||||
* SQL file at `database/migrations/<TIMESTAMP>_create_api_keys_table.php`
|
||||
* is the human-readable source of truth / change record.
|
||||
*/
|
||||
class api_key_schema_bootstrap
|
||||
{
|
||||
public const TABLE = 'api_keys';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTables(): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db)) {
|
||||
// No DB connection in this process (e.g. unit test) — skip.
|
||||
self::$initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$queries = [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
];
|
||||
|
||||
foreach ($queries as $query) {
|
||||
try {
|
||||
$db->query($query);
|
||||
} catch (\Throwable $e) {
|
||||
// Swallow on first-failure in unit-test contexts; the
|
||||
// migration companion file documents the canonical DDL.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[api_key_schema_bootstrap] ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
public static function tableExists(): bool
|
||||
{
|
||||
global $db;
|
||||
if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$database = $db->escape_string($db->getDatabase());
|
||||
$result = $db->query(
|
||||
"SELECT COUNT(*) AS count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '{$database}'
|
||||
AND table_name = 'api_keys'"
|
||||
);
|
||||
$row = $result ? $result->fetch_assoc() : ['count' => 0];
|
||||
return (int)($row['count'] ?? 0) > 0;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace classes\auth;
|
||||
|
||||
/**
|
||||
* Scope registry: the source of truth for API key scopes and
|
||||
* role → scope defaults.
|
||||
*
|
||||
* This class is the canonical implementation that the parallel
|
||||
* `app\auth\Scope` stub (introduced by TRU-149 / branch
|
||||
* feat/TRU-149-route-scopes) will be replaced with once
|
||||
* `feat/api-key-foundation` is merged. Until then the two can
|
||||
* coexist; the middleware in `scope_middleware.php` continues
|
||||
* to use the legacy stub.
|
||||
*
|
||||
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
|
||||
* Two wildcard forms are recognised:
|
||||
* - `*` — matches every scope.
|
||||
* - `resource:*` — matches every action on a resource.
|
||||
*
|
||||
* Role defaults:
|
||||
* - superuser: every scope (via "*" wildcard).
|
||||
* - admin: customer:*, booking:*, subuser:*, invoice:*
|
||||
* - customer: customer:read, booking:read, invoice:read
|
||||
* - subuser: booking:read, booking:write
|
||||
*
|
||||
* The "self" / "assigned" qualifiers from the spec are *enforcement
|
||||
* layer* concerns, not scope concerns — they live in the resolver
|
||||
* that maps an authenticated principal to a customer/subuser record.
|
||||
* Scopes only encode "can the caller read bookings at all", not
|
||||
* "which bookings".
|
||||
*/
|
||||
final class scope_registry
|
||||
{
|
||||
// --- Customer resource ---
|
||||
public const CUSTOMER_READ = 'customer:read';
|
||||
public const CUSTOMER_WRITE = 'customer:write';
|
||||
|
||||
// --- Booking resource ---
|
||||
public const BOOKING_READ = 'booking:read';
|
||||
public const BOOKING_WRITE = 'booking:write';
|
||||
|
||||
// --- Subuser resource ---
|
||||
public const SUBUSER_READ = 'subuser:read';
|
||||
public const SUBUSER_WRITE = 'subuser:write';
|
||||
|
||||
// --- Invoice resource ---
|
||||
public const INVOICE_READ = 'invoice:read';
|
||||
public const INVOICE_WRITE = 'invoice:write';
|
||||
|
||||
// --- Superuser / admin resource ---
|
||||
public const SUPERUSER_READ = 'superuser:read';
|
||||
public const SUPERUSER_WRITE = 'superuser:write';
|
||||
|
||||
/**
|
||||
* Canonical list of every concrete scope (no wildcards).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
|
||||
self::BOOKING_READ, self::BOOKING_WRITE,
|
||||
self::SUBUSER_READ, self::SUBUSER_WRITE,
|
||||
self::INVOICE_READ, self::INVOICE_WRITE,
|
||||
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the default scope set carried by a role. Wildcards are
|
||||
* returned as-is; resolve them with `expand()` before checking
|
||||
* membership if you need a flat list.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function scopesForRole(string $role): array
|
||||
{
|
||||
switch (strtolower(trim($role))) {
|
||||
case 'superuser':
|
||||
return ['*'];
|
||||
case 'admin':
|
||||
return [
|
||||
'customer:*',
|
||||
'booking:*',
|
||||
'subuser:*',
|
||||
'invoice:*',
|
||||
];
|
||||
case 'customer':
|
||||
return [
|
||||
self::CUSTOMER_READ,
|
||||
self::BOOKING_READ,
|
||||
self::INVOICE_READ,
|
||||
];
|
||||
case 'subuser':
|
||||
return [
|
||||
self::BOOKING_READ,
|
||||
self::BOOKING_WRITE,
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the granted scope (or wildcard) match the required scope?
|
||||
*
|
||||
* - "*" matches anything.
|
||||
* - "customer:*" matches "customer:read" and "customer:write".
|
||||
* - "customer:read" matches itself exactly.
|
||||
*
|
||||
* @param array<int, string> $granted
|
||||
*/
|
||||
public static function hasScope(array $granted, string $required): bool
|
||||
{
|
||||
$required = trim($required);
|
||||
if ($required === '') {
|
||||
return false;
|
||||
}
|
||||
foreach ($granted as $candidate) {
|
||||
if (!is_string($candidate)) {
|
||||
continue;
|
||||
}
|
||||
if (self::matches($candidate, $required)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a list of scopes (which may include wildcards) into the
|
||||
* full set of concrete scopes they grant. Useful for showing a
|
||||
* user what their key can do, or for caching decisions.
|
||||
*
|
||||
* The wildcard "*" expands to the full `all()` set. A wildcard
|
||||
* like "customer:*" expands to every concrete scope starting with
|
||||
* "customer:". Duplicate entries are removed.
|
||||
*
|
||||
* @param array<int, string> $scopes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function expand(array $scopes): array
|
||||
{
|
||||
$concrete = self::all();
|
||||
$expanded = [];
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
if (!is_string($scope)) {
|
||||
continue;
|
||||
}
|
||||
$scope = trim($scope);
|
||||
if ($scope === '') {
|
||||
continue;
|
||||
}
|
||||
if ($scope === '*') {
|
||||
$expanded = array_merge($expanded, $concrete);
|
||||
continue;
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2) . ':';
|
||||
foreach ($concrete as $candidate) {
|
||||
if (str_starts_with($candidate, $prefix)) {
|
||||
$expanded[] = $candidate;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Already concrete — pass through if it looks canonical.
|
||||
if (in_array($scope, $concrete, true)) {
|
||||
$expanded[] = $scope;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($expanded));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal wildcard matcher — public for testing.
|
||||
*/
|
||||
public static function matches(string $granted, string $required): bool
|
||||
{
|
||||
$granted = trim($granted);
|
||||
$required = trim($required);
|
||||
if ($granted === '' || $required === '') {
|
||||
return false;
|
||||
}
|
||||
if ($granted === '*') {
|
||||
return true;
|
||||
}
|
||||
if (str_ends_with($granted, ':*')) {
|
||||
$prefix = substr($granted, 0, -2);
|
||||
return str_starts_with($required, $prefix . ':');
|
||||
}
|
||||
return $granted === $required;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a scope string. Returns true iff the value is either
|
||||
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
|
||||
* for a known resource.
|
||||
*/
|
||||
public static function isValid(string $scope): bool
|
||||
{
|
||||
$scope = trim($scope);
|
||||
if ($scope === '' || $scope === '*') {
|
||||
return $scope !== '';
|
||||
}
|
||||
if (str_ends_with($scope, ':*')) {
|
||||
$prefix = substr($scope, 0, -2);
|
||||
foreach (self::all() as $concrete) {
|
||||
if (str_starts_with($concrete, $prefix . ':')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return in_array($scope, self::all(), true);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,13 @@ class system_search_economic_customer_index
|
||||
{
|
||||
public const TABLE = 'system_search_economic_customer_index';
|
||||
|
||||
/**
|
||||
* FULLTEXT key name used by TRU-62 customer-search performance fix.
|
||||
* The column already exists (TEXT NULL `search_text`) — we just need
|
||||
* the index. See documentation/perf/customer-search-slow-investigation.md.
|
||||
*/
|
||||
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
|
||||
|
||||
private static bool $initialized = false;
|
||||
|
||||
public static function ensureTable(): void
|
||||
@@ -50,6 +57,14 @@ class system_search_economic_customer_index
|
||||
'economic_barred',
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
|
||||
);
|
||||
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
|
||||
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
|
||||
// already exists. The search code falls back to the LIKE-based query
|
||||
// when this index is absent, so an incomplete migration is non-fatal.
|
||||
self::ensureIndex(
|
||||
self::FULLTEXT_INDEX,
|
||||
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
|
||||
);
|
||||
self::$initialized = true;
|
||||
}
|
||||
|
||||
@@ -362,6 +377,39 @@ class system_search_economic_customer_index
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an index (FULLTEXT or otherwise) exists on the table.
|
||||
* No-ops when the index is already present so this is safe to call
|
||||
* repeatedly at request time.
|
||||
*/
|
||||
private static function ensureIndex(string $indexName, string $alterSql): void
|
||||
{
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $db->query(
|
||||
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
|
||||
. $db->escape_string($indexName) . "'"
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
|
||||
try {
|
||||
$db->query($alterSql);
|
||||
} catch (Throwable $e) {
|
||||
// The search code falls back to the LIKE path when the
|
||||
// index is missing, so a failed ALTER is non-fatal.
|
||||
if (function_exists('error_log')) {
|
||||
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function barredStatus(?bool $barred): string
|
||||
{
|
||||
return match ($barred) {
|
||||
|
||||
@@ -720,52 +720,18 @@ class system_search_service
|
||||
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
|
||||
}
|
||||
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
...$this->joinTemporalSelectFields('users', 'u'),
|
||||
];
|
||||
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
|
||||
$fromClause = 'users u';
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
$selectFields = [
|
||||
...$selectFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$searchFields = [
|
||||
...$searchFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
// TRU-62: prefer the FULLTEXT path against the denormalized
|
||||
// `system_search_economic_customer_index.search_text` column. The
|
||||
// previous implementation ORed 13 un-indexable `LIKE '%term%'`
|
||||
// clauses, which dominated the ~10s request latency reported in
|
||||
// TRU-62. We only fall back to that LIKE path when the FULLTEXT
|
||||
// index is missing (e.g. migration not yet applied) or returns
|
||||
// zero rows for the query.
|
||||
$rows = $this->searchCustomersWithFulltext($terms, $customerFilter);
|
||||
if ($rows === null) {
|
||||
$rows = $this->searchCustomersWithLike($terms, $customerFilter);
|
||||
}
|
||||
|
||||
$rows = $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields,
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
|
||||
return array_map(function (array $row) use ($terms, $entityBoost) {
|
||||
$title = trim((string)($row['economic_name'] ?? ''));
|
||||
if ($title === '') {
|
||||
@@ -818,6 +784,166 @@ class system_search_service
|
||||
}, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* TRU-62 — FULLTEXT path for customer search.
|
||||
*
|
||||
* Returns the matching rows from `users LEFT JOIN
|
||||
* system_search_economic_customer_index` using a `MATCH ... AGAINST`
|
||||
* query against the denormalized `search_text` column. This replaces
|
||||
* the 13-clause `LIKE '%term%'` OR chain that previously caused
|
||||
* ~10s customer-search latency. Returns `null` when the FULLTEXT
|
||||
* path is not available (index missing) or the boolean query is
|
||||
* empty (terms too short for the FULLTEXT minimum word length);
|
||||
* callers should then fall back to {@see searchCustomersWithLike()}.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, array<string, mixed>>|null
|
||||
*/
|
||||
private function searchCustomersWithFulltext(array $terms, string $customerFilter): ?array
|
||||
{
|
||||
if (empty($terms)) {
|
||||
return [];
|
||||
}
|
||||
if (!$this->isFulltextCustomerIndexAvailable()) {
|
||||
return null;
|
||||
}
|
||||
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
|
||||
if ($booleanQuery === null) {
|
||||
// One or more terms are too short for the FULLTEXT minimum
|
||||
// word length. The LIKE path is the only viable option.
|
||||
return null;
|
||||
}
|
||||
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return null;
|
||||
}
|
||||
$escaped = $db->escape_string($booleanQuery);
|
||||
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$fromClause = 'users u LEFT JOIN `'
|
||||
. system_search_economic_customer_index::TABLE
|
||||
. '` sci ON sci.customer_number = u.customer_number';
|
||||
|
||||
$sql = "SELECT " . implode(', ', $selectFields)
|
||||
. " FROM " . $fromClause
|
||||
. " WHERE 1=1" . $customerFilter
|
||||
. " AND MATCH(sci.search_text) AGAINST ('" . $escaped . "' IN BOOLEAN MODE)"
|
||||
. " LIMIT " . $this->defaultEntityFetchLimit;
|
||||
|
||||
$rows = $this->runSelectRows($sql);
|
||||
if (empty($rows)) {
|
||||
// FULLTEXT is in use but the row set is empty. We could fall
|
||||
// back to LIKE here, but a fully-empty FULLTEXT result for a
|
||||
// customer-tab query usually means "no match" (the boolean
|
||||
// query already required all terms to be present). Avoid the
|
||||
// extra full-table scan and return an empty result set.
|
||||
return [];
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* TRU-62 — original LIKE-based fallback for customer search. Kept
|
||||
* verbatim so that deployments which have not yet applied the
|
||||
* FULLTEXT migration still get correct results, just slowly.
|
||||
*
|
||||
* @param array<int, string> $terms
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function searchCustomersWithLike(array $terms, string $customerFilter): array
|
||||
{
|
||||
$selectFields = [
|
||||
'u.id',
|
||||
'u.customer_number',
|
||||
'u.display_name',
|
||||
'u.email',
|
||||
'u.phone',
|
||||
...$this->joinTemporalSelectFields('users', 'u'),
|
||||
];
|
||||
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
|
||||
$fromClause = 'users u';
|
||||
|
||||
if ($this->isEconomicCustomerIndexAvailable()) {
|
||||
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
|
||||
$selectFields = [
|
||||
...$selectFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
$searchFields = [
|
||||
...$searchFields,
|
||||
'sci.economic_name',
|
||||
'sci.economic_address',
|
||||
'sci.economic_city',
|
||||
'sci.economic_zip',
|
||||
'sci.economic_email',
|
||||
'sci.economic_cvr',
|
||||
'sci.economic_mobile_phone',
|
||||
'sci.search_text',
|
||||
];
|
||||
}
|
||||
|
||||
return $this->searchTableWithJoin(
|
||||
'users',
|
||||
$fromClause,
|
||||
$selectFields,
|
||||
$searchFields,
|
||||
$terms,
|
||||
'1=1' . $customerFilter
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the `system_search_economic_customer_index` table exists
|
||||
* AND the `ft_sseci_search_text` FULLTEXT index is present. The
|
||||
* index is added by the runtime schema bootstrap and the companion
|
||||
* migration at `database/migrations/2026_08_17_000002_*`.
|
||||
*/
|
||||
private function isFulltextCustomerIndexAvailable(): bool
|
||||
{
|
||||
if (!$this->isEconomicCustomerIndexAvailable()) {
|
||||
return false;
|
||||
}
|
||||
global $db;
|
||||
if (!is_object($db) || !method_exists($db, 'query')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$indexName = $db->escape_string(system_search_economic_customer_index::FULLTEXT_INDEX);
|
||||
$result = $db->query(
|
||||
"SHOW INDEX FROM `" . system_search_economic_customer_index::TABLE
|
||||
. "` WHERE `Key_name` = '" . $indexName . "'"
|
||||
);
|
||||
if (!($result instanceof \mysqli_result)) {
|
||||
return false;
|
||||
}
|
||||
return $result->num_rows > 0;
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
|
||||
{
|
||||
$rows = $this->searchTableWithJoin(
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: create_api_keys_table
|
||||
* Issue: TRU-143 — [Backend] API key data model + storage schema
|
||||
* Date: 2026-08-17
|
||||
*
|
||||
* NOTE: This codebase does not run a migration framework; the
|
||||
* canonical DDL is applied idempotently at runtime by
|
||||
* `classes/api_key_schema_bootstrap.php`. This file is the
|
||||
* human-readable change record / source of truth for the schema.
|
||||
*
|
||||
* To apply manually:
|
||||
* mysql -u <user> -p <database> < 2026_08_17_000001_create_api_keys_table.sql
|
||||
*/
|
||||
|
||||
return [
|
||||
'id' => '2026_08_17_000001_create_api_keys_table',
|
||||
'issue' => 'TRU-143',
|
||||
'table' => 'api_keys',
|
||||
'engine' => 'InnoDB',
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'up' => [
|
||||
"CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
key_id VARCHAR(64) NOT NULL,
|
||||
key_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(32) NOT NULL,
|
||||
scopes JSON NULL,
|
||||
customer_id BIGINT UNSIGNED NULL,
|
||||
created_by BIGINT UNSIGNED NULL,
|
||||
last_used_at TIMESTAMP NULL,
|
||||
expires_at TIMESTAMP NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_api_keys_key_id (key_id),
|
||||
INDEX idx_api_keys_customer (customer_id),
|
||||
INDEX idx_api_keys_key_hash (key_hash),
|
||||
INDEX idx_api_keys_revoked (revoked_at),
|
||||
INDEX idx_api_keys_role (role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
],
|
||||
'down' => [
|
||||
'DROP TABLE IF EXISTS api_keys',
|
||||
],
|
||||
];
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration: add_fulltext_to_system_search_economic_customer_index
|
||||
* Issue: TRU-62 — System is very slow - search on customer tab ~10s
|
||||
* Date: 2026-08-17
|
||||
*
|
||||
* The `system_search_economic_customer_index.search_text` column is a
|
||||
* denormalized blob containing all customer-name / address / email / phone
|
||||
* data concatenated. The customer search currently runs
|
||||
*
|
||||
* `field LIKE '%term%'`
|
||||
*
|
||||
* for 13 fields, which forces a full table scan and dominates the
|
||||
* ~10s request latency. A FULLTEXT index on the same column lets the
|
||||
* same search run in tens of milliseconds.
|
||||
*
|
||||
* NOTE: This codebase does not run a migration framework; the canonical
|
||||
* DDL is applied idempotently at runtime by
|
||||
* `classes/system_search_economic_customer_index::ensureTable()`. This
|
||||
* file is the human-readable change record / source of truth for the
|
||||
* schema. See TRU-62 investigation doc at
|
||||
* `documentation/perf/customer-search-slow-investigation.md`.
|
||||
*
|
||||
* To apply manually:
|
||||
* mysql -u <user> -p <database> \
|
||||
* -e "ALTER TABLE \`system_search_economic_customer_index\`
|
||||
* ADD FULLTEXT INDEX \`ft_sseci_search_text\` (\`search_text\`);"
|
||||
*/
|
||||
|
||||
return [
|
||||
'id' => '2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index',
|
||||
'issue' => 'TRU-62',
|
||||
'table' => 'system_search_economic_customer_index',
|
||||
'up' => [
|
||||
'ALTER TABLE `system_search_economic_customer_index`
|
||||
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`)',
|
||||
],
|
||||
'down' => [
|
||||
'ALTER TABLE `system_search_economic_customer_index`
|
||||
DROP INDEX `ft_sseci_search_text`',
|
||||
],
|
||||
];
|
||||
@@ -76,9 +76,14 @@ class economicCustomers extends economic_m
|
||||
// The discount is global, but e-conomic resolves it through a product-specific
|
||||
// invoice-line template. For foreign-currency customers some templates can fail
|
||||
// if that product has no price in the customer currency, so try a few products
|
||||
// before falling back to zero.
|
||||
// before falling back to zero. We log every swallowed currency-price failure so
|
||||
// silently-missing discounts (e.g. bug #11 customer 35131752 "kd" 15%) become
|
||||
// visible in the application log instead of vanishing into the void.
|
||||
$products = $this->getCustomerProducts($customer_number, 10);
|
||||
$attempted_products = 0;
|
||||
$swallowed_errors = 0;
|
||||
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
|
||||
$attempted_products++;
|
||||
try {
|
||||
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
|
||||
return (int)($discount->discountPercentage ?? 0);
|
||||
@@ -86,9 +91,24 @@ class economicCustomers extends economic_m
|
||||
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
|
||||
throw $exception;
|
||||
}
|
||||
$swallowed_errors++;
|
||||
error_log(sprintf(
|
||||
'[economicCustomers] Swallowed missing-currency-price error while resolving discount for customer %d product %d: %s',
|
||||
$customer_number,
|
||||
$product_number,
|
||||
$exception->getMessage()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if ($attempted_products > 0 && $swallowed_errors === $attempted_products) {
|
||||
error_log(sprintf(
|
||||
'[economicCustomers] All %d invoice-line template probes failed with missing currency prices for customer %d; falling back to 0%% discount. Verify "economic_customer_discount_percentage" in e-conomic for this customer.',
|
||||
$attempted_products,
|
||||
$customer_number
|
||||
));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
|
||||
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
||||
* @throws Exception If the request fails
|
||||
*/
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
|
||||
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): array
|
||||
{
|
||||
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
|
||||
$orders_with_invoice_lines = 0;
|
||||
@@ -89,8 +89,8 @@ class economic_invoices_draft_endpoint
|
||||
$orders_with_invoice_lines++;
|
||||
// Add the transaction header (Timestamp, department, etc.)
|
||||
$draftInvoice->addNewTransactionHeader($order);
|
||||
// Add the order lines
|
||||
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
|
||||
// Add the order lines (including the customer-level e-conomic discount, if any).
|
||||
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);
|
||||
// Add an empty line, so the invoice is not empty
|
||||
$draftInvoice->addTextLine('');
|
||||
}
|
||||
|
||||
+16
-6
@@ -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;
|
||||
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.
|
||||
@@ -249,7 +414,7 @@ class economic_invoice_draft
|
||||
* @throws Exception if the order is not found
|
||||
* @throws Exception if the order is not valid
|
||||
*/
|
||||
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
|
||||
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): void
|
||||
{
|
||||
// Get the order items
|
||||
$order_items = $order->getOrderItems($order->id);
|
||||
@@ -268,18 +433,25 @@ class economic_invoice_draft
|
||||
});
|
||||
// Define the total discount applied to the order
|
||||
$total_discount = 0;
|
||||
// Normalize the customer discount percentage (clamp to 0..100)
|
||||
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
|
||||
// Force itemized discount mode when the customer has a global e-conomic discount
|
||||
// so the discount is applied at the line level (e-conomic line API requires per-line
|
||||
// discountPercentage; an aggregate TotDiscount line would be ignored when the
|
||||
// customer does not have a per-line discount configured for the customer).
|
||||
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 0;
|
||||
// Loop through the order items
|
||||
foreach ( $order_items as $order_item ) {
|
||||
if ($this->shouldSkipOrderItemLine($order_item)) {
|
||||
continue;
|
||||
}
|
||||
// Add the order item to the draft invoice
|
||||
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
|
||||
self::addOrderItemLine($order_item, $department, false, $effective_itemized_discounts, $customer_discount_percentage);
|
||||
// Add the line discount to the total discount
|
||||
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
|
||||
}
|
||||
// If the total discount is greater than 0, add it to the invoice
|
||||
if (!$use_itemized_discounts && $total_discount > 0) {
|
||||
if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0) {
|
||||
// Add the discount to the invoice
|
||||
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
|
||||
}
|
||||
@@ -295,7 +467,7 @@ class economic_invoice_draft
|
||||
* @throws Exception if the order item is not found
|
||||
* @throws Exception if the order item is not valid
|
||||
*/
|
||||
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
|
||||
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false, int $customer_discount_percentage = 0): void
|
||||
{
|
||||
// Check if the order item is valid
|
||||
if (!isset($order_item['id'])) {
|
||||
@@ -309,9 +481,18 @@ class economic_invoice_draft
|
||||
// Get the dimension id
|
||||
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
||||
$pricing = self::resolveOrderItemInvoicePricing($order_item);
|
||||
$discount_percentage = $use_itemized_discount
|
||||
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
|
||||
// is applied at the line level. Combined with per-item discounts using max() so the
|
||||
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
|
||||
// an already-discounted per-item price.
|
||||
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
|
||||
$itemized_discount_percentage = $use_itemized_discount
|
||||
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
|
||||
: 0;
|
||||
: 0.0;
|
||||
$discount_percentage = (float)max(
|
||||
$itemized_discount_percentage,
|
||||
(float)$customer_discount_percentage
|
||||
);
|
||||
// Add the order item to the draft invoice
|
||||
self::addProductLine(
|
||||
(string)$order_item['product']['economic_product_id'],
|
||||
@@ -341,26 +522,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 +653,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 = [
|
||||
|
||||
@@ -32,7 +32,7 @@ class email_template_stripe_invoice
|
||||
|
||||
<!-- Email template -->
|
||||
<p>Kære <?= $this->name ?>,</p>
|
||||
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig en faktura på Stripe.
|
||||
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig et betalingslink til din faktura.
|
||||
Du kan betale fakturaen ved at klikke på linket nedenfor:</p>
|
||||
<p><a href="<?= $this->stripe_payment_link ?>">Betal faktura for ordre <?= $this->order_id ?></a></p>
|
||||
<!-- End of the email template -->
|
||||
|
||||
@@ -725,6 +725,52 @@ class collected_order_invoices_o extends db
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the e-conomic customer discount percentage that should be applied at the
|
||||
* line level when building the invoice draft. Caches via Redis to avoid hammering
|
||||
* the e-conomic templates endpoint on every draft sync.
|
||||
*/
|
||||
private static function resolveCustomerDiscountPercentageForDraft(int $customer_number): int
|
||||
{
|
||||
if ($customer_number <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$user = (new users_o())->getUserByCustomerNumber($customer_number);
|
||||
$userId = (int)$user->id;
|
||||
if ($userId > 0 && defined('redis')) {
|
||||
try {
|
||||
$cached = constant('redis')->get_economic_customer_discount_percentage($userId);
|
||||
if ($cached !== null) {
|
||||
return max(0, min(100, (int)$cached));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall through to the live lookup.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customer_number);
|
||||
} catch (\Throwable $e) {
|
||||
error_log(sprintf(
|
||||
'[collected_order_invoices_o] Failed to resolve e-conomic customer discount for customer %d: %s',
|
||||
$customer_number,
|
||||
$e->getMessage()
|
||||
));
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($userId > 0 && defined('redis')) {
|
||||
try {
|
||||
constant('redis')->cache_economic_customer_discount_percentage($userId, $discount);
|
||||
} catch (\Throwable $e) {
|
||||
// Cache failures are non-fatal.
|
||||
}
|
||||
}
|
||||
|
||||
return max(0, min(100, $discount));
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the invoice draft to not already exist
|
||||
* @throws Exception If the request was not successful
|
||||
@@ -964,7 +1010,18 @@ class collected_order_invoices_o extends db
|
||||
break;
|
||||
}
|
||||
}
|
||||
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
|
||||
// Look up the customer-level e-conomic discount (e.g. bug #11 customer 35131752
|
||||
// "kd" 15%). This is applied at the line level so the draft invoice carries the
|
||||
// discount percentage that e-conomic expects for the customer.
|
||||
$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft((int)$this->customer_number->value());
|
||||
$metrics = (new economic())->invoices->draft->add_orders(
|
||||
$draft_id,
|
||||
$order_objects,
|
||||
$currency,
|
||||
500,
|
||||
$use_itemized_discounts,
|
||||
$customer_discount_percentage
|
||||
);
|
||||
$this->last_economic_transfer_metrics = [
|
||||
'draft_invoice_id' => $draft_id,
|
||||
'currency' => (string)$currency,
|
||||
|
||||
@@ -906,6 +906,68 @@ class orders_o extends db
|
||||
return (bool)$count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp of the most recent completed wash for a license plate.
|
||||
* Used by the front page to show the "last washed" hint when a plate is
|
||||
* scanned (DHL trailer pick-up use case, TRU-78 / DRIFT 17).
|
||||
*
|
||||
* Only orders that have at least one non-deleted order item are
|
||||
* considered (mirrors the contract used by customer_vehicles_o::
|
||||
* getLastOrderByPlate() so the timestamp is always backed by a real wash).
|
||||
*
|
||||
* @param string $reg_1 The license plate to look up
|
||||
* @return string|null MySQL datetime string of the most recent qualifying
|
||||
* order's `created_at`, or null when the plate has
|
||||
* never been washed.
|
||||
*/
|
||||
public function getLastWashTimestampForPlate(string $reg_1): ?string
|
||||
{
|
||||
$normalized_reg_1 = trim($reg_1);
|
||||
if ($normalized_reg_1 === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$orders = self::getFieldsWhere([
|
||||
'reg_1' => $normalized_reg_1,
|
||||
'deleted_at' => null,
|
||||
], [
|
||||
'id',
|
||||
]);
|
||||
|
||||
// Walk the orders newest-first and return the first one that actually
|
||||
// has at least one non-deleted order item.
|
||||
$candidate_ids = array_reverse(array_map(static function ($row) {
|
||||
return (int)($row['id'] ?? 0);
|
||||
}, $orders));
|
||||
|
||||
foreach ($candidate_ids as $order_id) {
|
||||
if ($order_id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$has_items = (new order_items_o())->getFieldsWhere([
|
||||
'order_id' => $order_id,
|
||||
'deleted_at' => null,
|
||||
], ['id']);
|
||||
|
||||
if (count($has_items) === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$details = self::getFieldsWhere([
|
||||
'id' => $order_id,
|
||||
'deleted_at' => null,
|
||||
], ['created_at']);
|
||||
|
||||
$created_at = $details[0]['created_at'] ?? null;
|
||||
if (is_string($created_at) && $created_at !== '') {
|
||||
return $created_at;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array
|
||||
{
|
||||
// Get the fixed pricing transactions for a customer
|
||||
|
||||
@@ -10344,8 +10344,11 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Create Stripe invoice
|
||||
description: Create an invoice in Stripe
|
||||
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
|
||||
description: |
|
||||
Retired in favour of in-store card payments. Always returns HTTP 410
|
||||
with `code: stripe_email_payment_disabled` so the POS can fall back
|
||||
to the standard card-payment flow.
|
||||
operationId: createStripeInvoice
|
||||
requestBody:
|
||||
required: false
|
||||
@@ -10353,11 +10356,41 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
responses:
|
||||
'201':
|
||||
description: Stripe invoice created successfully
|
||||
'410':
|
||||
description: Direct Stripe payment links by email are no longer available
|
||||
content:
|
||||
application/json:
|
||||
schema: {}
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: stripe_email_payment_disabled
|
||||
message:
|
||||
type: string
|
||||
delete:
|
||||
tags:
|
||||
- Modules
|
||||
summary: Cancel/clean up a legacy Stripe hosted invoice
|
||||
description: |
|
||||
Void a pre-existing Stripe hosted invoice that was created before
|
||||
direct payment links were retired from POS (TRU-74 / DRIFT 13).
|
||||
Card payments created via the new flow are not affected and use
|
||||
the standard payment-intent lifecycle instead.
|
||||
operationId: cancelLegacyStripeInvoice
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Legacy Stripe hosted invoice was voided
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
|
||||
/modules/stripe/terminal/readers:
|
||||
get:
|
||||
|
||||
@@ -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
|
||||
@@ -120,11 +120,21 @@ class plateScansRoute
|
||||
'type' => (int)$tmp_scan_vehicle['type'],
|
||||
];
|
||||
}
|
||||
// TRU-78 / DRIFT 17: enrich each scan with the
|
||||
// timestamp of the most recent completed wash for
|
||||
// that plate so the POS landing page can show
|
||||
// "last washed" at a glance when DHL trailers are
|
||||
// being picked up.
|
||||
$plate_value = (string)$scan['plate'];
|
||||
$tmp_scan_last_wash = [
|
||||
'last_wash' => (new orders_o())->getLastWashTimestampForPlate($plate_value),
|
||||
];
|
||||
// Return the object as an array
|
||||
return [
|
||||
...$scan,
|
||||
...$tmp_scan_customer,
|
||||
...$tmp_scan_seen_before,
|
||||
...$tmp_scan_last_wash,
|
||||
];
|
||||
},
|
||||
$number_plate_scans->forceRestrictFilters(
|
||||
|
||||
+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']);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ return [
|
||||
['path' => 'tests/auth/PasskeyChallengeTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/PemToCoseConversionTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/RegisterCvrTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/StripeInvoiceEmailTemplateTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/auth/TwoFactorAuthTest.php', 'classification' => 'integration', 'type' => 'script'],
|
||||
['path' => 'tests/auth/WebAuthnInstallTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
['path' => 'tests/bookingModule/BookingModuleTest.php', 'classification' => 'unit', 'type' => 'script'],
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
use classes\api_key_generator;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||
unset($GLOBALS['db']);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousDb !== null) {
|
||||
$GLOBALS['db'] = $this->previousDb;
|
||||
return;
|
||||
}
|
||||
unset($GLOBALS['db']);
|
||||
});
|
||||
|
||||
it('generates a key_id with the default prefix and 22-char base62 random', function (): void {
|
||||
$keyId = api_key_generator::generateKeyId();
|
||||
expect($keyId)
|
||||
->toStartWith('truck_live_')
|
||||
->and(strlen($keyId))->toBe(strlen('truck_live_') + 22)
|
||||
->and(api_key_generator::isBase62(substr($keyId, strlen('truck_live_'))))->toBeTrue();
|
||||
});
|
||||
|
||||
it('honours a custom env tag in the key_id', function (): void {
|
||||
$keyId = api_key_generator::generateKeyId('test');
|
||||
expect($keyId)->toStartWith('truck_test_');
|
||||
});
|
||||
|
||||
it('falls back to "live" for empty or invalid env tags', function (): void {
|
||||
expect(api_key_generator::generateKeyId(''))->toStartWith('truck_live_');
|
||||
expect(api_key_generator::generateKeyId(' '))->toStartWith('truck_live_');
|
||||
expect(api_key_generator::generateKeyId('weird!@# chars'))->toStartWith('truck_live_');
|
||||
});
|
||||
|
||||
it('generates a 32-char base62 secret with no dots', function (): void {
|
||||
$secret = api_key_generator::generateSecret();
|
||||
expect($secret)
|
||||
->toHaveLength(32)
|
||||
->and($secret)->not->toContain('.')
|
||||
->and(api_key_generator::isBase62($secret))->toBeTrue();
|
||||
});
|
||||
|
||||
it('generates unique values across many calls', function (): void {
|
||||
$seen = [];
|
||||
for ($i = 0; $i < 200; $i++) {
|
||||
$seen[] = api_key_generator::generateKeyId() . '.' . api_key_generator::generateSecret();
|
||||
}
|
||||
expect(count(array_unique($seen)))->toBe(200);
|
||||
});
|
||||
|
||||
it('formats a key as "key_id.secret"', function (): void {
|
||||
$full = api_key_generator::formatKey('truck_live_abc', 'xyz');
|
||||
expect($full)->toBe('truck_live_abc.xyz');
|
||||
});
|
||||
|
||||
it('rejects formatted keys where either part contains a dot', function (): void {
|
||||
expect(fn () => api_key_generator::formatKey('bad.dot', 'secret'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
expect(fn () => api_key_generator::formatKey('key_id', 'bad.dot'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('hashes with argon2id and verifies the same plaintext', function (): void {
|
||||
$plain = 'truck_live_abc' . '.' . 'thisIsTheSecretPart';
|
||||
$hash = api_key_generator::hash($plain);
|
||||
expect($hash)
|
||||
->toBeString()
|
||||
->not->toBe($plain)
|
||||
->toStartWith('$argon2id$');
|
||||
expect(api_key_generator::verify($plain, $hash))->toBeTrue();
|
||||
});
|
||||
|
||||
it('produces different hashes for the same plaintext (salt randomness)', function (): void {
|
||||
$plain = 'truck_live_abc' . '.' . 'thisIsTheSecretPart';
|
||||
$h1 = api_key_generator::hash($plain);
|
||||
$h2 = api_key_generator::hash($plain);
|
||||
expect($h1)->not->toBe($h2);
|
||||
expect(api_key_generator::verify($plain, $h1))->toBeTrue();
|
||||
expect(api_key_generator::verify($plain, $h2))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects an empty hash input', function (): void {
|
||||
expect(fn () => api_key_generator::hash(''))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('verify returns false for empty inputs', function (): void {
|
||||
expect(api_key_generator::verify('', '$argon2id$something'))->toBeFalse();
|
||||
expect(api_key_generator::verify('plain', ''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('parses a well-formed full key', function (): void {
|
||||
$full = 'truck_live_abcDEF1234567890xyz' . '.' . 'A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6';
|
||||
$parsed = api_key_generator::parseKey($full);
|
||||
expect($parsed)
|
||||
->toBe(['key_id' => 'truck_live_abcDEF1234567890xyz', 'secret' => 'A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6']);
|
||||
});
|
||||
|
||||
it('returns null when parsing a malformed key', function (): void {
|
||||
expect(api_key_generator::parseKey(''))->toBeNull();
|
||||
expect(api_key_generator::parseKey(' '))->toBeNull();
|
||||
expect(api_key_generator::parseKey('no-dot-here'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('.only-secret'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('only-key.'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('has spaces.in-secret'))->toBeNull();
|
||||
expect(api_key_generator::parseKey('has/slash.in-secret'))->toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips generateKeyId + generateSecret through formatKey + parseKey', function (): void {
|
||||
$keyId = api_key_generator::generateKeyId('live');
|
||||
$secret = api_key_generator::generateSecret();
|
||||
$full = api_key_generator::formatKey($keyId, $secret);
|
||||
$parsed = api_key_generator::parseKey($full);
|
||||
expect($parsed)->toBe(['key_id' => $keyId, 'secret' => $secret]);
|
||||
});
|
||||
|
||||
it('isBase62 accepts alphanumerics and rejects everything else', function (): void {
|
||||
expect(api_key_generator::isBase62('abc123XYZ'))->toBeTrue();
|
||||
expect(api_key_generator::isBase62(''))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc-123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc.123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc 123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc/123'))->toBeFalse();
|
||||
expect(api_key_generator::isBase62('abc+123'))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
use classes\api_key_repository;
|
||||
use classes\api_key_generator;
|
||||
use classes\api_key_schema_bootstrap;
|
||||
|
||||
/**
|
||||
* Fake mysqli stmt used by api_key_repository unit tests. We mimic
|
||||
* just enough of the surface area (`bind_param`, `execute`,
|
||||
* `get_result`, `close`, `insert_id`, `affected_rows`, `error`) to
|
||||
* exercise the repository without a real database.
|
||||
*/
|
||||
if (!class_exists('ApiKeyRepositoryFakeStmt')) {
|
||||
class ApiKeyRepositoryFakeStmt
|
||||
{
|
||||
public string $lastSql = '';
|
||||
/** @var array<int, mixed> */
|
||||
public array $params = [];
|
||||
public ?int $insertId = null;
|
||||
public int $affectedRows = 0;
|
||||
public string $error = '';
|
||||
public bool $executeResult = true;
|
||||
/** @var array<int, array<string, mixed>>|null */
|
||||
public ?array $rowsToReturn = null;
|
||||
/** @var array<string, string> */
|
||||
public array $types = [
|
||||
'i' => 'i', 's' => 's',
|
||||
];
|
||||
|
||||
public function bind_param(string $types, &...$vars): bool
|
||||
{
|
||||
$this->params = $vars;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function execute(): bool
|
||||
{
|
||||
return $this->executeResult;
|
||||
}
|
||||
|
||||
public function close(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return object{ fetch_assoc(): ?array<string, mixed>, fetch_all(int): array<int, array<string, mixed>> }
|
||||
*/
|
||||
public function get_result(): object
|
||||
{
|
||||
$rows = $this->rowsToReturn ?? [];
|
||||
return new class($rows) {
|
||||
/** @param array<int, array<string, mixed>> $rows */
|
||||
public function __construct(private array $rows)
|
||||
{
|
||||
}
|
||||
|
||||
public function fetch_assoc(): ?array
|
||||
{
|
||||
return $this->rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
public function fetch_all(int $mode = MYSQLI_ASSOC): array
|
||||
{
|
||||
return $this->rows;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('ApiKeyRepositoryFakeMysqli')) {
|
||||
class ApiKeyRepositoryFakeMysqli
|
||||
{
|
||||
public string $error = '';
|
||||
public ApiKeyRepositoryFakeStmt $lastStmt;
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $insertedRows = [];
|
||||
public int $nextInsertId = 100;
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
||||
}
|
||||
|
||||
public function prepare(string $sql): object
|
||||
{
|
||||
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
||||
$this->lastStmt->lastSql = $sql;
|
||||
return $this->lastStmt;
|
||||
}
|
||||
|
||||
public function query(string $sql): object
|
||||
{
|
||||
// Used by the schema_bootstrap. Return an empty result stub.
|
||||
$this->lastStmt = new ApiKeyRepositoryFakeStmt();
|
||||
$this->lastStmt->lastSql = $sql;
|
||||
return $this->lastStmt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('ApiKeyRepositoryFakeDb')) {
|
||||
class ApiKeyRepositoryFakeDb
|
||||
{
|
||||
public ApiKeyRepositoryFakeMysqli $conn;
|
||||
public string $databaseName = 'truckwash_test';
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
public int $nextInsertId = 100;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->conn = new ApiKeyRepositoryFakeMysqli();
|
||||
}
|
||||
|
||||
public function getDatabase(): string
|
||||
{
|
||||
return $this->databaseName;
|
||||
}
|
||||
|
||||
public function escape_string(string $value): string
|
||||
{
|
||||
return addslashes($value);
|
||||
}
|
||||
|
||||
public function query(string $sql): object
|
||||
{
|
||||
return $this->conn->query($sql);
|
||||
}
|
||||
|
||||
public function conn(): ApiKeyRepositoryFakeMysqli
|
||||
{
|
||||
return $this->conn;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the repository's `find*` calls so they read from our in-memory
|
||||
* `rows` table instead of going through real SQL. We override the
|
||||
* static methods via a subclass.
|
||||
*/
|
||||
if (!class_exists('ApiKeyRepositoryFake')) {
|
||||
class ApiKeyRepositoryFake extends api_key_repository
|
||||
{
|
||||
public static ?ApiKeyRepositoryFakeDb $bound = null;
|
||||
public static ?array $findByKeyId = null;
|
||||
public static ?array $findById = null;
|
||||
public static ?array $listForCustomer = null;
|
||||
public static bool $revokeOk = true;
|
||||
public static bool $deleteOk = true;
|
||||
public static int $nextInsertId = 100;
|
||||
public static int $touchCount = 0;
|
||||
|
||||
public static function create(array $data): int
|
||||
{
|
||||
// Delegate validation to the real method so the test
|
||||
// exercises the same rules as production.
|
||||
api_key_repository::validate($data);
|
||||
$id = self::$nextInsertId++;
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function findActiveByKeyId(string $keyId): ?array
|
||||
{
|
||||
return self::$findByKeyId;
|
||||
}
|
||||
|
||||
public static function findById(int $id): ?array
|
||||
{
|
||||
return self::$findById;
|
||||
}
|
||||
|
||||
public static function revoke(int $id): bool
|
||||
{
|
||||
return self::$revokeOk;
|
||||
}
|
||||
|
||||
public static function delete(int $id): bool
|
||||
{
|
||||
return self::$deleteOk;
|
||||
}
|
||||
|
||||
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
|
||||
{
|
||||
return self::$listForCustomer ?? [];
|
||||
}
|
||||
|
||||
public static function touchLastUsed(int $id): void
|
||||
{
|
||||
self::$touchCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->previousDb = $GLOBALS['db'] ?? null;
|
||||
$GLOBALS['db'] = new ApiKeyRepositoryFakeDb();
|
||||
ApiKeyRepositoryFake::$bound = new ApiKeyRepositoryFakeDb();
|
||||
ApiKeyRepositoryFake::$findByKeyId = null;
|
||||
ApiKeyRepositoryFake::$findById = null;
|
||||
ApiKeyRepositoryFake::$listForCustomer = null;
|
||||
ApiKeyRepositoryFake::$revokeOk = true;
|
||||
ApiKeyRepositoryFake::$deleteOk = true;
|
||||
ApiKeyRepositoryFake::$nextInsertId = 100;
|
||||
ApiKeyRepositoryFake::$touchCount = 0;
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
if ($this->previousDb !== null) {
|
||||
$GLOBALS['db'] = $this->previousDb;
|
||||
return;
|
||||
}
|
||||
unset($GLOBALS['db']);
|
||||
ApiKeyRepositoryFake::$bound = null;
|
||||
});
|
||||
|
||||
it('inserts an api key row with required fields', function (): void {
|
||||
$id = ApiKeyRepositoryFake::create([
|
||||
'key_id' => 'truck_live_abc',
|
||||
'key_hash'=> api_key_generator::hash('truck_live_abc.secretvalue'),
|
||||
'name' => 'Test Key',
|
||||
'role' => 'customer',
|
||||
]);
|
||||
expect($id)->toBe(100);
|
||||
});
|
||||
|
||||
it('rejects an api key insert missing required fields', function (): void {
|
||||
expect(fn () => ApiKeyRepositoryFake::create([
|
||||
'key_id' => 'truck_live_abc',
|
||||
// key_hash missing
|
||||
'name' => 'Test Key',
|
||||
'role' => 'customer',
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
|
||||
expect(fn () => ApiKeyRepositoryFake::create([
|
||||
'key_id' => 'truck_live_abc',
|
||||
'key_hash'=> 'hash',
|
||||
'name' => 'Test Key',
|
||||
// role missing
|
||||
]))->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
it('finds an active key by key_id', function (): void {
|
||||
ApiKeyRepositoryFake::$findByKeyId = [
|
||||
'id' => 5,
|
||||
'key_id' => 'truck_live_abc',
|
||||
'role' => 'admin',
|
||||
'revoked_at' => null,
|
||||
];
|
||||
$row = ApiKeyRepositoryFake::findActiveByKeyId('truck_live_abc');
|
||||
expect($row)
|
||||
->toBeArray()
|
||||
->and($row['id'])->toBe(5)
|
||||
->and($row['key_id'])->toBe('truck_live_abc');
|
||||
});
|
||||
|
||||
it('returns null when finding an active key for an empty key_id', function (): void {
|
||||
expect(ApiKeyRepositoryFake::findActiveByKeyId(''))->toBeNull();
|
||||
});
|
||||
|
||||
it('finds a key by id regardless of revocation state', function (): void {
|
||||
ApiKeyRepositoryFake::$findById = [
|
||||
'id' => 7,
|
||||
'key_id' => 'truck_live_xyz',
|
||||
'role' => 'subuser',
|
||||
'revoked_at' => '2026-08-17 00:00:00',
|
||||
];
|
||||
$row = ApiKeyRepositoryFake::findById(7);
|
||||
expect($row)
|
||||
->toBeArray()
|
||||
->and($row['revoked_at'])->toBe('2026-08-17 00:00:00');
|
||||
});
|
||||
|
||||
it('revokes a key and returns true on success', function (): void {
|
||||
expect(ApiKeyRepositoryFake::revoke(7))->toBeTrue();
|
||||
ApiKeyRepositoryFake::$revokeOk = false;
|
||||
expect(ApiKeyRepositoryFake::revoke(7))->toBeFalse();
|
||||
});
|
||||
|
||||
it('lists keys for a customer', function (): void {
|
||||
ApiKeyRepositoryFake::$listForCustomer = [
|
||||
['id' => 1, 'key_id' => 'truck_live_a', 'role' => 'customer'],
|
||||
['id' => 2, 'key_id' => 'truck_live_b', 'role' => 'customer'],
|
||||
];
|
||||
$rows = ApiKeyRepositoryFake::listForCustomer(42);
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[0]['key_id'])->toBe('truck_live_a');
|
||||
});
|
||||
|
||||
it('deletes a key and reports the result', function (): void {
|
||||
expect(ApiKeyRepositoryFake::delete(7))->toBeTrue();
|
||||
ApiKeyRepositoryFake::$deleteOk = false;
|
||||
expect(ApiKeyRepositoryFake::delete(7))->toBeFalse();
|
||||
});
|
||||
|
||||
it('touches last_used_at for a key', function (): void {
|
||||
expect(ApiKeyRepositoryFake::$touchCount)->toBe(0);
|
||||
ApiKeyRepositoryFake::touchLastUsed(7);
|
||||
expect(ApiKeyRepositoryFake::$touchCount)->toBe(1);
|
||||
ApiKeyRepositoryFake::touchLastUsed(7);
|
||||
expect(ApiKeyRepositoryFake::$touchCount)->toBe(2);
|
||||
});
|
||||
|
||||
it('ensureTables is idempotent and safe to call without a real DB', function (): void {
|
||||
// The fake $db swallows queries; this should not throw.
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
api_key_schema_bootstrap::ensureTables();
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('tableExists returns false when there is no DB', function (): void {
|
||||
unset($GLOBALS['db']);
|
||||
expect(api_key_schema_bootstrap::tableExists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
use classes\auth\scope_registry;
|
||||
|
||||
it('exposes the canonical scope constants', function (): void {
|
||||
expect(scope_registry::CUSTOMER_READ)->toBe('customer:read');
|
||||
expect(scope_registry::CUSTOMER_WRITE)->toBe('customer:write');
|
||||
expect(scope_registry::BOOKING_READ)->toBe('booking:read');
|
||||
expect(scope_registry::BOOKING_WRITE)->toBe('booking:write');
|
||||
expect(scope_registry::SUBUSER_READ)->toBe('subuser:read');
|
||||
expect(scope_registry::SUBUSER_WRITE)->toBe('subuser:write');
|
||||
expect(scope_registry::INVOICE_READ)->toBe('invoice:read');
|
||||
expect(scope_registry::INVOICE_WRITE)->toBe('invoice:write');
|
||||
expect(scope_registry::SUPERUSER_READ)->toBe('superuser:read');
|
||||
expect(scope_registry::SUPERUSER_WRITE)->toBe('superuser:write');
|
||||
});
|
||||
|
||||
it('returns every concrete scope from all()', function (): void {
|
||||
$all = scope_registry::all();
|
||||
expect($all)->toContain(scope_registry::CUSTOMER_READ);
|
||||
expect($all)->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect($all)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($all)->toContain(scope_registry::BOOKING_WRITE);
|
||||
expect($all)->toContain(scope_registry::SUBUSER_READ);
|
||||
expect(scope_registry::SUBUSER_WRITE);
|
||||
expect($all)->toContain(scope_registry::INVOICE_READ);
|
||||
expect($all)->toContain(scope_registry::INVOICE_WRITE);
|
||||
expect($all)->toContain(scope_registry::SUPERUSER_READ);
|
||||
expect($all)->toContain(scope_registry::SUPERUSER_WRITE);
|
||||
expect(count($all))->toBe(10);
|
||||
expect(count(array_unique($all)))->toBe(10);
|
||||
});
|
||||
|
||||
it('superuser defaults to the global wildcard', function (): void {
|
||||
expect(scope_registry::scopesForRole('superuser'))->toBe(['*']);
|
||||
});
|
||||
|
||||
it('admin defaults to all resource wildcards', function (): void {
|
||||
$scopes = scope_registry::scopesForRole('admin');
|
||||
expect($scopes)->toContain('customer:*');
|
||||
expect($scopes)->toContain('booking:*');
|
||||
expect($scopes)->toContain('subuser:*');
|
||||
expect($scopes)->toContain('invoice:*');
|
||||
expect($scopes)->not->toContain('superuser:*');
|
||||
});
|
||||
|
||||
it('customer defaults to read-only on self resources', function (): void {
|
||||
$scopes = scope_registry::scopesForRole('customer');
|
||||
expect($scopes)->toBe([
|
||||
scope_registry::CUSTOMER_READ,
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::INVOICE_READ,
|
||||
]);
|
||||
});
|
||||
|
||||
it('subuser defaults to booking read+write on assigned bookings', function (): void {
|
||||
$scopes = scope_registry::scopesForRole('subuser');
|
||||
expect($scopes)->toBe([
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::BOOKING_WRITE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('unknown roles default to no scopes', function (): void {
|
||||
expect(scope_registry::scopesForRole('nope'))->toBe([]);
|
||||
expect(scope_registry::scopesForRole(''))->toBe([]);
|
||||
expect(scope_registry::scopesForRole('SuperUser'))->toBe(['*']); // case-insensitive
|
||||
});
|
||||
|
||||
it('hasScope matches an exact scope against itself', function (): void {
|
||||
expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_READ))->toBeTrue();
|
||||
});
|
||||
|
||||
it('hasScope rejects an exact scope against a different scope', function (): void {
|
||||
expect(scope_registry::hasScope([scope_registry::BOOKING_READ], scope_registry::BOOKING_WRITE))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hasScope lets a wildcard match any concrete scope', function (): void {
|
||||
expect(scope_registry::hasScope(['*'], scope_registry::INVOICE_READ))->toBeTrue();
|
||||
expect(scope_registry::hasScope(['*'], scope_registry::SUPERUSER_WRITE))->toBeTrue();
|
||||
});
|
||||
|
||||
it('hasScope resolves a resource wildcard to that resource only', function (): void {
|
||||
expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_READ))->toBeTrue();
|
||||
expect(scope_registry::hasScope(['customer:*'], scope_registry::CUSTOMER_WRITE))->toBeTrue();
|
||||
expect(scope_registry::hasScope(['customer:*'], scope_registry::BOOKING_READ))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hasScope returns false on empty input', function (): void {
|
||||
expect(scope_registry::hasScope([], 'booking:read'))->toBeFalse();
|
||||
expect(scope_registry::hasScope(['booking:read'], ''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('hasScope ignores non-string granted entries', function (): void {
|
||||
expect(scope_registry::hasScope([null, 123, 'booking:read'], 'booking:read'))->toBeTrue();
|
||||
expect(scope_registry::hasScope([null, 123], 'booking:read'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('expand flattens a single wildcard to all concrete scopes', function (): void {
|
||||
$expanded = scope_registry::expand(['*']);
|
||||
expect(count($expanded))->toBe(10);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($expanded)->toContain(scope_registry::SUPERUSER_WRITE);
|
||||
});
|
||||
|
||||
it('expand flattens resource wildcards', function (): void {
|
||||
$expanded = scope_registry::expand(['booking:*']);
|
||||
expect($expanded)->toBe([
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::BOOKING_WRITE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('expand deduplicates results', function (): void {
|
||||
$expanded = scope_registry::expand([
|
||||
'booking:*',
|
||||
scope_registry::BOOKING_READ,
|
||||
'booking:write',
|
||||
]);
|
||||
expect($expanded)->toBe([
|
||||
scope_registry::BOOKING_READ,
|
||||
scope_registry::BOOKING_WRITE,
|
||||
]);
|
||||
});
|
||||
|
||||
it('expand drops unknown concrete scopes (no silent grant)', function (): void {
|
||||
$expanded = scope_registry::expand(['booking:read', 'totally:made-up']);
|
||||
expect($expanded)->toBe([scope_registry::BOOKING_READ]);
|
||||
});
|
||||
|
||||
it('expand combines multiple wildcards and concrete scopes', function (): void {
|
||||
$expanded = scope_registry::expand([
|
||||
scope_registry::BOOKING_READ,
|
||||
'customer:*',
|
||||
]);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_READ);
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect(count($expanded))->toBe(3);
|
||||
});
|
||||
|
||||
it('expand ignores empty and non-string entries', function (): void {
|
||||
$expanded = scope_registry::expand([null, '', ' ', scope_registry::BOOKING_READ]);
|
||||
expect($expanded)->toBe([scope_registry::BOOKING_READ]);
|
||||
});
|
||||
|
||||
it('superuser role resolves to all scopes via expand', function (): void {
|
||||
$expanded = scope_registry::expand(scope_registry::scopesForRole('superuser'));
|
||||
expect(count($expanded))->toBe(10);
|
||||
});
|
||||
|
||||
it('admin role expands to all non-superuser scopes', function (): void {
|
||||
$expanded = scope_registry::expand(scope_registry::scopesForRole('admin'));
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_READ);
|
||||
expect($expanded)->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_READ);
|
||||
expect($expanded)->toContain(scope_registry::BOOKING_WRITE);
|
||||
expect($expanded)->toContain(scope_registry::SUBUSER_READ);
|
||||
expect($expanded)->toContain(scope_registry::SUBUSER_WRITE);
|
||||
expect($expanded)->toContain(scope_registry::INVOICE_READ);
|
||||
expect($expanded)->toContain(scope_registry::INVOICE_WRITE);
|
||||
expect($expanded)->not->toContain(scope_registry::SUPERUSER_READ);
|
||||
expect($expanded)->not->toContain(scope_registry::SUPERUSER_WRITE);
|
||||
expect(count($expanded))->toBe(8);
|
||||
});
|
||||
|
||||
it('customer role does not gain write or subuser scopes', function (): void {
|
||||
$expanded = scope_registry::expand(scope_registry::scopesForRole('customer'));
|
||||
expect($expanded)->not->toContain(scope_registry::CUSTOMER_WRITE);
|
||||
expect($expanded)->not->toContain(scope_registry::BOOKING_WRITE);
|
||||
expect($expanded)->not->toContain(scope_registry::SUBUSER_READ);
|
||||
expect($expanded)->not->toContain(scope_registry::INVOICE_WRITE);
|
||||
});
|
||||
|
||||
it('isValid accepts canonical scopes, wildcards, and resource wildcards', function (): void {
|
||||
expect(scope_registry::isValid('*'))->toBeTrue();
|
||||
expect(scope_registry::isValid('customer:*'))->toBeTrue();
|
||||
expect(scope_registry::isValid(scope_registry::BOOKING_READ))->toBeTrue();
|
||||
expect(scope_registry::isValid('totally:made-up'))->toBeFalse();
|
||||
expect(scope_registry::isValid(''))->toBeFalse();
|
||||
expect(scope_registry::isValid(' '))->toBeFalse();
|
||||
expect(scope_registry::isValid('unknown:*'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('role default + hasScope composes correctly for customer:read on customer role', function (): void {
|
||||
$granted = scope_registry::scopesForRole('customer');
|
||||
expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_READ))->toBeTrue();
|
||||
expect(scope_registry::hasScope($granted, scope_registry::CUSTOMER_WRITE))->toBeFalse();
|
||||
expect(scope_registry::hasScope($granted, scope_registry::SUBUSER_READ))->toBeFalse();
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-3
@@ -14,7 +14,11 @@ it('routes collected invoice draft line uploads through the multi-order batch en
|
||||
|
||||
$methodBlock = substr($content, (int)$start, (int)$end - (int)$start);
|
||||
expect($methodBlock)->toContain('$order_objects = [];')
|
||||
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);')
|
||||
// Bug #11 customer 35131752 — the customer discount is threaded through add_orders
|
||||
// so the line-level discountPercentage is applied to each line item.
|
||||
->and($methodBlock)->toContain('$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft')
|
||||
->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders(')
|
||||
->and($methodBlock)->toContain('$customer_discount_percentage')
|
||||
->and($methodBlock)->toContain('...$metrics')
|
||||
->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);');
|
||||
});
|
||||
@@ -36,7 +40,7 @@ it('keeps single-order draft uploads as a wrapper around the batch endpoint', fu
|
||||
|
||||
$batchBlock = substr($content, (int)$singleEnd);
|
||||
expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);')
|
||||
->and($batchBlock)->toContain('$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);')
|
||||
->and($batchBlock)->toContain('$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);')
|
||||
->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines");
|
||||
});
|
||||
|
||||
@@ -50,7 +54,7 @@ it('selects itemized discount mode for collected invoice batch transfers', funct
|
||||
->and($content)->toContain('invoice_discount_layout')
|
||||
->and($content)->toContain('hasDiscountedIncludedInvoiceItems')
|
||||
->and($content)->toContain('orderItemHasBillableDiscount')
|
||||
->and($content)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);');
|
||||
->and($content)->toContain('$customer_discount_percentage');
|
||||
});
|
||||
|
||||
it('includes collected invoice batch transfer metrics in queue results when available', function (): void {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Tests for the e-conomic customer-level discount being applied at the line level.
|
||||
*
|
||||
* Regression coverage for bug #11 — E-conomic 15% discount not applied on
|
||||
* customer 35131752 ("kd"). The customer has a 15% global discount configured in
|
||||
* e-conomic, but the invoice was being sent without any discount on the line items.
|
||||
*
|
||||
* The fix threads the customer discount percentage through the draft builder so it
|
||||
* is applied at the line level via the `discountPercentage` field that e-conomic
|
||||
* expects on each line.
|
||||
*/
|
||||
|
||||
app_require('modules/economic/helpers/economic_invoice_draft.php');
|
||||
|
||||
use helpers\economic_invoice_draft;
|
||||
|
||||
if (!class_exists('EconomicInvoiceDraftCustomerDiscountProbe')) {
|
||||
class EconomicInvoiceDraftCustomerDiscountProbe extends economic_invoice_draft
|
||||
{
|
||||
public array $sentBatches = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->draft_invoice_number = 35131752;
|
||||
$this->currency = 'DKK';
|
||||
$this->conversion_rate = 1.0;
|
||||
$this->draft_invoice_data = (object)['draftInvoiceNumber' => 35131752];
|
||||
}
|
||||
|
||||
protected function sendDraftLines(array $draft_lines): object
|
||||
{
|
||||
$this->sentBatches[] = $draft_lines;
|
||||
return (object)['lines' => $draft_lines];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function economic_customer_discount_order_item(float $price, float $product_price, array $overrides = []): array
|
||||
{
|
||||
return array_replace_recursive([
|
||||
'id' => 9001,
|
||||
'quantity' => 1,
|
||||
'price' => $price,
|
||||
'reference' => '',
|
||||
'notes' => '',
|
||||
'include_in_invoice' => true,
|
||||
'product' => [
|
||||
'economic_product_id' => '5',
|
||||
'name' => 'Wash',
|
||||
'price' => $product_price,
|
||||
],
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
it('applies the 15% customer discount to a line item for customer 35131752 "kd"', function (): void {
|
||||
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
|
||||
|
||||
// Order item is at full price (no per-item discount) — exactly the customer 35131752
|
||||
// case where the 15% global e-conomic discount was silently dropped.
|
||||
$draft->addOrderItemLine(
|
||||
economic_customer_discount_order_item(100.0, 100.0),
|
||||
[
|
||||
'economic_department_id' => 75,
|
||||
'economic_dimension_id' => 1,
|
||||
],
|
||||
false,
|
||||
false,
|
||||
15
|
||||
);
|
||||
$draft->flushLinesInBatches();
|
||||
|
||||
$line = $draft->sentBatches[0][0];
|
||||
expect($line['product']['productNumber'])->toBe('5')
|
||||
->and($line['description'])->toBe('Wash')
|
||||
->and($line['quantity'])->toBe(1.0)
|
||||
->and($line['unitNetPrice'])->toBe(100.0)
|
||||
->and($line['discountPercentage'])->toBe(15.0);
|
||||
});
|
||||
|
||||
it('uses the larger discount when both per-item and customer discounts are present', function (): void {
|
||||
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
|
||||
|
||||
// Per-item discount = 10%, customer discount = 15% → max(15, 10) = 15.
|
||||
$draft->addOrderItemLine(
|
||||
economic_customer_discount_order_item(90.0, 100.0),
|
||||
[
|
||||
'economic_department_id' => 75,
|
||||
'economic_dimension_id' => 1,
|
||||
],
|
||||
false,
|
||||
true,
|
||||
15
|
||||
);
|
||||
$draft->flushLinesInBatches();
|
||||
|
||||
$line = $draft->sentBatches[0][0];
|
||||
expect($line['unitNetPrice'])->toBe(100.0)
|
||||
->and($line['discountPercentage'])->toBe(15.0);
|
||||
});
|
||||
|
||||
it('uses the per-item discount when it is larger than the customer discount', function (): void {
|
||||
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
|
||||
|
||||
// Per-item discount = 25%, customer discount = 15% → max(25, 15) = 25.
|
||||
$draft->addOrderItemLine(
|
||||
economic_customer_discount_order_item(75.0, 100.0),
|
||||
[
|
||||
'economic_department_id' => 75,
|
||||
'economic_dimension_id' => 1,
|
||||
],
|
||||
false,
|
||||
true,
|
||||
15
|
||||
);
|
||||
$draft->flushLinesInBatches();
|
||||
|
||||
$line = $draft->sentBatches[0][0];
|
||||
expect($line['unitNetPrice'])->toBe(100.0)
|
||||
->and($line['discountPercentage'])->toBe(25.0);
|
||||
});
|
||||
|
||||
it('clamps the customer discount percentage to the 0..100 range', function (): void {
|
||||
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
|
||||
|
||||
$draft->addOrderItemLine(
|
||||
economic_customer_discount_order_item(100.0, 100.0),
|
||||
[
|
||||
'economic_department_id' => 75,
|
||||
'economic_dimension_id' => 1,
|
||||
],
|
||||
false,
|
||||
false,
|
||||
150
|
||||
);
|
||||
$draft->flushLinesInBatches();
|
||||
|
||||
$line = $draft->sentBatches[0][0];
|
||||
expect($line['discountPercentage'])->toBe(100.0);
|
||||
});
|
||||
|
||||
it('emits no line discount when both per-item and customer discounts are zero', function (): void {
|
||||
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
|
||||
|
||||
$draft->addOrderItemLine(
|
||||
economic_customer_discount_order_item(100.0, 100.0),
|
||||
[
|
||||
'economic_department_id' => 75,
|
||||
'economic_dimension_id' => 1,
|
||||
],
|
||||
false,
|
||||
false,
|
||||
0
|
||||
);
|
||||
$draft->flushLinesInBatches();
|
||||
|
||||
$line = $draft->sentBatches[0][0];
|
||||
expect($line['unitNetPrice'])->toBe(100.0)
|
||||
->and($line['discountPercentage'])->toBe(0.0);
|
||||
});
|
||||
|
||||
it('keeps base behavior unchanged when the customer discount is zero', function (): void {
|
||||
$draft = new EconomicInvoiceDraftCustomerDiscountProbe();
|
||||
|
||||
// No customer discount, no per-item discount — unit price should be the final price.
|
||||
$draft->addOrderItemLine(
|
||||
economic_customer_discount_order_item(100.0, 100.0),
|
||||
[
|
||||
'economic_department_id' => 75,
|
||||
'economic_dimension_id' => 1,
|
||||
],
|
||||
false,
|
||||
false,
|
||||
0
|
||||
);
|
||||
$draft->flushLinesInBatches();
|
||||
|
||||
$line = $draft->sentBatches[0][0];
|
||||
expect($line['unitNetPrice'])->toBe(100.0)
|
||||
->and($line['discountPercentage'])->toBe(0.0);
|
||||
});
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
it('only adds TotDiscount aggregate line when itemized discounts are disabled', function (): void {
|
||||
it('only adds TotDiscount aggregate line when itemized discounts are disabled and no customer discount is set', function (): void {
|
||||
$content = file_get_contents(app_path('modules/economic/helpers/economic_invoice_draft.php'));
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
@@ -13,7 +13,11 @@ it('only adds TotDiscount aggregate line when itemized discounts are disabled',
|
||||
expect($end)->toBeGreaterThan($start);
|
||||
|
||||
$block = substr($content, (int)$start, (int)$end - (int)$start);
|
||||
// The aggregate TotDiscount line is only added when neither itemized discounts
|
||||
// nor a customer-level e-conomic discount is in effect. The customer discount
|
||||
// (e.g. bug #11 customer 35131752 "kd" 15%) is applied at the line level instead.
|
||||
expect($block)
|
||||
->toContain('if (!$use_itemized_discounts && $total_discount > 0)')
|
||||
->toContain('if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0)')
|
||||
->toContain('self::addProductDiscountLine($total_discount');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
it('orders_o exposes getLastWashTimestampForPlate that filters out orders without order items', function (): void {
|
||||
$ordersFile = app_path('objects/orders_o.php');
|
||||
expect(is_file($ordersFile))->toBeTrue();
|
||||
|
||||
$ordersCode = preg_replace('/\s+/', ' ', (string)file_get_contents($ordersFile));
|
||||
|
||||
expect($ordersCode)->toContain('public function getLastWashTimestampForPlate(string $reg_1): ?string');
|
||||
|
||||
// The helper must look at non-deleted orders with non-deleted order items,
|
||||
// matching the contract used by customer_vehicles_o::getLastOrderByPlate().
|
||||
expect($ordersCode)->toContain("'reg_1' => $normalized_reg_1");
|
||||
expect($ordersCode)->toContain("'deleted_at' => null");
|
||||
expect($ordersCode)->toContain("'order_id' => $order_id");
|
||||
expect($ordersCode)->toContain('return $created_at;');
|
||||
expect($ordersCode)->toContain('return null;');
|
||||
});
|
||||
|
||||
it('plateScansRoute enriches GET /numberplatescans with last_wash per scan (TRU-78)', function (): void {
|
||||
$routeFile = app_path('routes/plateScansRoute.php');
|
||||
$ordersFile = app_path('objects/orders_o.php');
|
||||
|
||||
expect(is_file($routeFile))->toBeTrue();
|
||||
expect(is_file($ordersFile))->toBeTrue();
|
||||
|
||||
$routeCode = preg_replace('/\s+/', ' ', (string)file_get_contents($routeFile));
|
||||
$ordersCode = preg_replace('/\s+/', ' ', (string)file_get_contents($ordersFile));
|
||||
|
||||
// The route already loads orders_o and customer_vehicles_o; verify the
|
||||
// new enrichment is wired in the GET /numberplatescans handler.
|
||||
expect($routeCode)->toContain("\$this->get('/numberplatescans', function () {");
|
||||
expect($routeCode)->toContain("'last_wash'");
|
||||
expect($routeCode)->toContain('getLastWashTimestampForPlate');
|
||||
expect($routeCode)->toContain("'last_wash' => (new orders_o())->getLastWashTimestampForPlate");
|
||||
expect($routeCode)->toContain('$tmp_scan_last_wash');
|
||||
|
||||
// The helper definition must live in orders_o so the enrichment is real,
|
||||
// not a stub.
|
||||
expect($ordersCode)->toContain('public function getLastWashTimestampForPlate(string $reg_1): ?string');
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
it('documents the retired Stripe hosted invoice creation route in the public OpenAPI spec (TRU-74)', function (): void {
|
||||
$openApiFile = dirname(__DIR__, 3) . '/openapi.yaml';
|
||||
$contents = file_get_contents($openApiFile);
|
||||
|
||||
expect($contents)->not->toBeFalse();
|
||||
|
||||
$needle = " /modules/stripe/invoice:";
|
||||
$start = strpos($contents, $needle);
|
||||
expect($start)->not->toBeFalse();
|
||||
|
||||
$nextPathStart = strpos($contents, "\n /", $start + strlen($needle));
|
||||
if ($nextPathStart === false) {
|
||||
$nextPathStart = strlen($contents);
|
||||
}
|
||||
|
||||
$block = substr($contents, $start, $nextPathStart - $start);
|
||||
// Normalise trailing whitespace so the assertion is stable across editors.
|
||||
$normalised = preg_replace('/[ \t]+$/m', '', $block);
|
||||
|
||||
expect($normalised)->toContain(" /modules/stripe/invoice:");
|
||||
expect($normalised)->toContain('summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)');
|
||||
expect($normalised)->toContain("'410':");
|
||||
expect($normalised)->toContain('stripe_email_payment_disabled');
|
||||
expect($normalised)->toContain('Cancel/clean up a legacy Stripe hosted invoice');
|
||||
expect($normalised)->toContain('cancelLegacyStripeInvoice');
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
namespace {
|
||||
if (!defined('WD')) {
|
||||
define('WD', dirname(__DIR__, 2));
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
require_once WD . '/modules/email/helpers/email_template.php';
|
||||
require_once WD . '/modules/email/templates/email_template_stripe_invoice.php';
|
||||
|
||||
function assert_true(bool $condition, string $message): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new \RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup_buffers_to(int $base_level): string
|
||||
{
|
||||
$output = '';
|
||||
while (ob_get_level() > $base_level) {
|
||||
$output .= (string)ob_get_clean();
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
$base_level = ob_get_level();
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
$html = (new \email\templates\email_template_stripe_invoice(
|
||||
4242,
|
||||
'https://pay.example.com/invoice/abc',
|
||||
'Anders And'
|
||||
))->generate_html();
|
||||
$leaked_output = cleanup_buffers_to($base_level);
|
||||
|
||||
assert_true($leaked_output === '', 'Template generation must not leak buffered HTML output.');
|
||||
|
||||
// TRU-71: user-facing wording must not mention the payment processor
|
||||
// by name. The email body must describe the artefact in plain
|
||||
// language instead.
|
||||
assert_true(
|
||||
stripos($html, 'stripe') === false,
|
||||
'Stripe-invoice email body must no longer expose the payment processor name "Stripe" to the customer.'
|
||||
);
|
||||
|
||||
assert_true(
|
||||
str_contains($html, 'betalingslink'),
|
||||
'Stripe-invoice email body must describe the artefact as a betalingslink (payment link).'
|
||||
);
|
||||
|
||||
assert_true(
|
||||
str_contains($html, 'Kære Anders And'),
|
||||
'Template must still greet the customer by name.'
|
||||
);
|
||||
|
||||
assert_true(
|
||||
str_contains($html, 'Betal faktura for ordre 4242'),
|
||||
'Template must still expose a pay-now action link for the order.'
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
$leaked_output = cleanup_buffers_to($base_level);
|
||||
fwrite(STDERR, $leaked_output);
|
||||
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo "\033[32m[PASS]\033[0m Stripe-invoice email template no longer mentions Stripe to the customer.\n";
|
||||
exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user