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.
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).
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
## 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
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
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>
## 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
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>
## 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>
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>
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>
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.
## 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)
## 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>
## 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.
Keep automation metadata out of the strict legacy XL-Vask helper payload while returning it separately. This restores the invoice-period XL-Vask orders GET after the guarded autopilot deployment.
## Summary
- gate object-tree v2 on exact, schema-backed supersession audit columns
- serialize checked additive DDL and fail closed without disrupting
legacy invoicing
- preserve pre-schema supersession markers when structured columns are
still null
- add a superuser-only, self-scoped canary endpoint with locked
legacy-to-canonical allowlist migration
- verify effective activation, roll back failed readiness, and audit
enable/disable/failure
## Verification
- focused invoicing safety: 16 tests passed (99 assertions)
- full unit suite: 1,237 passed (9,003 assertions), 2 skipped, existing
warnings only
- PHP syntax and `git diff --check` clean
- independent architecture, security, and reviewer gates: GO
## Activation
Deploy with global database/environment enablement off. POST the
self-canary endpoint for one authenticated superuser, require
`configured_enabled=true` and `effective_enabled=true`, then verify the
exact period and tree GET routes. Roll back with the same endpoint using
`enabled=false`.
Complete and secure public customer/driver registration, authoritative limited-backoffice department scope, one-time employee QR login, and pricing concurrency for the Sæby demo.
Add the Bird Control Plane gateway, signed webhook ingestion, policy-gated writes, fail-closed production auto-activation, and RSA-OAEP bootstrap credential flow.
## Summary
Adds the missing backend contract used by Pleno Control Plane
Conversations/Suggestions to create an employee login action safely.
- issues 60–900 second one-time limited-backoffice login grants
- persists only SHA-256 bearer digests; bearer recovery is deterministic
under the server encryption key for identical idempotent retries
- enforces manager permissions, department scope, active
managed-employee constraints, one-time atomic exchange, revocation,
expiry, and account-deletion cleanup
- adds employee-create idempotency so an approved automation retry
cannot duplicate an employee
- documents the create, revoke, and unauthenticated exchange endpoints
in OpenAPI
## Security and concurrency
- bearer values are returned only in a URL fragment and are never
written to logs or database plaintext
- employee and grant rows use a consistent employee-then-grant lock
order
- deactivation revokes outstanding grants and existing sessions in the
same transaction
- consumed, revoked, expired, or payload-mismatched idempotent replays
fail closed
## Verification
- `scripts/php-ci-test.sh api`: 273 passed, 11,086 assertions (one
inherited warning)
- focused security contract: 1 passed, 21 assertions
- PHP syntax checks passed for the service and routes
- `git diff --check` passed
## Dependency
Required by copenhagentruckwash/pleno-control-plane#1. Merge before the
matching frontend and Control Plane PRs.
## Summary
- Makes Stripe Terminal card payment intents always use 25% moms in the
API, independent of any client-supplied `tax_percentage`.
- Updates amount calculation, metadata persistence, stored-intent reuse
matching, the authoritative OpenAPI contracts, and operation-specific
Writerside outputs.
- Prevents double charging and false order closure across stale,
concurrently succeeded, partially recorded, or mismatched intents.
- Serializes payment create/capture/closure with order-item changes and
every order-to-invoice-collection reassignment through shared database
locks.
- Converts expected lock contention and reconciliation cases into
deliberate 409 responses.
## Exact-head evidence
Current head: `3a0f70d315a94d2efe586a2188d2c54f8ff11cd4`
- PHP syntax passed for all changed runtime files.
- Focused Orders suite: **42 tests / 293 assertions passed**.
- `git diff --check` passed.
- Fresh exact-head Tests and Qodana are running.
- Every Codex finding has a concrete reply; a fresh exact-head review is
requested below.
## Safety behavior
- Caller-controlled VAT is absent from request contracts; fixed 25% moms
is server-owned.
- A succeeded payment is preserved, requires the full expected
`amount_received`, and cannot close a changed/mismatched or
already-claimed collection.
- A compatible partially recorded Stripe closure is completed
idempotently; conflicting partial state fails closed for manual
reconciliation.
- Every cancellation/delete caller honors a concurrent-success result
and never falsely reports a completed payment as cleared.
- Price changes and invoice-collection reassignment share the payment
lock through validation, capture, post-capture reload, and closure.
- Reader changes are persisted only for reusable matching intents, so
stale intent cancellation targets the original terminal.
- Accepted legacy succeeded intents normalize stored tax to 25% before
response construction.
---------
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
## Summary
- retire Stripe hosted payment-link creation routes used by POS and
order management
- automatically capture authorized payment intents rather than requiring
a separate manual capture action
- preserve ordinary terminal payment and payment-intent lifecycle
behavior
- add API and wiring regressions for payment-link retirement and
automatic capture
Paired frontend change:
https://github.com/copenhagentruckwash/pleno-vue/pull/233
## Verification
- focused backend unit suite: 2 tests, 36 assertions passed
- PHP syntax checks passed
- paired frontend unit and Playwright suites passed locally
- full required GitHub runner suites are required before this task may
enter Review or merge
## Security and operational notes
- no credentials, terminal secrets, or payment data are added
- no live Stripe account or physical terminal was exercised locally
- automatic merge remains gated on both paired PRs having passing
required checks and current branches
## Summary
- notify customers by SMS with approve/deny links when a subuser
requests access
- notify subusers by SMS after approval or denial, including manual
grant changes
- support subuser password reset and authenticated password changes
- add read-only token previews followed by explicit POST confirmation
- store short-lived one-time purpose-bound action tokens only as SHA-256
digests
- serialize grant decisions transactionally to prevent conflicting
concurrent actions
- document the API contract in OpenAPI
## Security
- generic reset responses reduce account enumeration
- URL tokens are removed from browser history after frontend bootstrap
- approval previews never mutate state
- concurrent decisions lock the exact grant row
- SMS failures remain non-fatal and are returned as delivery status
Residual risk: existing subuser sessions cannot all be centrally
invalidated after password reset because there is no per-subuser session
index; they expire normally within the existing session lifetime.
## Verification
- backend Pest: 14 tests, 91 assertions
- PHP syntax checks passed
- focused PHPStan passed
- OpenAPI YAML parsed successfully
- `git diff --check` passed
Database-backed API integration tests were unavailable because the local
environment lacks the required database configuration.
## Paired delivery
Paired Frontend PR:
https://github.com/copenhagentruckwash/pleno-vue/pull/231
Both PRs are required before completion. The frontend PR contains the
responsive visual comparisons.
Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
## Summary
- Add self-service deletion for the authenticated customer or subuser
identity only.
- Preserve shared customer grants, reset keys, bookings, order bookings,
vehicles, invoices, and legally required history.
- Require password/TOTP or a fresh deletion-specific, five-minute,
single-use WebAuthn assertion.
- Reject support impersonation and expired legacy plain-session tokens.
- Use durable database throttling, transactional request processing, a
durable outbox, and terminal `manual_review` state.
- Keep API and worker default-off behind separate
`account_deletion.api_enabled` and `account_deletion.worker_enabled`
module-config flags.
## Safe rollout
1. Keep both flags disabled.
2. Run `php scripts/account-deletion-schema.php check`.
3. If needed, run `php scripts/account-deletion-schema.php apply --yes`,
then rerun `check` until `ready:true`.
4. Deploy the frontend companion PR while the API remains disabled.
5. Enable `api_enabled` for a controlled canary; verify password and
passwordless request flows plus immediate authentication revocation.
6. Inspect queued request/outbox state, then enable `worker_enabled`.
7. Verify anonymization, preserved tenant/history data, outbox delivery,
retries, and manual-review behavior before broad rollout.
## Verification
- Account deletion unit tests: 2 passed, 43 assertions.
- PHP lint, both OpenAPI YAML parses, runtime-DDL scan,
destructive-scope scan, and `git diff --check` passed.
- Full API/unit/integration evidence is required from exact-head CI;
local Docker is unavailable and shared-vendor tests were explicitly
discarded.
## Security notes
- Schema mutation is CLI-only; web and cron paths perform read-only
readiness checks.
- Runtime behavior fails closed when schema/config/throttle/delivery
prerequisites are unavailable.
## What changed
- validate every normalized order-booking item against active customer
product rules before reservation and persistence
- return a structured HTTP 400 response containing the rejected product
and matching rule metadata
- document the rejection response in both OpenAPI specifications
- add API coverage for restricted base products, restricted add-ons, and
allowed neighboring products
## Why
Frontend rule guidance alone cannot prevent stale or crafted requests
from persisting restricted booking products. The booking write boundary
must enforce the same customer rules.
## Validation
- full backend API suite
- focused order-booking API coverage
- PHP syntax checks
- OpenAPI and diff checks
## Related frontend PR
The coordinated frontend PR provides fail-closed selection, recovery,
and responsive booking-page behavior.
Resolve recommended-profile Critical and High findings, retain narrow analyzer exceptions, and update the edge-broker WebSocket dependency to a non-vulnerable release.