## TRU-70: DRIFT 9 — Customer rule "auto-send invoice toggle (3rd
business day each month)"
Adds a per-customer rule that auto-sends the customer's invoice on the
3rd business day of each month. The rule is implemented as a new
customer attribute (`autoSendInvoiceThirdBusinessDay`) that can be
toggled through the existing `POST /customer/attributes` endpoint, plus
a daily cron task that, on the trigger day, enqueues every ready
collected invoice for export via the existing `economic_transfer_queue`.
## Linear
- **TRU-70** (DRIFT 9)
## Changes
- **Customer attribute (TRU-70 / DRIFT 9)**
- `classes/customer_rule_product_restriction_service.php`
Adds `'autoSendInvoiceThirdBusinessDay'` to `SUPPORTED_ATTRIBUTES` so
the existing customer-attributes route can persist the toggle.
- **3rd-business-day service**
- `classes/auto_send_invoice_third_business_day_service.php` (new)
- Computes the 3rd business day of any month (weekend-aware, holiday
provider override).
- `runOnce()` is a no-op on every day except the 3rd business day.
- On the trigger day, scans `customer_attributes` for opted-in customers
and loads their ready `collected_order_invoices` (not booked, not
closed, has at least one order, not deleted).
- Enqueues each via `economic_transfer_queue` and returns a summary
`{triggered, customers, collections_scanned, jobs_enqueued,
skipped_already_queued, errors[]}`.
- Public hooks (`loadEligibleCustomerNumbers`,
`loadReadyInvoiceCollections`) and protected `createTransferQueue()` are
designed for unit-test isolation so no live database is required.
- **Cron task (TRU-70 / DRIFT 9)**
- `modules/economic/cron/tasks.php`
Registers `'economic.auto_send_invoices_third_business_day'` with a 24h
interval, 15 min timeout, priority 25. Anchored in the economic module
because the actual export goes through `economic_transfer_queue`.
- `cron/Cron.php`
New `AutoSendInvoicesThirdBusinessDay()` handler. Mirrors the
surrounding cron-task conventions (`warn` + `error_log` breadcrumb on
failure) and only logs a one-liner when the trigger fires.
## Tests
- `tests/Unit/Cron/AutoSendInvoiceThirdBusinessDayTest.php` (new, 16
tests)
- The 3rd-business-day computation (weekday-start, weekend-start,
Saturday, 4th-business-day, holiday skip, holiday forward-shift).
- `thirdBusinessDayOfMonth()` helper for the three reference months used
in the spec (Aug/Sep 2026 and Jul 2026).
- `runOnce()` no-op path on non-trigger days.
- `runOnce()` summary on trigger day with zero opted-in customers.
- `runOnce()` summary with customers and ready collections.
- `runOnce()` per-collection error recording when enqueue throws.
- `clearOverrides()` reset.
- The new attribute is in
`customer_rule_product_restriction_service::SUPPORTED_ATTRIBUTES` and
exposes a stable `ATTRIBUTE` constant.
- `tests/Unit/Cron/CronTaskRegistryTest.php`
Updated the discovery assertion from 22 to 23 definitions and added
coverage that the new `'economic.auto_send_invoices_third_business_day'`
task is discovered with the expected schedule and module.
## Backwards compatibility
- The new attribute is additive; existing `customer_attributes` rows are
unaffected. The default customer has no auto-send rule.
- The cron task is registered in the standard task registry; the
existing `cron_worker` (15s poll) handles the trigger without any new
infrastructure.
- The service gracefully no-ops when the transfer queue is unavailable
in unit-test contexts; the production cron task will surface a `warn()`
breadcrumb if e-conomic is unreachable, exactly like the other economic
cron tasks.
## Verification
```
vendor/bin/pest tests/Unit/Cron/ tests/Unit/Customers/
# 43 passed (199 assertions)
```
---------
Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
Co-authored-by: bugfix <bugfix@truckwash.local>
Co-authored-by: bugfix-subagent <[email protected]>
Resolves TRU-78 (DRIFT 17: License plate scan - show 'last washed'
timestamp on landing page / DHL use case).
The POS landing page already surfaces license plate scans via `GET
/numberplatescans`, but it has no way to tell the operator **when a
plate was last washed**. With DHL trailers going in and out several
times a day, the front-desk needs that hint to decide whether a trailer
needs another wash before pick-up.
## Changes
- **`orders_o::getLastWashTimestampForPlate(string $reg_1): ?string`** —
new helper that returns the `created_at` (MySQL DATETIME) of the most
recent non-deleted order for the plate that has at least one non-deleted
order item. Mirrors the contract used by
`customer_vehicles_o::getLastOrderByPlate()` so the timestamp is always
backed by a real wash.
- **`GET /numberplatescans`** now enriches each scan row with a
`last_wash` key (string or `null`). No breaking change to the existing
payload; new field is additive.
- **New Pest test**
`services/nginx/app/tests/Unit/Orders/OrderLastWashTimestampForPlateTest.php`
— static-analysis assertions for the helper definition and the route
wiring (matches the style of `OrderBookingsCountsRouteWiringTest`).
## Frontend companion
https://github.com/copenhagentruckwash/pleno-vue/pull/335 renders this
`last_wash` in the inline details of each scan row on the POS landing
page (`PosLastScannedLicensePlatesV2.vue`), with an "Aldrig vasket" /
"Never washed" fallback when the API returns `null`.
## Risk
- `getLastWashTimestampForPlate` does one extra indexed read per scan
row (`SELECT id FROM orders WHERE reg_1 = ? AND deleted_at IS NULL`).
The existing `isPlateSeenBefore` call already does the same, so the
route's per-row query count is unchanged in shape.
- The new field is additive and ignored by older clients, so this can
roll forward without a coordinated client release.
Co-authored-by: openclaw bugfix <openclaw@copenhagentruckwash.local>
## 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>
Three fixes for the failing CI checks (PHP api, PHP integration):
1. RouteScopeTest.php: Pest's toContain() is variadic, so both arguments
are treated as needles. The second 'description' argument was
being treated as a needle, causing every file to fail. Removed the
misleading second argument.
2. Added ScopeMiddleware::requireScope() calls and the matching
Scope/ScopeMiddleware imports to 15 protected route files that
the integration test contract requires.
3. documentation/auth/route-scope-audit.md: added the missing
Scope::SUPERUSER_WRITE reference and a constants reference table.
Also registered tests/auth/StripeInvoiceEmailTemplateTest.php in the
legacy test manifest.
## 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>
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]>
The scope-middleware injection on the public driver registration
endpoints (POST /subusers and POST /subusers/me) breaks the public
registration contract — new drivers cannot hold a scope before they
exist, so requiring one would make self-registration impossible.
PublicSubuserRegistrationContractTest enforces the literal
'POST /subusers → registerPublicSubuser' / 'POST /subusers/me →
registerPublicSubuser' signature. Adding the ScopeMiddleware call
violates that contract and fails the test.
Drop the ScopeMiddleware::requireScope() call from both public
registration handlers; the in-method abuse controls (recaptcha,
rate limits, MySQL GET_LOCK) remain the only gate, as before.
Fixes CI on PR #397 (Required CI, PHP unit, PHP api, PHP integration).
## 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>
Adds a scope-based access control layer to all 81 existing API routes.
Sits alongside existing session-cookie auth (does not replace it).
What this PR does:
- Audits every existing route and documents required scope per route
(see documentation/auth/route-scope-audit.md)
- Adds classes/auth/scope.php with 10 scope constants and role→scope defaults
- Adds classes/auth/scope_middleware.php with requireScope/requireAnyScope/requireRole
- Applies require*() calls to all 81 existing routes
- Adds ScopeMiddlewareTest (unit, 178 lines) and RouteScopeTest (integration, 212 lines)
Coexistence note:
This branch's classes/auth/scope.php is a stub that will be replaced
by classes/auth/scope_registry.php (from TRU-145 / PR #396) when that
PR merges first. The two have compatible APIs.
Refs: TRU-149
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## 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>
## Summary
Adds an optional `invoice_email` (Danish: *faktura email*) field to
customers, so e-conomic can deliver invoices to a dedicated accounting
mailbox instead of the customer's primary email.
Linear: **TRU-77** (DRIFT 16)
## Changes
- **Migration** (additive, via existing schema_bootstrap pattern)
- New `customer_invoice_email_schema_bootstrap` adds the `invoice_email
VARCHAR(255) NULL` column to `users` after `wash_certificate_email`.
Idempotent — skips when the column already exists.
- **Domain object — `objects/users_o.php`**
- New `invoice_email` object property.
- `getInvoiceEmail()` returns the dedicated address or falls back to the
primary `email`.
- `getInvoiceEmailOverride()` returns only the explicit override (no
fallback).
- `setInvoiceEmail($email)` validates and writes the value; `null`/empty
clears it.
- `add($customer_number, $password, $role, ?$invoice_email = null)` now
accepts the optional field and persists it.
- The user payload output now exposes `invoice_email` and
`invoice_email_fallback`.
- **API — `routes/usersRoute.php`**
- `POST /users` accepts an optional `invoice_email`, validated before
insert.
- `PUT /users` accepts `invoice_email` (including null/empty to clear)
on existing users.
- **Customer mass import — `classes/customer_mass_import_service.php`**
- Payload now accepts `invoice_email`.
- `normalizeInvoiceEmail()` rejects malformed addresses before any
e-conomic call.
- `resolveInvoiceEmail()` / `resolveCreateEmail()` route the e-conomic
customer email to the dedicated address when set, otherwise the primary
`email` (with the existing `jb@truckwash.dk` fallback when neither is
provided).
- `syncLocalCustomer()` persists `invoice_email` on the local user.
- `import()` result now includes the resolved `invoice_email`.
- **Tests — `tests/Unit/Customers/CustomerInvoiceEmailTest.php` (new)**
- Schema bootstrap adds the column when missing.
- Schema bootstrap is a no-op when the column already exists.
- Schema bootstrap skips when the `users` table is not present.
- e-conomic customer email is set to `invoice_email` when provided.
- e-conomic customer email falls back to `email` when `invoice_email` is
omitted.
- Invalid `invoice_email` is rejected before any e-conomic call.
## Backwards compatibility
- The column is nullable; existing rows are unaffected.
- The `add()` signature is additive (new optional parameter with default
`null`).
- The route payloads ignore `invoice_email` unless supplied, so no
client change is required.
## Linear
- TRU-77 (DRIFT 16: "Add 'faktura email' field to customer creation
form")
---------
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix Bot <openclaw-bot@truckwash.dk>
Co-authored-by: bugfix sub-agent <bugfix@openclaw.local>
## Summary
Fixes **TRU-128** ("Jeg kan ikke fakturere") — a customer in
#afdelingsansvarlige could not invoice because the customer-facing
invoice PUT endpoint returned a misleading 400 error.
## Root cause
`PUT /collected-invoices` in
`services/nginx/app/routes/userInvoicesRoute.php` had two related bugs:
1. **Misleading error message** — the 'both fields missing' guard
errored with
`'Missing required parameters: po_number, closed_at'`, which reads as
if BOTH fields are required. The actual condition (`&&`) only fires
when neither is set, so only one is required. Customers who tried
different combinations kept getting the same error and concluded the
system was broken.
2. **Inconsistent `closed_at` clearing** — the 'forbidden closed_at for
non-superusers' guard fired for ANY present `closed_at` key,
including `null` and `""`. That blocked customers from CLEARING a
previously-set `closed_at`, even though the handler further down
already nulls the field when it receives an empty value.
## Fix
- Reword the missing-fields error to state the actual contract:
*"At least one of po_number or closed_at must be provided"*.
- Narrow the forbidden guard to *non-empty* `closed_at`, so customers
can still pass `null` / `""` to clear a previously-set value.
The clear-on-null/empty logic further down in the handler is unchanged
— the guard now matches it.
## Test
`tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`
- Locks in the new error message.
- Locks in the new `$closed_at_is_non_empty` guard shape with the
`if (self::isParametersSet(['closed_at'])) { ... }` pre-check.
- Locks in the regression: the previous 'any present closed_at -> 403'
pattern is explicitly asserted to be absent.
## Files changed
- `services/nginx/app/routes/userInvoicesRoute.php`
-
`services/nginx/app/tests/Unit/Invoicing/UserCollectedInvoiceUpdateRouteValidationTest.php`
## Refs
- TRU-128
- Slack: #afdelingsansvarlige (kunde-rapport)
---------
Co-authored-by: OpenClaw Backend Agent <agent@openclaw.ai>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: jeppemaxclaw[bot] <bot@jeppemaxclaw.local>
Co-authored-by: Bugfix Subagent <bugfix-subagent@openclaw.local>
## Summary
Fixes **TRU-18 / AUT-14** — `truckwash.io` invoices were being routed to
the wrong Economic (EC) account for some users.
## Root cause
`getUserByCustomerNumber()` in `services/nginx/app/objects/users_o.php`
trusted the **inverse Redis cache** (`customer_number → user_id`)
without verifying that the user it loaded actually owned the requested
EC customer_number in the local DB.
When that cache went stale — e.g. after a `customer_number` re-mapping
on a code path that did not clear the inverse-cache entry —
`getUserByCustomerNumber()` would silently return a **different user**
whose current `customer_number` no longer matched the one the caller
asked for. Downstream invoice export code (`getCustomerEcocomicData()` →
`$customer_economic->customer_number` →
`economic_invoice_draft->setCustomerNumber(...)`) then used that wrong
user's current EC customer_number, and the draft invoice was created
against the **wrong Economic account**.
Because this only manifests when the inverse cache is stale, it surfaces
as "some users" — exactly the symptom reported.
## Fix
Minimal change in `getUserByCustomerNumber()`:
1. After the Redis fast-path loads a user, read the actual
`customer_number` from the DB via `getObjectProperties()`.
2. **Verify** that it equals the requested `$customer_number`.
3. If not, the inverse cache is stale: clear it
(`clear_user_id_from_customer_number`) and re-fetch via the recursive
call, which now falls through to the authoritative `SELECT id FROM users
WHERE customer_number = ?` DB query.
The DB path was always correct (it filters by exact `customer_number`);
the bug was exclusively in the unchecked Redis fast-path.
## Regression test
`tests/Unit/Users/GetUserByCustomerNumberStaleCacheTest.php` — wiring
tests that assert the verification + cache-clear + recursive re-fetch
are present, plus that the DB lookup path is the source of truth.
Prevents the regression from reappearing silently.
## Test run
PHP is unavailable in the sandbox, so the new test has not been executed
locally. It is a pure wiring test (string assertions on the source file)
and will be verified by CI on PR open.
## Out of scope
- No change to `openclaw.json`, deployment config, or any other config
files.
- Auto-merge is intentionally **not** enabled — leaving that to the
existing auto-merge cron.
- Existing tests untouched.
## Linear
- TRU-18 will be moved to "In Review" with the PR URL in a follow-up
comment.
🤖 Generated with [MaxClaw](https://maxclaw.ai)
---------
Co-authored-by: TRU-18 backend bot <bot@truckwash.dev>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
The api does NOT expose a /programs endpoint by design — program names ("FF Uvs", "10min", "SF", etc.) live on the wash bay hardware itself, not in the api.
This test locks that architecture so any future /programs endpoint must be explicitly added and documented, and so the machine-types endpoint remains reachable as the api-side closest equivalent.
Three assertions:
1. No /programs endpoint exists in any route file (or per-module route file)
2. /department/selfserve/machine-types is wired with the list permission and returns the success() envelope
3. /modules/self-serve/lane/relay/machine_program_picker/{status,set,enable} endpoints exist
Refs TRU-19
Brings all of the develop branch's commits into master.
## What this contains
The 2 commits on develop that landed during the XL Vask integration
dispatch:
- **PR #373** (TRU-6 / AUT-2) — feat(edge-broker): expose lastActivityAt
on /api/health (AUT-2/TRU-6)
- **PR #375** (TRU-49 / AUT-49) — fix(api): include wash_id in
xlvask_missing_order_link flag text (AUT-49/TRU-49)
## Why
The XL Vask integration dispatch via the OpenSymphony orchestrator
(MiniMax M3) produced 2 api-side fixes:
- **PR #373** — adds `lastActivityAt` to the api health endpoint so
operators can see if the edge-broker has processed any requests
recently.
- **PR #375** — the actual root-cause fix for the user-reported symptom
"XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre
i den valgte periode doesn't show the wash". The bug was in
`messageParts()` for the `xlvask_missing_order_link` arm — the link text
was hard-coded to 'XL Vask wash' instead of using the actual wash_id.
This PR makes the link identify the wash it points to.
## Verification
Both source PRs passed:
- Required CI (PHP unit, PHP integration, PHP api, PHP legacy, edge
broker, edge agent, edge gateway backend)
- The api ruleset allows squash merges
## Notes
- The pleno-vue repo has its own equivalent develop→master PR (#312)
with the 9 UI fixes (component, i18n, and a Playwright E2E).
---------
Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io>
Co-authored-by: openhands <openhands@all-hands.dev>
## Problem
The mobile POS step 2 \"enter note\" dialog was triggering for product
24
(\"Højtryk - ekstra tid\" / spotfree-lastbil) because product 24 was
listed
in `AFFECTED_PRODUCT_IDS` in
`services/nginx/app/classes/order_item_reason_policy.php`.
Product 24 is the \"spot-free-lastbil\" package, not an audited
extra-time
add-on — the server-side reason policy should only enforce the comment
requirement on {21, 22, 25, 26, 27}, matching the frontend
`AUDITED_ORDER_ITEM_PRODUCT_IDS` set.
Companion to the frontend PR copenhagentruckwash/pleno-vue#301.
## Fix
Drop product 24 from `AFFECTED_PRODUCT_IDS`.
## Verification
Tracked under workboard-94209138-31f6-422e-ac8c-181ad391b8a7.
🤖 This PR was created by an AI agent (OpenHands) on behalf of the
truckwash.io team.
Co-authored-by: openhands <openhands@all-hands.dev>
## Summary
Customer indicator chips (e.g. *Faktura pr. ordre*, *Fastpris*,
*Tankrengøring*) currently only render on the matching view tab because
the period paged response strips customer data from every non-active
view bucket. The front-end therefore cannot determine which other
categories a customer belongs to from the *Alle* tab.
This change projects a deduplicated lightweight customer marker
`{customer_number, membership_only: true}` onto every non-active view
bucket in `applyPeriodPagination`. The active bucket still carries full
customer cards so paginated full-data output, type_counts and
type_totals are unchanged. Filters, search, sort, flag tab and workflow
filters upstream of the membership projection make the non-active
membership set match the active-bucket semantics for the same request.
## Contract change (openapi.yaml)
* New schema `InvoicingPeriodCustomerMembership` with `{
customer_number, membership_only: true }` and `additionalProperties:
false`.
* `InvoicingPeriodData.types[view].items` is now a `oneOf` of
`InvoicingPeriodCustomer` and `InvoicingPeriodCustomerMembership`.
* `InvoicingPeriodCustomer.required` relaxed to `customer_number` only
(other fields are now reported per-view).
## Implementation
* New helper `summarizeNonActiveCustomerMemberships()` projects +
deduplicates by `customer_number`.
* Pagination emits full cards for the active bucket and lightweight
memberships everywhere else.
## Tests
* Updated existing `fixed_pricing` assertion to include the membership
marker.
* Added four new tests:
* default projection across all view buckets (with explicit dedup
assertion),
* search filter propagation,
* flag-tab filter propagation,
* single-customer active-bucket edge case.
All 15 `InvoicingPeriodPaginationTest` tests pass locally (158
assertions).
🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.
Co-authored-by: openhands <openhands@all-hands.dev>
The self-hosted backend runner pool has been offline since Aug 9. Switching the two jobs that hard-required it to ubuntu-24.04 (GitHub-hosted) restores the normal release pipeline for the api. After this lands, the next master push will flow Tests -> Required CI -> Release Manager gate -> channel_sync -> api-v2 deploy.
🤖 This PR was merged by an AI agent (OpenHands) on behalf of jepp9350.
## What
Centralises the truthy-string → bool coercion that six different classes
were reimplementing (and which two implementations disagreed about).
The shared helper lives in
`services/nginx/app/traits/boolean_normalization_t.php`:
```php
namespace traits;
trait boolean_normalization_t {
public static function normalizeBoolean(mixed $value): bool {
if (is_bool($value)) return $value;
return in_array(strtolower(trim((string)$value)), ['1','true','yes','on'], true);
}
}
```
`traits/module_config_variable_t::inputToBool` now delegates to it. The
seven call sites that previously inlined the same expression (or wrapped
it in a private `toBool`/`boolValue`/`isEnabled`) are reduced to a
single `self::normalizeBoolean(...)` call:
| Class | Old helper | New |
| --- | --- | --- |
| `classes/cron_worker.php` | inline in `boolOption` |
`self::normalizeBoolean(...)` (after empty-value short-circuit) |
| `classes/replica_failover_manager.php` | `boolValue` |
`self::normalizeBoolean(...)` |
| `classes/release_manager.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/releasemanager.php` | `isEnabled` |
`self::normalizeBoolean(...)` |
| `classes/superuser_system_status_service.php` | inline in
`parseModuleConfigValue` | `self::normalizeBoolean(...)` |
| `classes/module_usage_service.php` | `toBool` |
`self::normalizeBoolean(...)` |
| `classes/account_deletion_service.php` | inline in `apiEnabled` |
`self::normalizeBoolean(...)` |
| `traits/module_config_variable_t.php` | `inputToBool` (narrow set) |
`self::normalizeBoolean(...)` (full set) |
## Why
The pre-PR repo had two silent bugs:
1. **`inputToBool` accepted only `'true'`/`'1'`** while the six inline
copies accepted the wider `['1','true','yes','on']` set. Config values
such as `"yes"` or `" ON "` would round-trip to `false` through
`inputToBool` but `true` through any of the inline copies. This PR picks
the wider set as the single source of truth; the change is a strict
superset, so no caller flips from truthy to falsy.
2. **Six copies of the same expression** to drift in any of the seven
places (whitespace handling, case sensitivity, empty-string semantics).
One trait replaces them.
## Tests
* `tests/Unit/Traits/BooleanNormalizationTest.php` — Pest, runs the
helper directly through anonymous-class composition (no DB/HTTP).
* `tests/Smoke/boolean_normalization_smoke.php` — standalone PHP smoke
runner for environments without composer installed. Verified locally:
7/7 consumer wiring checks pass, 19/19 normalization cases pass (`true`,
`false`, `1`, `0`, `'true'`, `'TRUE'`, `'1'`, `'yes'`, `'YES'`, `'on'`,
`' ON '`, `'false'`, `'no'`, `'off'`, `''`, `null`, `'0'`, `[]`,
stdClass).
* All eight touched files pass `php -l` syntax check.
## Risk
* Behavioural change is a strict superset for the shared expression
path, so no caller can flip from truthy → falsy. The only consumer that
saw a behaviour change for *negative* inputs is `inputToBool` itself,
which previously rejected `'yes'`/`'on'`. Worth a CI pass on the
unit/integration suites before merge.
## Co-author
Co-authored-by: openhands <openhands@all-hands.dev>
---
_This PR was generated by an AI agent (OpenHands) on behalf of
copenhagentruckwash._
---------
Co-authored-by: openhands <openhands@all-hands.dev>
## Summary
Removes the XLVask autopilot / automation / MiniMax / OpenAI pipeline
and the related module config, CLI, cron, and migration scaffolding. The
Selvvask view (Superuser -> Fakturaer -> Periode -> Selvvask) is reduced
to a single read-only listing of usage logs plus operator-driven ignore
/ unignore / accept / reject endpoints gated on the
`review_xlvask_usage_order` permission.
See `inventory/self-serve-inventory.md` for the full surface map.
## Test plan
- [x] `vendor/bin/pest --testsuite=Unit` -> **1266 passed**, 1 unrelated
pre-existing failure (`BirdControlPlaneActivationTest`, needs
`PLENO_REPO_ROOT_FOR_TESTS`).
- [x] `php -l` on every modified PHP file -> no syntax errors.
- [x] Grep validation -> zero production-code references to removed
surfaces (`xlvask_autopilot_service`, `xlvask_automation_service`,
`xlvask_automation_policy_service`, `EnsureXLVaskAutomationSchema`,
`runScheduledAutomationIfReady`, `processAutopilotQueue`, `MiniMax`,
`minimax`, ...).
- [ ] Qodana + Tests workflows green on this PR.
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
## Why
The Selvvask view depends on the `/automation/capabilities` endpoint
returning `can_review=true` and the `/decisions/preview` endpoint
admitting the operator. Both were previously locked to
`manage_xlvask_usage_automation` and silently disabled the Accept /
Reject / Ignore buttons for every operator. copenhagentruckwash/api#365
fixed the contract; this PR adds the missing end-to-end API tests so a
future refactor cannot re-tighten the gating without anyone noticing.
## What changed
`tests/Api/XLVaskReviewApiTest.php` — five `usesApiSuite` specs that
exercise the new contract against a real MySQL + Redis stack with a
`createUserSession` fixture:
- `list_xlvask_usage_orders_own` lights `can_review=true` (the
back-compat path so existing operator groups work without a permission
grant).
- `review_xlvask_usage_order` lights `can_review=true`.
- The new permission does NOT unlock `can_dry_run` / `can_execute` /
`can_manage_policy` / `can_halt`, so an operator cannot trigger the
autopilot or change policy from the selvvask view.
- An operator with no xlvask permissions is rejected at the capability
inspection gate with a 403 + missing-permission envelope.
- An operator with only `list_xlvask_usage_orders_own` can inspect
capabilities but is rejected at `/decisions/preview` (which still
requires `review_xlvask_usage_order` or
`manage_xlvask_usage_automation`).
## Notes
The test was committed to master directly because that's where the
contract lives; this PR is the back-port to a branch so the CI Required
gate can run on it.
---------
Co-authored-by: Hermes Agent <agent@truckwash.io>
## Why
Operators on the Superuser → Fakturaer → Periode → Selvvask view were
unable to Accept / Reject / Ignore / Link XL Vask washes even though the
UI claimed the buttons should be there. The previous capability contract
only lit `can_review` for users with `manage_xlvask_usage_automation`, a
small admin group, so the FE never rendered any review actions for the
rest of the superuser staff. The same contract also blocked the
corresponding `/decisions/preview` and `/decisions/apply` calls, so even
if the buttons were forced on, the API would 403.
## What changed
- `xlvaskUsageLogsRoute.php`:
- New permission string `review_xlvask_usage_order` for operators.
- `/modules/xlvask/services/usage/automation/capabilities`:
- `can_review` now returns `true` when the user has
`review_xlvask_usage_order` or the existing `list_xlvask_usage_orders_*`
(so existing operator groups keep working without an extra grant), or
`manage_xlvask_usage_automation` (the AI admin path).
- `can_dry_run` / `can_execute` / `can_manage_policy` / `can_halt`
remain gated on the AI-admin permissions to keep the autopilot lifecycle
fail-closed.
- The new permission is registered in the route's permission manifest.
- `/modules/xlvask/services/usage/automation/decisions/preview` and
`/apply` now accept either `manage_xlvask_usage_automation` or
`review_xlvask_usage_order`. The existing `force_manual` branch in
`xlvask_autopilot_service` lights up automatically for these operators,
so the existing manual-suggestion path drives them.
- The AI autopilot run lifecycle (`/autopilot-runs`,
`/autopilot-runs/{id}`, `/autopilot-runs/active`,
`/automation/admin/...`) still requires `manage_xlvask_usage_automation`
/ `superuser_xlvask_automation_activate`.
## Tests
- New contract in `XLVaskUsageRouteContractTest`:
- "exposes a review_xlvask_usage_order permission on decision endpoints
for the selvvask operator flow" — locks the new permission string, the
new `can_review` flag, and the manage-only `can_dry_run` / `can_execute`
flags.
- "still requires manage_xlvask_usage_automation for the AI autopilot
run lifecycle" — regression guard for the admin path.
- All 87 XLVask unit tests pass. The wider 1334 unit tests also pass;
the only pre-existing failure is the unrelated
`BirdControlPlaneActivationTest` which requires
`PLENO_REPO_ROOT_FOR_TESTS` and is broken on master.
## Companion frontend PR
`copenhagentruckwash/pleno-vue` → `fix/xlvask-selvvask-review-actions`
(the FE was already wired correctly: `allow-review-actions =
automationWorkspace && capabilities.can_review`. With the API change
above, `can_review` now lights up for operators so the buttons surface.
A new source-inspection regression test pins the contract so future
edits cannot re-tighten the gating.)
Co-authored-by: Hermes Agent <agent@truckwash.io>
Partition primary rows by reg_2 presence in getPrimaryProductHistory() so a single-tractor order (reg_2 empty) is compared only against other single-tractor orders, not against historical tractor-trailer orders. 2 new tests + 1 updated signature contract test in InvoicePeriodFlagServiceTest.
Defensive ORDER BY in orders_o.php::getOrderItems so primary items render before their addons (related_item_id IS NULL DESC, related_item_id ASC, id ASC). Pinned with OrderItemsListingOrderingTest which locates orders_o.php via worktree-aware resolver.
## Summary
orders_o::getOrdersWithRegistrationNumberInDateRange() selects orders
matching a registration number within a date range without an explicit
ORDER BY clause. MySQL is free to return rows in any order. The endpoint
at routes/orderInvoicesRoute.php then iterates the result and calls
assignToInvoiceCollection() on each row, so the audit-log +
invoice-collection numbering depend on the arbitrary backend row order.
Add a stable `ORDER BY id ASC` to the SELECT and pin the contract with a
new Pest unit test.
## Test plan
- New Pest test `OrdersRegistrationDateRangeQueryTest` asserts the
SELECT still carries `ORDER BY id ASC`.
- Existing tests in the same file still pass unchanged (they don't
assert on ordering).
- Manual php -l on both modified files shows no syntax errors.
## Commits
- bbd50239 fix(api): order getOrdersWithRegistrationNumberInDateRange by
id ASC
Co-authored-by: Worktree Fix Verifier <agent@truckwash.local>
## Summary
orders_o::getOrderItems() selected order items without an explicit ORDER
BY clause, so MySQL was free to return rows in any order. On the POS
Fuldfør click and the superuser invoice tree, addons (related_item_id !=
NULL) were sometimes returned before their primary item, which broke the
FE tree-builder and the OrderContentTable render.
Add a stable ordering: primary items first (related_item_id IS NULL
DESC), addons grouped by their parent (related_item_id ASC), and
insertion order as the final tiebreaker (id ASC).
## Commits
- 7ec64ec8 fix(api): order order_items so primary precedes addons in
getOrderItems
- 68e19bee test(api): pin order_items listing ordering in getOrderItems
## Test plan
- Wiring unit test asserts the SELECT inside getOrderItems still carries
ORDER BY (related_item_id IS NULL) DESC, related_item_id ASC, id ASC.
- Verified locally with php -l on the modified file.
- Existing OrderItemReasonPolicyTest, CustomerOrderProductPolicyTest,
OrdersIncludeInInvoiceOverrideTest continue to pass in the worktree
setup (no DB fixtures touched).
---------
Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
## Bug
PR #345 (order_item_reason_policy wiring) accidentally broadened the
legacy
\`Notes is required for this product\` check to fire for every product
whose
POST body carried an empty/whitespace \`notes\` field.
Mobile POS step 2 always posts the primary product (e.g. Sættevognstræk,
product id 3) with \`notes: ''\` as part of
\`syncCurrentTransactionToOrder\`. After #345, the API started returning
400 for that primary item. The frontend silently swallowed the 400 in
the next-step click handler, and the operator saw **"Fuldfør doesn't
continue"** with no feedback.
## Repro
1. Log in to the mobile POS (e.g. dept 12 / Taulov)
2. Scan / type a customer's plates (e.g. EP68666 + GG1876)
3. Long-press Sættevognstræk to add the service
4. Tap **Fuldfør**
Before this fix: \`POST /order/items\` → 400 \`Notes is required for
this product\`. Frontend catches and logs \`Next-step action was
interrupted: AxiosError: Request failed with status code 400\`. Operator
sees no error in the UI.
After this fix: \`POST /order/items\` → 200 for the primary product; the
order completes normally.
## Fix
Scope the empty-notes rejection to products whose \`requires_note\` flag
(or extraordinary-chemistry special case) is set, matching the existing
PUT handler behaviour. Products that don't require notes can post
\`notes=''\` without rejection.
## Lock-in tests
Two Pest tests under \`Tests\\Api\\OrderItemsApiTest\`:
- \`allows empty notes for primary products that do not require a note\`
— \`requires_note=0\` product with \`notes=''\` returns 200
- \`still rejects empty notes for products whose requires_note flag is
enabled\` — \`requires_note=1\` product with \`notes=' '\` returns 400
with the legacy message
## Verification
PHP API suite: **292/292 passing** (11892 assertions). Local
\`scripts/php-ci-test.sh api\`.
## Companion PR
\`copenhagentruckwash/pleno-vue\` →
\`fix/fuldfor-surface-order-item-error\` will surface order-item API
errors in the UI so silent failures become visible. That PR is a
follow-up; this one is the actual root cause fix.
Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local>
PHP truthy semantics treat the literal `'false'` as truthy, so `$value ? 'true' : 'false'` in `setVariableValue()` persisted every 'switch off' request as `'true'` for ~30 modules with bool config variables. Canonicalise via the existing `inputToBool()` helper before the ternary so JSON.stringify(boolean) inputs land on the right storage string.
Live repro (api-v2.truckwash.io, 2026-08-10 09:25):
```
POST /minimax/config {variable:"enabled", value:"false"} → 200
GET /minimax/config?variable=enabled → {value:true, ...} ← unchanged
POST /minimax/config {variable:"enabled", value:false} → 200
GET /minimax/config?variable=enabled → {value:false, ...} ← JSON bool works
```
Tests: `tests/Unit/MiniMax/MiniMaxEnabledSetVariableValueTest.php` (6 assertions, captures the UPDATE column value via stubbed `updateVariableValue`/`insertVariableValue` for string/boolean × true/false × insert/update paths).
Companion pleno-vue PR #281 lands the matching UI-side fix (`onMiniMaxEnabledSwitch` re-fetches + optimistic rollback).
Regression coverage for the read-side shape that the frontend `ConfigurationXLVask.refreshMiniMaxApiKeyStatus` parses. Mirrors `BirdConfigSecretRedactionTest` — locks the contract that `api_key` is redacted with `isSet` reflecting actual persistence, so the XL Vask autopilot's `requireModuleEnabled` cannot silently break on a regression.
No production-code change — backend already correct (verified live 2026-08-10 07:58 against `api-v2.truckwash.io` with both `{variable, value}` and raw-key payload shapes).
Companion pleno-vue PR: #281 ("fix(pleno-vue): persist MiniMax API key across refresh + render fix").
Adds `scripts/xlvask-automation-migrate.php` mirroring the existing schema-script pattern (`check` / `apply --yes`), a new AUTOMATION_RUNBOOK §2a documenting both operator entry points, and `XLVaskAutomationMigrateScriptTest` pinning the gate, the WD check, and the bootstrap references.
Production autopilot-runs (POST /modules/xlvask/services/usage/autopilot-runs) was returning 500 with:
```
XL Vask automation schema is not ready. Apply migration 20260804_xlvask_ai_auto_policy_v2 explicitly.
```
because no operator had invoked the gated `applyExplicitMigration()` since PR #348 shipped the migration class. The frontend half of the fix is the companion change in pleno-vue#280 (missing `minimax_integration_enabled` key).
Operator action required once merged:
```
php index.php run xlvask-automation-migrate
# or
php scripts/xlvask-automation-migrate.php apply --yes
```
Both produce identical JSON status; retain the artifact and rerun `check` to confirm postflight is green.
Diff: +140/-0 (3 files). Tests: 87/87 XL Vask unit + 5/5 new migration script test pass.