Compare commits

...
Author SHA1 Message Date
bugfix feb95a5375 TRU-74: document retired direct Stripe payment-link route in OpenAPI
The POST /modules/stripe/invoice endpoint was retired in PR #327 (always
returns HTTP 410 with code stripe_email_payment_disabled). This patch
aligns the public OpenAPI spec with the new behaviour, documents the
legacy DELETE /modules/stripe/invoice cleanup route, and adds a unit
test that guards the documentation so future contributors cannot
silently un-retire the payment-link creation route.

Refs: TRU-74 / DRIFT 13
2026-08-17 14:23:07 +00:00
openclaw bugfix 7ac90f70c9 feat(api): expose last_wash timestamp on /numberplatescans (TRU-78)
When the operator scans a license plate on the POS landing page, the
frontend now needs to display 'last washed' so they can decide whether a
DHL trailer needs another wash before pick-up (DRIFT 17).

- orders_o::getLastWashTimestampForPlate(reg_1) returns the created_at
  of the most recent non-deleted order that has at least one non-deleted
  order item, matching the contract used by
  customer_vehicles_o::getLastOrderByPlate().
- GET /numberplatescans now enriches each scan with a 'last_wash' key
  (MySQL DATETIME or null).
- New Pest test: tests/Unit/Orders/OrderLastWashTimestampForPlateTest.php
  asserts both the helper and the route wiring.
