52d43fc16c2d1fd69b7e148cc645a7451c59c23d
1762
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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>
|
||
|
|
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> |
||
|
|
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]> |
||
|
|
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>
|
||
|
|
4b08453ee2 |
docs(economic): audit e-conomic invoice templates (TRU-197) (#394)
Auto-merged by cron with review-gate (trivial change, no critical path). |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
34df80530c |
feat(api): product merging infrastructure for SF (TRU-94) (#379)
Auto-merged by cron with review-gate (trivial change, no critical path). |
||
|
|
3d0a8eeae7 |
feat(api): add optional invoice_email field for customers (TRU-77) (#381)
## 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> |
||
|
|
60222a7d91 |
fix(api): clarify user-invoice PUT validation so customers can invoice (TRU-128) (#382)
## 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>
|
||
|
|
78b11d0b79 |
fix(api): route invoices to correct Economic account per-customer (TRU-18) (#380)
## 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> |
||
|
|
55ddabb0ee |
test(api): lock program-registry contract for TRU-19 (#377)
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
|
||
|
|
16048e2ce3 |
chore(release): merge develop into master — XL Vask flag text fix + edge-broker health (Aug 15 2026) (#376)
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>release-aug-2026 |
||
|
|
cdf8541e78 |
fix(api): exclude spot-free-lastbil from audited add-on reason policy (#372)
## 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>
|
||
|
|
51e5c2ed01 |
feat(invoicing-period): surface lightweight customer membership on non-active views (#371)
## 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>
|
||
|
|
0dea2f0b76 |
fix(ci): run api edge-agent and release-manager-gate on GitHub-hosted runner (#370)
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.
|
||
|
|
22ec6696b5 |
chore(agent-mcp-smoke): verify GitHub MCP write/PR wiring (#369)
Generated automatically by Hermes to verify the GitHub MCP is wired into OpenHands. Safe to close — no production change. |
||
|
|
a71194d46e |
refactor(api): centralise truthy-string -> bool coercion in a shared trait (#368)
## 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>
|
||
|
|
5441fea665 |
fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367)
## 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> |
||
|
|
02a4665bc7 |
test(api): lock XL Vask Selvvask review capabilities end-to-end (#366)
## 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> |
||
|
|
3e683c8559 |
fix(api): allow operators to review XL Vask automation decisions in the Selvvask view (#365)
## 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>
|
||
|
|
d81634033e |
fix(api): do not raise historical_primary_product_mismatch for single-tractor orders (#363)
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. |
||
|
|
58d5e177a9 |
fix(api): order order_items so primary precedes addons in getOrderItems (#364)
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. |
||
|
|
82a3684f05 |
fix(api): order getOrdersWithRegistrationNumberInDateRange by id ASC (#362)
## 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> |
||
|
|
d850075397 |
fix(api): order order_items so primary precedes addons in getOrderItems (#361)
## 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 - |
||
|
|
43df3e4dca |
fix(api): only require notes when the product actually requires them on POST /order/items (#360)
## 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> |
||
|
|
7174e3be6c |
fix(api): coerce string booleans before persisting module_config bool values (#359)
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).
|
||
|
|
2ef0f78541 |
test(api): lock MiniMax config redaction + isSet contract (#358)
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").
|
||
|
|
801c8e1f6d |
feat(api): standalone xlvask-automation-migrate script + runbook section (#357)
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. |
||
|
|
3e89085296 |
feat(api): support manual XL Vask operator decisions via force_manual (#356)
Adds a deterministic manual-suggestion path so operators can drive accept/reject/ignore decisions on the self-wash view before the AI autopilot has produced a suggestion. Whitelists force_manual in the preview route. Adds unit tests for the new constant, method, and route contract. --------- Co-authored-by: Cleanup Agent <agent@truckwash.io> |
||
|
|
2cf2538525 |
feat(api): add MiniMax M3 client and force XL Vask autopilot to use it (#355)
Adds a new `modules/miniMax` module mirroring the existing OpenAI pattern (`api_key` + `enabled` config keys). Adds a `classes/minimax.php` client that calls `https://api.minimax.io/anthropic/v1/messages` (the same endpoint OpenClaw's minimax-portal provider uses) and returns structured JSON via the Anthropic tool_use response shape. **Replaces the autopilot planner:** - `PLANNER_MODEL`: `gpt-5.6-sol` → `MiniMax-M3` - `xlvask_automation_service`: `new openai()` → `new minimax()` in the planner + the isOpenAiIntegrationEnabled() guard - New xlvask config flag `minimax_integration_enabled` gates the autopilot. The OpenAI flag is kept so deployments can roll back. **Compatibility shims** (so the autopilot keeps working with minimal churn): - `minimax_request_exception` extends `openai_request_exception`, so every existing `catch (openai_request_exception)` block catches MiniMax errors unchanged. - The result payload also carries the legacy `_openai_response_model` and `_openai_usage` aliases, so `sanitizeOpenAiResult` keeps working unchanged. **New endpoints:** GET/POST `/minimax/config` in `moduleConfigRoute` guarded by `modules_minimax_config`, mirroring `/openai/config`. **Tests:** PHP api suite 290/290 passing locally (no regressions). All xlvask tests still pass. **Frontend counterpart** ships in a separate PR on pleno-vue (ConfigurationXLVask.vue + SessionUser.modules.minimax + i18n in all 5 locales). **Followup (post-merge):** operator (jeppe) enters the MiniMax API key in superuser XL Vask settings → I'll optimize/test/debug live XL Vask usage logs against the new model. --------- Co-authored-by: Truck Wash Agent <agent@copenhagentruckwash.local> |
||
|
|
6475f817c7 |
fix(api): wire order_item_reason_policy into POST/PUT and persist reason fields (#345)
Adds `reason_code`, `reason_label_snapshot`, `reason_comment` columns to `order_items` and integrates the `order_item_reason_policy` class into the POST and PUT /order/items routes. Validation order on audited products (consistent across POST and PUT): 1. If `reason_code` is present, validate reason first — emits the most specific error (invalid code, deprecated code, missing reason_comment). 2. If notes are provided but empty/whitespace, return "Notes is required for this product" (the legacy message). 3. Otherwise run reason validation — covers the missing-reason_code case. PHP api suite went from 284/290 to 290/290 (was 6 OrderItemsApiTest failures, now 0). Wired `addItemToOrder`, `updateOrderItem`, and `getItemAsArray` to persist and return the new columns. |
||
|
|
b107ba649c |
Wire XL Vask usage-log sync, order linking, and order creation (#353)
Wires the three broken paths in the cron-driven XL Vask integration: - runSyncUsage() now calls the revision-aware importUsageLogsWithSummary() on xlvask_usage_logs_o (was a no-op stub; the upstream API was never queried for washes). - linkImportedUsageLogsToOrders() persists xlvask_potential_order_matches rows so the accept/compare/link/deny UI has data to render. - When automatic_order_creation_enabled is on and the wash qualifies, falls through to createOrderFromWash() → orders_o::addXLVaskOrder(). - Removes the obsolete TODO in RunXLVaskModuleCron.php. - Returns linked + orders_created alongside the existing counters so ops can observe the pipeline. |
||
|
|
0aaf32efa4 |
Surface silent-skip paths and additional silent failures in email/booking/order flows (#352)
## Why Customer `k.sand@ksand.dk` reported never receiving wash certificates for completed bookings. Two methods contained silent early-return guards so the actual reason was unobservable from container logs: - `order_bookings_o::sendWashCertificateToCustomer()` — 5 silent returns - `email::sendWashCertificateEmailToCustomer()` — 1 silent return The most likely root cause: `email_notifications_enabled` defaults to `0` in the schema and `users.add()` does not set it on insert, so newly imported customers have notifications off until toggled. `wantsEmailNotifications()` then returns false and the email silently skips. ## What changed ### Original commit (`0ead5de5`) - `objects/order_bookings_o.php` — all 5 silent early-returns now log via new `logWashCertificateSkip()` helper (Redis stream `module=email / action=WASH_CERT_SKIP` + `error_log('[wash-cert-skip] …')`). - `classes/email.php` — silent `hasTransaction()` return in `sendWashCertificateEmailToCustomer()` now logs too. - `objects/bookings_o.php` — emits `WASH_CERT_SKIP` (legacy_no_wash_certificate_email) when `washCertificateEmail` is empty; no behavioural change. - **New** `routes/washCertificateDebugRoute.php` — `GET /debug/wash-certificates/diagnose?customer_number=&from=&to=` (404 in prod via $DEBUG; superuser-auth otherwise) replays the decision tree and reports `blocking_reason` per booking. ### Follow-up commit (`46a59e4e`) — silent-failure sweep **PART A — silent returns / silent errors (10 fixes):** - `email::sendEmailMailerSend()` — blacklisted-recipient skip now logs with context. - `email::sendNewCustomerRegistrationNotifications()` — empty-email skip + per-recipient try/catch with error_log (was unprotected; a single MailerSend error broke the loop). - `bookings_new_o::generateWashCertificate()` — wrapped `sendWashCertificateEmail()` in try/catch with error_log and re-throw (same pattern as the k.sand fix). - `users_o::getCustomerName()` — replaced catch-and-swallow with structured error_log. - `users_o::getCustomerEcocomicData()` — same. - `bookingsRoute.php` — added booking-id context to 4 × `$response->error('Booking not found', 404)` calls. **PART B — cron paths (10 files):** Added error_log breadcrumb + try/catch to `CheckUnfulfilledBookings`, `ClearAllUsersEconomicCustomerDetails`, `ClearAllUsersEconomicCustomerDiscounts`, `RunXLVaskModuleCron`, `SyncBookings`, `SyncEconomicInvoiceStatus`, `SyncLogs`, `BackfillEconomicV2History`, `EnsureXLVaskAutomationSchema`, and 3 functions in `Cron.php`. Each uses a distinct `[cron-…]` prefix for grep-ability. **PART C — real bugs (2 fixed):** 1. `email::sendEmailMailerSend()` attachment `array_map` — the previous exception message emitted a binary blob because `$attachment[0]` was already overwritten by `file_get_contents()`. Now captures $path first. 2. `bookings_new_o::generateWashCertificate()` — booking persisted as `completed` before email was sent, with no try/catch. Fixed (see PART A). ## How to verify 1. Deploy to staging. 2. Hit `/debug/wash-certificates/diagnose?customer_number=<k.sand's customer_number>` as a superuser — the response lists every booking's `blocking_reason`. 3. Tail container logs for `[wash-cert-skip]`, `[email-skip]`, `[cron-…]`, and Redis stream `module=email` action `WASH_CERT_SKIP` to see real-world skips going forward. ## Follow-ups (out of scope) - Schema migration to default `email_notifications_enabled` to `1` and backfill non-empty-email customers. - Move `error_log` to a proper PSR-3 logger. ## Risk - Logging only + new debug endpoint (404-gated in prod). No behavioural change for any path that previously sent mail successfully. `php -l` could not be run in the original sandbox; please verify on your CI box before deploying. 🤖 Generated with [OpenClaw](https://openclaw.ai) |
||
|
|
8735bae8d5 |
Fix queue export 400 by aligning e-conomic reference payloads (#351)
## Context Queue job **#3572** failed in `COLLECTED_INVOICE_EXPORT` with e-conomic HTTP 400 (`Validation failed. 2 errors found.`) while creating draft for customer `35131752`. ## Fix - align draft payload optional references to e-conomic object references (not id-only fragments): - `recipient.attention` - `references.customerContact` - `references.salesPerson` - `references.vendorReference` (legacy path) - `deliveryLocation` - include upstream `self` links when available from customer payload - keep both collected and legacy draft creation paths consistent - improve e-conomic error formatting so nested annotated validation errors and `developerHint` are included in thrown messages ## Tests - `vendor/bin/pest tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php tests/Unit/Invoicing/EconomicUpstreamErrorFormattingTest.php tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php --colors=never` Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
bd2ff1be9a |
Fix false 409 snapshot expiry in invoice-period tree preview (#350)
## Problem `POST /superuser/invoicing/period/tree-actions/preview` could return `409 Invoice-period snapshot is missing or expired` during normal UI flows when a snapshot binding aged out before the user triggered the action. ## Fix - introduce a dedicated snapshot cache TTL (`SNAPSHOT_BINDING_TTL_SECONDS`) - keep preview cache TTL unchanged (`PREVIEW_TTL_SECONDS`) - use the longer snapshot TTL for actor/customer snapshot binding writes This preserves existing safety because snapshot bindings are still revalidated against fresh revision data before use. ## Tests - `vendor/bin/pest tests/Unit/Invoicing/InvoiceCollectionBulkActionSafetyTest.php --colors=never` - `vendor/bin/pest tests/Api/CollectedInvoiceBulkActionsApiTest.php --colors=never` (suite present; skipped without `RUN_API_TESTS=1`) Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
59107a6bb2 |
Enforce e-conomic discount template + EAN draft metadata (#349)
## What changed - enforce EAN draft delivery wiring by setting `recipient.nemHandelType=ean` when customer EAN is present - copy existing e-conomic customer metadata into draft payload: `recipient.attention`, `references.customerContact`, `references.salesPerson`, and `deliveryLocation` - keep `references.other` external-id mapping intact - remove legacy explicit `Rabat:` text-line injection and use line-level `discountPercentage` instead - add/update unit tests for helper extraction and discount/EAN wiring ## Tests - `vendor/bin/pest tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php --colors=never` ## Notes - full unit suite in this environment still has an unrelated pre-existing failure in `Tests\\Unit\\Bird\\BirdControlPlaneActivationTest` requiring `PLENO_REPO_ROOT_FOR_TESTS`. - live manual verification against customer `12345679` remains environment-blocked due missing e-conomic credentials. --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
622fe59f5c |
origin/schema-migration-xlvask-autopilot (#348)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk> |
||
|
|
23fc410d25 |
xlvask-autopilot (#347)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk> |
||
|
|
db9f589bf7 |
Resolve issue causing crash when plate_scanners.deleted_at was missing. (#346)
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk> |
||
|
|
ab6c3ba5b6 |
Fix route permission instance calls (#344)
## Root cause `route_t::hasPermission()` and `requirePermission()` are instance methods. Route code was invoking them with `self::`; the new XL Vask hall-scope helper made that call from a genuinely static context, causing PHP to throw: `Non-static method routes\\xlvaskUsageLogsRoute::hasPermission() cannot be called statically` ## Changes - Invoke route permission methods through `$this` across all 273 executable legacy calls in 45 route classes. - Make `xlvaskUsageLogsRoute::allowedHallIdsForUser()` an instance helper and update all 13 callers. - Preserve the existing all-scope and own-scope hall selection rules. - Add a token-aware regression test that rejects executable `self::hasPermission()` and `self::requirePermission()` calls, while ignoring comments. - Add focused XL Vask tests for global scanner hall scope and group-limited own scope. - Update affected route contract assertions to the instance-call form. ## Verification - PHP lint: all 53 changed PHP files - Focused PHPStan: changed XL Vask route and both new regression tests — clean - Focused regression slice: 58 passed, 748 assertions - Full local unit suite: 1,300 passed, 9,442 assertions (1 unrelated existing warning, 1 environment skip) - Full local API suite: 285 passed, 11,704 assertions - Exact-SHA GitHub Tests workflow: all 7 jobs passed (unit, API, integration, legacy, edge gateway, and supporting checks) - Independent exact-SHA QA gate: PASS, no findings - Independent exact-SHA security gate: PASS, no findings - Independent exact-SHA reviewer gate: PASS, no findings - Remote comparison: exactly one commit ahead of `40b104abed7723a7d1b7028190ecda0e7aeef829`; all 53 remote blob hashes matched the reviewed worktree ## Delivery state Draft only for human review. No merge or deployment is included. Qodana is skipped while the PR remains draft and is therefore not represented as a passed gate. |
||
|
|
40b104abed | Add governed XL Vask AI invoice automation (#343) |