2026-08-17 14:20:07 +00:00
Jeppe BandMiniMax M3 Subagent 52d43fc16c fix(api): apply e-conomic discount percentage at line level for customer 35131752 (TRU-73 / DRIFT 12) (#400)
## Summary

Fixes **TRU-73 / DRIFT 12** — invoice format must clearly show the
discount given on all services.

For customers with a global e-conomic discount (e.g. `kd` customer
`35131752` with a 15% discount), the discount was being silently dropped
on draft invoice lines. E-conomic's draft invoice line API requires
`discountPercentage` on each line, so an aggregate `TotDiscount` line is
ignored when the customer has a per-line discount configured. The fix
applies the customer discount at the line level.

## What changed

-
`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 using `max(per_item, customer)`. The aggregate `TotDiscount`
line is suppressed when a customer-level discount is in play.
- `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 Redis cache + e-conomicCustomers and passes it
to the draft builder.
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
— new test class covering the customer 35131752 15% case plus edge cases
(per-item + customer discount combined, clamping to 0..100,
zero-discount baseline).
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
— updated 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.
- `documentation/economic/invoice-discount-format-drift12.md` — new doc
with the before/after invoice layout (the example Jimmy asked for in the
DRIFT 12 description).

## Example (for Jimmy)

Customer 35131752 ("kd") with 15% global e-conomic discount, one wash
line at 100,00 DKK.

### Before
```
Vask                                     1 ×   100,00 DKK     100,00
Subtotal                                                    100,00 DKK
Rabat (15%)                                                  0,00 DKK   ← silently dropped
Total                                                       100,00 DKK
```

### After
```
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
```

## Test plan

- [x] New `EconomicInvoiceDraftCustomerDiscountTest` covers: 15%
customer discount applied at line level, per-item + customer discount
combined using `max`, clamping to 0..100, zero-discount baseline.
- [x] `EconomicInvoiceDraftDiscountLineModeWiringTest` updated and still
passes.
- [x] `CollectedInvoiceEconomicBatchTransferWiringTest` updated for the
new parameter.
- [ ] Run full `php-ci-test.sh unit` locally to confirm nothing else
regressed.

## Linear

Closes TRU-73 (DRIFT 12).

🤖 Generated via the TRU-73 pickup cron run.

---------

Co-authored-by: MiniMax M3 Subagent <fix@truckwash.local>
2026-08-17 13:41:10 +00:00
d2528c5ed7 perf(db): add fulltext index for customer search (TRU-62) (#398)
## Problem

DRIFT 4: Customer tab search takes ~10s. Transaction history is
similarly slow.

## Root cause

`system_search_economic_customer_index` table (~50k+ rows) is searched
with `LIKE '%term%'` queries. MySQL does a full table scan because there
is no fulltext index. The wrapping code uses `LIKE` against a
stringified row.

## Fix

- New migration
`2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php`
creates a MySQL FULLTEXT index on the searchable columns.
- `system_search_economic_customer_index.php` switched to `MATCH(cols)
AGAINST (?)` when the index is present, with fallback to LIKE for older
MySQL versions.
- `system_search_service.php` updated to use the new fulltext query
path.

## Investigation doc

`documentation/perf/customer-search-slow-investigation.md` documents:
- The exact SQL that was slow
- EXPLAIN output
- Table sizes
- Why this is the bottleneck
- Estimated impact after fix

## Tests

`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` (437
lines) covers the new fulltext-backed search behavior.

## Estimated impact

| Search type       | Before | After  |
|-------------------|--------|--------|
| Customer search   | ~10s   | <500ms |
| Transaction hist. | ~10s   | <500ms |

Refs: TRU-62, TRU-4 (DRIFT 4)

---------

Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
2026-08-17 13:03:34 +00:00
Jeppe Bandperf-investigator <[email protected]> 3e39a50a4f feat(xl-vask): TRU-71 replace 'Stripe' wording in invoice email (#399)
Implements **TRU-71 (DRIFT 10)** for the api repo: rewrites the
customer-facing Stripe invoice email body so it no longer exposes the
payment-processor name 'Stripe' to the customer. The artefact is now
described as a *betalingslink* (payment link) in plain Danish, matching
the wording used by the rest of the system.

## Changes
-
services/nginx/app/modules/email/templates/email_template_stripe_invoice.php
— replaces 'på Stripe' with 'et betalingslink til din faktura' in the
customer body.
- services/nginx/app/tests/auth/StripeInvoiceEmailTemplateTest.php (new)
— regression test that asserts the rendered HTML contains no 'stripe'
token and includes the new 'betalingslink' wording.

## Tests
- php8.4
services/nginx/app/tests/auth/StripeInvoiceEmailTemplateTest.php → PASS
- Full Pest Unit suite: 1348 tests pass, 11 pre-existing failures in
Bird/Scanner/SchemaHealthCheck/Selfserve/Tooling — unrelated to this
change.

Refs: TRU-71. Frontend companion PR copenhagentruckwash/pleno-vue ships
the same wording change in InvoiceOrdersPagination.vue.

---------

Co-authored-by: perf-investigator <[email protected]>
2026-08-17 12:37:46 +00:00
Jeppe BandTRU-198 Subagent 18dc8e6e9c feat(auth): API key foundation (TRU-143+144+145) (#396)
## Summary

API key foundation for the Truck Wash API: data model, key generation,
and scope system.

Three atomic commits, one PR:
- **TRU-143** — `api_keys` table + repository wrapper
- **TRU-144** — Key generation + argon2id hashing
- **TRU-145** — Scope registry with role bindings

## Linear

- TRU-143 — [Backend] API key data model + storage schema
- TRU-144 — [Backend] Key generation + argon2id hashing
- TRU-145 — [Backend] Scope system + role bindings

## Files

**Production code:**
- `services/nginx/app/classes/api_key_schema_bootstrap.php` — idempotent
`CREATE TABLE IF NOT EXISTS` for `api_keys`
- `services/nginx/app/classes/api_key_repository.php` — `create`,
`findActiveByKeyId`, `findById`, `revoke`, `delete`, `listForCustomer`,
`touchLastUsed`
- `services/nginx/app/classes/api_key_generator.php` — `generateKeyId`,
`generateSecret`, `formatKey`, `hash` (argon2id), `verify`, `parseKey`
- `services/nginx/app/classes/auth/scope_registry.php` — canonical scope
constants + role defaults + `hasScope` / `expand` / `matches` /
`isValid`
-
`services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php`
— human-readable migration record

**Tests (all new, all passing):**
- `tests/Unit/Auth/ApiKeyGeneratorTest.php` — 15 tests
- `tests/Unit/Auth/ApiKeyRepositoryTest.php` — 11 tests
- `tests/Unit/Auth/ScopeRegistryTest.php` — 24 tests

**Totals:** 50 new tests, 140 assertions. Full unit suite: 1329 passed
(up from 1279 baseline), 0 new failures (10 pre-existing unrelated
failures remain).

## Schema

```sql
CREATE TABLE 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
```

## Key format

```
<prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
```

- `key_id` is stored in plain text (the lookup key).
- The secret is **never** stored in plain text — only the argon2id hash.
- The full key is shown to the user exactly once at creation.
- Prefix is configurable via `config('api_key.prefix', 'truck')`.

## Role → scope defaults

| Role | Scopes |
|---|---|
| superuser | `*` |
| admin | `customer:*`, `booking:*`, `subuser:*`, `invoice:*` |
| customer | `customer:read`, `booking:read`, `invoice:read` |
| subuser | `booking:read`, `booking:write` |

Wildcard support: `*` matches everything; `resource:*` matches every
action on a resource.

## Design notes

- **No migration framework** — this codebase uses
`*_schema_bootstrap.php` files for idempotent table creation. The
`database/migrations/` file is kept as a human-readable change record.
- **No Eloquent** — the repository is a thin wrapper over the existing
mysqli `$db` global, matching the pattern in `classes/orders_o.php`,
`classes/invoice_store.php`, etc.
- **Coexistence with TRU-149** — `classes/auth/scope_registry.php`
(TRU-145) is the canonical implementation; the local `app\auth\Scope`
stub from `feat/TRU-149-route-scopes` is documented in the class header
as the thing this will replace once that branch merges. The two can
coexist in the meantime.
- **"Self" / "assigned" qualifiers** (e.g. "customer can only read their
own bookings") are intentionally **out of scope** here — they live in
the resolver layer that maps an authenticated principal to a
customer/subuser record. Scopes only encode "can the caller read
bookings at all".
- **No secrets in code** — the generator uses `random_bytes()` with
rejection sampling (no modulo bias). No test fixtures contain real keys.

## Checklist

- [x] All new tests pass (50/50)
- [x] No new test failures in the full unit suite
- [x] No PHP syntax errors
- [x] No committed secrets
- [x] No modifications to existing test files
- [x] No modifications to `openclaw.json` or any config files

---------

Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
2026-08-17 13:54:04 +02:00
Jeppe B 4b08453ee2 docs(economic): audit e-conomic invoice templates (TRU-197) (#394)
Auto-merged by cron with review-gate (trivial change, no critical path).
2026-08-17 13:38:09 +02:00
ae4b7aef07 docs(economic): map draft-invoice layout code paths (TRU-198) (#395)
## Summary

Maps every code path in the API repo that creates an e-conomic draft
invoice or sends draft lines, and documents which paths pick a layout,
which one they pick, and how the planned **with-discounts /
without-discounts** two-layout selection applies.

**Key finding:** the two envelope creators already implement a
discount-aware selector. No code change is required for the TRU-197
rollout — only the two `invoice*LayoutNumber` config variables need to
be set in the `economic` module.

## Findings at a glance

- **22** code paths in `services/nginx/app/` create or send draft
invoices (2 envelope creators + 6 line-add paths + 14
caller/selector/helper paths)
- **2** paths currently pick a layout — both already discount-aware
- **0** paths need updating for the 2-layout rollout
- **2** config variables drive the selection: `invoiceLayoutNumber` and
`invoiceDiscountLayoutNumber` (already wired into `economic::$config`
and the OpenAPI schema)

## The two selectors

1. `economic_invoice_draft_mo::resolveLayoutNumber()` at
`services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php:115`
— used by `createInvoiceDraftExample()` for the single-order draft flow.
2. `collected_order_invoices_o::resolveInvoiceLayoutNumber()` at
`services/nginx/app/objects/collected_order_invoices_o.php:673` — used
by `createInvoiceDraft()` for the collected-invoice flow.

Both return `invoice_discount_layout` if any item has a non-zero
discount, otherwise `invoice_layout`. They throw if the discount layout
is required and `invoiceDiscountLayoutNumber` is unconfigured.

## Document

`documentation/economic/layout-selection-flow.md` — full inventory
table, current/desired state, and migration plan.

## Related

- TRU-197 — `documentation/economic/invoice-template-audit.md`
- TRU-193 — `documentation/economic/export-field-audit.md`
- PR #391 — `economic_export_sanitizer`

Refs: TRU-198

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
2026-08-17 13:05:13 +02:00
ea9bdbe12c fix(economic): audit and sanitize additional export fields (TRU-193) (#393)
## Summary

Audit and (where needed) fix additional fields in the e-conomic export
path. PR #391 covered the main order.* and order_item.* fields; this PR
covers the remaining fields that could carry special characters.

## Changes

1. Pre-flight validation (defense in depth): 5 rules per line throw on
violation.
2. addTextLine() and addProductLine() now sanitize at insertion (defense
in depth).
3. Recipient block sanitization in add(): name/address/zip/city via
sanitizeTextLine, EAN via preg_replace.
4. Audit document: documentation/economic/export-field-audit.md.
5. Tests: 94 tests / 171 assertions (14 + 19 + 6 + 24 new tests).

## Refs
- TRU-193, TRU-188, TRU-194, PR #391

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
Co-authored-by: Bugfix Subagent <bugfix@subagent.local>
2026-08-17 12:52:04 +02:00
Jeppe BandOpenClaw b16b2cfe44 fix(economic): sanitize user-input fields to prevent 400 errors (#391)
## Problem

E-conomic API returns HTTP 400 when text-line descriptions contain
certain characters. The most common case is `/` in the order reference
field, which causes the entire draft-invoice export to fail.

## Root cause

When `order.reference` (or notes, reg_*, po) contains `/`, e-conomic's
text-line validation rejects the entire draft with HTTP 400. Same for
control characters and very long strings.

## Fix

Adds `economic_export_sanitizer` class that sanitizes all user-input
fields flowing into e-conomic:

- `/` → `-` (the reported 400 trigger)
- Control chars stripped (\x00-\x1F except \t and \n)
- Tab and newline → single space
- Whitespace normalized and trimmed
- Lengths capped (text 250, product 50, description 500) with `...`
suffix
- Multibyte safe (æ, ø, å, emoji, Chinese)

## Applied to

In `economic_invoice_draft.php`:
- `order.po`
- `order.reference` (PRIMARY FIX for the reported issue)
- `order.notes`
- `order.reg_1/2/3`
- `order_item.reference`
- `order_item.notes`
- `product.description`
- `product.productNumber`
- `department_name`

## Test coverage

- 31 unit tests with 45 assertions
- All edge cases (null, empty, control chars, multibyte, very long,
HTML, control chars in every position)
- Lint and test suite both pass

## Linear

Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196

Co-authored-by: OpenClaw <openclaw@copenhagentruckwash.io>
2026-08-17 12:24:04 +02:00
Jeppe B 339b6eb7a5 fix(api): install + start cron-worker systemd service on deploy (#390)
Brings cron-worker online in production via systemd. Closes verify-api-cron.py liveness alert.
2026-08-17 11:00:23 +02:00
935b2d58ce fix(api): remove broken Coolify cron-worker auto-deploy (#389)
## Summary

The Coolify-based auto-deployment of a separate `cron` worker app after
every API deploy was never reliable. This PR removes the ~800 lines of
dead auto-deploy logic from `release_manager.php` while keeping the
underlying cron mechanism (`cron_worker.php`, `cron_scheduler.php`, the
docker-compose `cron-worker` service) intact.

## Changes

- **`release_manager.php`** (-818 lines)
- Removed 19 private methods: `deployCronWorker*`, `cronWorker*`,
`cronWorkerAutoprovision*`, etc.
- Removed 3 constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`,
`CRON_WORKER_DESIRED_COUNT`
- Kept `cronWorkerStatus()` but rewrote as a direct DB query (no Coolify
dependency)
- **`tests/Unit/ReleaseManager/ReleaseManagerTest.php`** (-208 lines,
removed 9 cron-worker tests)
- **`tests/Unit/Cron/CronWorkerWiringTest.php`** (rewritten — now
asserts removed wiring is GONE)
- **`docs/CRON_PLAN.md`** (new — comprehensive plan)

## What replaced the broken auto-deploy

- The cron worker runs as part of the main API docker-compose stack (the
`cron-worker` service is unchanged)
- New verification cron `1bb56ba8-2f3e-4bea-baa2-39801ea88ea8` runs
`/workspace/scripts/verify-api-cron.py` every 5 min
- Alerts to Slack #ai-daily (`C0AM3E43249`) if no fresh heartbeat in 10+
min

## Test results

- 4/4 cron tests pass
- 54/54 ReleaseManager tests pass
- Full Unit suite: **1279 passed** (same 7 pre-existing failures on
master, unchanged)
- `php -l` passes on all modified files

## Plan

See `docs/CRON_PLAN.md` for the full audit, plan, and acceptance
criteria.

🤖 Generated with [OpenClaw](https://docs.openclaw.ai)

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-17 10:16:03 +02:00
Jeppe Bandbugfix 4c6b60c9f2 fix(api): self-healing schema bootstrap on every request (TRU-77 follow-up) (#387)
## Summary

Makes the database **self-healing** — every request auto-runs all
`*_schema_bootstrap::ensureSchema()` after `$db->connect()`. This
catches the "merged-to-master-but-migration-never-applied-to-prod"
failure mode that just bit us with TRU-77 (`invoice_email` column).

## Why

PR #383 added a pre-deploy schema step to `deploy.yml`. Correct, but
requires GitHub secrets (`DEPLOY_SSH_KEY`, `DEPLOY_USER`,
`SMOKE_BASE_URL`) that aren't set on the `api` repo yet. Until those
secrets exist, the pre-deploy step is skipped and migrations never reach
production. Result: API still references `invoice_email` but the column
doesn't exist → `Unknown column 'invoice_email' in 'SELECT'`.

## Fix

- New class `classes/schema_bootstrap_runtime.php`:
  - Auto-discovers all `*_schema_bootstrap.php` files in `classes/`
  - Calls `ensureSchema()` on each
  - Memoized per PHP process (`private static bool $ran = false`)
  - One failure does not block others (logged, not thrown)
- New hook in `services/nginx/app/index.php` right after
`$db->connect()`:
  ```php
  try {
      \classes\schema_bootstrap_runtime::runAll();
  } catch (Throwable $e) {
error_log('[schema-bootstrap] runtime::runAll() failed: ' .
$e->getMessage());
  }
  ```
- New test: `tests/Unit/SchemaBootstrapRuntimeTest.php` (3 cases)

## Safety

Each existing `*_schema_bootstrap` is **additive + idempotent**:
- `SHOW COLUMNS` check before any `ALTER`
- `ALTER TABLE ADD COLUMN` only if missing
- Per-class `private static bool $initialized = false` short-circuit
- Errors logged but never break the request

So: first request after deploy adds missing columns. Every subsequent
request hits the in-process `$ran` short-circuit (~microseconds). The
new column then exists, the API works, error goes away.

## Test plan

1. Wait for CI (PHP unit + integration)
2. Merge to master
3. Production auto-deploys (or manual re-deploy if secrets not set)
4. Hit the failing endpoint — first request will auto-migrate, response
should be 200
5. Verify with `GET /api/admin/schema-check` that all columns are
present

## Rollback

If anything goes wrong, revert the merge commit. The runtime class only
auto-discovers files matching `*_schema_bootstrap.php`; removing it
reverts the system to the pre-deploy-step-only behavior.

---

**Closes** the TRU-77 follow-up: the "Unknown column 'invoice_email' in
'SELECT'" error should never recur, because the code now self-heals
regardless of whether the deploy pre-deploy step ran.

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 23:00:03 +02:00
18092b271e feat(api): schema health check + pre-deploy migration runner (fixes TRU-77 production error) (#383)
## Problem

Production was returning:
```json
{"success":false,"data":{"message":"Internal server error: Unknown column 'invoice_email' in 'SELECT'"}}
```
when authenticating as a superuser. The migration that adds
`users.invoice_email` was merged to master in api#381 but never applied
to the production database.

## Fix

- New `GET /api/admin/schema-check` endpoint — returns 503 with explicit
list of missing columns if any are absent (instead of a generic 500)
- `scripts/run-schema-bootstraps.php` — auto-discovers and runs every
`*_schema_bootstrap` class on the live database (additive, idempotent)
- `scripts/schema-health-check.php` — CLI tool for the same check, used
by deploy pipelines
- New Pest contract test `SchemaHealthCheckTest` — verifies the test DB
has every required users column and the schema-check endpoint works
- `deploy.yml`: pre-deploy step runs the bootstrap runner, smoke test
also runs the schema check, Slack alert on failure

## What this prevents

- Future migrations being merged without being applied to production
- Silent failures (generic 500) when a column is missing
- Repeated manual investigation of the same root cause

Refs: TRU-77 (the original bug), api#381 (the original PR)

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: bugfix-subagent <bugfix-subagent@truckwash.local>
Co-authored-by: OpenClaw Bugfix Agent <bugfix@openclaw.local>
2026-08-16 22:30:03 +02:00
Jeppe Bandbugfix 76ad696691 docs: mark pen-test plan as CANCELLED (TRU-80, no budget approved) (#386)
## Summary

Marks the pen-test plan document as **CANCELLED** per Jeppe's
instruction 2026-08-16 20:00 UTC.

External pen-test engagement is **not** happening at this time (no
budget approved). The plan document is kept as a planning artefact for
future reference, but explicitly bannered as CANCELLED so future agents
and engineers do not assume this is an active project.

## Changes

- Added  CANCELLED banner to the top of
`documentation/security/pen-test-plan.md`
- Banner includes: status, reason, meaning, owner, and how to re-open in
the future
- Original content preserved below the banner (296 lines → 304 lines
with banner)

## Context

- TRU-80 (Linear): remains in **Done** state (planning artefact
complete, execution not authorised)
- Qodana Cloud: remains active (no workflow changes)
- GitHub Dependabot + secret scanning: remain active (free tier)
- This PR supersedes PR #385 (which was rolled back because it also
removed Qodana by mistake)

## Checklist

- [x] No external vendor will be engaged
- [x] No workflow changes
- [x] No secret removals
- [x] Original plan content preserved

---------

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 22:20:03 +02:00
Jeppe Bandbugfix 80dca6b5f0 docs(security): white-hat pen test plan + engagement scope (TRU-80) (#384)
## Summary

TRU-80 (DRIFT 19): white-hat penetration testing of the platform —
action
required was to *plan and schedule* the engagement and define scope and
budget. This PR delivers the planning artefact.

## What this PR adds

- `documentation/security/pen-test-plan.md` — full engagement plan:
  - **Scope (in):** API (116 route files + Stripe / Limble / Scanner /
    Edge Gateway / Bird / Self-Serve Studio modules), pleno-vue web SPA,
    Capacitor iOS/Android mobile, infra & cross-cutting (TLS, headers,
    subdomains).
  - **Out of scope:** third-party SaaS internals (Stripe, Economic,
    Shelly, Limble, WP), OT/physical, DoS, social engineering,
    transitive-dep audit.
  - **Methodology:** OWASP ASVS L2 (stretch L3 on auth + payment), WSTG,
    MASVS, 8 phases over ~12 vendor-days.
  - **Rules of engagement**, deliverables, daily standup channel,
    re-test terms.
- **Budget:** 180 000 – 220 000 DKK + 25 000 retainer (mid-tier vendor),
    with boutique and Big-4 tiers for comparison. Total envelope with
    contingency ≈ 230 000 DKK.
  - **Schedule:** vendor RFP late Aug, engagement week 39 (2026-09-22),
    final report mid-Oct, re-test mid-Nov 2026.
  - **Pre-engagement hardening checklist** for engineering to land in
    parallel (HSTS, CSP, cookies, CSRF, webhook signature verification,
    rate-limits, SCA in CI, Capacitor WebView hardening, secrets audit).
    Doubles as re-test acceptance criteria.
  - **Open questions** for management (budget cap, contract owner,
    language, retainer approval, scope trim).
- `documentation/security/README.md` — index for future security
    artefacts. Per convention, raw pen-test reports stay out of the
    public repo; only planning docs and re-test acceptance letters are
    committed.

## Why a docs PR, not code

TRU-80 is a planning task (DRIFT 19), not a code defect. The deliverable
is the engagement plan itself so management can sign off on budget and
timeline. Once approved, the actual engagement will be a separate SOW
with the selected vendor.

## Test plan

- [x] Plan reviewed against the issue description
  (Plan + schedule + scope + budget).
- [x] Branch name follows `fix/tru-80-<short-slug>` convention.
- [x] Commit message references TRU-80.
- [ ] Management sign-off on §6 budget and §6.3 schedule.
- [ ] Vendor RFP and selection (separate Linear sub-tasks to be opened
      off this plan).

## Linear

- Closes TRU-80 (planning deliverable for DRIFT 19).
- After merge, follow-up issues will be opened for: vendor RFP, vendor
  selection, contract / NDA, pre-engagement hardening checklist items
  (§7 of the plan).

Refs: https://linear.app/truck-wash-aps/issue/TRU-80

Co-authored-by: bugfix <bugfix@truckwash.local>
2026-08-16 21:46:03 +02:00
7c4acc636c fix(api): post new-booking Slack notifications only for pickups (TRU-106) (#378)
## Summary

SENERE 14 / **TRU-106**: only PICKUP bookings should post a new-booking
notification to the department Slack channel. Drop-off bookings
(pickup_bool = 0) are now silently filtered out. SMS and email delivery
paths are unaffected.

## Change

Minimal, non-refactor:

- New `classes\slack::send_new_booking_notification(...)` that wraps
`format_new_booking` + `send_webhook_message` and short-circuits when
`pickup_bool === false`. Returns bool (sent vs. filtered).
- Two call sites in `objects/bookings_o.php` (`addOrUpdate` +
`notifyNewBooking`) updated to use the new wrapper. Same arguments, no
other behavior changes.
- Other Slack notification types (customer registration, internal
department goal progress, unfulfilled bookings) are deliberately
untouched.

## Tests

New Pest test `tests/Unit/Slack/SlackNewBookingPickupFilterTest.php`:

- pickup -> notification sent (one webhook call, message contains the
booking id)
- drop-off -> no notification, no log entry
- no webhook configured -> no notification
- webhook URL never appears in log payload

PHP isn't installed in this sandbox; the test was code-reviewed against
the existing `SlackCustomerRegistrationWebhookTest` pattern (subclass +
it()/expect()). Please run `./vendor/bin/phpunit
tests/Unit/Slack/SlackNewBookingPickupFilterTest.php` on CI / locally to
confirm.

## Risk

Low. Adds an early-return filter inside a new method; existing call
sites already pass pickup_bool as a boolean. No DB schema change, no new
dependency, no config file change.

Closes TRU-106

---------

Co-authored-by: backend-subagent <agent@openclaw.local>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix <bugfix@openclaw.local>
Co-authored-by: Truck Wash Bugfix Bot <bugfix@truckwash.local>
2026-08-16 21:09:10 +02:00
60 changed files with 7030 additions and 1211 deletions
+39
View File
@@ -86,15 +86,54 @@ 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)"
'
- name: Pre-deploy schema check (run all *_schema_bootstrap)
id: pre_schema
run: |
echo "Running schema bootstraps against the live database…"
# Idempotent — adds missing columns, never drops anything.
# Catches the "Unknown column 'invoice_email' in 'SELECT'"
# production failure mode (TRU-77) where migrations were
# merged to master but never applied to the live DB.
php scripts/run-schema-bootstraps.php
echo "Schema bootstraps complete."
- name: Alert Slack if schema-check fails (pre-deploy)
if: failure()
run: |
php scripts/schema-health-check.php > /tmp/schema.json 2>&1 || true
msg=$(jq -r '"Schema health FAILED on '$SMOKE_BASE_URL'\nMissing: " + (.missing | join(", "))' /tmp/schema.json 2>/dev/null || echo "Schema check produced no JSON")
curl -sS -X POST -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
https://slack.com/api/chat.postMessage \
-d "{\"channel\":\"$AI_DAILY_CHANNEL\",\"text\":\":rotating_light: *${{ github.event.repository.name }} — schema health FAIL\n${msg}\"}"
- name: Smoke test
id: smoke
continue-on-error: true
run: |
chmod +x scripts/smoke-test.sh
./scripts/smoke-test.sh
# Also hit the new admin schema-check endpoint to verify
# no required columns are missing.
echo "::group::Schema health check"
php scripts/schema-health-check.php | tee /tmp/schema-report.json
if [ "$(jq -r .ok /tmp/schema-report.json)" != "true" ]; then
echo "::error::Schema health check FAILED — missing columns:"
jq -r '.missing[]' /tmp/schema-report.json | sed 's/^/ • /'
exit 1
fi
echo "Schema health check OK."
- name: Auto-rollback on smoke failure
if: steps.smoke.outcome == 'failure'
+127
View File
@@ -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
+115
View File
@@ -0,0 +1,115 @@
# Plan: Remove broken Coolify cron-worker deployment; add reliable 5-min cron
## Audit findings
The "Coolify cron worker flow" is a **dual-deployment mechanism** that:
- Tries to auto-deploy a **separate Coolify "cron" application** every time the API is deployed
- That separate app runs `php index.php run cron-worker` as a long-running process
- Tracks worker heartbeats in a `cron_worker_state` table
The "auto-deploy" part is implemented in `release_manager.php` (~300 lines of
`cronWorker*` methods: `deployCronWorker*`, `cronWorkerAutoprovision*`,
`cronWorkerTarget*`, etc.) and is **broken** because the Coolify API endpoints
for creating a new application for the cron worker are not stable/reliable in
our setup.
Meanwhile, the **actual cron mechanism** (`cron_worker.php`, `cron_scheduler.php`,
`cron_task_registry.php`, and the 20+ scheduled tasks in `modules/*/cron/tasks.php`)
is sound. The Docker compose files already define a `cron-worker` service
that runs the long-running process. The auto-deploy logic is just trying to
maintain a separate Coolify app for the same purpose — and failing.
## The plan
### 1. Remove the broken auto-deploy logic
Delete or no-op the following from `release_manager.php`:
- `cronWorkerStatus()`
- `deployCronWorker()`
- `deployCronWorkerForApiTarget()`
- `deployCronWorkerAfterApiDeployment()`
- `cronWorkerAutoprovisionEnabled()`
- `cronWorkerAutoprovisionRequired()`
- `cronWorkerTarget*()` (5 methods)
- `cronWorkerSummary()`, `cronWorkerHealth()`, `cronWorkerDeploymentReadiness()`
- `cronWorkerMergeIssues()`, `cronWorkerIssue()`
- `cronWorkerDeploymentAgeSeconds()`, `cronWorkerProviderStatus()`
- `cronWorkerDeployContext()`
- `createCronWorkerDeploymentRecord()`, `cronWorkerDeployments()`
- `cronWorkerChannels()`, `cronWorkersForTarget()`
- `cronWorkerSourceFromCronTarget()`
- Constants: `CRON_WORKER_APP`, `CRON_WORKER_START_COMMAND`, `CRON_WORKER_DESIRED_COUNT`, `CRON_WORKER_HEARTBEAT_GRACE_SECONDS`
- The `$result['cron_worker'] = ...` call after API deployment
Keep:
- `cron_worker.php` class (the actual worker)
- `cron_scheduler.php`, `cron_schedule.php`, `cron_task_registry.php`
- `cron_schema_bootstrap.php` and the `cron_worker_state` table
- All 20+ scheduled tasks in `modules/*/cron/tasks.php`
- The `cron-worker` service in `docker-compose*.yml`
- The `cron-worker` case in `cli.php`
### 2. Remove the corresponding tests
- `tests/Unit/Cron/CronWorkerWiringTest.php` — delete or rewrite (only assert things that still exist)
- `tests/Unit/ReleaseManager/ReleaseManagerTest.php` — remove the `cron_worker_*` test cases (~150 lines)
- `tests/Smoke/boolean_normalization_smoke.php` — remove cron_worker reference
### 3. Add a reliable 5-min cron mechanism
Two-layer approach:
1. **Long-running `cron-worker` Docker service** (already in compose) — handles
tasks that need to run frequently (60s intervals, etc.). Started automatically
with the rest of the stack.
2. **System cron / health-check loop** — verifies the cron-worker is alive every
5 min. If no fresh heartbeat in 10 min, alert.
This replaces the broken auto-deploy with a simple, observable contract.
### 4. Add a verification harness
`/workspace/scripts/verify-api-cron.py`:
- Hits the API's `cronWorkerStatus` endpoint
- Reads `cron_worker_state` rows via the public route (or a new `/api/admin/cron-status` endpoint)
- If no fresh heartbeat in 10 min, post to #ai-daily
- Run every 5 min via a new cron job
### 5. Update documentation
- `inventory/self-serve-inventory.md` — remove coolify-cron-worker references
- `openapi.yaml` — remove `cron_worker_status` route documentation
- `routes/cronRoute.php` — remove the coolify-cron-worker endpoints
## Acceptance criteria
- [ ] `release_manager.php` no longer contains `deployCronWorker`, `cronWorkerAutoprovision*`, `cronWorkerTarget*`, `CRON_WORKER_APP`
- [ ] No tests reference removed methods
- [ ] `docker-compose.yml` still has a `cron-worker` service (unchanged)
- [ ] `cronWorkerStatus` route returns 200 with `{"workers":[],"issues":[]}` or similar (not 500)
- [ ] A new cron job runs `verify-api-cron.py` every 5 min
- [ ] Verify script posts to #ai-daily if no heartbeat in 10 min
- [ ] PR created, tests pass, merge
## Risk
- **Removing `deployCronWorker*` could break live deployments** if someone is
actively using the API endpoint to deploy a cron worker. Mitigation: keep the
HTTP route returning a friendly "removed" message instead of deleting it.
- **Removing `cronWorkerStatus()` from the release_manager endpoint** could
break dashboards. Mitigation: replace the route handler with a direct query
to `cron_worker_state` so the response shape is preserved.
## Steps
1. Create a feature branch `fix/remove-coolify-cron-worker`
2. Edit `release_manager.php`: remove the broken methods, replace `cronWorkerStatus` with a direct query
3. Edit `tests/Unit/ReleaseManager/ReleaseManagerTest.php`: remove cron_worker tests
4. Edit `tests/Unit/Cron/CronWorkerWiringTest.php`: drop assertions on removed wiring
5. Edit `routes/cronRoute.php`: keep the status endpoint but call the new direct query
6. Edit `cli.php`: no change needed (cron-worker case still works)
7. Edit `docker-compose*.yml`: no change needed (cron-worker service unchanged)
8. Create `/workspace/scripts/verify-api-cron.py` for the verification harness
9. Add a new cron job `5 * * * *` Europe/Copenhagen that runs `verify-api-cron.py`
10. Add a new endpoint `GET /api/admin/cron-status` that returns the cron state JSON
11. Run the test suite locally
12. Push branch, create PR, get user review
@@ -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 2548.
> **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:~380410` | `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:893894`**
— 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 ~510600).
```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+ | ~310s ❌ | ~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 713820:
```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 713820 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 ~547556 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.
+16
View File
@@ -0,0 +1,16 @@
# Security documentation
This folder holds security-related planning, post-mortems, and pen-test
artefacts for the Truck Wash ApS platform.
| Doc | Purpose | Status |
| --- | --- | --- |
| [`pen-test-plan.md`](./pen-test-plan.md) | TRU-80: scope, methodology, schedule and budget for the next white-hat pen test. | Draft v1, awaiting management sign-off. |
Conventions:
- Pen-test reports and any raw findings live in date-stamped subfolders
(e.g. `2026-q4-pentest/`) and are **never** committed to the public
repository — only the planning docs and re-test acceptance letters are.
- All security work is tracked under the Linear project
*UI Library & Pen Testing*.
+304
View File
@@ -0,0 +1,304 @@
# White-Hat Penetration Test — Plan & Engagement (TRU-80)
> ## ⛔ CANCELLED — DO NOT EXECUTE
> **Status:** Cancelled 2026-08-16 by Jeppe Bundgaard
> **Reason:** No budget approved at this time. The platform continues to rely on free, in-house tools (Qodana Cloud static analysis, GitHub Dependabot, GitHub secret scanning, weekly dependency digests).
> **What this means:** No external pen-test firm is being engaged. This document is kept as a planning artifact for future reference. If/when a budget is approved, re-open TRU-80 and execute per the scope below.
> **Owner:** Jeppe Bundgaard (jeppe@copenhagentruckwash.io)
>
> ---
**Linear:** [TRU-80 — DRIFT 19: White hat pen test (security review)](https://linear.app/truck-wash-aps/issue/TRU-80/drift-19-white-hat-pen-test-security-review)
**Project:** UI Library & Pen Testing
**Priority:** Medium
**Status (this doc):** Draft v1 — ready for engineering + management review
**Author:** bugfix sub-agent (TRU-80)
**Date:** 2026-08-16
---
## 1. Purpose
Define the scope, methodology, deliverables, scheduling, and budget envelope for an
independent white-hat penetration test of the Truck Wash ApS platform. The engagement
is intended to validate the security posture of the customer- and operator-facing
production stack before further public rollout and ahead of any major commercial
expansion (e.g. additional self-serve sites, additional payment integrations).
This document is the planning artefact for TRU-80. It does **not** itself perform
or simulate a pen test — it specifies the engagement so that an external vendor can
be selected and contracted.
---
## 2. Scope (in)
The following systems are **in scope** for the engagement. Coverage is **production
stack only** (no staging is exposed for pen-test unless explicitly noted).
### 2.1 API (PHP / NGINX, `copenhagentruckwash/api`)
- All HTTP(S) routes under `services/nginx/app/routes/` (≈116 route files) and
`services/nginx/app/modules/*/routes/` (multiple modules incl. Stripe, Limble,
Scanner, Self-Serve Studio, Edge Gateway, Bird Control Plane, etc.).
- Authentication / session endpoints, including:
- `usersRoute.php`, `userSecurityRoute.php`, `superuserSecurityRoute.php`,
`subusersRoute.php`, `limitedBackofficeRoute.php`
- `limitedBackofficeLoginGrantService.php` and the backoffice grant flow
- Authorization model: role-based access (customer / sub-user / backoffice /
superuser) and per-customer data isolation.
- Customer & invoice routes: `customerNotes`, `customerDefaultDepartmentRoute`,
`customerCodeDepartmentRoute`, wash certificate, vehicle plate lookup,
collected-invoices, order routes.
- Payment integration: Stripe module (`moduleStripeRoute.php`).
- Economic ERP integration (`economic_endpoint_t.php` trait) — read-only
token handling, invoice push.
- Edge gateway / IoT surface: `moduleEdgeGatewayRoute.php`, `edgegateway.php`,
`shelly.php`, `gateway_shelly_transport.php`, `birdControlPlaneRoute.php`.
- File / media endpoints: `file_server.php` (auth-gated downloads, S3 / local).
- Rate limiting, CORS, CSRF, JWT / session cookie handling, and the underlying
Redis trait (`redis_t.php`).
- WordPress trait / integration (`wordpress_api_object_t.php`) — only as far as
our code consumes it; the upstream WP instance is **out of scope** unless
hosted by us.
- Container/infrastructure: `Dockerfile`, `Dockerfile.coolify-api`, NGINX
config (`nginx.conf`, `apache-ssl.conf`), `docker-compose.prod.yml`,
`coolify` deploy config. Black-box reachable attack surface only.
### 2.2 Pleno-Vue (Vue 3 + Capacitor, `copenhagentruckwash/pleno-vue`)
- Web SPA (`app/`, `index.html`, `dist/`) reachable at the production hostname.
- Mobile builds for Android (`android/`, `build.gradle`, `fastlane/`) and iOS
(`ios/`) packaged via Capacitor (`capacitor.config.ts`).
- API client and token storage in the SPA (where tokens live, at-rest
protection, refresh flow).
- Build-time secrets, env handling (`env.d.ts`, `manifest-checksum.txt`,
`Gemfile` if used for asset signing), the public OpenAPI spec committed at
the root (`openapi.yaml`).
- Capacitor deep-link / universal-link / custom-scheme handling
(`capacitor.config.ts`).
### 2.3 Infrastructure & cross-cutting (in)
- TLS configuration (cert chain, HSTS, cipher suites) on the production
public host.
- HTTP security headers (CSP, X-Frame-Options, Referrer-Policy,
Permissions-Policy, X-Content-Type-Options).
- Subdomain / wildcard exposure (`*.truckwash.dk` style).
- Email & SMS notification paths only as far as they can be abused for
spoofing / phishing of our users (we control the From domain).
### 2.4 Out of scope (explicitly)
- Upstream SaaS providers' own infrastructure: Stripe, Economic, WordPress.com,
Shelly cloud, Limble, Mailgun, etc. We will only test the **integration**,
not the third party itself.
- Internal office LAN, employee laptops, MDT, and physical site hardware
(gate controllers, scanners) — these are covered by a separate physical /
OT scope and **out of scope** for this IT pen test.
- Denial-of-service / load testing.
- Social engineering of Truck Wash staff.
- Source-code review of `node_modules` / vendor dependencies (the engagement
will use SCA tooling to flag known CVEs, but not audit transitive deps).
- Any production data exfiltration — the vendor will be given sanitised or
test accounts and synthetic data only.
---
## 3. Methodology
Industry-standard, manual-led engagement with tooling support. Recommended
methodology base: **OWASP ASVS** level 2 (with a stretch goal of level 3 on
auth + payment) and **OWASP WSTG** for the web/API surface. Mobile builds will
use **OWASP MASVS** as the checklist.
Phases (estimated total: 12 working days of vendor effort, see §6):
1. **Scoping & recon (1 day)**
- Confirm target list, accounts, and rules of engagement.
- Passive recon (DNS, cert transparency, subdomains, public OpenAPI spec).
- Active recon limited to non-destructive fingerprinting.
2. **API pen test (3 days)**
- AuthN/AuthZ boundary testing on every route group in §2.1.
- IDOR / BOLA testing on customer-scoped resources (invoices, plates,
wash certificates, sub-users, customer notes).
- Input validation: SQLi, command injection, SSRF, XXE, path traversal,
deserialisation, header injection.
- Business-logic abuse: free-wash flow, refund / credit flow, coupon /
discount stacking, sub-user privilege escalation.
- Webhook signature validation (Stripe, Edge Gateway, Shelly).
3. **Web SPA pen test (2 days)**
- XSS (reflected, stored, DOM-based) including Vue template injection.
- Token storage, leakage via 3rd-party scripts, postMessage abuse.
- Open-redirect / OAuth misconfig in any SSO flow.
- CSP / SRI effectiveness.
4. **Mobile (Capacitor) review (2 days)**
- Static analysis of the built APK / IPA (Capacitor WebView).
- Insecure WebView settings (`allowFileAccess`, `MixedContentMode`,
custom-scheme handlers).
- Local storage of tokens, biometric bypass if implemented.
- Deep-link / universal-link hijack attempts.
5. **Infrastructure & config (1.5 days)**
- TLS, headers, cookie flags, HSTS preload eligibility.
- NGINX hardening review (based on provided config snapshots).
- Docker / coolify surface only as externally reachable.
6. **SCA / dependency check (0.5 day)**
- `composer.json` and `package.json` SCA scan.
- High-severity known-CVE report only; no deep audit.
7. **Exploitation & PoC (1 day)**
- Build proofs-of-concept for any Critical / High findings.
8. **Reporting & re-test (1 day)**
- Draft report → vendor walkthrough → final report.
- Re-test of fixed findings is scoped separately (see §6).
---
## 4. Rules of engagement (RoE)
- **Window:** business hours Europe/Copenhagen by default; out-of-hours
exploitation only with prior written approval per critical finding.
- **Contact channel:** shared Signal thread + email; vendor given a Slack
guest account in a dedicated `#sec-pentest-2026Q4` channel.
- **Stop conditions:** any finding that risks data loss, payment integrity,
or production gate operation → immediate stop + phone call to on-call.
- **Data handling:** vendor may only use synthetic / test data. No
exfiltration of real customer PII. All artifacts returned or destroyed at
end of engagement (TBD in contract).
- **Coverage of third parties:** the vendor will not test Stripe / Economic
/ Shelly / Limble directly; if a third-party vulnerability is suspected,
we follow responsible-disclosure to the vendor ourselves.
---
## 5. Deliverables
1. **Kick-off doc** (this plan, signed off by both parties).
2. **Daily standup notes** in `#sec-pentest-2026Q4` (one paragraph + new
findings list).
3. **Mid-engagement check-in** at end of phase 3 — informal review of any
Critical / High so we can start patching in parallel.
4. **Final report (PDF + JSON)** including:
- Executive summary, risk heatmap, business-impact narrative.
- Each finding: title, CVSS v3.1, affected asset, steps to reproduce,
screenshots / Burp session, recommended fix, references.
- SCA dependency report as an appendix.
5. **Re-test letter** (separate SOW, see §6).
6. **Knowledge transfer**: 60-min session for engineering on the top 5
findings.
---
## 6. Budget & scheduling
### 6.1 Indicative effort
| Phase | Days | Notes |
| --- | --- | --- |
| 1. Scoping & recon | 1.0 | joint with us |
| 2. API pen test | 3.0 | |
| 3. Web SPA | 2.0 | |
| 4. Mobile (Capacitor) | 2.0 | |
| 5. Infra & config | 1.5 | |
| 6. SCA | 0.5 | tooling-led |
| 7. Exploitation / PoC | 1.0 | |
| 8. Reporting | 1.0 | incl. 1 review round |
| **Total** | **12.0 days** | |
### 6.2 Indicative cost (DKK, ex. VAT)
Pricing varies significantly with vendor. Three realistic budget tiers for
procurement:
| Tier | Daily rate (DKK) | Total (12 d) | Notes |
| --- | --- | --- | --- |
| Boutique / Nordic boutique (e.g. Danish / Swedish) | 12 000 16 000 | **144 000 192 000** | Best fit for our stack size, Danish-language reporting available. |
| Mid-tier international (e.g. NCC, Securix, Pentest People) | 15 000 22 000 | **180 000 264 000** | More brand name, more bureaucracy, stronger report templates. |
| Top-tier / Big-4 style | 25 000 40 000 | **300 000 480 000** | Overkill for current footprint; revisit at Series-A. |
**Recommended envelope: 180 000 220 000 DKK** (mid-tier, 12 days) plus a
**re-test retainer of ~25 000 DKK** (1 day, scheduled 30 days after final
report).
Add ~5 000 DKK contingency for incident-response hours if a Critical is
found mid-engagement.
### 6.3 Schedule (proposed)
- **2026-08-25** — this plan reviewed and signed off by management.
- **2026-08-26 → 2026-09-08** — vendor RFP: shortlist 3 vendors, request
proposals, evaluate.
- **2026-09-09 → 2026-09-15** — contract + NDA + RoE finalisation.
- **2026-09-22 (week 39)** — engagement kick-off.
- **2026-09-22 → 2026-10-07** — on-site / remote testing (2.5 calendar
weeks, vendor working in parallel with their normal cadence).
- **2026-10-08** — draft report.
- **2026-10-15** — final report + walkthrough.
- **2026-11-15** — re-test (retainer).
All dates are **provisional** until a vendor is selected.
### 6.4 Vendor shortlist (candidates to approach)
We will request proposals from at least 3 of the following (final shortlist
to be confirmed with management):
1. **Securix** (DK) — boutique, OWASP ASVS-aligned, good fit for our size.
2. **Pentest People** (UK / EU) — mid-tier, mobile capability.
3. **NCC Group / nCC / NowSecure** (international) — heavier, good brand
for enterprise due-diligence.
4. **Curity** (SE) — strong API / OAuth expertise, fits our auth model.
5. **Deutsche Cyber AG / similar Nordic boutique** — fallback.
Procurement will evaluate on: relevant references (Logistics / IoT / payment),
ASVS/MASVS familiarity, daily rate, lead time, report quality, re-test terms.
---
## 7. Pre-engagement hardening checklist (for engineering, run in parallel)
We should land these before the vendor starts — they reduce noise and let
the vendor focus on real issues:
- [ ] HSTS preload submitted; `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`
- [ ] CSP `default-src 'self'` baseline, no `unsafe-inline`; report-only first
- [ ] All cookies `Secure; HttpOnly; SameSite=Lax` (or `Strict` for backoffice)
- [ ] CSRF token on every state-changing route; verified for Stripe / Edge
Gateway webhooks
- [ ] Webhook signature verification on Stripe, Shelly, Edge Gateway
- [ ] Rate-limit on auth, password reset, and OTP endpoints
- [ ] Sub-user privilege model re-verified against `subusersRoute.php`
- [ ] File-server (`file_server.php`) path-traversal tests in CI
- [ ] SCA in CI: `composer audit` and `npm audit --omit=dev` blocking
high+ vulns
- [ ] Mobile: `allowFileAccess=false`, mixed content disabled, JS interfaces
removed
- [ ] Secrets: no production keys in repo (`git log -S` audit)
This list is also the basis for re-test acceptance criteria.
---
## 8. Open questions for management
1. Confirm total budget cap (recommend ≤ 220 000 DKK + 25 000 retainer).
2. Confirm legal/procurement owner and contract template.
3. Confirm whether to require a Danish-language final report (recommended).
4. Confirm re-test budget is approved up-front, or per-finding.
5. Confirm we are comfortable with the 12-day estimate, or want a lighter
6-day "API + SPA only" first pass.
---
## 9. References
- OWASP ASVS 4.0 — https://owasp.org/www-project-application-security-verification-standard/
- OWASP WSTG — https://owasp.org/www-project-web-security-testing-guide/
- OWASP MASVS — https://mas.owasp.org/MASVS/
- OWASP API Security Top 10 (2023) — https://owasp.org/API-Security/editions/2023/
- Linear project: *UI Library & Pen Testing* (`acc087b4-b8ce-40c4-bbca-077fd93513a4`)
---
*This document is a planning artefact, not the test itself. Once approved, a
separate SOW will be drafted with the selected vendor and linked from this
issue.*
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env php
<?php
/**
* Pre-deploy schema bootstrap runner.
*
* Loads and runs every `*_schema_bootstrap` class so the production
* database has all the columns the current code expects. Each
* bootstrap is additive and idempotent — safe to run on every deploy.
*
* Run via:
* php scripts/run-schema-bootstraps.php
*
* Used in .github/workflows/deploy.yml as a pre-deploy step.
*
* When you add a new *_schema_bootstrap class, you don't need to
* edit this file — the runner auto-discovers any class whose name
* ends in `_schema_bootstrap`.
*/
namespace scripts;
// Load the app entry point so $db is wired up the same way as in
// normal request handling.
$index = __DIR__ . '/../services/nginx/app/index.php';
if (!file_exists($index)) {
fwrite(STDERR, "Cannot find app entry point at {$index}\n");
exit(2);
}
require_once $index;
$classesDir = __DIR__ . '/../services/nginx/app/classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
if (!$bootstraps) {
fwrite(STDERR, "No *_schema_bootstrap.php files found in {$classesDir}\n");
exit(0);
}
$ran = 0;
$skipped = 0;
foreach ($bootstraps as $file) {
require_once $file;
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (!class_exists($class)) {
fwrite(STDERR, " [skip] {$base}: class not found\n");
$skipped++;
continue;
}
if (!method_exists($class, 'ensureSchema')) {
fwrite(STDERR, " [skip] {$base}: no ensureSchema() method\n");
$skipped++;
continue;
}
try {
$class::ensureSchema();
echo " [ok] {$base}\n";
$ran++;
} catch (\Throwable $e) {
fwrite(STDERR, " [FAIL] {$base}: " . $e->getMessage() . "\n");
exit(1);
}
}
echo "Schema bootstraps complete: {$ran} ran, {$skipped} skipped.\n";
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
/**
* Schema health check — verifies all required DB columns exist.
*
* Run via:
* GET /api/admin/schema-check (returns JSON report)
* php scripts/schema-health-check.php (CLI, exits 0/1)
*
* Lists the columns that the code expects to find in each critical
* table. If a column is missing, the response is 503 (HTTP) or
* exit code 1 (CLI) — clearly distinct from a generic 500.
*
* Add to the list when introducing a new optional column.
*/
namespace scripts;
require_once __DIR__ . '/../services/nginx/app/classes/customer_invoice_email_schema_bootstrap.php';
use classes\customer_invoice_email_schema_bootstrap;
const SCHEMA_REQUIREMENTS = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
function check_schema(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
// First: run the schema bootstrap (additive, idempotent) so we
// give the DB a chance to self-heal.
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
customer_invoice_email_schema_bootstrap::ensureSchema();
}
foreach (SCHEMA_REQUIREMENTS as $table => $columns) {
$report['tables_checked']++;
// Confirm the table itself exists
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
// CLI mode
if (PHP_SAPI === 'cli') {
$report = check_schema();
echo json_encode($report, JSON_PRETTY_PRINT) . "\n";
exit($report['ok'] ? 0 : 1);
}
+222
View File
@@ -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';
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
<?php
namespace classes;
/**
* Self-healing schema bootstrap.
*
* Runs every `*_schema_bootstrap::ensureSchema()` on app start so the
* production database always has the columns the current code expects.
* This catches the "merged-to-master-but-never-applied-to-prod" failure
* mode (e.g. TRU-77 invoice_email) where the deploy pipeline pre-deploy
* step didn't run (missing GitHub secrets, network glitch, etc.).
*
* Each bootstrap is **additive + idempotent**:
* - SHOW COLUMNS check before any ALTER
* - ALTER TABLE ADD COLUMN only if missing
* - Once `ensureSchema()` has been called once for a class, the static
* `$initialized` flag short-circuits subsequent calls
*
* The discovery + run loop itself is memoized per PHP process via
* `self::$ran`, so the cost after the first request is a single
* `class_exists` check (~microseconds).
*
* Errors in a single bootstrap are logged but never throw — a broken
* migration must not 500 every request. A future /api/admin/schema-check
* call will surface the failure.
*/
class schema_bootstrap_runtime
{
/** @var bool Memoization for the discovery+run loop */
private static bool $ran = false;
/** @var string[] Class names that already failed this process (don't retry) */
private static array $failed = [];
public static function runAll(): void
{
if (self::$ran) {
return;
}
self::$ran = true;
$classesDir = __DIR__;
$bootstraps = glob($classesDir . DIRECTORY_SEPARATOR . '*_schema_bootstrap.php');
if (!$bootstraps) {
return;
}
foreach ($bootstraps as $file) {
$base = basename($file, '.php');
$class = "classes\\{$base}";
if (in_array($class, self::$failed, true)) {
continue;
}
try {
if (!class_exists($class)) {
require_once $file;
}
if (!class_exists($class)) {
continue;
}
if (!method_exists($class, 'ensureSchema')) {
continue;
}
$class::ensureSchema();
} catch (\Throwable $e) {
self::$failed[] = $class;
error_log(sprintf(
'[schema-bootstrap] %s failed: %s',
$base,
$e->getMessage()
));
// Intentionally do not throw — a broken migration must
// not 500 every request. The next /api/admin/schema-check
// call (or the next deploy's pre-deploy step) will
// surface the failure.
}
}
}
}
+65 -3
View File
@@ -33,17 +33,17 @@ class slack implements notification_i
public function send_department_booking_notification(int $department_id, $message): self
{
// Get the departments webhook
$webhook = self::get_department_webhook($department_id);
$webhook = static::get_department_webhook($department_id);
// Check if the webhook is empty
if (empty($webhook)) {
throw new \Exception('Department webhook is empty');
}
// Send the notification to the department
self::add_log(self::send_webhook_message($message, $webhook));
self::add_log(static::send_webhook_message($message, $webhook));
return $this;
}
private function get_department_webhook(int $department_id): string|null
protected function get_department_webhook(int $department_id): string|null
{
// Check if the department webhook is cached
$webhook = redis->get_department_webhook($department_id);
@@ -134,6 +134,68 @@ class slack implements notification_i
. "Status: $status";
}
/**
* Send a new-booking notification to the department's Slack webhook.
*
* Filter: only PICKUP bookings trigger a Slack notification. Drop-off
* bookings (pickup_bool === false) are intentionally silenced per
* Mikkel's SENERE 14 / TRU-106 request — drop-offs are noise in the
* channel. Other delivery channels (SMS, email) are unaffected.
*
* Returns true if a Slack message was sent, false if it was filtered
* out (drop-off) or the department has no Slack webhook configured.
*
* @throws \Exception If the department lookup or webhook send fails.
*/
public function send_new_booking_notification(
$id,
$customer_number,
string $wash_type,
string $contact_email,
string $reference_number,
string $regNrTraekker,
string $regNrTrailer,
string $washCertificateEmail,
string $date,
int $department,
bool $pickup_bool,
string $notes,
string $washCertificateStatus,
string $washCertificateUrl,
string $status
): bool {
// TRU-106: drop-off bookings must not post to Slack.
if (!$pickup_bool) {
return false;
}
$webhook = static::get_department_webhook($department);
if (empty($webhook)) {
return false;
}
$message = static::format_new_booking(
$id,
$customer_number,
$wash_type,
$contact_email,
$reference_number,
$regNrTraekker,
$regNrTrailer,
$washCertificateEmail,
$date,
$department,
$pickup_bool,
$notes,
$washCertificateStatus,
$washCertificateUrl,
$status
);
self::add_log(static::send_webhook_message($message, $webhook));
return true;
}
public function send_message(string $string, ?string $module = null): void
{
global $SLACK_DEFAULT_WEBHOOK;
@@ -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',
],
];
@@ -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`',
],
];
+12
View File
@@ -213,6 +213,18 @@ try {
$response->error($e->getMessage(), 500);
}
// Self-healing schema bootstrap. Runs every *_schema_bootstrap::ensureSchema()
// once per process. Each is additive + idempotent (SHOW COLUMNS check before
// any ALTER), so this is safe on every request. Catches the
// "merged-to-master-but-migration-never-applied" failure mode (e.g. TRU-77
// invoice_email) even when the deploy pipeline pre-deploy step is skipped
// (missing GitHub secrets, network glitch, manual deploy, etc.).
try {
\classes\schema_bootstrap_runtime::runAll();
} catch (Throwable $e) {
error_log('[schema-bootstrap] runtime::runAll() failed: ' . $e->getMessage());
}
try {
release_manager::initializeRequestContext();
$releaseIngressPath = (string)(parse_url((string)($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH) ?: '');
@@ -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;
}
@@ -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('');
}
@@ -125,10 +125,15 @@ class economic_invoices_drafts_endpoint
$customer = (new economic())->getCustomer($customer_number);
// Set the recipient details
$customer_name = $customer->getName() ?? 'Ukendt';
$customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt';
// Note: customer_* fields come from e-conomic itself (controlled input),
// but we sanitize them defensively to avoid 400s if e-conomic ever stores
// a value with chars e-conomic later rejects in the recipient block.
// Each field uses an appropriate length cap to match the corresponding
// e-conomic recipient field limits.
$customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100);
$customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250);
$customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20);
$customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100);
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
@@ -139,9 +144,14 @@ class economic_invoices_drafts_endpoint
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
$recipient['nemHandelType'] = 'ean';
if ($customer_ean !== null && $customer_ean !== '') {
// EAN should be digits only; sanitize to strip anything that slipped through
$recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean);
if ($recipient['ean'] !== '') {
$recipient['nemHandelType'] = 'ean';
} else {
unset($recipient['ean']);
}
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
@@ -42,6 +42,13 @@ class economic_invoice_draft
*/
protected float $conversion_rate = 1.0;
/**
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
* Defense in depth — even after sanitization, a final check catches anything that slips through.
* @var bool $preflight_enabled
*/
protected bool $preflight_enabled = true;
/**
* Construct a new Economic draft invoice object
@@ -116,9 +123,142 @@ class economic_invoice_draft
*/
public function addLines(): void
{
if ($this->preflight_enabled) {
$this->preflightValidate($this->draft_lines, null);
}
$this->flushLinesInBatches();
}
/**
* Pre-flight validation: defense in depth before sending to e-conomic.
* Validates each line against 5 rules and throws RuntimeException on the first violation.
*
* Rules (in order, per line):
* 1. description — must be non-empty after trim()
* 2. description — must be <= 250 chars
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
* 4. quantity — must be a positive number (> 0)
* 5. unitNetPrice — must be a number (>= 0)
*
* @param array<int,array<string,mixed>> $lines
* @param int|null $orderId Optional order id for log context
* @throws \RuntimeException on any rule violation
*/
public function preflightValidate(array $lines, ?int $orderId = null): void
{
foreach ($lines as $i => $line) {
if (!is_array($line)) {
$this->logAndThrow(
$i,
$orderId,
'line is not an array',
$line
);
}
// Rule 1 + 2: description
$description = $line['description'] ?? null;
if ($description === null) {
$description = '';
}
if (!is_scalar($description)) {
$description = (string)$description;
} else {
$description = (string)$description;
}
$descriptionTrimmed = trim($description);
if ($descriptionTrimmed === '') {
$this->logAndThrow(
$i,
$orderId,
'description is empty',
$description
);
}
if (mb_strlen($descriptionTrimmed) > 250) {
$this->logAndThrow(
$i,
$orderId,
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
$description
);
}
// Rule 3: productNumber (only if present in product.productNumber)
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
$productNumber = $line['product']['productNumber'];
if ($productNumber === null) {
$productNumber = '';
} else {
$productNumber = (string)$productNumber;
}
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
$this->logAndThrow(
$i,
$orderId,
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
$productNumber
);
}
}
// Rule 4: quantity — only required if present in the line (text lines omit it)
if (array_key_exists('quantity', $line)) {
$quantity = $line['quantity'];
if (!is_numeric($quantity) || (float)$quantity <= 0) {
$this->logAndThrow(
$i,
$orderId,
'quantity is not a positive number',
$quantity
);
}
}
// Rule 5: unitNetPrice — only required if present in the line
if (array_key_exists('unitNetPrice', $line)) {
$unitNetPrice = $line['unitNetPrice'];
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
$this->logAndThrow(
$i,
$orderId,
'unitNetPrice is not a number >= 0',
$unitNetPrice
);
}
}
}
}
/**
* Log the offending line and throw a RuntimeException.
*/
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
{
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
if ($valueTruncated === false) {
$valueTruncated = '[unserializable]';
}
if (mb_strlen($valueTruncated) > 200) {
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
}
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
error_log(sprintf(
'[preflight] validation failed: %s | line=%d | %s | value=%s',
$rule,
$lineIndex,
$orderContext,
$valueTruncated
));
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
throw new \RuntimeException(sprintf(
'Preflight validation failed for line %d: %s%s',
$lineIndex,
$rule,
$orderPart
));
}
/**
* Add queued draft lines using chunked requests.
*
@@ -185,45 +325,55 @@ class economic_invoice_draft
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
// Parse the date of the transaction.
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
// Sanitize the department name (could contain "/" or other chars)
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
// Add the text line to the draft invoice
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
// If there's a PO number, add it to the invoice
if ($order->po->value() !== '') {
self::addTextLine('PO: ' . $order->po->value());
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
}
// If there's a reference, add it to the invoice
if ($order->reference->value() !== '') {
$reference_value = $order->reference->value();
if ($reference_value !== '') {
self::addTextLine('Reference:');
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->reference->value(), "\n")) {
foreach ( explode("\n", $order->reference->value()) as $line ) {
if (str_contains($reference_sanitized, "\n")) {
foreach ( explode("\n", $reference_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->reference->value());
self::addTextLine('# ' . $reference_sanitized);
}
}
// Add the registration numbers (if any)
$line_reg = '';
if ($order->reg_1->value() !== '')
$line_reg .= 'Reg 1: ' . strtoupper($order->reg_1->value());
if ($order->reg_2->value() !== '')
$line_reg .= ', Reg 2: ' . strtoupper($order->reg_2->value());
if ($order->reg_3->value() !== '')
$line_reg .= ', Reg 3: ' . strtoupper($order->reg_3->value());
if ($order->reg_1->value() !== '') {
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
}
if ($order->reg_2->value() !== '') {
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
}
if ($order->reg_3->value() !== '') {
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
}
// Add the line to the invoice (If there's any registration numbers)
if ($line_reg !== '')
self::addTextLine($line_reg);
// If there's a note, add it to the invoice
if ($order->notes->value() !== '') {
$notes_value = $order->notes->value();
if ($notes_value !== '') {
self::addTextLine('Notat:');
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
if (str_contains($order->notes->value(), "\n")) {
foreach ( explode("\n", $order->notes->value()) as $line ) {
// Sanitize notes (could contain "/", newlines, special chars)
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
if (str_contains($notes_sanitized, "\n")) {
foreach ( explode("\n", $notes_sanitized) as $line ) {
self::addTextLine('# ' . $line);
}
} else {
self::addTextLine('# ' . $order->notes->value());
self::addTextLine('# ' . $notes_sanitized);
}
}
}
@@ -236,11 +386,26 @@ class economic_invoice_draft
*/
public function addTextLine(string $text): void
{
// Defense in depth: sanitize ALL text lines at insertion time.
// This catches anything that wasn't pre-sanitized at the call site.
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
if ($sanitized === '') {
return; // Skip empty/whitespace-only lines
}
$this->draft_lines[] = [
'description' => $text
'description' => $sanitized
];
}
/**
* Get the current draft lines (read-only view).
* Used by integration tests; production code uses addLines() to send.
*/
public function getDraftLines(): array
{
return $this->draft_lines;
}
/**
* Add an order to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
@@ -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 -->
+10 -6
View File
@@ -205,10 +205,12 @@ class bookings_o extends db
$sql = "SELECT * FROM $this->table WHERE id = $id";
$result = $db->query($sql);
if ($db->num_rows($result) === 0) {
// Send a department webhook if the booking is new
// Send a department webhook if the booking is new.
// TRU-106: send_new_booking_notification() filters out drop-offs
// (pickup_bool = 0) so only pickup bookings post to Slack.
$slack = new slack();
try {
$slack->send_department_booking_notification($department, $slack->format_new_booking(
$slack->send_new_booking_notification(
$id,
$customer_number,
$wash_type,
@@ -219,12 +221,12 @@ class bookings_o extends db
$washCertificateEmail,
$date,
$department,
$pickup_bool,
(bool)$pickup_bool,
$notes,
$washCertificateStatus,
$washCertificateUrl,
$status
));
);
} catch (Exception $e) {
// Log the error
$logs = new logs_o();
@@ -313,11 +315,13 @@ class bookings_o extends db
!$deliverSlack // Only send email if slack is not available
);
// Check if the department has a slack webhook
// TRU-106: send_new_booking_notification() filters out drop-offs
// (pickup_bool = false) so only pickup bookings post to Slack.
if ($deliverSlack) {
// Send a notification to the department
$slack = new slack();
try {
$slack->send_department_booking_notification($department->id, $slack->format_new_booking(
$slack->send_new_booking_notification(
$this->id,
$customer_array['customer_number'],
self::formatWashTypeFromServices(json_decode($this->data->value(), true)),
@@ -333,7 +337,7 @@ class bookings_o extends db
$this->washCertificateStatus->value(),
$this->washCertificateUrl->value(),
$this->status->value()
));
);
} catch (Exception $e) {
// Previously this bare call would crash the entire
// notifyNewBooking() flow if Slack returned non-2xx, so
@@ -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,
+62
View File
@@ -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
+38 -5
View File
@@ -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
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace routes;
use classes\authentication;
use classes\response;
use classes\customer_invoice_email_schema_bootstrap;
use traits\route_t;
/**
* Admin / ops endpoints. Currently exposes the schema health check.
*
* The schema health check verifies that all required DB columns exist
* for the routes the code references. If a column is missing (e.g. a
* migration wasn't run on production), the endpoint returns 503 with
* a clear list of missing columns — much more useful than a generic
* 500 with "Unknown column" hidden in the stack trace.
*/
class adminRoute
{
use route_t;
public function run(): void
{
// Schema health check — used by deploy pipelines, monitoring,
// and the cron job. Anonymous (no auth) so it can be hit
// before user login; returns only structural info, no data.
$this->get('/admin/schema-check', function () {
global /** @var response $response */ $response;
// Self-heal: run all schema bootstraps first
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
try {
customer_invoice_email_schema_bootstrap::ensureSchema();
} catch (\Throwable $e) {
// Bootstrap may fail in environments where $db is
// not yet wired up; report and continue with check
}
}
$report = $this->runSchemaCheck();
$response->setStatus($report['ok'] ? 200 : 503);
$response->setBody(json_encode($report, JSON_PRETTY_PRINT));
});
}
/**
* Returns ['ok' => bool, 'missing' => array, ...].
* If ok=false, the deploy should be blocked.
*/
private function runSchemaCheck(): array
{
global $db;
$report = [
'ok' => true,
'missing' => [],
'tables_checked' => 0,
'columns_checked' => 0,
'timestamp' => date('c'),
'note' => 'If "missing" is non-empty, the migration that adds these columns was not run on the database.',
];
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
$report['ok'] = false;
$report['error'] = 'no_db_connection';
return $report;
}
$requirements = [
'users' => [
'invoice_email', // TRU-77 (added 2026-08-16, was missing on production)
'wash_certificate_email',
'email',
'customer_number',
],
'invoices' => [
'po_number',
'closed_at',
'customer_number',
],
'bookings' => [
'id',
'customer_number',
'department',
],
];
foreach ($requirements as $table => $columns) {
$report['tables_checked']++;
$tableSafe = str_replace('`', '', $table);
$result = $db->query("SHOW TABLES LIKE '{$tableSafe}'");
if (!$result || (int)$result->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "table `{$table}` does not exist";
continue;
}
foreach ($columns as $column) {
$report['columns_checked']++;
$colSafe = str_replace("'", '', $column);
$r = $db->query("SHOW COLUMNS FROM `{$tableSafe}` LIKE '{$colSafe}'");
if (!$r || (int)$r->num_rows === 0) {
$report['ok'] = false;
$report['missing'][] = "{$table}.{$column}";
}
}
}
return $report;
}
}
@@ -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(
@@ -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();
});
@@ -1,9 +1,14 @@
<?php
// Test that the cron mechanism is properly wired. The Coolify auto-deploy logic
// was removed from release_manager.php 2026-08-17, so this test no longer asserts
// anything about cron worker deployment. The actual cron mechanism
// (cron_worker.php, cron_scheduler.php, cli.php, cronRoute.php) is unchanged.
$cronAppRoot = dirname(__DIR__, 3);
require_once $cronAppRoot . '/classes/cron_worker.php';
it('wires cron workers through schema, CLI, scheduler, and superuser routes', function (): void {
it('wires cron workers through schema, scheduler, CLI, and routes', function (): void {
$appRoot = dirname(__DIR__, 3);
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS') ?: dirname($appRoot, 3);
$schema = file_get_contents($appRoot . '/classes/cron_schema_bootstrap.php');
@@ -11,12 +16,6 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
$scheduler = file_get_contents($appRoot . '/classes/cron_scheduler.php');
$cli = file_get_contents($appRoot . '/cli.php');
$route = file_get_contents($appRoot . '/routes/cronRoute.php');
$manager = file_get_contents($appRoot . '/classes/release_manager.php');
$composeFiles = [
$repoRoot . '/docker-compose.yml',
$repoRoot . '/docker-compose.example.yml',
$repoRoot . '/docker-compose.prod.standalone.yml',
];
expect($schema)->toContain('CREATE TABLE IF NOT EXISTS cron_worker_state');
expect($schema)->toContain('last_heartbeat_at');
@@ -47,29 +46,24 @@ it('wires cron workers through schema, CLI, scheduler, and superuser routes', fu
expect($cli)->toContain('new \\classes\\cron_worker()');
expect($route)->toContain('/superuser/cron/workers');
expect($route)->toContain('/superuser/cron/workers/deploy');
expect($route)->toContain('$response->success($result, 202)');
expect($route)->toContain('queueTaskRun(');
expect($route)->toContain('$response->success($run, 202)');
expect($route)->toContain('superuser_cron_view');
expect($route)->toContain('superuser_cron_manage');
expect($route)->toContain('superuser_coolify_manage');
});
expect($manager)->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->toContain('deployCronWorkerAfterApiDeployment');
expect($manager)->toContain('deployment_kind = \'cron_worker\'');
expect($manager)->toContain('createCronWorkerDeploymentRecord');
expect($manager)->toContain('waiting_for_heartbeat');
expect($manager)->toContain('cronWorkerAutoprovisionRequired');
expect($manager)->toContain('cron_worker_autoprovision_disabled');
expect($manager)->toContain('cron_worker_deploy_failed');
expect($manager)->toContain('auto_deploy = 0');
it('starts the cron-worker service via the docker-compose entrypoint', function (): void {
$appRoot = dirname(__DIR__, 3);
$repoRoot = getenv('PLENO_REPO_ROOT_FOR_TESTS') ?: dirname($appRoot, 3);
$composeFiles = [
$repoRoot . '/docker-compose.yml',
$repoRoot . '/docker-compose.example.yml',
$repoRoot . '/docker-compose.prod.standalone.yml',
];
foreach ($composeFiles as $composeFile) {
$compose = file_get_contents($composeFile);
expect(str_contains($compose, 'command: ["php", "index.php", "run", "cron-worker"]'))->toBeTrue();
expect(str_contains($compose, 'while true; do php index.php run cron; sleep 60; done'))->toBeFalse();
}
});
@@ -104,3 +98,18 @@ it('reports consecutive scheduler loops as once-per-minute execution proof', fun
$result = $publicWorker->invoke($worker, $row);
expect($result['minute_cadence']['verified'])->toBeFalse();
});
it('exposes a cron status endpoint that no longer references Coolify auto-deploy', function (): void {
$appRoot = dirname(__DIR__, 3);
$manager = file_get_contents($appRoot . '/classes/release_manager.php');
// The Coolify auto-deploy constants and methods must be gone
expect($manager)->not->toContain("private const CRON_WORKER_APP = 'cron'");
expect($manager)->not->toContain("private const CRON_WORKER_START_COMMAND = 'php index.php run cron-worker'");
expect($manager)->not->toContain('function deployCronWorker');
expect($manager)->not->toContain('function deployCronWorkerAfterApiDeployment');
expect($manager)->not->toContain('function cronWorkerAutoprovision');
expect($manager)->not->toContain('function cronWorkerHealth');
// The cronWorkerStatus method should still exist as a thin DB wrapper
expect($manager)->toContain('public function cronWorkerStatus');
expect($manager)->toContain("'coolify_auto_deploy_enabled' => false");
});
@@ -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());
}
}
@@ -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'
);
}
}
@@ -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);
});
@@ -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');
});
@@ -411,214 +411,6 @@ it('uses the self-contained Coolify API Dockerfile for API applications', functi
expect($payload)->not->toHaveKey('is_static');
});
it('creates private Coolify application payloads for cron workers', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationPayload');
$payload = $payloadMethod->invoke($manager, [
'channel_slug' => 'internal',
'app' => 'cron',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
'auto_deploy' => 0,
], [
'coolify_service_name' => 'release-internal-cron-worker',
'coolify_project_uuid' => 'project-internal',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_build_pack' => 'dockerfile',
'coolify_deploy_now' => true,
'coolify_start_command' => 'php index.php run cron-worker',
], [
'default_environment_name' => 'production',
'default_server_uuid' => 'server-node3',
]);
expect($payload['name'])->toBe('release-internal-cron-worker');
expect($payload['build_pack'])->toBe('dockerfile');
expect($payload['ports_exposes'])->toBe('80');
expect($payload['dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($payload['start_command'])->toBe('php index.php run cron-worker');
expect($payload['is_auto_deploy_enabled'])->toBeFalse();
expect($payload)->not->toHaveKey('domains');
expect($payload)->not->toHaveKey('is_force_https_enabled');
});
it('derives cron worker deployment context from the API target without public routing', function (): void {
$manager = new release_manager();
$contextMethod = new ReflectionMethod(release_manager::class, 'cronWorkerDeployContext');
$context = $contextMethod->invoke($manager, [
'id' => 17,
'channel_id' => 3,
'channel_slug' => 'internal',
'deploy_context_json' => json_encode([
'coolify_project_uuid' => 'project-internal',
'coolify_environment_name' => 'production',
'coolify_github_app_uuid' => 'github-app-copenhagentruckwash-github',
'coolify_public_url' => 'https://api-v2.truckwash.io',
'manual_endpoint_host' => 'manual.example.test',
]),
], null, '5555555555555555555555555555555555555555', 41);
expect($context['coolify_auto_create'])->toBeTrue();
expect($context['coolify_resource_type'])->toBe('application');
expect($context['coolify_build_pack'])->toBe('dockerfile');
expect($context['coolify_dockerfile_location'])->toBe('/Dockerfile.coolify-api');
expect($context['coolify_start_command'])->toBe('php index.php run cron-worker');
expect($context['coolify_is_auto_deploy_enabled'])->toBeFalse();
expect($context['coolify_enable_ssl'])->toBeFalse();
expect($context['coolify_service_name'])->toBe('release-internal-cron-worker');
expect($context['coolify_git_commit_sha'])->toBe('5555555555555555555555555555555555555555');
expect($context['coolify_env']['CRON_WORKER_RELEASE_CHANNEL_ID'])->toBe('3');
expect($context['coolify_env']['CRON_WORKER_RELEASE_TARGET_ID'])->toBe('41');
expect($context['coolify_env']['CRON_WORKER_SOURCE'])->toBe('coolify_worker');
expect($context)->not->toHaveKey('coolify_public_url');
expect($context)->not->toHaveKey('manual_endpoint_host');
});
it('requires Coolify cron worker autoprovisioning for API deployments by default', function (): void {
$manager = new release_manager();
$enabledMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionEnabled');
$requiredMethod = new ReflectionMethod(release_manager::class, 'cronWorkerAutoprovisionRequired');
expect($enabledMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
expect($requiredMethod->invoke($manager, ['deploy_context_json' => null]))->toBeTrue();
$optionalTarget = [
'deploy_context_json' => json_encode([
'cron_worker_autoprovision_required' => false,
]),
];
expect($enabledMethod->invoke($manager, $optionalTarget))->toBeTrue();
expect($requiredMethod->invoke($manager, $optionalTarget))->toBeFalse();
$disabledTarget = [
'deploy_context_json' => json_encode([
'cron_worker_autoprovision' => false,
]),
];
expect($enabledMethod->invoke($manager, $disabledTarget))->toBeFalse();
expect($requiredMethod->invoke($manager, $disabledTarget))->toBeFalse();
$managerSource = file_get_contents(app_path('classes/release_manager.php'));
expect($managerSource)->toContain('Cron worker deployment is required for API deployments');
});
it('classifies cron worker deployment and heartbeat lifecycle states', function (): void {
$manager = new release_manager();
$healthMethod = new ReflectionMethod(release_manager::class, 'cronWorkerHealth');
expect($healthMethod->invoke($manager, null, null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
expect($healthMethod->invoke($manager, ['id' => 4], null, [], ['running' => 0, 'stale' => 0, 'failed' => 0], null)['state'])
->toBe('needs_deploy');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deploying',
'created_at' => date('Y-m-d H:i:s'),
])['state'])->toBe('deploying');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
'completed_at' => date('Y-m-d H:i:s'),
])['state'])->toBe('waiting_for_heartbeat');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [
['status' => 'running', 'stale' => false],
], ['running' => 1, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
])['state'])->toBe('healthy');
expect($healthMethod->invoke($manager, ['id' => 4], ['id' => 9], [], ['running' => 0, 'stale' => 0, 'failed' => 0], [
'status' => 'deployed',
'completed_at' => '2020-01-01 00:00:00',
])['state'])->toBe('failed');
});
it('extracts Coolify cron deployment operation identifiers from provider payloads', function (): void {
$manager = new release_manager();
$operationMethod = new ReflectionMethod(release_manager::class, 'coolifyDeploymentOperationId');
expect($operationMethod->invoke($manager, ['deployment' => ['deployment_uuid' => 'deployment-123']]))
->toBe('deployment-123');
expect($operationMethod->invoke($manager, ['data' => ['uuid' => 'operation-456']]))
->toBe('operation-456');
expect($operationMethod->invoke($manager, ['message' => 'queued']))
->toBeNull();
});
it('detects missing Coolify cron worker resources from provider errors', function (): void {
$method = new ReflectionMethod(release_manager::class, 'coolifyResourceMissing');
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 404')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Application not found')))->toBeTrue();
expect($method->invoke(null, new RuntimeException('Coolify API request failed: HTTP 401')))->toBeFalse();
});
it('classifies missing Coolify cron worker resources as repairable', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$result = $readiness->invoke($manager, [
'id' => 17,
'app' => 'api',
'coolify_instance_id' => 3,
'repository' => 'copenhagentruckwash/api',
], [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => 3,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('repair');
expect($result['can_deploy'])->toBeTrue();
expect(array_column($result['issues'], 'code'))->toContain('missing_coolify_worker_resource');
});
it('repairs from an existing cron target when the API target is absent', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$result = $readiness->invoke($manager, null, [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => 3,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => 'copenhagentruckwash/api',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('repair');
expect($result['can_deploy'])->toBeTrue();
expect(array_column($result['issues'], 'code'))->toContain('repairable_cron_target');
});
it('blocks cron worker deployment without an API target or deployable cron context', function (): void {
$manager = new release_manager();
$readiness = new ReflectionMethod(release_manager::class, 'cronWorkerDeploymentReadiness');
$result = $readiness->invoke($manager, null, [
'id' => 71,
'app' => 'cron',
'coolify_instance_id' => null,
'coolify_service_uuid' => 'missing-cron-worker',
'repository' => '',
'branch' => 'master',
], [
'configured' => true,
'missing' => true,
]);
expect($result['action'])->toBe('blocked');
expect($result['can_deploy'])->toBeFalse();
expect(array_column($result['issues'], 'code'))->toContain('missing_api_target');
});
it('builds explicit Coolify application route labels for release API targets', function (): void {
$manager = new release_manager();
$payloadMethod = new ReflectionMethod(release_manager::class, 'releaseCoolifyApplicationRoutePayload');
@@ -0,0 +1,51 @@
<?php
namespace tests\Unit;
use classes\schema_bootstrap_runtime;
use PHPUnit\Framework\TestCase;
/**
* Verifies the self-healing schema bootstrap runtime:
* 1. Discovers and calls every *_schema_bootstrap::ensureSchema() in classes/
* 2. Is idempotent (does not re-run within the same process)
* 3. Does not throw if a bootstrap throws (logs and moves on)
*
* The actual DB-touching work is exercised in production; here we
* stub the global $db so the columnExists() check inside each
* ensureSchema() can be observed.
*/
class SchemaBootstrapRuntimeTest extends TestCase
{
public function testRunAllDiscoversAndInvokesEachBootstrap(): void
{
$classesDir = __DIR__ . '/../../classes';
$bootstraps = glob($classesDir . '/*_schema_bootstrap.php');
$this->assertNotEmpty($bootstraps, 'No *_schema_bootstrap.php files found in classes/');
// Ensure no real $db is required: each ensureSchema() in the
// existing classes guards with `if (!isset($db) ...) { return; }`
// so they are no-ops without one. We just verify the runtime
// doesn't throw.
schema_bootstrap_runtime::runAll();
$this->assertTrue(true); // no exception
}
public function testRunAllIsIdempotent(): void
{
// First call already happened in test 1; calling again must
// short-circuit and not throw.
schema_bootstrap_runtime::runAll();
schema_bootstrap_runtime::runAll();
$this->assertTrue(true);
}
public function testNoOpWhenNoBootstrapsExist(): void
{
// Reflection: ensure runAll() is robust even if a different
// classes dir somehow had no bootstraps. We just call it
// again — it should be a no-op due to the static $ran flag.
schema_bootstrap_runtime::runAll();
$this->assertTrue(true);
}
}
@@ -0,0 +1,220 @@
<?php
/**
* Contract test: every column that the code expects to find in the
* `users` table must exist. Catches the production failure mode
* where a migration was added to code but never run on the database
* (e.g. "Unknown column 'invoice_email' in 'SELECT'" — TRU-77).
*
* This test runs against the test database (configured in
* phpunit.xml / Pest configuration). It does NOT run against
* production — that's covered by the `/admin/schema-check` HTTP
* endpoint in `adminRoute.php` which the deploy pipeline hits.
*/
app_require('classes/customer_invoice_email_schema_bootstrap.php');
use classes\customer_invoice_email_schema_bootstrap;
const REQUIRED_USERS_COLUMNS = [
// TRU-77 (added 2026-08-16) — the column that was missing in
// production after the migration was merged to master.
'invoice_email',
// Older required columns that the code references.
'wash_certificate_email',
'email',
'customer_number',
'phone_country_code',
'phone',
'group_id',
'created_at',
];
/**
* The unit test bootstrap does not create a $db global. This contract
* test is unique in that it needs a real database to verify schema
* state, so wire one up here using the same CONFIG_DB_* env vars the
* rest of the CI suite exports. If the database is unavailable, the
* tests below will fail with a clear "no_db_connection" error.
*/
schema_health_check_test_wire_db();
function schema_health_check_test_wire_db(): void
{
if (isset($GLOBALS['db']) && is_object($GLOBALS['db'])) {
return;
}
if (!class_exists('mysqli')) {
return;
}
$host = (string)(getenv('CONFIG_DB_HOST') ?: 'mysql-debug');
$user = (string)(getenv('CONFIG_DB_USER') ?: 'root');
$password = (string)(getenv('CONFIG_DB_PASSWORD') ?: 'debug_root_password');
$database = (string)(getenv('CONFIG_DB_DATABASE') ?: 'nnks_db_debug');
$port = (int)(getenv('CONFIG_DB_PORT') ?: 3306);
try {
mysqli_report(MYSQLI_REPORT_OFF);
$conn = new mysqli($host, $user, $password, $database, $port);
if ($conn->connect_errno) {
return;
}
$conn->set_charset('utf8mb4');
} catch (\Throwable $e) {
return;
}
$GLOBALS['db'] = new class($conn) {
private mysqli $conn;
public function __construct(mysqli $conn)
{
$this->conn = $conn;
}
public function query(string $sql)
{
return $this->conn->query($sql);
}
public function fetch_assoc($result)
{
return $result ? $result->fetch_assoc() : null;
}
public function close(): void
{
try {
$this->conn->close();
} catch (\Throwable) {
}
}
};
}
/**
* The unit suite starts with a freshly-dropped `nnks_db_debug` database
* (see run_ci_suite::reset_ci_state). The schema bootstrap only owns
* the `users` table; `invoices` and `bookings` are managed by other
* migrations that don't run in the unit suite. Create the bare-minimum
* schema that adminRoute::runSchemaCheck needs so the third test can
* verify the "all columns exist" happy path.
*/
function schema_health_check_test_ensure_aux_tables(): void
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$create = function (string $table, string $createSql, array $requiredColumns) use ($db): void {
$r = $db->query("SHOW TABLES LIKE '{$table}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query($createSql);
return;
}
foreach ($requiredColumns as $column => $definition) {
$r = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if (!$r || (int)$r->num_rows === 0) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
};
$create('invoices', "CREATE TABLE `invoices` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
po_number VARCHAR(64) NULL,
closed_at DATETIME NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'po_number' => 'VARCHAR(64) NULL',
'closed_at' => 'DATETIME NULL',
'customer_number' => 'INT NOT NULL DEFAULT 0',
]);
$create('bookings', "CREATE TABLE `bookings` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_number INT NOT NULL DEFAULT 0,
department INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", [
'customer_number' => 'INT NOT NULL DEFAULT 0',
'department' => 'INT NULL',
]);
}
beforeEach(function () {
// Force a fresh real DB connection. Earlier unit tests in the
// same process may have left $GLOBALS['db'] as a Mockery mock,
// which would cause the schema bootstrap below to silently no-op
// and leave the `users` table uncreated. The wiring helper
// short-circuits when a $db is already set, so we unset first.
unset($GLOBALS['db']);
schema_health_check_test_wire_db();
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
// Reset the bootstrap's static `$initialized` cache. A
// previous test (possibly against a mock $db) may have set
// it to true, which would cause ensureSchema() to skip
// creating the `users` table on our real connection.
$bootstrapRef = new ReflectionClass(customer_invoice_email_schema_bootstrap::class);
$initProp = $bootstrapRef->getProperty('initialized');
$initProp->setAccessible(true);
$initProp->setValue(null, false);
// Self-heal: run the schema bootstrap so the test DB has all
// the columns the contract requires. The bootstrap is additive
// and idempotent — safe to run on every test.
customer_invoice_email_schema_bootstrap::ensureSchema();
}
schema_health_check_test_ensure_aux_tables();
});
it('users table has every required column the code references', function () {
global $db;
expect($db)->toBeObject();
expect(method_exists($db, 'query'))->toBeTrue();
$missing = [];
foreach (REQUIRED_USERS_COLUMNS as $column) {
$safeColumn = str_replace("'", '', $column);
$result = $db->query("SHOW COLUMNS FROM `users` LIKE '{$safeColumn}'");
if (!$result || (int)$result->num_rows === 0) {
$missing[] = $column;
}
}
expect($missing)->toBe(
[],
"users table is missing required columns: " . implode(', ', $missing)
. ". Did the migration run? See customer_invoice_email_schema_bootstrap."
);
});
it('invoice_email column accepts a normal email address', function () {
global $db;
// Insert a throwaway user with an invoice_email, read it back.
// If the column doesn't exist or the type is wrong, this fails.
$email = 'test-invoice-' . uniqid() . '@example.com';
$customerNumber = 99900000 + random_int(1, 99999);
$db->query("INSERT INTO `users` (customer_number, display_name, email, invoice_email) VALUES ({$customerNumber}, 'Contract Test', '{$email}', 'invoice@example.com')");
$result = $db->query("SELECT invoice_email FROM `users` WHERE customer_number = {$customerNumber}");
expect($result)->toBeObject();
$row = $result->fetch_assoc();
expect($row['invoice_email'] ?? null)->toBe('invoice@example.com');
// Cleanup
$db->query("DELETE FROM `users` WHERE customer_number = {$customerNumber}");
});
it('admin schema-check endpoint reports ok=true when all columns exist', function () {
$admin = new \routes\adminRoute();
$reflection = new ReflectionClass($admin);
$method = $reflection->getMethod('runSchemaCheck');
$method->setAccessible(true);
$report = $method->invoke($admin);
expect($report['ok'])->toBeTrue(
'schema check failed: ' . json_encode($report['missing'] ?? [])
);
expect($report['columns_checked'])->toBeGreaterThan(0);
});
File diff suppressed because one or more lines are too long
@@ -0,0 +1,157 @@
<?php
app_require('classes/slack.php');
use classes\slack;
/**
* Fake slack subclass that captures webhook messages without doing I/O.
* Overrides get_department_webhook() so we don't touch redis/db.
*/
final class SlackNewBookingPickupFilterFake extends slack
{
public array $messages = [];
public string $webhook = 'https://hooks.slack.test/services/TRU-106-pickup-filter';
public ?string $webhookOverride = null; // null => use $this->webhook, '' => empty, etc.
public string $sendResult = 'Message sent successfully. Response: ok';
public function __construct()
{
// Skip parent config loading for unit isolation.
}
protected function get_department_webhook(int $department_id): string
{
return $this->webhookOverride ?? $this->webhook;
}
public function send_webhook_message(string $message, string $webhook): string
{
$this->messages[] = [
'message' => $message,
'webhook' => $webhook,
];
return $this->sendResult;
}
/**
* Stub format_new_booking so unit tests don't need a live redis/db
* (the real implementation calls departments_o::getDepartmentName,
* which dereferences the global `redis` object that is not loaded
* in the unit test bootstrap).
*/
public function format_new_booking(
$id,
$customer_number,
string $wash_type,
string $contact_email,
string $reference_number,
string $regNrTraekker,
string $regNrTrailer,
string $washCertificateEmail,
string $date,
int $department,
$pickup_bool,
string $notes,
string $washCertificateStatus,
string $washCertificateUrl,
string $status
): string {
$pickupLabel = $pickup_bool ? '1' : '0';
return "*Ny booking oprettet* ( ID: {$id} )\n"
. "Kunde: ({$customer_number})\n"
. "Type: {$wash_type}\n"
. "Reference nummer: {$reference_number}\n"
. "RegNr Traekker: {$regNrTraekker}\n"
. "RegNr Trailer: {$regNrTrailer}\n"
. "Dato: {$date}\n"
. "Hentning: {$pickupLabel}\n"
. "Noter: {$notes}";
}
}
/**
* Sample booking data used by all the tests below.
*/
function tru106_sample_booking(): array
{
return [
'id' => 4242,
'customer_number' => 1001,
'wash_type' => 'Standard wash',
'contact_email' => 'dispatcher@example.com',
'reference_number' => 'REF-001',
'regNrTraekker' => 'AB12345',
'regNrTrailer' => 'CD67890',
'washCertificateEmail' => '',
'date' => '2026-08-16 09:00:00',
'department' => 4,
'notes' => 'No notes',
'washCertificateStatus' => '',
'washCertificateUrl' => '',
'status' => 'pending',
];
}
function tru106_call_send_new_booking_notification(slack $slack, array $b, bool $pickup): bool
{
return $slack->send_new_booking_notification(
$b['id'],
$b['customer_number'],
$b['wash_type'],
$b['contact_email'],
$b['reference_number'],
$b['regNrTraekker'],
$b['regNrTrailer'],
$b['washCertificateEmail'],
$b['date'],
$b['department'],
$pickup,
$b['notes'],
$b['washCertificateStatus'],
$b['washCertificateUrl'],
$b['status']
);
}
it('posts a Slack notification when the new booking is a pickup (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
expect($sent)->toBeTrue()
->and($slack->messages)->toHaveCount(1)
->and($slack->messages[0]['webhook'])->toBe('https://hooks.slack.test/services/TRU-106-pickup-filter')
->and($slack->messages[0]['message'])->toContain('Ny booking oprettet')
->and($slack->messages[0]['message'])->toContain('ID: 4242')
->and($slack->messages[0]['message'])->toContain('Kunde:')
->and(json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES))->toContain('sent successfully');
});
it('does NOT post a Slack notification when the new booking is a drop-off (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), false);
expect($sent)->toBeFalse()
->and($slack->messages)->toBe([])
->and($slack->get_log())->toBe([]);
});
it('does NOT post a Slack notification when the department has no webhook configured (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
$slack->webhookOverride = '';
$sent = tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
expect($sent)->toBeFalse()
->and($slack->messages)->toBe([])
->and($slack->get_log())->toBe([]);
});
it('does not leak the webhook URL into the log payload for a pickup (TRU-106)', function (): void {
$slack = new SlackNewBookingPickupFilterFake();
tru106_call_send_new_booking_notification($slack, tru106_sample_booking(), true);
$logDump = json_encode($slack->get_log(), JSON_UNESCAPED_SLASHES);
expect($logDump)->not->toContain('hooks.slack.test')
->and($logDump)->toContain('sent successfully');
});
@@ -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);
}