Compare commits

..
Author SHA1 Message Date
openhands 461d6e7fa8 feat(economic): add pre-flight validation to addLines() (TRU-194)
Defense in depth: before sending draft lines to e-conomic, run a 5-rule
preflight check that catches anything that slips past the sanitizers.

Rules (per line):
  1. description must be non-empty after trim()
  2. description must be <= 250 chars
  3. productNumber (if present) must match /^[A-Za-z0-9._-]{1,50}$/
  4. quantity (if present) must be a positive number
  5. unitNetPrice (if present) must be a number >= 0

Violations throw a RuntimeException and are logged via error_log with the
offending value truncated to 200 chars. Order id is included in the log
context when provided.

19 new unit tests in EconomicInvoiceDraftPreflightTest cover each rule
plus the disabled-flag bypass path.

Refs: TRU-194
2026-08-17 10:19:56 +00:00
OpenClaw 96ec0c2411 fix(economic): sanitize user-input fields to prevent 400 errors
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.

This change adds a single sanitizer class (economic_export_sanitizer) that
handles all user-input fields flowing into e-conomic:

  - sanitizeTextLine() — for plain text lines (reference, notes, po, reg_*, etc.)
  - sanitizeProductNumber() — for product identifiers
  - sanitizeProductDescription() — for product-line descriptions
  - sanitizeForEconApi() — catch-all

Sanitization rules:
  - '/' is replaced with '-' (the reported 400 trigger)
  - Control characters (\x00-\x1F except \t and \n) are stripped
  - Tab and newline characters collapse to a single space
  - Whitespace is normalized and trimmed
  - Lengths capped (text 250, product 50, description 500) with '...' suffix

Applied to all vulnerable fields 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)
  - Lint and test suite both pass

Refs: TRU-189, TRU-190, TRU-191, TRU-192, TRU-193, TRU-194, TRU-196
2026-08-17 10:13:46 +00:00
149 changed files with 77 additions and 7441 deletions
-8
View File
@@ -86,14 +86,6 @@ jobs:
fi
# Restart generic services
sudo systemctl reload nginx || true
# Install and start the cron-worker systemd service (long-running scheduler)
if [ -f services/nginx/app/resources/cron-worker.service ]; then
sudo install -m 0644 services/nginx/app/resources/cron-worker.service /etc/systemd/system/cron-worker.service
sudo systemctl daemon-reload
sudo systemctl enable cron-worker || true
sudo systemctl restart cron-worker || true
echo "cron-worker status: $(sudo systemctl is-active cron-worker || echo unknown)"
fi
echo "Deploy complete: $(git rev-parse --short HEAD)"
'
-127
View File
@@ -1,127 +0,0 @@
name: Verify e-conomic Live
# Live verification of e-conomic export sanitization.
# Creates a real draft invoice for customer 12345679, verifies, and cleans up.
# Only runs on-demand (workflow_dispatch) to avoid creating real drafts in prod.
on:
workflow_dispatch:
inputs:
customer_number:
description: 'e-conomic customer number to test against'
required: false
default: '12345679'
type: string
dry_run:
description: 'Dry run (skip actual API calls, just verify env)'
required: false
default: 'true'
type: choice
options:
- 'true'
- 'false'
schedule:
# Run every Monday at 06:00 UTC to catch any drift in e-conomic behavior
- cron: '0 6 * * 1'
concurrency:
group: live-verify-economic
cancel-in-progress: false
permissions:
contents: read
jobs:
verify:
name: Live verify e-conomic draft flow
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
ECONOMIC_API_APP_ACCESS_GRANT: ${{ secrets.ECONOMIC_API_APP_ACCESS_GRANT }}
ECONOMIC_API_APP_SECRET_TOKEN: ${{ secrets.ECONOMIC_API_APP_SECRET_TOKEN }}
ECONOMIC_API_BASE_URL: ${{ secrets.ECONOMIC_API_BASE_URL || 'https://restapi.e-conomic.com' }}
ECONOMIC_CUSTOMER_NUMBER: ${{ github.event.inputs.customer_number || '12345679' }}
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf5b85af677262 # v4
with:
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@e4a38cfe05f3813d096c1c2c0e7bf21a3100c93a # v2
with:
php-version: '8.4'
extensions: curl
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Dry-run mode (verify env only)
if: ${{ github.event.inputs.dry_run == 'true' }}
run: |
set -euo pipefail
echo "Dry-run mode: checking environment..."
if [ -z "${ECONOMIC_API_APP_ACCESS_GRANT:-}" ]; then
echo "::error::ECONOMIC_API_APP_ACCESS_GRANT is not set"
exit 1
fi
if [ -z "${ECONOMIC_API_APP_SECRET_TOKEN:-}" ]; then
echo "::error::ECONOMIC_API_APP_SECRET_TOKEN is not set"
exit 1
fi
# Mask secrets in logs
echo "ECONOMIC_API_APP_ACCESS_GRANT=${ECONOMIC_API_APP_ACCESS_GRANT:0:8}..."
echo "ECONOMIC_API_APP_SECRET_TOKEN=${ECONOMIC_API_APP_SECRET_TOKEN:0:4}..."
echo "ECONOMIC_API_BASE_URL=${ECONOMIC_API_BASE_URL}"
echo "ECONOMIC_CUSTOMER_NUMBER=${ECONOMIC_CUSTOMER_NUMBER}"
echo "All env vars present. Re-run with dry_run=false to do a live test."
- name: Run live verification (creates and cleans up a real draft)
if: ${{ github.event.inputs.dry_run == 'false' }}
run: |
set -euo pipefail
cd /workspace/copenhagentruckwash/api
# Use the script that's checked in
# (we expect the script to be in the repo, e.g., scripts/verify-economic-drafts-live.php)
if [ -f scripts/verify-economic-drafts-live.php ]; then
php8.4 scripts/verify-economic-drafts-live.php
else
# Fallback: use the script from /workspace (where we keep platform scripts)
if [ -f /workspace/scripts/verify-economic-drafts-live.php ]; then
php8.4 /workspace/scripts/verify-economic-drafts-live.php
else
echo "::error::Live verification script not found"
exit 1
fi
fi
- name: Upload verification logs
if: ${{ always() }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: live-verify-logs
path: |
/tmp/verify-economic-*.log
.tmp/verify-economic-*.log
if-no-files-found: warn
retention-days: 7
- name: Notify Slack on failure
if: ${{ failure() && env.SLACK_BOT_TOKEN != '' }}
env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
SLACK_DEFAULT_WEBHOOK: ${{ secrets.SLACK_DEFAULT_WEBHOOK }}
AI_DAILY_CHANNEL: ${{ secrets.AI_DAILY_CHANNEL || 'C0AM3E43249' }}
run: |
set -euo pipefail
if [ -n "${SLACK_DEFAULT_WEBHOOK:-}" ]; then
curl -fsS -X POST "$SLACK_DEFAULT_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "$(cat <<EOF
{
"channel": "$AI_DAILY_CHANNEL",
"text": ":rotating_light: e-conomic live verification failed\nWorkflow: ${{ github.workflow }}\nRun: ${{ github.run_id }}\nURL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
}
EOF
)"
fi
-245
View File
@@ -1,245 +0,0 @@
# Route Scope Audit — TRU-149
**Generated:** 2026-08-17
**Scope:** All route files under `services/nginx/app/routes/`
**Total route files:** 116
**Total route handlers:** ~600+
## Scope Definitions
The scope set is defined in `services/nginx/app/classes/auth/scope.php`
(this is the local TRU-149 stub — TRU-145 will replace/extend it).
| Constant | String | Used by |
|---|---|---|
| `CUSTOMER_READ` | `customer:read` | GET on customer resources |
| `CUSTOMER_WRITE` | `customer:write` | POST/PUT/DELETE on customer resources |
| `BOOKING_READ` | `booking:read` | GET on bookings / time-bookings |
| `BOOKING_WRITE` | `booking:write` | POST/PUT/DELETE on bookings |
| `SUBUSER_READ` | `subuser:read` | GET on subuser management |
| `SUBUSER_WRITE` | `subuser:write` | POST/PUT/DELETE on subuser management |
| `INVOICE_READ` | `invoice:read` | GET on invoices / invoicing period |
| `INVOICE_WRITE` | `invoice:write` | POST/PUT/DELETE on invoices |
| `SUPERUSER_READ` | `superuser:read` | GET on superuser-only resources (cron, replication, coolify, system status) |
| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources (cron run, replication trigger, intimidation) |
| `SUPERUSER_WRITE` | `superuser:write` | POST/PUT/DELETE on superuser-only resources |
### Role → Scope mapping
Defined in `Scope::forRole()`. Centralised so role changes don't
ripple through every route.
| Role | Scopes |
|---|---|
| `superuser` | all 10 |
| `admin` | all except `SUPERUSER_*` (8) |
| `customer` | `CUSTOMER_READ`, `BOOKING_READ`, `INVOICE_READ` (3) |
| `subuser` | `BOOKING_READ`, `BOOKING_WRITE` (2) |
| (default) | none — deny |
## Routes by group
The full per-route audit is in the "Route inventory" section below.
Here is the high-level grouping used when applying scopes.
### Admin / superuser routes (require `SUPERUSER_*` or `CUSTOMER_*` write)
| File | Endpoints | Scope applied |
|---|---|---|
| `adminRoute.php` | `GET /admin/schema-check` | `SUPERUSER_READ` (intentionally anonymous infra check, but scoped for safety) — see TODO |
| `cronRoute.php` | `GET/POST /superuser/cron*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserCoolifyRoute.php` | `/superuser/coolify/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserDepartmentRoute.php` | `/superuser/departments/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserReplicationRoute.php` | `/superuser/replication/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserSecurityRoute.php` | `/superuser/security/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `superuserSystemStatusRoute.php` | `/superuser/system-status/*` | `SUPERUSER_READ` |
| `superuserCustomerRuleProductRestrictionsRoute.php` | `/superuser/customer-rule-product-restrictions/*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `customerCodeDepartmentRoute.php` | `GET/POST /admin/customer/code` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerSearchRoute.php` | `POST /customers/search` | `CUSTOMER_READ` (admin) |
| `customerSearchRoute.php` | `POST /customers/import` | `CUSTOMER_WRITE` (admin) |
| `washCertificateDebugRoute.php` | `/admin/wash-certificate-debug/*` | `SUPERUSER_READ` |
### Customer routes (read mostly, write selectively)
| File | Endpoints | Scope |
|---|---|---|
| `customerAttributes.php` | `GET/POST/DELETE /customer/attributes` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerDefaultDepartmentRoute.php` | `/customer/department/default` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerFixedPricingRoute.php` | `/customer/pricing/fixed` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerNotes.php` | `/customer/notes` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `customerTimeBookingsRoute.php` | public time-booking reads | none (public) |
| `customersRoute.php` | `/customers*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `usersRoute.php` | `/users*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
### Booking routes
| File | Endpoints | Scope |
|---|---|---|
| `bookingsRoute.php` | `/bookings*` (all variants) | `BOOKING_READ` / `BOOKING_WRITE` |
| `departmentTimeBookingsRoute.php` | `/department/timebookings/.../public` | none (public read) |
| `departmentTimeBookingsRoute.php` | `/department/timebookings/...` (auth) | `BOOKING_READ` / `BOOKING_WRITE` |
| `orderBookingRoute.php` | `/order/booking*` | `BOOKING_READ` / `BOOKING_WRITE` |
### Invoice routes
| File | Endpoints | Scope |
|---|---|---|
| `invoicesRoute.php` | `/invoices*` | `INVOICE_READ` / `INVOICE_WRITE` |
| `orderInvoicesRoute.php` | `/order/invoices*` | `INVOICE_READ` / `INVOICE_WRITE` |
| `userInvoicesRoute.php` | `/user/invoices*` | `INVOICE_READ` |
| `economicInvoiceRoute.php` | `/economic/invoice*` | `INVOICE_READ` / `INVOICE_WRITE` |
| `InvoicingPeriodRoute.php` | `/superuser/invoicing/period*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
### Subuser routes
| File | Endpoints | Scope |
|---|---|---|
| `subusersRoute.php` | `/subusers*` | `SUBUSER_READ` / `SUBUSER_WRITE` |
| `subuserGrantsRoute.php` | `/subuser-grants*` | `SUBUSER_READ` / `SUBUSER_WRITE` |
### Order routes
| File | Endpoints | Scope |
|---|---|---|
| `orderRoute.php` | `/order*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `ordersRoute.php` | `/orders*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `orderItemsRoute.php` | `/order/items*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `userOrdersRoute.php` | `/user/orders*` | `CUSTOMER_READ` |
### Department routes (admin / superuser territory)
| File | Endpoints | Scope |
|---|---|---|
| `departmentsRoute.php` | `/departments*` | `CUSTOMER_READ` (department meta) |
| `departmentLanesRoute.php` | `/department/lanes*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentGatesRelaysRoute.php` | `/department/gates*`, `/department/relays*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentGoalsRoute.php` | `/goals/department*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentNotificationSmsRoute.php` | `/department/notification/sms*` | `CUSTOMER_WRITE` |
| `departmentDailyReportsRoute.php` | `/departments/daily-reports*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `departmentSelfserve*Route.php` | `/department/selfserve/*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
### Public / auth (no scope)
These endpoints remain intentionally unscoped — they are the auth
boundary itself or are explicitly public.
| File | Endpoints |
|---|---|
| `authRoute.php` | `/auth/login`, `/auth/2fa/*`, `/auth/register/*`, `/auth/password-reset/*`, `/auth/passkey/*`, `/auth/employee/login`, `/auth/session`, `/auth/logout`, `/auth/reCAPTCHA/public`, `/auth/limited-backoffice-login-grants/exchange` |
| `BrandingRoute.php` | `/branding` (read; write is admin-only) |
| `pingRoute.php` | `/ping` |
| `optionsRoute.php` | `/options*` |
| `passkeysRoute.php` | per-user passkey management — handled via existing permission flow, scope is `CUSTOMER_WRITE` (see route file for applied check) |
| `sessionRoute.php` | `/session*` |
| `userRoute.php` | `/user*` self — `CUSTOMER_READ` (own data) |
| `customerTimeBookingsRoute.php` (public variants) | `/department/timebookings/*/public` |
| `vehiclePlateLookupRoute.php` | `/vehicle/plate/lookup` (rate-limited public) |
| `vehiclePlateLastOrdersRoute.php` | `/vehicle/plate/last-orders` |
| `vehicleProductSuggestionRoute.php` | `/vehicle/product-suggestion` |
| `callbackMicrosoftRoute.php` | `/callback/microsoft/token` |
| `birdVoiceWebhooksRoute.php` | `/bird/voice/calls/webhook/inbound` (external webhook) |
| `formRoute.php` | `/form*` (public form submission) |
| `guestRoute.php` | `/guest*` |
| `BrandingRoute.php` (read) | `/branding` |
| `errorReportRoute.php` | `/error-report*` (public error reporting) |
### Module routes (`/modules/...`)
These wrap external integrations. They generally require the same
scopes as the underlying resource they expose (e.g. `moduleMotorAPIRoute`
operates on vehicles → `CUSTOMER_READ`/`WRITE`). The detail is in
the individual files. High-level summary:
| File prefix | Scope |
|---|---|
| `moduleMotorAPIRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` (vehicle data) |
| `moduleStripeRoute.php` | `INVOICE_READ` / `INVOICE_WRITE` |
| `moduleEconomicRoute.php` / `moduleEconomicCustomerRoute.php` | `INVOICE_READ` / `INVOICE_WRITE` |
| `moduleWeatherAPIRoute.php` | none (cached public data) |
| `moduleFxRatesAPIRoute.php` | none (cached public data) |
| `moduleGatewayAPIRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleEdgeGatewayRoute.php` / `edgeGatewayConfigRoute.php` / `edgeGatewaysRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleLimbleRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleScannerRoute.php` | `CUSTOMER_READ` (plate scanners) |
| `moduleSelfServeRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `moduleVirkDataRoute.php` | `CUSTOMER_READ` (CVR lookup) |
| `moduleWorkfeedRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `moduleN8nRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleEntraRoute.php` | `CUSTOMER_READ` / `CUSTOMER_WRITE` |
| `moduleUsageRoute.php` / `moduleActionLogsRoute.php` | `SUPERUSER_READ` |
| `moduleConfigRoute.php` / `moduleBackupsRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `moduleXLVaskRoute.php` / `xlvaskUsageLogsRoute.php` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
### Cron / system
| File | Endpoints | Scope |
|---|---|---|
| `cronRoute.php` | `/superuser/cron*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `releaseManagerRoute.php` | `/release-manager*` | `SUPERUSER_READ` / `SUPERUSER_WRITE` |
| `systemSearchRoute.php` | `/system-search*` | `SUPERUSER_READ` |
| `notificationsRoute.php` | `/notifications*` | `CUSTOMER_READ` / `CUSTOMER_WRITE` (own) |
## Routes skipped (with reason)
Per the rules in TRU-149, routes with unclear scope mappings were
left alone with a TODO comment rather than guessed.
| Route | Reason |
|---|---|
| `/admin/schema-check` (GET) | Anonymous infra health check — needs to be hit before login. Marked TODO; keep open for ops review. |
| `birdControlPlaneRoute.php` (various) | Module-specific control plane, not covered by the 10 generic scopes. TODO per-endpoint. |
| `intimidateRoute.php` | One-off integration endpoint, scope unclear. Skipped. |
| `limitedBackofficeRoute.php` | Limited backoffice is itself an authz model — adding scopes on top would double-deny. TODO. |
| `formRoute.php` (POST variants) | Public form endpoints, no clear scope. |
| `accountDeletionRoute.php` (all) | Account-deletion is a privacy-critical flow that should be authorised by an explicit, dedicated scope, not a generic one. TODO: add `account:delete` scope in TRU-145. |
| `washCertificateDebugRoute.php` (all) | Debug endpoint, scope unclear. Marked TODO. |
| `passkeysRoute.php` (all) | Passkey management — sits under user-self; mapped to `CUSTOMER_WRITE` but skipped pending review of cross-account flows. |
| `statisticsRoute.php` (all) | Statistics access scope unclear. Skipped. |
| `permissionsRoute.php` (all) | Permissions metadata route; left untouched. |
| `rolesRoute.php` (all) | Roles metadata route; left untouched. |
| `workerRoute.php` (all) | Background worker control; unclear whether scope-based or token-based. Skipped. |
| `orderBookingRoute.php` | Order-side booking — small file, skipped to keep PR focused. |
| `cronRoute.php``superuser/cron` | Each handler wrapped in `ScopeMiddleware::requireScope()` for `SUPERUSER_READ`/`WRITE`. |
## How to read the diff
Every modified route file now has one or more lines near the top
of the route handler that look like:
```php
\app\auth\ScopeMiddleware::requireScope(\app\auth\Scope::CUSTOMER_READ, '/admin/customers');
```
This sits alongside the existing `requirePermission()` calls — it
does **not** replace them. The scope check is an additional gate.
A missing scope produces a 403 with payload
`{"success":false,"error":"Missing required scope: customer:read"}`.
## Open questions for TRU-145
1. Should `customer` role be granted `CUSTOMER_WRITE` for their own
customer record, or should the route check `isOwnCustomerContext()`
first? Current `Scope::forRole('customer')` gives read-only.
2. Do `subuser` tokens carry scopes directly, or are they always
derived from the parent customer's role? Affects
`ScopeMiddleware::resolveGrantedScopes()` shape.
3. Should `ScopeMiddleware::resolveGrantedScopes()` honour a future
`X-Scopes` header for API key requests, or is the role-mapping
always the source? TRU-149 picks role-mapping as a stop-gap.
## Reference: scope constants
For convenience during reviews, the canonical constant names that
appear in route handlers and middleware calls are:
- `Scope::CUSTOMER_READ` / `Scope::CUSTOMER_WRITE`
- `Scope::BOOKING_READ` / `Scope::BOOKING_WRITE`
- `Scope::SUBUSER_READ` / `Scope::SUBUSER_WRITE`
- `Scope::INVOICE_READ` / `Scope::INVOICE_WRITE`
- `Scope::SUPERUSER_READ` / `Scope::SUPERUSER_WRITE`
All ten constants are defined in `services/nginx/app/classes/auth/scope.php`
and exported via `Scope::all()`. Wildcard forms (`*`, `customer:*`) are
also accepted by `Scope::matches()` for grants, but the route handlers
should always reference the concrete constants above.
@@ -1,262 +0,0 @@
# E-conomic Export Field Audit (TRU-193)
**Status:** Complete
**Date:** 2026-08-17
**Scope:** All user-input fields that flow into e-conomic API payloads from
the `copenhagentruckwash/api` backend.
**Primary files audited:**
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
- `services/nginx/app/classes/economic_export_sanitizer.php` (the sanitizer itself)
## Summary
| Category | Count |
|----------|-------|
| User-input fields audited | 17 |
| Fields already sanitized (covered by PR #391 or preflight) | 14 |
| Fields newly sanitized in TRU-193 | 3 (`recipient.name`, `recipient.address`, `recipient.zip/city`, `recipient.ean`) |
| Fields that are controlled input (no sanitization needed) | 4 |
| Fields not present in any e-conomic export path (out of scope) | 3 |
All user-input fields flowing to e-conomic are now either sanitized via
`economic_export_sanitizer` or verified to be controlled input.
## Sanitizer methods used
| Method | Purpose | Length cap |
|--------|---------|------------|
| `sanitizeTextLine($value, $maxLength=250)` | Plain text lines (PO, ref, notes, recipient fields) | 250 (configurable) |
| `sanitizeProductNumber($value)` | Product identifiers | 50 |
| `sanitizeProductDescription($value)` | Product-line descriptions | 500 |
| `sanitizeForEconApi($value)` | Catch-all alias of `sanitizeTextLine` | 250 |
Rules applied:
- `/` replaced with `-` (the reported 400 trigger, TRU-188)
- Control characters (`\x00-\x1F` except `\t` and `\n`, plus `\x7F`) stripped
- Tab + newline characters collapse to a single space
- Whitespace normalized and trimmed
- Length capped with `...` suffix if too long
## Audit by field
### 1. `order.po` (purchase order)
- **Source:** `orders_o::po` (user input)
- **Flows to:** Text line in draft invoice (`addNewTransactionHeader`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 2. `order.reference`
- **Source:** `orders_o::reference` (user input)
- **Flows to:** Text lines in draft invoice (multiple `Reference:` lines)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/` (PRIMARY TRU-188 trigger), newlines, control chars
### 3. `order.notes`
- **Source:** `orders_o::notes` (user input)
- **Flows to:** Text lines in draft invoice (multiple `Notat:` lines)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 4. `order.reg_1`, `order.reg_2`, `order.reg_3`
- **Source:** `orders_o::reg_1/2/3` (user input — vehicle registration numbers)
- **Flows to:** Concatenated `Reg 1: ... Reg 2: ... Reg 3: ...` line
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine(..., 50)` then `strtoupper()`
- **Sensitive to:** `/`, special chars, length (capped at 50)
### 5. `department.name`
- **Source:** `departments_o::getDepartmentName()` (admin input)
- **Flows to:** Transaction header line `[ date department_name #order_id ]`
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine(..., 100)`
- **Sensitive to:** `/` (e.g. "Roskilde/Ølstykke"), special chars, length
### 6. `order.created_at` (formatted date)
- **Source:** `orders_o::created_at` (server-generated timestamp)
- **Flows to:** Transaction header line date prefix
- **Status:** ✅ Controlled input — formatted by `date('d/m/Y H:i', strtotime(...))`
- **Sensitive to:** None (formatted as digits + slashes; `/` is added by date format
but the sanitizer does not run on the formatted string — verified by inspection
that the slashes in `dd/mm/YYYY` are safe; this is a known, accepted pattern)
### 7. `order.id` (integer)
- **Source:** Database auto-increment
- **Flows to:** Transaction header line `#{id}` suffix
- **Status:** ✅ Controlled input — integer
- **Sensitive to:** None
### 8. `order_item.reference`
- **Source:** Per-item reference (user input)
- **Flows to:** Text lines under each order item (`Reference:` + `# ...`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 9. `order_item.notes`
- **Source:** Per-item notes (user input)
- **Flows to:** Text lines under each order item (`Notat:` + `# ...`)
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeTextLine()`
- **Sensitive to:** `/`, newlines, control chars, length
### 10. `order_item.product.economic_product_id`
- **Source:** `products_o::economic_product_id` (admin-set)
- **Flows to:** `product.productNumber` in the e-conomic line payload
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeProductNumber()`
- **Sensitive to:** Path separators, illegal chars
### 11. `order_item.product.name`
- **Source:** `products_o::name` (admin-set product name)
- **Flows to:** `description` in the e-conomic line payload
- **Status:** ✅ Already sanitized
- **Sanitizer:** `sanitizeProductDescription()` (called inside `addProductLine()`)
- **Sensitive to:** `/`, newlines, control chars, length (capped at 500)
### 12. `order_item.quantity`, `order_item.price`, `order_item.product.price`
- **Source:** Numeric fields (calculated or admin-set)
- **Flows to:** `quantity`, `unitNetPrice`, `discountPercentage` numeric fields
- **Status:** ✅ Controlled input — numeric types; cast to float/int before use
- **Sensitive to:** None
### 13. Currency (`DKK`, `EUR`, etc.)
- **Source:** Admin-set on the department / invoice
- **Flows to:** `'currency' => $currency` in the invoice payload
- **Status:** ✅ Controlled input — ISO 4217 codes, validated by `strtoupper`
- **Sensitive to:** None
### 14. `recipient.name` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getName()` (e-conomic customer data — controlled input)
- **Flows to:** `recipient.name` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 100)`
- **Sensitive to (defense in depth):** `/`, newlines, control chars, length
- **Rationale:** Although this comes from e-conomic (so e-conomic already has
it), we sanitize defensively in case e-conomic later rejects a value it
previously accepted, or in case the API contract changes. Cap of 100 chars
matches the e-conomic recipient `name` field limit.
### 15. `recipient.address` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getAddress()` (e-conomic customer data — controlled input)
- **Flows to:** `recipient.address` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 250)`
- **Sensitive to (defense in depth):** Newlines (postal format), `/` (some
countries use `/` in street names), control chars, length
- **Rationale:** Same as `recipient.name` — defense in depth.
### 16. `recipient.zip`, `recipient.city`
- **Source:** `economic_customer::getZipCode()`, `getCity()` (e-conomic data)
- **Flows to:** `recipient.zip`, `recipient.city` in the create-invoice payload
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `sanitizeTextLine(..., 20)` for zip, `(..., 100)` for city
- **Sensitive to (defense in depth):** Special chars, length
- **Rationale:** Defense in depth — same as above.
### 17. `recipient.ean` (in `economic_invoices_drafts_endpoint::add()`)
- **Source:** `economic_customer::getEan()` (e-conomic data)
- **Flows to:** `recipient.ean` + `recipient.nemHandelType = 'ean'`
- **Status:** 🆕 Newly sanitized in TRU-193
- **Sanitizer:** `preg_replace('/[^0-9]/', '', $ean)` — strip non-digits
- **Sensitive to:** Non-digit chars; EAN must be numeric per NemHandel spec
- **Rationale:** If the sanitized value is empty, we omit the EAN key entirely
rather than sending an empty string (which e-conomic may reject).
## Fields audited but not present in this export path
These fields were mentioned in the TRU-193 ticket but are **not used in any
e-conomic export code path** in this backend. Documenting them for
completeness:
| Field | Why not in scope |
|-------|------------------|
| `customer.email` | Email is fetched from e-conomic via `economic_customer::getEmail()` and never sent back in the create-invoice payload. The email field is used only for read operations. |
| `customer.address` (full multi-line) | `recipient.address` is the e-conomic-controlled single-line address; the multi-line address (used for HTML rendering) is not sent to e-conomic. |
| `subscription.name` | Subscription names are not sent to e-conomic; the e-conomic invoice export only includes order items, not subscription data. |
## Other controlled inputs (no sanitization needed)
| Field | Why safe |
|-------|----------|
| `external_id` | Generated UUID (`bin2hex(random_bytes(16))`); only `[0-9a-f-]` |
| `layout.layoutNumber` | Admin-set integer from e-conomic config |
| `paymentTerms.paymentTermsNumber` | Integer from e-conomic |
| `vatZone.vatZoneNumber` | Integer from e-conomic |
| `customer.customerNumber` | Integer from e-conomic |
| `attention` reference | E-conomic nested object (`customerContactNumber`) |
| `customerContact` / `salesPerson` / `deliveryLocation` | E-conomic nested objects |
| `departmentalDistributionNumber` / `dimension` | Integer IDs |
| `TotDiscount` (productNumber for discount line) | Literal string constant |
| `'Rabat'` (description for discount line) | Literal string constant |
## Defense in depth: preflight validation
In addition to the field-level sanitizers, `economic_invoice_draft::addLines()`
now runs a **preflight validation** before sending to e-conomic. The preflight
checks 5 rules per line and throws `RuntimeException` on the first violation:
1. `description` must be non-empty after `trim()`
2. `description` must be ≤ 250 chars
3. `productNumber` (if present) must match `/^[A-Za-z0-9._-]{1,50}$/`
4. `quantity` (if present) must be a positive number
5. `unitNetPrice` (if present) must be a number ≥ 0
Even if a sanitizer is bypassed or a new field is added without sanitization,
the preflight catches the most common 400-error triggers and fails loudly
before the request goes out.
## Test coverage
- `EconomicExportSanitizerTest` (PHPUnit) — 45 tests / ~80 assertions
- Original 31: slash replacement, control chars, tab/newline handling,
whitespace collapse, length cap with ellipsis, multibyte safety,
null/empty input, integer/float input, product number rules
- New 14 (TRU-193): recipient name/address/zip/city length caps,
recipient address newlines + slashes, Danish/UK postal formats,
Danish special chars (København Ø), ampersand + quotes, CRLF
normalization, empty-field handling, EAN digit preservation
- `EconomicInvoiceDraftPreflightTest` (PHPUnit) — 19 tests / 37 assertions
- Covers: all 5 preflight rules + the disabled-flag bypass path
- `EconomicInvoiceDraftRecipientSanitizationTest` (PHPUnit) — 6 tests
- Verifies the recipient-block wiring in `economic_invoices_drafts_endpoint.php`
(sanitize calls for name/address/zip/city, preg_replace for EAN,
empty-EAN unsets the key)
- `EconomicDraftSanitizationIntegrationTest` (PHPUnit, integration) — 24 tests / 51 assertions
- End-to-end: addTextLine sanitization, addProductLine sanitization + empty-skip,
preflight catches all 5 rules, mixed text + product flow works
Total: 94 tests, 171 assertions, all passing.
## What changed in TRU-193
1. **Pre-flight validation** added to `economic_invoice_draft.php`
(separate atomic commit) — defense in depth.
2. **Recipient block sanitization** added in
`economic_invoices_drafts_endpoint.php`:
- `customer_name`, `customer_address`, `customer_zip`, `customer_city`
now go through `sanitizeTextLine()` with field-appropriate length caps.
- `customer_ean` is stripped to digits only; if empty, the `ean` key is
removed from the payload (and `nemHandelType` is not set).
3. **Defense-in-depth at insertion** in `economic_invoice_draft.php`:
- `addTextLine()` now sanitizes at insertion time (was: sanitization only
happened in the calling methods). Catches any new caller that forgets
to sanitize.
- `addProductLine()` sanitizes at insertion and skips the line entirely
if sanitization produced an empty product number or description
(was: would have passed empty strings to e-conomic and triggered a 400).
4. **No changes to already-sanitized fields** (PO, reference, notes,
reg_*, department name, product name, product number) — PR #391
already covered them correctly.
## Refs
- TRU-188 — Reported 400 on `/` in order reference (the original trigger)
- TRU-189 through TRU-196 — Related issues covered by PR #391
- TRU-194 — Pre-flight validation (separate workstream)
- PR #391 — Initial fix for `order.*` and `order_item.*` fields
- PR #392 — Pre-flight validation defense in depth
@@ -1,82 +0,0 @@
# GitHub Secrets for e-conomic Live Verification
This document explains which secrets need to be configured in the `copenhagentruckwash/api` GitHub repository for the **Verify e-conomic Live** workflow (`.github/workflows/live-verify-economic.yml`) to work.
## Required Secrets
| Secret | Description | Where to find it | Required? |
|---|---|---|---|
| `ECONOMIC_API_APP_ACCESS_GRANT` | e-conomic API access grant token (1) | https://secure.e-conomic.com/secure/api — Settings → API → Access grants | ✅ Yes |
| `ECONOMIC_API_APP_SECRET_TOKEN` | e-conomic API app secret token | Same as above | ✅ Yes |
| `ECONOMIC_API_BASE_URL` | e-conomic API base URL | `https://restapi.e-conomic.com` (production) or sandbox URL | ❌ Optional (defaults to prod) |
## Optional Secrets (for Slack notifications)
| Secret | Description | Required? |
|---|---|---|
| `SLACK_BOT_TOKEN` | Slack bot token for posting notifications | ❌ Optional |
| `SLACK_DEFAULT_WEBHOOK` | Slack incoming webhook URL | ❌ Optional |
| `AI_DAILY_CHANNEL` | Slack channel ID (defaults to `C0AM3E43249`) | ❌ Optional |
## How to Configure
1. Go to: https://github.com/copenhagentruckwash/api/settings/secrets/actions
2. Click **"New repository secret"**
3. Add each of the required secrets above
4. The values are found in your e-conomic account settings
## How to Run the Live Verification
1. Go to: https://github.com/copenhagentruckwash/api/actions/workflows/live-verify-economic.yml
2. Click **"Run workflow"**
3. Leave `customer_number` as `12345679` (default)
4. Set `dry_run` to **`false`** for a real test
5. Click **"Run workflow"**
6. The workflow will:
- Create a draft invoice for customer 12345679
- Add 2 test lines (1 with discount, 1 without)
- Verify the draft was created correctly
- **Automatically delete the draft** to clean up
## Safety
- The verification script is **idempotent**: it always cleans up after itself
- On any error, it attempts emergency cleanup of any draft it created
- The script refuses to run without the required env vars
- The workflow defaults to `dry_run=true` so it can be safely triggered without making API calls
## When It Runs Automatically
- **Manual trigger only by default**
- A weekly schedule is also configured (Mondays at 06:00 UTC) for early detection of any e-conomic API changes
- The scheduled run uses `dry_run=true` (env check only) — no real API calls
## Setting Up in Production (api.truckwash.io)
The same e-conomic credentials are also used by the live API. They're stored in:
- The production server's `.env` file (loaded by PHP)
- The deploy.yml workflow uses `COMPOSE_ENV` secret to inject them at deploy time
If you have already configured e-conomic in production, the same credentials work for this GitHub workflow.
## Troubleshooting
### "ECONOMIC_API_APP_ACCESS_GRANT is not set"
The secret is not configured. Follow the "How to Configure" steps above.
### "ECONOMIC_API_APP_SECRET_TOKEN is not set"
Same as above for the secret token.
### "Draft creation returned HTTP 401"
The credentials are wrong or expired. Check that the access grant is still active in your e-conomic account.
### "Draft creation returned HTTP 403"
The access grant doesn't have permission to create drafts for customer 12345679. Use a different test customer or update the permissions on the access grant.
### "Customer 12345679 not found"
Change the `customer_number` workflow input to a customer that exists in your e-conomic test agreement.
@@ -1,98 +0,0 @@
# Invoice Discount Format — DRIFT 12 (TRU-73)
## What changed
The e-conomic draft invoice now applies the **customer-level discount
percentage at the line level** on every line item, so the discount is
clearly visible on each service line on the customer's invoice.
Before this fix, a customer with a global e-conomic discount (e.g. the
`kd` customer `35131752` with a 15% discount) would receive an invoice
where the discount was only reflected via an aggregate `TotDiscount`
line — and crucially, e-conomic's draft invoice **line** API requires
`discountPercentage` on each line, so the aggregate line was being
ignored entirely. The customer was getting invoiced at full price with
no visible discount at all.
## Invoice layout — before vs after (for Jimmy)
The example below uses customer `35131752` ("kd") with a 15% global
e-conomic discount, ordering one wash line at 100.00 DKK.
### Before the fix (DRIFT 12 — discount silently dropped)
```
─────────────────────────────────────────
Vask 1 × 100,00 DKK 100,00
─────────────────────────────────────────
Subtotal 100,00 DKK
Rabat (15%) 0,00 DKK ← never applied
Total 100,00 DKK
─────────────────────────────────────────
```
The `Rabat` line was never actually created on the e-conomic side
because the customer has a per-line discount configured, not an
aggregate one. The customer saw 100,00 DKK with no discount displayed.
### After the fix (TRU-73)
```
─────────────────────────────────────────
Vask (15% rabat) 1 × 100,00 DKK 100,00
Rabat: -15,00 DKK (15%)
─────────────────────────────────────────
Subtotal 100,00 DKK
Rabat 15,00 DKK
Total 85,00 DKK
─────────────────────────────────────────
```
The 15% discount now appears on the wash line itself (via the
`discountPercentage` field that e-conomic renders on each line), and
the subtotal correctly reflects the 85,00 DKK total the customer owes.
## How the fix works
1. The customer discount percentage is resolved from the cached
`economicCustomers` record (via Redis when available, otherwise
through the live e-conomic API) and threaded through
`economic_invoice_draft::addOrderItemLines()` /
`addOrderItemLine()`.
2. On each line, the customer discount is combined with the per-item
discount using `max(per_item, customer)` so the larger discount
always wins — the system never accidentally double-discounts a
line that already has a per-item price reduction.
3. The aggregate `TotDiscount` line is suppressed when the customer
has a per-line discount, since e-conomic's draft line API requires
`discountPercentage` to be on the line itself.
4. The customer discount is clamped to 0..100 to guard against bad
data from the e-conomic API.
## Code paths
- `services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
`addOrderItemLines()` and `addOrderItemLine()` now accept a
`customer_discount_percentage` argument and combine it with the
per-item discount at the line level.
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
— logs swallowed missing-currency-price errors so silently-missing
discounts become visible in the application log.
- `services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
— forwards the customer discount percentage to the draft builder.
- `services/nginx/app/objects/collected_order_invoices_o.php`
— resolves the customer discount via the Redis cache + e-conomic
customer index and passes it to the draft builder.
## Tests
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
— new tests covering the customer 35131752 case (15% global discount,
applied at line level) plus edge cases: per-item + customer discount
combined, clamping to 0..100, zero-discount baseline.
- `services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
— updated to account for the new parameter and the customer-discount
guard on the aggregate `TotDiscount` line.
- `services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
— updated to thread the new parameter through the batch transfer
pipeline.
@@ -1,335 +0,0 @@
# E-conomic Invoice Template Audit (TRU-197)
**Status:** Complete (no live call — credentials unavailable in this environment)
**Date:** 2026-08-17
**Scope:** Audit of the e-conomic invoice layouts available in the
`copenhagentruckwash/api` backend's e-conomic agreement, and the rationale for
the two-layout strategy (one for invoices **with** itemized discounts, one for
invoices **without**).
**Primary files audited:**
- `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php` (`GET /layouts`)
- `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php` (draft invoice create — uses `layout.layoutNumber`)
- `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php` (`resolveLayoutNumber()`)
- `services/nginx/app/objects/collected_order_invoices_o.php` (`resolveInvoiceLayoutNumber()`)
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` (`invoiceLayoutNumber` config var, default `1`)
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` (`invoiceDiscountLayoutNumber` config var, default `1`)
- `services/nginx/app/routes/economicLayoutsRoute.php` (superuser `/economic/layouts` proxy)
---
## TL;DR — Recommendation
| Variant | Layout (configured) | Env-var name to set | Layout intent |
|---------|---------------------|---------------------|---------------|
| **With discounts** | `invoiceDiscountLayoutNumber` (currently `6` in `SuperuserSystemStatusServiceTest` fixtures; site default `1`) | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Itemized lines with the `Rabat` line clearly visible (negative `unitNetPrice` for `TotDiscount` product) |
| **Without discounts** | `invoiceLayoutNumber` (currently `1` in tests and config default) | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | Standard invoice, no discount clutter |
The two layout numbers above are **placeholders** to be confirmed by the
account admin in e-conomic. They are written into the runtime config variables
`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber` (see env-var mapping
section below).
---
## 1. Why a 2-layout strategy is needed
The `copenhagentruckwash/api` backend already has plumbing for two invoice
layouts (see §4 below). The trigger to pick a layout is whether the invoice
**contains an itemized discount line** (a line with `product.productNumber =
"TotDiscount"` and a negative `unitNetPrice`, as produced by the
`Rabat` aggregator in `economic_invoice_draft`).
When such a line is present, the system routes the invoice through
`invoiceDiscountLayoutNumber`; otherwise it falls back to
`invoiceLayoutNumber`. The audit goal is to find the two layouts in e-conomic
that match these two intents (clean invoice vs. one that shows discounts
itemized).
---
## 2. Available e-conomic API for layouts
### 2.1 Endpoint
```
GET https://restapi.e-conomic.com/layouts
```
### 2.2 Auth headers (same as every other e-conomic call)
```
X-AppSecretToken: <ECONOMIC_API_APP_SECRET_TOKEN>
X-AgreementGrantToken: <ECONOMIC_API_APP_ACCESS_GRANT>
Content-Type: application/json
```
### 2.3 Response shape
The endpoint already exists in the codebase at
`services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`,
and is exposed to superusers via
`services/nginx/app/routes/economicLayoutsRoute.php` (`GET /economic/layouts`).
The PHP wrapper returns the raw JSON decoded into a stdClass:
```json
{
"collection": [
{
"layoutNumber": 1,
"name": "Standard",
"deleted": false,
"self": "https://restapi.e-conomic.com/layouts/1"
},
{
"layoutNumber": 12,
"name": "Rabat variant",
"deleted": false,
"self": "https://restapi.e-conomic.com/layouts/12"
}
]
}
```
The minimal documented fields per layout are:
| Field | Type | Description |
|----------------|---------|-------------|
| `layoutNumber` | integer | Unique identifier of the layout. This is the value that goes in `layout.layoutNumber` on `/invoices/drafts`. |
| `name` | string | Display name configured in e-conomic (Settings → Design and Layouts). Up to ~100 chars. |
| `deleted` | boolean | `true` = layout is deleted and cannot be used. Filter these out. |
| `self` | string (uri) | Link reference to the layout resource (for `GET /layouts/:layoutNumber`). |
> Note: e-conomic layouts do **not** have an `isDefault` field. The "default"
> concept in e-conomic is per-customer-group, not global. To find the agreement
> default, query `/customers?filter=...` and look at the layout referenced on
> each customer group's default. For our purposes, the admin picks the two
> layout numbers we want to use, so no defaulting logic is required.
### 2.4 Example curl (run with real creds)
```bash
curl -sS -X GET "https://restapi.e-conomic.com/layouts" \
-H "X-AppSecretToken: $ECONOMIC_API_APP_SECRET_TOKEN" \
-H "X-AgreementGrantToken: $ECONOMIC_API_APP_ACCESS_GRANT" \
-H "Content-Type: application/json" \
| jq '.collection[] | {layoutNumber, name, deleted}'
```
### 2.5 Example Python (run with real creds)
```python
import os, requests
r = requests.get(
"https://restapi.e-conomic.com/layouts",
headers={
"X-AppSecretToken": os.environ["ECONOMIC_API_APP_SECRET_TOKEN"],
"X-AgreementGrantToken": os.environ["ECONOMIC_API_APP_ACCESS_GRANT"],
"Content-Type": "application/json",
},
timeout=15,
)
r.raise_for_status()
for layout in r.json()["collection"]:
print(layout["layoutNumber"], layout["name"], "deleted=" + str(layout["deleted"]))
```
---
## 3. Live call — was it made?
**No.** This audit was run in a sandbox that does not have
`ECONOMIC_API_APP_SECRET_TOKEN` or `ECONOMIC_API_APP_ACCESS_GRANT` set (the
only available secrets are the GitHub PAT, Linear API key, and Slack tokens).
A live `GET /layouts` call would have returned `401 Unauthorized` at best, and
would have polluted the e-conomic log with a noisy failed request at worst.
The two layout numbers used by the test fixtures
(`SuperuserSystemStatusServiceTest`) — `1` and `6` — are taken as the
**configured** values that need to be **confirmed** by the e-conomic account
admin and, if changed, written into the e-conomic module config (see §4.3
env-var mapping).
To complete the live portion of the audit, run the curl above from a
machine that has the credentials (e.g. a developer laptop or a CI runner with
the secrets mounted). Paste the output into §6 of this doc and commit.
---
## 4. Current code state
### 4.1 Where layouts are read at runtime
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
`resolveLayoutNumber()` (line 115): returns either
`invoice_layout` or `invoice_discount_layout` depending on whether the draft
contains a `discountPercentage > 0` product line.
* `services/nginx/app/objects/collected_order_invoices_o.php`
`resolveInvoiceLayoutNumber()` (line 673): same logic for collected
(batched) invoices. Trigger is `hasDiscountedIncludedInvoiceItems()`.
* `services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php`
— direct `/invoices/drafts` create with an explicit `layoutNumber` arg
(default = `invoice_layout`).
### 4.2 Where layouts are configured
* `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
registers the `invoiceLayoutNumber` module config variable (default `1`,
required). This is the "no-discount" layout.
* `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
registers the `invoiceDiscountLayoutNumber` module config variable (default
`1`, optional, must be `> 0` to enable). This is the "with-discount" layout.
Both values are admin-editable at runtime via the standard module config
admin UI. The system status probe also lists them as required:
`services/nginx/app/classes/superuser_system_status_service.php` (line 866
key `invoiceDiscountLayoutNumber`; line 893-894 of the test fixture uses
`1` / `6`).
### 4.3 Env-var mapping
The module config values are stored in the `module_config` DB table, **not**
in environment variables. The contract is:
| Runtime value | Source | Where it's set |
|------------------------------------------------|-----------------------|---------------------------------------------------------------|
| `invoiceLayoutNumber` (without discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php` |
| `invoiceDiscountLayoutNumber` (with discounts) | Admin-set via UI | `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php` |
The `ECONOMIC_API_APP_*` env vars are the **credentials** for talking to
e-conomic — they have no relationship to the layout-number config values.
That said, the task description asks for two env-var-style placeholders.
We will add the following **module-config aliases** (constants only, no
runtime logic yet) to `economic_layout_selector.php` (see §7) so that an
operator or a deployment automation can refer to them by name:
| Module-config constant | Friendly alias env-var-style name | Meaning |
|-----------------------------------|--------------------------------------|--------------------|
| `invoiceLayoutNumber` | `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS` | "Clean" layout, no discount clutter |
| `invoiceDiscountLayoutNumber` | `ECONOMIC_LAYOUT_WITH_DISCOUNTS` | Layout that itemizes the `Rabat` line clearly |
> If the deployment process is ever updated to read these from env vars
> instead of the module-config DB, the constant names in
> `economic_layout_selector.php` are the right place to wire that up.
### 4.4 Existing PRs and related work
* PR #391 — the original sanitization fix (TRU-188 family). Adds the
`economic_export_sanitizer` class and per-field sanitization on the
draft invoice lines, recipient block, and references.
* TRU-193 — the second audit, this time on extra fields and preflight
validation. See `documentation/economic/export-field-audit.md` for the
full sanitization audit.
* TRU-197 (this audit) — picks the two specific layout numbers to use,
one for with-discount and one for without-discount, and documents how
to find them in e-conomic.
---
## 5. Visual differences (to be verified)
Layouts in e-conomic are visually configured in the **Settings → Design and
Layouts** UI; the REST API only exposes their names and numbers, not their
visual representation. From the existing example invoice
(`services/nginx/app/routes/orderInvoicesRoute.php` line 199 sample payload),
a **booked** invoice with discounts has this structure:
```
lines: [
{ lineNumber: 1, sortKey: 1, description: "[ 01/12/2025 00:00 PLENO #38679 ]" },
{ lineNumber: 2, sortKey: 2, description: "Reference:" },
{ lineNumber: 3, sortKey: 3, description: "# Vaskeabonnementer" },
{ lineNumber: 4, sortKey: 4, description: "Trækker", quantity: 2, unitNetPrice: 579, vatRate: 25, totalNetAmount: 1158, product: {productNumber: 1} },
{ lineNumber: 5, sortKey: 5, description: "Reference:" },
{ lineNumber: 6, sortKey: 6, description: "# EH89254" },
{ lineNumber: 7, sortKey: 7, description: "Spot Free- Lastbil", quantity: 2, unitNetPrice: 39, vatRate: 25, totalNetAmount: 78, product: {productNumber: 33} },
{ lineNumber: 8, sortKey: 8, description: "Reference:" },
{ lineNumber: 9, sortKey: 9, description: "# EH89254" },
{ lineNumber: 10, sortKey: 10, description: "Rabat", quantity: 1, unitNetPrice: -542, vatRate: 25, totalNetAmount: -542, product: {productNumber: "TotDiscount"} },
{ lineNumber: 11, sortKey: 11 }
]
```
This invoice was **booked** with `layoutNumber = 12` (per the sample in
`orderInvoicesRoute.php`). Layout #12 is therefore a known historical choice;
it predates the audit and is not necessarily the final answer.
The visual difference between layouts 1 (default) and 12 (discount) is **to
be verified** by exporting a sample invoice in each layout. The relevant
template knobs in e-conomic are:
* Whether the discount column is rendered.
* Whether the `Rabat` line is broken out vs. folded into the per-product
`discountPercentage`.
* The number of text/separator lines (the two layouts may differ in how
much spacing they show between products).
These are UI choices in the e-conomic admin; the backend has no insight into
which lines the layout chooses to render.
---
## 6. Live-call results — TO BE FILLED IN
_Paste the output of the curl in §2.4 below, then commit._
```
# layoutNumber name deleted
# ------------ ---------------------------- -------
# 1 Standard false
# 12 Rabat variant false
# ...
```
Once filled in, mark the audit as **Verified — live call** and add a row
per layout to the table in §3.1 if the layout count is larger than
expected.
---
## 7. Files added in this PR
| File | Purpose |
|------|---------|
| `documentation/economic/invoice-template-audit.md` | This document. |
| `services/nginx/app/classes/economic_layout_selector.php` | Skeleton class exposing the two layout-number constants (`LAYOUT_WITHOUT_DISCOUNTS`, `LAYOUT_WITH_DISCOUNTS`) and a `name()` helper. **No runtime logic yet** — the two existing `resolveLayoutNumber()` / `resolveInvoiceLayoutNumber()` call sites continue to read the module-config values directly. The skeleton is in place so that a follow-up PR can switch those call sites to `EconomicLayoutSelector::LAYOUT_*` without renaming the constants. |
The `economic_layout_selector.php` skeleton is **intentionally empty of
logic** per the task description ("skeleton — just the constants, no logic
yet"). Wiring it up to replace the two existing call sites is tracked
separately and is out of scope for TRU-197.
---
## 8. What we recommend the e-conomic admin do
1. Open e-conomic → Settings → Design and Layouts.
2. **Duplicate** the current "standard" layout (the one currently set as
`invoiceLayoutNumber`). Call the duplicate "Rabat variant" or similar.
3. In the duplicate, **ensure the discount column is shown** (so the
negative `Rabat` line we push as `TotDiscount` renders cleanly).
4. Note the `layoutNumber` of:
* The original (clean) layout → set as `invoiceLayoutNumber` in
`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
(admin override, or via the module config UI).
* The duplicate (with-discounts) layout → set as
`invoiceDiscountLayoutNumber` in
`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`.
5. Book a test invoice with a discount and a test invoice without, and
confirm the PDF looks right in each case.
---
## 9. Refs
* TRU-188 — original 400 on `/` in order reference (PR #391)
* TRU-193 — second-wave audit on extra fields, preflight validation
(`documentation/economic/export-field-audit.md`)
* PR #391 — initial sanitization fix
* `services/nginx/app/modules/economic/endpoints/economic_layouts_endpoint.php`
`GET /layouts` wrapper
* `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php`
`resolveLayoutNumber()` for single draft invoices
* `services/nginx/app/objects/collected_order_invoices_o.php`
`resolveInvoiceLayoutNumber()` for collected (batched) invoices
* E-conomic REST API docs: https://restdocs.e-conomic.com/ (search "Layouts")
@@ -1,274 +0,0 @@
# E-conomic Draft-Invoice Layout-Selection Flow (TRU-198)
**Status:** Complete (investigation only — no code changes)
**Date:** 2026-08-17
**Scope:** Inventory every code path in `copenhagentruckwash/api` that creates
an e-conomic draft invoice or sends draft lines, and document whether each
path currently picks a layout, which one it picks, and how the planned
**with-discounts / without-discounts** two-layout selection should apply.
**Related work:**
- TRU-197 (`documentation/economic/invoice-template-audit.md`) — picks the two
e-conomic layout numbers to use (one for clean invoices, one for invoices
that show itemized discounts).
- TRU-193 (`documentation/economic/export-field-audit.md`) — field-level audit
/ sanitization, unrelated to layout selection but consumed by the same code
paths.
- PR #391`economic_export_sanitizer`, the sanitizer that all draft-line
paths now run their text through.
---
## Overview
A draft invoice in this codebase is built in two phases:
1. **Create the draft envelope**`POST /invoices/drafts` with a payload
that contains `customer`, `paymentTerms`, `layout.layoutNumber`,
`recipient`, `currency`, `date`, etc. This is the only place where
`layout.layoutNumber` is set on the draft.
2. **Add lines to the draft**`POST /invoices/drafts/{id}/lines` with an
array of product / text / discount lines. Lines are added either one
order at a time (single-order draft flow) or in accumulated batches
(collected-invoice flow). The layout is **already fixed** at this point
and is not re-sent.
There are therefore only **two** code paths in the entire backend that
create the draft envelope and could pick a layout. Both already implement
a discount-aware selector that returns either `invoice_layout` (no
discounts) or `invoice_discount_layout` (itemized discounts present):
| Selector function | Used by | File |
|---|---|---|
| `collected_order_invoices_o::resolveInvoiceLayoutNumber()` | `collected_order_invoices_o::createInvoiceDraft()``economic_invoices_drafts_endpoint::add()` | `objects/collected_order_invoices_o.php:673` |
| `economic_invoice_draft_mo::resolveLayoutNumber()` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` |
The two selectors are independent implementations of the same idea. They
both:
1. Inspect the lines that will be sent (or the orders that will be added
to the draft).
2. If any line / order has a non-zero `discountPercentage` (or, in the
collected-invoice path, any "billable discount" per
`economic_invoice_draft::orderItemHasBillableDiscount()`), return
`invoice_discount_layout`.
3. Otherwise return `invoice_layout`.
4. Throw a `RuntimeException` / `Exception` if the discount layout is
required but `invoiceDiscountLayoutNumber` is unconfigured (≤ 0).
The two config variables are defined in:
- `services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`
`invoiceLayoutNumber`, `int`, **required** (default `1`).
- `services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`
`invoiceDiscountLayoutNumber`, `int`, **optional** (default `null`).
- Both are wired into `classes\economic::$config` via
`services/nginx/app/modules/economic/economic_c.php` lines 2548.
> **Net result of the audit:** the two-layout selection is already
> implemented in both places where a draft envelope is created. There is
> **no** code path that creates a draft without going through one of these
> two selectors. The migration is therefore a configuration change (set
> `invoiceDiscountLayoutNumber` to the layout TRU-197 picks), not a code
> change. See §5 *Migration plan* for the small set of files that still
> touch the layout topic and may need follow-up.
---
## 1. Inventory of code paths
The table below lists every PHP function in `services/nginx/app/` that
either (a) creates a draft invoice envelope (`POST /invoices/drafts`) or
(b) sends draft lines (`POST /invoices/drafts/{id}/lines`). Read-only
operations (`GET /invoices/drafts`, `GET /invoices/drafts/{id}/pdf`, the
diagnostic view in `orderInvoicesRoute.php`, and the `getInvoiceDraft`
helper) are excluded — they never pick a layout.
| # | File:line | Function | What it does | Picks layout? | Layout used | Discount-aware? | Recommendation |
|---|---|---|---|---|---|---|---|
| 1 | `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:107` | `economic_invoices_drafts_endpoint::add()` | Low-level `POST /invoices/drafts` envelope builder; accepts an optional `$layout_number` arg. | **Yes (caller-driven).** Sets `layout.layoutNumber` from the arg, falling back to `invoice_layout` if no arg is passed. | `invoice_layout` (default) or whatever the caller passes. | **No** — does not inspect lines. | Keep as-is. The two selector wrappers above already choose the right number before calling `add()`. |
| 2 | `modules/economic/invoices/draft/economicInvoicesDrafts.php:5` | `economicInvoicesDrafts::createInvoiceDraft()` | Raw `POST /invoices/drafts` used by the MO class; payload is built entirely by the caller. | **No (caller-driven).** The `data` array the caller passes must already contain `layout.layoutNumber`. | Whatever the caller put in `data['layout']['layoutNumber']`. | No. | Keep as-is. Only called by `economic_invoice_draft_mo::createInvoiceDraft()`, which itself goes through `resolveLayoutNumber()`. |
| 3 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:45` | `economic_invoice_draft_mo::createInvoiceDraftExample()` | The single-order draft envelope builder. Builds the full payload including `lines` and `layout.layoutNumber`, then calls `createInvoiceDraft()`. | **Yes — discount-aware.** Calls `resolveLayoutNumber()` (line 89) which returns `invoice_discount_layout` if any line has `discountPercentage > 0`, otherwise `invoice_layout`. | `invoice_layout` (no discount) or `invoice_discount_layout` (with discount). | **Yes** via `hasDiscountedItemizedLines()` (line 130). | **Already correct.** This is the canonical single-order selector — no changes needed for the 2-layout rollout. |
| 4 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:115` | `economic_invoice_draft_mo::resolveLayoutNumber()` (private) | The selector for path #3. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes. | Keep as-is. |
| 5 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:130` | `economic_invoice_draft_mo::hasDiscountedItemizedLines()` (private) | Line scan: any line with `product` set and `discountPercentage > 0`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
| 6 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:151` | `economic_invoice_draft_mo::createInvoiceDraft()` | Thin wrapper around `economicInvoicesDrafts::createInvoiceDraft()`. | No (caller-driven). | Whatever the caller put in `$data`. | No. | Keep as-is. |
| 7 | `modules/economic/invoices/draft/economic_invoice_draft_mo.php:209` | `economic_invoice_draft_mo::addLinesToInvoiceDraft()` | `POST /invoices/drafts/{id}/lines` — adds already-buffered `$this->lines` to an existing draft. | **No** — the draft's layout is already set when it was created. | Whatever the draft was created with. | n/a. | No change. Document that this path inherits the layout chosen by the selector that created the draft. |
| 8 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:114` | `economic_invoices_draft_endpoint::add_lines()` | Raw `POST /invoices/drafts/{id}/lines` with caller-supplied `$draft_lines`. | No. | n/a. | n/a. | No change. |
| 9 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:75` | `economic_invoices_draft_endpoint::add_orders()` | Iterates over `orders_o[]` and adds them to an existing draft via `economic_invoice_draft` (helper). Batched. | No. | n/a. | n/a (the helper may emit `use_itemized_discounts`-style lines, but those are *lines*, not layout). | No change. |
| 10 | `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:144` | `economic_invoices_draft_endpoint::add_environmental_and_oil_fees()` | Adds env/oil fee product lines to an existing draft. | No. | n/a. | n/a. | No change. |
| 11 | `modules/economic/helpers/economic_invoice_draft.php:124` | `economic_invoice_draft::addLines()` | Sends accumulated `$draft_lines` to `/invoices/drafts/{id}/lines`. Optionally runs preflight validation. | No. | n/a. | n/a. | No change. |
| 12 | `modules/economic/helpers/economic_invoice_draft.php:267` | `economic_invoice_draft::flushLinesInBatches()` | Splits `$draft_lines` into 500-line chunks and calls `sendDraftLines()` for each. | No. | n/a. | n/a. | No change. |
| 13 | `classes/economic_transfer_executor.php:24` | `economic_transfer_executor::exportOrderDraftInvoice()` | **Caller** for path #3. Builds `economic_invoice_draft_mo` per order, adds lines, then either appends to an open draft (via `addOrderToInvoiceDraft`) or creates a new draft (via `createInvoiceDraftExample`). | Inherits path #3's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #3). | No change. |
| 14 | `classes/economic_transfer_executor.php:192` | `economic_transfer_executor::exportCollectedInvoice()` | **Caller** for path #1's selector (via `collected_order_invoices_o::addToEconomic()``createInvoiceDraft()``resolveInvoiceLayoutNumber()`). | Inherits path #1's selector. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #15). | No change. |
| 15 | `classes/economic_transfer_executor.php:388` | `economic_transfer_executor::addOrderToInvoiceDraft()` | **Caller** for path #7. Appends an order's lines to an *existing* draft via `addLinesToInvoiceDraft()`. | No — draft already has a layout. | n/a. | n/a. | No change. The existing draft must already be on the right layout (chosen when the open draft was created). |
| 16 | `objects/collected_order_invoices_o.php:624` | `collected_order_invoices_o::createInvoiceDraft()` | The collected-invoice envelope builder. Resolves the layout via path #17, then calls `economic->invoices->drafts->add(..., $layout_number)`. | **Yes — discount-aware.** | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | **Already correct.** Canonical collected-invoice selector. |
| 17 | `objects/collected_order_invoices_o.php:673` | `collected_order_invoices_o::resolveInvoiceLayoutNumber()` (private) | The selector for path #16. | Yes. | `invoice_layout` or `invoice_discount_layout`. | Yes (via path #18). | Keep as-is. |
| 18 | `objects/collected_order_invoices_o.php:692` | `collected_order_invoices_o::hasDiscountedIncludedInvoiceItems()` | Iterates the orders on the collection; returns true if any included invoice item is a billable discount. | n/a (read-only) | n/a | Yes. | Keep as-is. |
| 19 | `objects/collected_order_invoices_o.php:709` | `collected_order_invoices_o::orderHasDiscountedIncludedInvoiceItems()` (private static) | Single-order version of #18; delegates to `economic_invoice_draft::orderItemHasBillableDiscount()`. | n/a (read-only) | n/a | Yes. | Keep as-is. |
| 20 | `objects/collected_order_invoices_o.php:564` | `collected_order_invoices_o::addToEconomic()` | The top-level "push this invoice collection to e-conomic" entry point. Calls path #16 then path #21. | Inherits path #16. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
| 21 | `objects/collected_order_invoices_o.php:925` | `collected_order_invoices_o::addInvoicesToDraft()` | After the envelope exists, iterates the orders and calls path #9 to add the line batches. | No — line-add path. | n/a. | n/a. | No change. |
| 22 | `routes/economicInvoiceRoute.php:~380410` | `economicInvoiceRoute::exportOrderToDraft()` (HTTP route handler) | HTTP wrapper around the executor's single-order flow. Builds `economic_invoice_draft_mo` and calls `createInvoiceDraftExample()` (path #3). | Inherits path #3. | `invoice_layout` or `invoice_discount_layout`. | Yes. | No change. |
**Read-only paths (excluded from the migration list):**
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:21``get(int $invoice_id)`
- `modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php:39``get_from_external_id(string $external_id)`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:35``get(array $filters, array $pagination)`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:60``get_all()`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:73``get_invoice_lines(array $invoice_ids, array $filters)`
- `modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php:201``exists(int $draft_invoice_number)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:200``getInvoiceDraft(int $int)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:170``getInvoicePdf(int $param)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:160``deleteInvoiceDraft(int $value)`
- `modules/economic/invoices/draft/economic_invoice_draft_mo.php:166``publishInvoiceDraft(int $invoiceDraftId)`**important**: this is the *book* step (`POST /invoices/booked` with `{draftInvoice:{draftInvoiceNumber:N}}`). It does not pick a layout; the booked invoice inherits the layout from the draft. Keep as-is.
- `routes/orderInvoicesRoute.php:2178` — diagnostic fetch (`$economic->invoices->draft->get(...)`)
- `modules/economic/helpers/economic_tasks.php:48, 192` — sanity / sync checks (read-only)
**Out of scope (no draft creation):**
- `classes/economic_v2_distribution_service.php` — distribution *reporting*
(read-only aggregations over booked invoices). Never creates a draft.
- `modules/economic/helpers/economic_invoice_booked.php` — the booked-invoice
data class. No HTTP calls.
---
## 2. Current state
- **Both** envelope creators (path #3 / `createInvoiceDraftExample` and path
#16 / `createInvoiceDraft`) already have a working discount-aware selector
that returns one of two layout numbers from the config store.
- The selectors read from the same two config variables
(`invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`) which are
already wired into `economic::$config` and surfaced in the
`EconomicConfigEntry` OpenAPI schema.
- The `invoiceDiscountLayoutNumber` config var is currently **optional**
(see `economic_invoice_discount_layout_c.php``setupConfigVariable(...,
true, ...)` with `required = true` in the call signature but the
constructor's third arg `false` means a null value is allowed; the
selectors throw if it is required and ≤ 0).
- The selectors are independent code paths. They each inspect lines
slightly differently:
- The MO selector (`hasDiscountedItemizedLines`) checks
`discountPercentage > 0` per line.
- The collected-invoice selector (`hasDiscountedIncludedInvoiceItems`)
delegates to `economic_invoice_draft::orderItemHasBillableDiscount`,
which checks for a `TotDiscount` product (negative net price) on
included invoice items.
- Both reach the same boolean result: *does this draft need the discount
layout?* — so the layout chosen by either selector is consistent.
---
## 3. Desired state
After TRU-197 picks the two layout numbers and the operator configures
them in the `economic` module:
- `invoiceLayoutNumber` = the layout TRU-197 picked for **clean**
invoices.
- `invoiceDiscountLayoutNumber` = the layout TRU-197 picked for
**discount** invoices.
Then:
- A single-order draft with no itemized discount goes out with
`layout.layoutNumber = invoiceLayoutNumber` (path #3 / selector #4).
- A single-order draft with an itemized discount goes out with
`layout.layoutNumber = invoiceDiscountLayoutNumber` (path #3 / selector
#4).
- A collected-invoice draft with no billable discount goes out with
`invoiceLayoutNumber` (path #16 / selector #17).
- A collected-invoice draft with a billable discount goes out with
`invoiceDiscountLayoutNumber` (path #16 / selector #17).
No code changes are required to achieve this — only the two config
variables need to be set in the `economic` module (and validated by
the superuser status probe at `superuser_system_status_service.php:866`).
---
## 4. Migration plan
Because the selectors already exist, the migration is a **configuration
rollout** plus a small handful of defensive tasks. Files to touch:
### 4.1 Required for rollout
- **`services/nginx/app/modules/economic/config/economic_invoice_layout_c.php`**
— confirm `invoiceLayoutNumber` is configured to TRU-197's "clean" layout.
- **`services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php`**
— set `invoiceDiscountLayoutNumber` to TRU-197's "discount" layout. (The
constructor signature already allows this to be a non-required variable,
but the selectors will throw a `RuntimeException` / `Exception` if the
discount layout is required and the value is 0 or null — so the rollout
must include setting this var in every environment.)
### 4.2 Verify-only (no edits expected)
- **`services/nginx/app/classes/superuser_system_status_service.php:866`**
— already lists `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`
as required keys for the `economic` module probe. Confirm the probe
treats `invoiceDiscountLayoutNumber` as required and surfaces a clear
error when missing (it currently appears in the `required` array, which
is the correct behavior).
- **`services/nginx/app/openapi.yaml:18644`** — `EconomicConfigEntry.variable`
enum already includes `invoiceDiscountLayoutNumber`. No change.
- **`services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php:893894`**
— test fixtures already cover both layout config vars. Confirm values
match TRU-197's picks.
### 4.3 Optional follow-ups (not blocking the rollout)
- **Defensive logging** in the two selector functions
(`economic_invoice_draft_mo::resolveLayoutNumber` and
`collected_order_invoices_o::resolveInvoiceLayoutNumber`) to log which
layout was chosen and why (e.g.
`[TRU-198] draft {id} uses discount layout (3 discounted lines)`).
This is useful for post-rollout verification in the e-conomic UI.
- **A single, shared selector helper** that both paths use, to avoid
drift between the two private selectors. Recommended location:
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
or a new
`services/nginx/app/modules/economic/helpers/economic_invoice_layout_resolver.php`.
Out of scope for the configuration rollout; consider for a follow-up
refactor.
- **E2E / integration test** that:
1. Creates a single-order draft with at least one discounted line and
asserts the resulting draft's `layout.layoutNumber` equals
`invoiceDiscountLayoutNumber`.
2. Creates a single-order draft with no discounted lines and asserts
`invoiceLayoutNumber`.
3. Creates a collected-invoice draft with at least one
`TotDiscount` line and asserts `invoiceDiscountLayoutNumber`.
4. Creates a collected-invoice draft with no `TotDiscount` lines and
asserts `invoiceLayoutNumber`.
See `tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php` and
`EconomicLegacyDraftPayloadWiringTest.php` for the existing patterns.
### 4.4 Files that explicitly need NO changes
- `services/nginx/app/classes/economic_v2_distribution_service.php`
distribution reporting, not a draft creator.
- `services/nginx/app/modules/economic/helpers/economic_invoice_booked.php`
— booked-invoice data class.
- All `add_lines` / `addLines` / `addLinesToInvoiceDraft` / `flushLinesInBatches`
/ `add_environmental_and_oil_fees` paths — they operate on an existing
draft whose layout was fixed at create time.
---
## 5. Summary
| Metric | Count |
|---|---|
| Code paths in `services/nginx/app/` that create or send draft invoices | **22** (2 envelope creators + 6 line-add paths + 14 caller / selector / helper paths) |
| Paths that currently pick a layout | **2** (`economic_invoice_draft_mo::createInvoiceDraftExample` and `collected_order_invoices_o::createInvoiceDraft`, both via private selectors) |
| Paths that need updating for the 2-layout rollout | **0** — both selectors already implement the with/without-discount logic |
| Config variables that drive the 2-layout selection | 2 — `invoiceLayoutNumber` (required, default 1) and `invoiceDiscountLayoutNumber` (optional, default null). Already wired into `economic::$config` and the OpenAPI schema. |
| Files that need editing for the rollout | 2 — `economic_invoice_layout_c.php` and `economic_invoice_discount_layout_c.php` (config only) |
The 2-layout selection is already wired through the backend. The TRU-198
investigation confirms that the rollout reduces to setting the two
`invoice*LayoutNumber` config variables to the layout numbers TRU-197
picks, plus optional defensive logging and an E2E test for verification.
@@ -1,323 +0,0 @@
# TRU-62 — Customer search / transaction history slow (~10s)
**Investigation date:** 2026-08-17
**Branch:** `feat/TRU-62-perf-customer-search`
**Investigator:** automated perf-investigation agent
**Test DB:** none available locally (no MySQL/MariaDB installed in sandbox). Analysis is **static** + based on code paths.
---
## 1. Summary
Both "search on customer tab" (~10s) and "transaction history" slowness are caused by **un-indexable `LIKE '%term%'` predicates** over text columns of the local MySQL database, combined with a **5-minute dirty-index window** that disables the existing FULLTEXT-backed search index path.
The customer-tab search lives in two places; both are slow for different reasons:
| Surface | Endpoint | Where the slowness is | Indexable today? |
| --- | --- | --- | --- |
| Customer tab (backoffice) | `POST /search/system` + `GET /search/system` (`routes/systemSearchRoute.php`) | `system_search_service::searchCustomers` runs `LIKE '%term%'` over 13 fields, joined to a denormalized e-conomic table | **No** (leading wildcard) |
| Customer tab (legacy) | `GET /customers` (`routes/customerSearchRoute.php`) | Outbound call to e-conomic REST API with multiple `$like` filters | N/A (third-party) |
| Transaction history | `GET /orders` (`routes/ordersRoute.php`) | `db_object_t::listObjectsWithPagination` runs `LIKE '%term%'` over **every** column of the `orders` view | **No** (leading wildcard, plus view) |
---
## 2. Root causes (ranked)
### RC1 — `LIKE '%term%'` is a full table scan (the #1 cause)
**Where:** `services/nginx/app/classes/system_search_service.php` (the `searchTable` + `searchTableWithJoin` helpers at lines ~1888 and ~1968) and `services/nginx/app/traits/db_object_t.php` (the `listObjectsWithPagination` builder at lines ~510600).
```php
// system_search_service.php — searchTableWithJoin() (excerpt)
$termClauses = [];
foreach ($terms as $term) {
$escaped = $db->escape_string($term);
foreach ($searchFields as $field) {
$termClauses[] = "$field LIKE '%$escaped%'";
}
}
```
```php
// db_object_t.php — listObjectsWithPagination() (excerpt)
foreach ( $fields as $field ) {
$searchClauses[] = "`$field` LIKE ?";
$params[] = "%$search%";
}
```
* A B-tree index **cannot** be used because of the leading wildcard. MySQL is forced to scan every row of the target table.
* For the customer search the `OR` chain has **13 predicates** (5 on `users` + 8 on `system_search_economic_customer_index`). The optimizer cannot pick a single index.
* For the order list, `$fields` defaults to *every* column of the `orders_with_invoice_collections` view (22 columns). Every search term is replicated against all of them, all ORed together.
**Symptom → data size (estimate).**
| `users` rows | `orders` rows | customer tab (LCP99) | transaction history (LCP99) |
| --- | --- | --- | --- |
| 1k | 100k | ~50ms | ~300ms |
| 10k | 1M | ~500ms | ~3s |
| 50k+ | 5M+ | ~310s ❌ | ~10s+ ❌ |
The reported 10s lines up with the upper part of that table (Danish truck-wash customer base has tens of thousands of customers and millions of historical orders).
### RC2 — The existing FULLTEXT index is bypassed for up to 5 minutes after every write
There is already a denormalized, FULLTEXT-indexed `system_search_documents` table (`FULLTEXT KEY ft_ssd_text (title, description, search_text)`, see `classes/system_search_document_index.php` line 44). `executeLexicalSearch` *prefers* the indexed path when no dirty tables exist:
```php
// system_search_service.php — executeLexicalSearch() (excerpt)
if ($this->canUseIndexedSearch($entityType, $dirtyTables)) {
$rows = $this->searchIndexedEntity(...); // FULLTEXT MATCH AGAINST
} else {
$rows = $this->searchEntity(...); // LIKE fallback (RC1)
}
```
The dirty flag is set on **every** user write via `db_object_t::markSystemSearchDirtyTable` (line 73). The `SystemSearchCacheMaintenanceCron` (`cron/Cron.php` line 704) rebuilds the index every **300 s** (5 min). Therefore:
* Any user write (login, profile update, password reset, subuser grant, etc.) ⇒ customer search degrades to LIKE for up to 5 minutes.
* In a normal backoffice the table is almost always dirty ⇒ the FULLTEXT path is almost never used ⇒ RC1 dominates.
### RC3 — `searchCustomers` joins two large tables and ORs the predicates
`services/nginx/app/classes/system_search_service.php` lines 713820:
```php
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `system_search_economic_customer_index` sci ON sci.customer_number = u.customer_number';
}
$rows = $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields, // 13 fields
$terms,
'1=1' . $customerFilter
);
```
The LEFT JOIN with an OR over 13 columns forces MySQL into a full scan of both tables. There is no `LIMIT` pushdown and no covering index. Even with a moderate number of users, this is the worst case for the optimizer.
### RC4 — e-conomic customer search goes off-box and can't be tuned locally
`GET /customers` (`routes/customerSearchRoute.php`) delegates to `customers/economicCustomers::listCustomers()`, which assembles a `where: $or: [name $like %term%, address $like %term%, ...]` filter for the e-conomic REST API. Latency there is third-party; we cannot add an index on their side. **The only way to make this endpoint fast is to cache results locally.**
### RC5 — `orders` search is run against the `orders_with_invoice_collections` view, not the base table
`GET /orders` sets `$orders->setView('orders_with_invoice_collections')` and then calls `listObjectsWithPaginationIfSet`. The default `searchableFields` is empty, so `listObjectsWithPagination` falls back to **every** column of the view, including JSON columns. No index on a view can satisfy a `LIKE '%x%'`; the optimizer materializes the row set and filters in place.
### RC6 — `users.display_name` has no index at all
From `tests/Support/Api/ApiSchemaBootstrap.php` (the canonical schema):
```sql
CREATE TABLE IF NOT EXISTS `users` (
...
KEY `idx_users_customer_number` (`customer_number`),
KEY `idx_users_group_id` (`group_id`)
);
```
There is no index on `display_name`, `email`, or `phone` even though those are the primary search targets. (We still need a FULLTEXT for the `LIKE '%x%'` pattern, but the B-tree index would help prefix searches and equality lookups.)
---
## 3. SQL queries involved (verbatim paths)
### 3.1 Customer search via the unified search endpoint
`classes/system_search_service.php` lines 713820 produce something like:
```sql
SELECT u.id, u.customer_number, u.display_name, u.email, u.phone,
sci.economic_name, sci.economic_address, ..., sci.search_text
FROM users u
LEFT JOIN system_search_economic_customer_index sci
ON sci.customer_number = u.customer_number
WHERE 1=1
AND ( u.id LIKE '%foo%' OR u.customer_number LIKE '%foo%'
OR u.display_name LIKE '%foo%' OR u.email LIKE '%foo%'
OR u.phone LIKE '%foo%' OR sci.economic_name LIKE '%foo%'
OR sci.economic_address LIKE '%foo%' OR sci.economic_city LIKE '%foo%'
OR sci.economic_zip LIKE '%foo%' OR sci.economic_email LIKE '%foo%'
OR sci.economic_cvr LIKE '%foo%' OR sci.economic_mobile_phone LIKE '%foo%'
OR sci.search_text LIKE '%foo%' )
LIMIT 50
```
* No index usable ⇒ full table scan of `users` × `system_search_economic_customer_index`.
* Cost grows linearly with row count; with a 5-token query and 13 fields per token this is **65 LIKE clauses** in a single query.
### 3.2 Order list / transaction history
`traits/db_object_t.php` lines ~547556 produce, for a search of `foo` and a filter `customer_id:123`:
```sql
SELECT *
FROM orders_with_invoice_collections
WHERE customer_id = 123
AND deleted_at IS NULL
AND ( id LIKE '%foo%' OR customer_id LIKE '%foo%' OR cashier_id LIKE '%foo%'
OR department_id LIKE '%foo%' OR reference LIKE '%foo%' OR notes LIKE '%foo%'
OR reg_1 LIKE '%foo%' OR reg_2 LIKE '%foo%' OR reg_3 LIKE '%foo%'
OR invoice_collection_id LIKE '%foo%' OR booking_id LIKE '%foo%'
OR wash_id LIKE '%foo%' OR lane LIKE '%foo%' OR po LIKE '%foo%'
OR safety_seal LIKE '%foo%' OR using_hand_held LIKE '%foo%'
OR include_in_invoice LIKE '%foo%' OR created_at LIKE '%foo%'
OR updated_at LIKE '%foo%' OR completed_at LIKE '%foo%'
OR deleted_at LIKE '%foo%' OR invoice_period_id LIKE '%foo%' )
ORDER BY id ASC
LIMIT ? OFFSET ?
```
* 22 ORed LIKE clauses against the view, all un-indexable.
* The existing composite index `idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)` is wasted — the `customer_id` filter is materialized by the LIKE scan, not by the index.
---
## 4. Schema snapshots
### `users` (from `tests/Support/Api/ApiSchemaBootstrap.php`)
```sql
PRIMARY KEY (id)
KEY idx_users_customer_number (customer_number)
KEY idx_users_group_id (group_id)
-- Missing: KEY/FULLTEXT on (display_name, email, phone)
```
### `orders` (from `ApiSchemaBootstrap.php` + `classes/orders_schema_bootstrap.php`)
```sql
PRIMARY KEY (id)
KEY idx_orders_customer_id (customer_id)
KEY idx_orders_department_id (department_id)
KEY idx_orders_invoice_collection_id (invoice_collection_id)
KEY idx_orders_reg_1 (reg_1)
KEY idx_orders_period_customer_created_deleted (customer_id, created_at, deleted_at)
KEY idx_orders_period_created_deleted_customer (created_at, deleted_at, customer_id)
-- Missing: FULLTEXT on (reference, notes, reg_1, reg_2, reg_3, po)
```
### `system_search_economic_customer_index` (from `classes/system_search_economic_customer_index.php`)
```sql
PRIMARY KEY (customer_number)
INDEX idx_system_search_econ_customer_user (user_id)
INDEX idx_system_search_econ_customer_name (economic_name)
INDEX idx_system_search_econ_customer_email (economic_email)
INDEX idx_system_search_econ_customer_cvr (economic_cvr)
-- Missing: FULLTEXT on (search_text)
```
### `system_search_documents` (from `classes/system_search_document_index.php`)
```sql
PRIMARY KEY (entity_type, entity_id)
INDEX idx_ssd_customer (customer_number)
INDEX idx_ssd_department (department_id)
INDEX idx_ssd_entity (entity_type)
FULLTEXT KEY ft_ssd_text (title, description, search_text) -- ✓ already exists
```
**Note.** The denormalized `search_text` column already exists in `system_search_economic_customer_index`; it is exactly the right thing to FULLTEXT-index, but the index is missing.
---
## 5. EXPLAIN (expected)
I could not run EXPLAIN locally (no MySQL/MariaDB in the sandbox; this constraint is honored — no prod touched). For the customer search query the expected plan is:
```
type: ALL -- full table scan
key: NULL
rows: N (all users)
Extra: Using where
```
For the order list query the expected plan against the view is:
```
type: ALL
key: NULL
rows: N
Extra: Using where; Using filesort
```
Once a FULLTEXT index is added the same queries should become:
```
type: fulltext
key: ft_xxx
rows: O(log N)
Extra: Using where; Ft_hints: ...
```
---
## 6. Recommended fixes (ordered by ROI)
| # | Fix | Estimated effort | Estimated impact | Risk |
| --- | --- | --- | --- | --- |
| **F1** | Add `FULLTEXT` index on `system_search_economic_customer_index.search_text` and switch `searchCustomers` to `MATCH … AGAINST` (with LIKE fallback) | 1 migration + ~50 lines | Customer tab 10s → <200ms | Low — LIKE fallback preserved |
| **F2** | Stop marking the whole `users` table dirty on every row write; scope the dirty marker to the affected `customer_number` (or remove the per-row mark entirely and rely on the cron) | ~30 lines | Eliminates the 5-min FULLTEXT-disabled window ⇒ sustained <200ms | Low — cron is already idempotent |
| **F3** | Add `FULLTEXT` index on `orders (reference, notes, reg_1, reg_2, reg_3, po)` and tighten `listObjectsWithPagination` to a small explicit field list for the orders route | 1 migration + ~30 lines | Transaction history 10s → <500ms | Low — must update `setSearchableFields` callsite |
| **F4** | Cache the e-conomic customer search results in Redis with a short TTL (e.g. 60 s) keyed by query | ~40 lines | `/customers` latency bound by cache TTL | Low — cache invalidation on import already wired |
| **F5** | Document `users` and add a B-tree on `display_name` for prefix searches / equality lookups | 1 migration | Minor — only helps when there is *no* leading wildcard | None |
| **F6** | (follow-up, separate ticket) | Decouple e-conomic customer sync from the request path and pre-warm the search index in a background job | n/a | n/a |
### Recommended sequencing
The **F1** fix alone will take the customer tab from ~10s to <200ms in the common case (when the dirty index is not too stale) and is a single migration + single-method refactor — well within the "obvious minimum fix" budget. The F2 / F3 / F4 follow-ups are tracked as separate Linear issues.
---
## 7. Implementation plan (this PR)
This PR ships **F1 only**, as a low-risk drop-in:
1. New migration file: `services/nginx/app/database/migrations/2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index.php` that emits:
```sql
ALTER TABLE `system_search_economic_customer_index`
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`);
```
* Self-healing: also add a `classes/system_search_economic_customer_index_fulltext_schema_bootstrap.php` to apply the same `ALTER` at runtime, mirroring the existing pattern.
2. `system_search_service::searchCustomers`: when the FULLTEXT index is present, run
```sql
SELECT … FROM users u LEFT JOIN system_search_economic_customer_index sci …
WHERE MATCH(sci.search_text) AGAINST (? IN BOOLEAN MODE)
```
and only fall back to the 13-clause OR if MATCH returns zero rows.
3. A unit test (`tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php`) that:
* Stubs `$db` to record the last query.
* Asserts that when the FULLTEXT index is reported as available, the emitted SQL contains `MATCH(...) AGAINST`.
* Asserts that the LIKE fallback still runs when MATCH returns no rows.
### What this PR does **not** do
* No changes to `/customers` (e-conomic) — that needs F4 (cache) which is a separate ticket.
* No changes to `/orders` — that needs F3 (FULLTEXT on `orders`) which is a separate ticket.
* No schema changes to `users`.
* No changes to the cron / dirty-table logic (F2).
These are tracked as follow-up issues.
---
## 8. Test impact
* `tests/Unit/Search/*` (existing): 7 tests, all currently pass.
* New test: `tests/Unit/Search/SystemSearchFulltextCustomerIndexTest.php` — verifies the new behaviour.
* Baseline (Unit suite): **1399 passed, 10 pre-existing failures (not related to this issue)**.
The 10 pre-existing failures are in `Tests\Unit\Selfserve\EdgeGatewayRelayExecutionTimerTest`,
`Tests\Unit\Tooling\ComposerEntrypointTest`, etc. They are environmental and present on
`master` before this change.
---
## 9. Open questions / follow-ups
* Q1: Is `/customers` (e-conomic) actually a hot path, or is the customer tab now using only `/search/system`? If `/customers` is hot, F4 (cache) becomes critical.
* Q2: How long does the e-conomic customer API actually take from this environment? (We can't measure from the sandbox.) If <1s, the e-conomic latency is not a contributor and we can deprioritize F4.
* Q3: Confirm table sizes in production so we can size the FULLTEXT minimum word length / `ft_min_word_len` / `innodb_ft_min_token_size` correctly.
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env php8.4
<?php
/**
* Live verification of e-conomic draft invoice creation using customer 12345679.
*
* This script:
* 1. Connects to the real e-conomic API (requires env credentials)
* 2. Creates a draft invoice for customer 12345679 with TEST items
* 3. Verifies the draft was created correctly
* 4. DELETES the draft to clean up
*
* Usage (on production server with credentials):
* php8.4 verify-economic-drafts-live.php
*
* Required env vars (set in .env or pass inline):
* ECONOMIC_API_APP_ACCESS_GRANT
* ECONOMIC_API_APP_SECRET_TOKEN
*
* Optional:
* ECONOMIC_CUSTOMER_NUMBER=12345679 (default)
* ECONOMIC_API_BASE_URL=... (default: https://restapi.e-conomic.com)
*
* Exit codes:
* 0 = all verifications passed, draft cleaned up
* 1 = error during verification
* 2 = cleanup failed (draft still exists, manual intervention required)
*/
declare(strict_types=1);
// 1. Load credentials
$grant = getenv('ECONOMIC_API_APP_ACCESS_GRANT');
$secret = getenv('ECONOMIC_API_APP_SECRET_TOKEN');
$customer = (int)(getenv('ECONOMIC_CUSTOMER_NUMBER') ?: '12345679');
$baseUrl = getenv('ECONOMIC_API_BASE_URL') ?: 'https://restapi.e-conomic.com';
if (!$grant || !$secret) {
fwrite(STDERR, "ERROR: ECONOMIC_API_APP_ACCESS_GRANT and ECONOMIC_API_APP_SECRET_TOKEN must be set\n");
fwrite(STDERR, " This script must be run on the production server or in CI with secrets.\n");
exit(1);
}
$auth = 'X-AppSecretToken: ' . $secret . "\r\n" . 'Authorization: Bearer ' . $grant . "\r\n";
/**
* Send a request to the e-conomic API.
*
* @return array{status: int, body: string, json?: array}
*/
function econ_request(string $method, string $url, ?array $body = null): array
{
global $auth;
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
trim(explode("\r\n", $auth)[0]),
trim(explode("\r\n", $auth)[1]),
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false) {
return ['status' => 0, 'body' => $error];
}
$json = json_decode($response, true);
return ['status' => $status, 'body' => $response, 'json' => $json];
}
$draftInvoiceNumber = null;
$pass = 0;
$fail = 0;
$total = 0;
function check(string $name, bool $ok, string $detail = ''): void
{
global $pass, $fail, $total;
$total++;
if ($ok) {
$pass++;
echo "$name\n";
if ($detail) echo " $detail\n";
} else {
$fail++;
echo "$name\n";
if ($detail) echo " $detail\n";
}
}
echo "=== E-conomic Live Draft Verification ===\n";
echo "Customer: $customer\n";
echo "API base: $baseUrl\n\n";
try {
// ------------------------------------------------------------------
// Step 1: Verify customer exists
// ------------------------------------------------------------------
echo "Step 1: Verify customer $customer exists...\n";
$resp = econ_request('GET', "$baseUrl/customers/$customer");
check('Customer exists', $resp['status'] === 200, "HTTP {$resp['status']}");
if ($resp['status'] !== 200) {
echo "Cannot proceed without valid customer. Body: " . substr($resp['body'], 0, 200) . "\n";
exit(1);
}
$customerName = $resp['json']['name'] ?? 'unknown';
echo " Customer name: $customerName\n\n";
// ------------------------------------------------------------------
// Step 2: Create draft invoice
// ------------------------------------------------------------------
echo "Step 2: Create draft invoice for customer $customer...\n";
$resp = econ_request('POST', "$baseUrl/invoices/drafts", [
'currency' => 'DKK',
'customer' => ['customerNumber' => $customer],
'paymentTerms' => ['paymentTermsNumber' => 1],
'layout' => ['layoutNumber' => 1],
'recipient' => ['name' => 'OpenClaw Live Verification'],
'notes' => ['heading' => 'Live verification', 'textLine1' => 'Created by verify-economic-drafts-live.php', 'textLine2' => 'Will be deleted automatically'],
]);
check('Draft invoice created', $resp['status'] === 201, "HTTP {$resp['status']}");
if ($resp['status'] !== 201) {
echo "Cannot create draft. Body: " . substr($resp['body'], 0, 300) . "\n";
exit(1);
}
$draftInvoiceNumber = $resp['json']['draftInvoiceNumber'] ?? null;
echo " Draft invoice number: $draftInvoiceNumber\n\n";
if (!$draftInvoiceNumber) {
echo "No draftInvoiceNumber returned. Body: " . substr($resp['body'], 0, 300) . "\n";
exit(1);
}
// ------------------------------------------------------------------
// Step 3: Add test lines to draft
// ------------------------------------------------------------------
echo "Step 3: Add 2 product lines (1 with discount, 1 without)...\n";
$lines = [
[
'product' => ['productNumber' => 'OPENCLAW-TEST-01'],
'quantity' => 1.0,
'unitNetPrice' => 100.00,
'discountPercentage' => 0.0,
'description' => 'Test line 1: no discount (verify-economic-drafts-live.php)',
],
[
'product' => ['productNumber' => 'OPENCLAW-TEST-02'],
'quantity' => 2.0,
'unitNetPrice' => 200.00,
'discountPercentage' => 15.0,
'description' => 'Test line 2: 15% discount (verify-economic-drafts-live.php)',
],
];
$resp = econ_request('POST', "$baseUrl/invoices/drafts/$draftInvoiceNumber/lines", [
'lines' => $lines,
]);
check('Lines added to draft', $resp['status'] === 200, "HTTP {$resp['status']}, " . count($lines) . " lines");
// ------------------------------------------------------------------
// Step 4: Verify draft contents
// ------------------------------------------------------------------
echo "\nStep 4: Verify draft contents...\n";
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
$draft = $resp['json'] ?? [];
$draftLines = $draft['lines'] ?? [];
check('Draft has 2 lines', count($draftLines) === 2, 'found ' . count($draftLines));
check('Customer is 12345679', ($draft['customer']['customerNumber'] ?? 0) === $customer);
check('Line 1 has 0% discount', abs(($draftLines[0]['discountPercentage'] ?? -1)) < 0.01);
check('Line 2 has 15% discount', abs(($draftLines[1]['discountPercentage'] ?? -1) - 15.0) < 0.01);
// ------------------------------------------------------------------
// Step 5: Cleanup - delete the draft
// ------------------------------------------------------------------
echo "\nStep 5: Cleanup - delete draft $draftInvoiceNumber...\n";
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
check('Draft deleted', $resp['status'] === 204 || $resp['status'] === 200, "HTTP {$resp['status']}");
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
echo "\n⚠️ WARNING: Cleanup failed. Draft $draftInvoiceNumber still exists in e-conomic.\n";
echo " Delete it manually: curl -X DELETE -H \"$auth\" $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
exit(2);
}
// ------------------------------------------------------------------
// Step 6: Verify deletion
// ------------------------------------------------------------------
echo "\nStep 6: Verify draft is gone...\n";
$resp = econ_request('GET', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
check('Draft no longer exists', $resp['status'] === 404, "HTTP {$resp['status']} (expected 404)");
} catch (\Throwable $e) {
echo "\n💥 UNCAUGHT ERROR: " . $e->getMessage() . "\n";
echo "Stack trace:\n" . $e->getTraceAsString() . "\n";
// Best-effort cleanup
if ($draftInvoiceNumber !== null) {
echo "\nAttempting emergency cleanup of draft $draftInvoiceNumber...\n";
$resp = econ_request('DELETE', "$baseUrl/invoices/drafts/$draftInvoiceNumber");
echo " Cleanup HTTP status: {$resp['status']}\n";
if ($resp['status'] !== 204 && $resp['status'] !== 200) {
echo " ⚠️ MANUAL CLEANUP REQUIRED: DELETE $baseUrl/invoices/drafts/$draftInvoiceNumber\n";
exit(2);
}
}
exit(1);
}
echo "\n=== Summary: $pass/$total checks passed ===\n";
exit($fail === 0 ? 0 : 1);
@@ -1,227 +0,0 @@
<?php
namespace classes;
/**
* Static utility for generating, formatting, hashing, and parsing
* API keys.
*
* Key format: <prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
* e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
*
* The key_id (everything before the dot) is stored in plain text in
* the database as the lookup key. The secret is NEVER stored in plain
* text — only the argon2id hash is persisted. The full key is shown
* to the user exactly once at creation time.
*/
class api_key_generator
{
/** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */
public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
/** Characters permitted in the public key_id portion. */
public const KEY_ID_RANDOM_LENGTH = 22;
/** Characters in the secret portion. */
public const SECRET_LENGTH = 32;
/**
* Build the public key_id portion: <prefix>_<env>_<random>.
*/
public static function generateKeyId(string $env = 'live'): string
{
$env = self::normaliseEnv($env);
$prefix = self::prefix();
$random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH);
return $prefix . '_' . $env . '_' . $random;
}
/**
* Generate the secret portion (32-char base62).
*/
public static function generateSecret(): string
{
return self::randomBase62(self::SECRET_LENGTH);
}
/**
* Join key_id and secret with a single dot.
*/
public static function formatKey(string $keyId, string $secret): string
{
if ($keyId === '' || strpos($keyId, '.') !== false) {
throw new \InvalidArgumentException('key_id must not contain a dot');
}
if ($secret === '' || strpos($secret, '.') !== false) {
throw new \InvalidArgumentException('secret must not contain a dot');
}
return $keyId . '.' . $secret;
}
/**
* Hash the full key (or just the secret) using argon2id.
*/
public static function hash(string $plain): string
{
if ($plain === '') {
throw new \InvalidArgumentException('Cannot hash an empty value');
}
$hash = password_hash($plain, PASSWORD_ARGON2ID);
if ($hash === false) {
throw new \RuntimeException('Failed to hash with argon2id');
}
return $hash;
}
/**
* Verify a plaintext key against a stored argon2id hash.
*/
public static function verify(string $plain, string $hash): bool
{
if ($plain === '' || $hash === '') {
return false;
}
try {
return password_verify($plain, $hash);
} catch (\Throwable) {
return false;
}
}
/**
* Split a full "key_id.secret" string back into its parts.
*
* The key_id may contain underscores (as separators between
* prefix/env/random) and must be base62 + underscores. The
* secret must be strictly base62 with no separators.
*
* @return array{key_id:string, secret:string}|null
* null if the input is malformed.
*/
public static function parseKey(string $full): ?array
{
$full = trim($full);
if ($full === '' || strpos($full, '.') === false) {
return null;
}
// Split on the FIRST dot only — secrets are base62 and contain
// no dots, so there's exactly one separator.
$parts = explode('.', $full, 2);
if (count($parts) !== 2) {
return null;
}
[$keyId, $secret] = $parts;
$keyId = trim($keyId);
$secret = trim($secret);
if ($keyId === '' || $secret === '') {
return null;
}
// The key_id is "<prefix>_<env>_<random>" — base62 with
// underscore separators. The secret is pure base62.
if (!self::isKeyId($keyId) || !self::isBase62($secret)) {
return null;
}
return ['key_id' => $keyId, 'secret' => $secret];
}
/**
* Validate a key_id string: base62 with optional underscore
* separators. Exposed for testing.
*/
public static function isKeyId(string $value): bool
{
if ($value === '') {
return false;
}
return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1;
}
/**
* Configurable prefix (default: "truck"). Reads from
* `config('api_key.prefix', 'truck')` if available, otherwise the
* default. Always lowercased and stripped of separators.
*/
public static function prefix(): string
{
$default = 'truck';
$value = $default;
if (function_exists('config')) {
try {
$candidate = config('api_key.prefix', $default);
if (is_string($candidate) && $candidate !== '') {
$value = $candidate;
}
} catch (\Throwable) {
$value = $default;
}
}
$value = strtolower(trim((string)$value));
$value = preg_replace('/[^a-z0-9_]/', '', $value) ?? '';
if ($value === '') {
$value = $default;
}
return $value;
}
/**
* @internal — exposed for testing.
*/
public static function randomBase62(int $length): string
{
if ($length < 1) {
throw new \InvalidArgumentException('Length must be positive');
}
$alphabet = self::ALPHABET;
$alphabetMax = strlen($alphabet) - 1; // 61
$out = '';
$bytesNeeded = (int)ceil($length * 1.3) + 8;
$bytes = random_bytes($bytesNeeded);
$byteIndex = 0;
while (strlen($out) < $length) {
if (!isset($bytes[$byteIndex])) {
$bytes = random_bytes($bytesNeeded);
$byteIndex = 0;
}
// Mask off 0xC0 to get a value 0-63, then reject > 61 to
// avoid modulo bias.
$byte = ord($bytes[$byteIndex]);
$byteIndex++;
$value = $byte & 0x3F;
if ($value > $alphabetMax) {
continue;
}
$out .= $alphabet[$value];
}
return $out;
}
/**
* @internal — exposed for testing.
*/
public static function isBase62(string $value): bool
{
if ($value === '') {
return false;
}
return preg_match('/^[0-9A-Za-z]+$/', $value) === 1;
}
private static function normaliseEnv(string $env): string
{
$trimmed = strtolower(trim($env));
$sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? '';
// If the input contained characters outside the allowed
// set, the sanitised result will differ from the trimmed
// input — in that case fall back to "live" rather than
// echoing a mangled version. Empty / whitespace-only input
// also falls back to "live".
if ($sanitised === '' || $sanitised !== $trimmed) {
return 'live';
}
return $sanitised;
}
}
@@ -1,249 +0,0 @@
<?php
namespace classes;
use Exception;
use Throwable;
/**
* Repository for the `api_keys` table.
*
* This is a thin procedural wrapper that uses the project's existing
* `$db` global (mysqli) — no Eloquent, no ORM. The pattern matches
* other repositories in this codebase (see `classes/orders_o.php`,
* `classes/invoice_store.php`, etc.).
*
* Records are returned as associative arrays. The caller is expected
* to interact with them as plain dicts; there is no dedicated model
* class for api keys.
*/
class api_key_repository
{
public const TABLE = 'api_keys';
private static function db()
{
global $db;
if (!isset($db) || !is_object($db)) {
throw new Exception('Database connection ($db) is not available');
}
// Lazy-create the table on first use so callers don't have to
// remember to call ensureTables().
if (class_exists(api_key_schema_bootstrap::class)) {
api_key_schema_bootstrap::ensureTables();
}
return $db;
}
/**
* Validate the input data for create(). Exposed so test doubles
* can exercise the same validation without touching a real DB.
*
* @param array<string, mixed> $data
*/
public static function validate(array $data): void
{
$required = ['key_id', 'key_hash', 'name', 'role'];
foreach ($required as $field) {
if (!isset($data[$field]) || !is_string($data[$field]) || $data[$field] === '') {
throw new \InvalidArgumentException("Missing required field: {$field}");
}
}
$allowedRoles = ['superuser', 'admin', 'customer', 'subuser'];
if (!in_array($data['role'], $allowedRoles, true)) {
throw new \InvalidArgumentException("Invalid role: {$data['role']}");
}
}
/**
* @param array<string, mixed> $data
* @return int inserted id
*/
public static function create(array $data): int
{
self::validate($data);
$db = self::db();
$scopesJson = isset($data['scopes']) && $data['scopes'] !== null
? (is_string($data['scopes']) ? $data['scopes'] : json_encode($data['scopes'], JSON_UNESCAPED_SLASHES))
: null;
$stmt = $db->conn()->prepare(
'INSERT INTO api_keys (key_id, key_hash, name, role, scopes, customer_id, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
if ($stmt === false) {
throw new Exception('Failed to prepare insert: ' . $db->conn()->error);
}
$customerId = isset($data['customer_id']) ? (int)$data['customer_id'] : null;
$createdBy = isset($data['created_by']) ? (int)$data['created_by'] : null;
$expiresAt = isset($data['expires_at']) && $data['expires_at'] !== null
? (string)$data['expires_at']
: null;
$stmt->bind_param(
'sssssiss',
$data['key_id'],
$data['key_hash'],
$data['name'],
$data['role'],
$scopesJson,
$customerId,
$createdBy,
$expiresAt
);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to insert api_key: ' . $err);
}
$id = $stmt->insert_id;
$stmt->close();
return (int)$id;
}
/**
* Find a non-revoked key by its public key_id.
*
* @return array<string, mixed>|null
*/
public static function findActiveByKeyId(string $keyId): ?array
{
if ($keyId === '') {
return null;
}
$db = self::db();
$stmt = $db->conn()->prepare(
'SELECT * FROM api_keys WHERE key_id = ? AND revoked_at IS NULL LIMIT 1'
);
if ($stmt === false) {
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
}
$stmt->bind_param('s', $keyId);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to execute select: ' . $err);
}
$result = $stmt->get_result();
$row = $result ? $result->fetch_assoc() : null;
$stmt->close();
return $row ?: null;
}
/**
* Find any key by id (including revoked).
*
* @return array<string, mixed>|null
*/
public static function findById(int $id): ?array
{
$db = self::db();
$stmt = $db->conn()->prepare('SELECT * FROM api_keys WHERE id = ? LIMIT 1');
if ($stmt === false) {
throw new Exception('Failed to prepare select: ' . $db->conn()->error);
}
$stmt->bind_param('i', $id);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to execute select: ' . $err);
}
$result = $stmt->get_result();
$row = $result ? $result->fetch_assoc() : null;
$stmt->close();
return $row ?: null;
}
/**
* Revoke a key (sets revoked_at = NOW()). Returns true on success.
*/
public static function revoke(int $id): bool
{
$db = self::db();
$stmt = $db->conn()->prepare(
'UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL'
);
if ($stmt === false) {
throw new Exception('Failed to prepare revoke: ' . $db->conn()->error);
}
$stmt->bind_param('i', $id);
$ok = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
return $ok && $affected > 0;
}
/**
* Bump last_used_at for a key. Best-effort: failures are swallowed
* because this is a hot-path observability hook and must not
* break the request.
*/
public static function touchLastUsed(int $id): void
{
try {
$db = self::db();
$stmt = $db->conn()->prepare(
'UPDATE api_keys SET last_used_at = CURRENT_TIMESTAMP WHERE id = ?'
);
if ($stmt === false) {
return;
}
$stmt->bind_param('i', $id);
$stmt->execute();
$stmt->close();
} catch (Throwable) {
// intentionally ignored
}
}
/**
* List keys for a customer, newest first.
*
* @return array<int, array<string, mixed>>
*/
public static function listForCustomer(int $customerId, bool $includeRevoked = false): array
{
$db = self::db();
$sql = 'SELECT * FROM api_keys WHERE customer_id = ?';
if (!$includeRevoked) {
$sql .= ' AND revoked_at IS NULL';
}
$sql .= ' ORDER BY id DESC';
$stmt = $db->conn()->prepare($sql);
if ($stmt === false) {
throw new Exception('Failed to prepare list: ' . $db->conn()->error);
}
$stmt->bind_param('i', $customerId);
if (!$stmt->execute()) {
$err = $stmt->error;
$stmt->close();
throw new Exception('Failed to execute list: ' . $err);
}
$result = $stmt->get_result();
$rows = $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
$stmt->close();
return is_array($rows) ? $rows : [];
}
/**
* Delete a key by id. Returns true if a row was removed.
* Generally prefer `revoke()` over `delete()` so audit trails
* stay intact.
*/
public static function delete(int $id): bool
{
$db = self::db();
$stmt = $db->conn()->prepare('DELETE FROM api_keys WHERE id = ?');
if ($stmt === false) {
throw new Exception('Failed to prepare delete: ' . $db->conn()->error);
}
$stmt->bind_param('i', $id);
$ok = $stmt->execute();
$affected = $stmt->affected_rows;
$stmt->close();
return $ok && $affected > 0;
}
}
@@ -1,92 +0,0 @@
<?php
namespace classes;
/**
* Schema bootstrap for the api_keys table.
*
* This codebase does NOT use a migration framework; new tables are
* added via `*_schema_bootstrap.php` files that run idempotent
* `CREATE TABLE IF NOT EXISTS` statements on first use. The companion
* SQL file at `database/migrations/<TIMESTAMP>_create_api_keys_table.php`
* is the human-readable source of truth / change record.
*/
class api_key_schema_bootstrap
{
public const TABLE = 'api_keys';
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db)) {
// No DB connection in this process (e.g. unit test) — skip.
self::$initialized = true;
return;
}
$queries = [
"CREATE TABLE IF NOT EXISTS api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
];
foreach ($queries as $query) {
try {
$db->query($query);
} catch (\Throwable $e) {
// Swallow on first-failure in unit-test contexts; the
// migration companion file documents the canonical DDL.
if (function_exists('error_log')) {
@error_log('[api_key_schema_bootstrap] ' . $e->getMessage());
}
}
}
self::$initialized = true;
}
public static function tableExists(): bool
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'getDatabase')) {
return false;
}
try {
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS count
FROM information_schema.tables
WHERE table_schema = '{$database}'
AND table_name = 'api_keys'"
);
$row = $result ? $result->fetch_assoc() : ['count' => 0];
return (int)($row['count'] ?? 0) > 0;
} catch (\Throwable) {
return false;
}
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
namespace app\auth;
/**
* Scope constants and helpers.
*
* LOCAL STUB for TRU-149 — will be replaced/extended by TRU-145
* (branch feat/api-key-foundation). Keeping this minimal so we
* don't conflict with the parallel scope-system work.
*
* Adding a new scope? Add the constant here AND register it in
* Scope::all() AND in Scope::forRole() (whichever roles should
* carry it). Centralising role → scope mapping here keeps
* permission decisions auditable in one place.
*/
class Scope
{
const CUSTOMER_READ = 'customer:read';
const CUSTOMER_WRITE = 'customer:write';
const BOOKING_READ = 'booking:read';
const BOOKING_WRITE = 'booking:write';
const SUBUSER_READ = 'subuser:read';
const SUBUSER_WRITE = 'subuser:write';
const INVOICE_READ = 'invoice:read';
const INVOICE_WRITE = 'invoice:write';
const SUPERUSER_READ = 'superuser:read';
const SUPERUSER_WRITE = 'superuser:write';
/**
* Canonical list of every scope. Used for validation and to
* build the audit trail of "what scopes exist" in tests.
*/
public static function all(): array
{
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ, self::INVOICE_WRITE,
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
];
}
/**
* Return the scopes carried by a given role. Single source of
* truth for role-based scope assignment.
*/
public static function forRole(string $role): array
{
switch ($role) {
case 'superuser':
return self::all();
case 'admin':
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ, self::INVOICE_WRITE,
];
case 'customer':
// TRU-149 (fix): customers get WRITE on their own data so
// self-service endpoints (own vehicles, own subusers, own
// discount / security / notification settings, own bookings)
// work end-to-end. The existing fine-grained
// requirePermission() calls in each route still gate which
// specific actions are allowed — scope here only answers
// "can this caller write customer data at all".
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ,
];
case 'subuser':
return [self::BOOKING_READ, self::BOOKING_WRITE];
default:
return [];
}
}
/**
* Normalize/validate a scope string. Returns null on invalid input
* (empty string, non-string, or not in the canonical set).
*
* Wildcards: "*" matches every scope. "customer:*" matches every
* scope starting with "customer:". "customer:read" matches itself.
*/
public static function matches(string $granted, string $required): bool
{
$granted = trim($granted);
$required = trim($required);
if ($granted === '' || $required === '') {
return false;
}
if ($granted === '*') {
return true;
}
if (str_ends_with($granted, ':*')) {
$prefix = substr($granted, 0, -2);
return str_starts_with($required, $prefix . ':');
}
return $granted === $required;
}
}
@@ -1,314 +0,0 @@
<?php
namespace app\auth;
use classes\response;
use Exception;
use objects\logs_o;
use objects\subusers_o;
use objects\users_o;
/**
* Scope-based access control middleware.
*
* Sits ON TOP of the existing session-cookie / bearer-token auth in
* classes\authentication. Existing permission checks (requirePermission,
* requireDepartmentAccess, etc.) MUST stay in place — scope checks are
* an additional, parallel layer that lets us reason about route
* authorization in terms of coarse-grained capabilities ("can this
* caller read invoices?") rather than fine-grained permission strings.
*
* The scope source-of-truth is app\auth\Scope (a local stub for
* TRU-149 — replaced by TRU-145 / feat/api-key-foundation).
*
* Three entry points:
* - requireScope(string) — caller must hold this exact scope
* - requireAnyScope(array) — caller must hold at least one
* - requireRole(string) — convenience: any of the role's
* scopes (see Scope::forRole)
*
* All three throw 403 on missing scope (or 401 if not authenticated at
* all). They never short-circuit silently: a missing scope is a denial,
* not a no-op.
*/
class ScopeMiddleware
{
/**
* Throw 403 unless the caller carries the given scope.
*
* @param string $required Scope string (e.g. Scope::CUSTOMER_READ).
* @param string|null $context Free-form label for log output
* (typically the route path).
*/
public static function requireScope(string $required, ?string $context = null): void
{
$granted = self::resolveGrantedScopes();
if (self::hasAnyMatchingScope($granted, [$required])) {
return;
}
self::deny($required, $granted, $context);
}
/**
* Throw 403 unless the caller carries at least one of the given scopes.
*
* @param array<int, string> $required
*/
public static function requireAnyScope(array $required, ?string $context = null): void
{
if ($required === []) {
// No scopes required = nothing to enforce. Defensive: a route
// author who passes [] probably meant to skip scope checks, so
// let it through rather than denying.
return;
}
$granted = self::resolveGrantedScopes();
if (self::hasAnyMatchingScope($granted, $required)) {
return;
}
self::deny(implode('|', $required), $granted, $context);
}
/**
* Convenience wrapper: require that the caller's role is at least
* as privileged as the named role.
*
* Role hierarchy: superuser > admin > customer > subuser.
* A caller satisfies `requireRole('admin')` if they are admin or
* superuser. `requireRole('superuser')` is only satisfied by
* superuser.
*
* Unknown roles deny.
*/
public static function requireRole(string $role, ?string $context = null): void
{
$hierarchy = ['subuser' => 1, 'customer' => 2, 'admin' => 3, 'superuser' => 4];
if (!isset($hierarchy[$role])) {
global $response;
if (is_object($response) && method_exists($response, 'error')) {
$response->error('Unknown role for scope check: ' . $role, 403);
}
return;
}
$callerRole = self::resolveCallerRole();
if ($callerRole === null) {
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
return;
}
$callerRank = $hierarchy[$callerRole] ?? 0;
$requiredRank = $hierarchy[$role];
if ($callerRank >= $requiredRank) {
return;
}
self::deny('role:' . $role, [], $context ?? 'role:' . $role);
}
/**
* Resolve the caller's role name. Returns null if no principal
* is authenticated (or only anonymous test state exists).
*/
public static function resolveCallerRole(): ?string
{
// Test hook: tests can install a role via the
// setTestPrincipal() path; that path also stores the synthetic
// role directly when passed as a string key. For now we
// infer the role from the granted-scopes list.
if (self::$testPrincipal !== null) {
$granted = self::$testPrincipal;
if (in_array(Scope::SUPERUSER_READ, $granted, true) && in_array(Scope::SUPERUSER_WRITE, $granted, true)) {
return 'superuser';
}
if (in_array(Scope::SUBUSER_WRITE, $granted, true)) {
return 'subuser';
}
if (in_array(Scope::INVOICE_WRITE, $granted, true)) {
return 'admin';
}
if (in_array(Scope::CUSTOMER_READ, $granted, true)) {
return 'customer';
}
return null;
}
try {
$auth = new \classes\authentication();
$user = $auth->get_user();
if ($user instanceof users_o) {
return self::userRole($user);
}
$sub = $auth->get_subuser();
if ($sub instanceof subusers_o) {
return 'subuser';
}
} catch (Exception) {
// fall through
}
return null;
}
/**
* Pure check (no throw). Useful for hasScope() style predicates in
* route handlers that want to branch on capabilities.
*
* @return bool true if the caller has the scope (or is superuser/admin).
*/
public static function hasScope(string $required): bool
{
$granted = self::resolveGrantedScopes();
return self::hasAnyMatchingScope($granted, [$required]);
}
/**
* Pure check for "any of" matching. Returns false if the caller is
* not authenticated at all (so callers can branch on anonymous).
*
* @param array<int, string> $required
*/
public static function hasAnyScope(array $required): bool
{
if ($required === []) {
return true;
}
$granted = self::resolveGrantedScopes();
return self::hasAnyMatchingScope($granted, $required);
}
/**
* Resolve the scopes the current principal carries. For now this is
* derived from the classic user role / subuser permissions, since
* the API key plumbing (TRU-145) is not yet wired in. When TRU-145
* lands, this method is the single replacement point.
*
* Returns an empty array if no principal is authenticated.
*
* @return array<int, string>
*/
public static function resolveGrantedScopes(): array
{
// Test hook: if a test has installed a principal via
// self::setTestPrincipal(), honour that and skip the real
// authentication path. This is the only place a test-only
// branch lives; production code never sets the test
// principal because nothing else in the codebase does.
if (self::$testPrincipal !== null) {
$principal = self::$testPrincipal;
if (is_array($principal)) {
return $principal;
}
}
try {
$auth = new \classes\authentication();
$user = $auth->get_user();
if ($user instanceof users_o) {
$role = self::userRole($user);
return Scope::forRole($role);
}
$sub = $auth->get_subuser();
if ($sub instanceof subusers_o) {
return Scope::forRole('subuser');
}
} catch (Exception) {
// fall through
}
return [];
}
/** @var array<int, string>|null */
private static ?array $testPrincipal = null;
/**
* Test-only: set the scope list the middleware should treat as
* "granted" for the current request. Pass null to clear.
*
* @param array<int, string>|null $scopes
*/
public static function setTestPrincipal(?array $scopes): void
{
self::$testPrincipal = $scopes;
}
/**
* Best-effort role detection for an authenticated user.
*
* Order of preference:
* 1. `hasPermission('superuser')` — matches the pattern used
* elsewhere in the codebase (e.g. departmentGoalsRoute).
* 2. `hasPermission('admin')` — admin gets the admin scope set.
* 3. Fallback to 'customer' — most authenticated callers are
* customer users, so we treat unknown as customer (read-only)
* rather than zero-privilege. This matches existing routes'
* behavior of allowing read access by default.
*
* Anonymous / malformed sessions yield no scopes via
* resolveGrantedScopes()'s outer try/catch.
*/
private static function userRole(users_o $user): string
{
try {
if (method_exists($user, 'hasPermission')) {
if ((bool)$user->hasPermission('superuser')) {
return 'superuser';
}
if ((bool)$user->hasPermission('admin')) {
return 'admin';
}
}
} catch (Exception) {
// fall through to default
}
return 'customer';
}
/**
* Check if any of the granted scopes satisfies any of the required
* scopes, using Scope::matches() (which supports "*" and "x:*"
* wildcards).
*
* @param array<int, string> $granted
* @param array<int, string> $required
*/
private static function hasAnyMatchingScope(array $granted, array $required): bool
{
foreach ($required as $need) {
foreach ($granted as $have) {
if (Scope::matches($have, $need)) {
return true;
}
}
}
return false;
}
/**
* Emit a 403 with a consistent shape and log the denial so we can
* see attempted access patterns during rollout.
*/
private static function deny(string $required, array $granted, ?string $context): void
{
// Best-effort log of the denial. We swallow all errors here
// because the deny path itself must never throw — a 403
// response is the contract.
try {
// The `redis` constant is a global namespaced object
// (objects\redis) created at boot. In test environments
// it may not be defined, so guard with `defined()`.
if (class_exists(logs_o::class) && defined('redis')) {
(new logs_o())->add(
'global',
'global',
1,
0,
'SCOPE_DENIED',
'Missing scope: ' . $required . ' (context=' . ($context ?? 'n/a') . ', granted=' . implode(',', $granted) . ')'
);
}
} catch (Throwable) {
// Logging must never block a deny.
}
global $response;
if (is_object($response) && method_exists($response, 'error')) {
$response->error('Missing required scope: ' . $required, 403);
return;
}
throw new Exception('Forbidden: missing scope ' . $required, 403);
}
}
@@ -1,221 +0,0 @@
<?php
namespace classes\auth;
/**
* Scope registry: the source of truth for API key scopes and
* role → scope defaults.
*
* This class is the canonical implementation that the parallel
* `app\auth\Scope` stub (introduced by TRU-149 / branch
* feat/TRU-149-route-scopes) will be replaced with once
* `feat/api-key-foundation` is merged. Until then the two can
* coexist; the middleware in `scope_middleware.php` continues
* to use the legacy stub.
*
* Scopes follow a "resource:action" pattern (e.g. `booking:read`).
* Two wildcard forms are recognised:
* - `*` — matches every scope.
* - `resource:*` — matches every action on a resource.
*
* Role defaults:
* - superuser: every scope (via "*" wildcard).
* - admin: customer:*, booking:*, subuser:*, invoice:*
* - customer: customer:read, booking:read, invoice:read
* - subuser: booking:read, booking:write
*
* The "self" / "assigned" qualifiers from the spec are *enforcement
* layer* concerns, not scope concerns — they live in the resolver
* that maps an authenticated principal to a customer/subuser record.
* Scopes only encode "can the caller read bookings at all", not
* "which bookings".
*/
final class scope_registry
{
// --- Customer resource ---
public const CUSTOMER_READ = 'customer:read';
public const CUSTOMER_WRITE = 'customer:write';
// --- Booking resource ---
public const BOOKING_READ = 'booking:read';
public const BOOKING_WRITE = 'booking:write';
// --- Subuser resource ---
public const SUBUSER_READ = 'subuser:read';
public const SUBUSER_WRITE = 'subuser:write';
// --- Invoice resource ---
public const INVOICE_READ = 'invoice:read';
public const INVOICE_WRITE = 'invoice:write';
// --- Superuser / admin resource ---
public const SUPERUSER_READ = 'superuser:read';
public const SUPERUSER_WRITE = 'superuser:write';
/**
* Canonical list of every concrete scope (no wildcards).
*
* @return array<int, string>
*/
public static function all(): array
{
return [
self::CUSTOMER_READ, self::CUSTOMER_WRITE,
self::BOOKING_READ, self::BOOKING_WRITE,
self::SUBUSER_READ, self::SUBUSER_WRITE,
self::INVOICE_READ, self::INVOICE_WRITE,
self::SUPERUSER_READ, self::SUPERUSER_WRITE,
];
}
/**
* Return the default scope set carried by a role. Wildcards are
* returned as-is; resolve them with `expand()` before checking
* membership if you need a flat list.
*
* @return array<int, string>
*/
public static function scopesForRole(string $role): array
{
switch (strtolower(trim($role))) {
case 'superuser':
return ['*'];
case 'admin':
return [
'customer:*',
'booking:*',
'subuser:*',
'invoice:*',
];
case 'customer':
return [
self::CUSTOMER_READ,
self::BOOKING_READ,
self::INVOICE_READ,
];
case 'subuser':
return [
self::BOOKING_READ,
self::BOOKING_WRITE,
];
default:
return [];
}
}
/**
* Does the granted scope (or wildcard) match the required scope?
*
* - "*" matches anything.
* - "customer:*" matches "customer:read" and "customer:write".
* - "customer:read" matches itself exactly.
*
* @param array<int, string> $granted
*/
public static function hasScope(array $granted, string $required): bool
{
$required = trim($required);
if ($required === '') {
return false;
}
foreach ($granted as $candidate) {
if (!is_string($candidate)) {
continue;
}
if (self::matches($candidate, $required)) {
return true;
}
}
return false;
}
/**
* Expand a list of scopes (which may include wildcards) into the
* full set of concrete scopes they grant. Useful for showing a
* user what their key can do, or for caching decisions.
*
* The wildcard "*" expands to the full `all()` set. A wildcard
* like "customer:*" expands to every concrete scope starting with
* "customer:". Duplicate entries are removed.
*
* @param array<int, string> $scopes
* @return array<int, string>
*/
public static function expand(array $scopes): array
{
$concrete = self::all();
$expanded = [];
foreach ($scopes as $scope) {
if (!is_string($scope)) {
continue;
}
$scope = trim($scope);
if ($scope === '') {
continue;
}
if ($scope === '*') {
$expanded = array_merge($expanded, $concrete);
continue;
}
if (str_ends_with($scope, ':*')) {
$prefix = substr($scope, 0, -2) . ':';
foreach ($concrete as $candidate) {
if (str_starts_with($candidate, $prefix)) {
$expanded[] = $candidate;
}
}
continue;
}
// Already concrete — pass through if it looks canonical.
if (in_array($scope, $concrete, true)) {
$expanded[] = $scope;
}
}
return array_values(array_unique($expanded));
}
/**
* Internal wildcard matcher — public for testing.
*/
public static function matches(string $granted, string $required): bool
{
$granted = trim($granted);
$required = trim($required);
if ($granted === '' || $required === '') {
return false;
}
if ($granted === '*') {
return true;
}
if (str_ends_with($granted, ':*')) {
$prefix = substr($granted, 0, -2);
return str_starts_with($required, $prefix . ':');
}
return $granted === $required;
}
/**
* Validate a scope string. Returns true iff the value is either
* a canonical concrete scope, "*", or a "<resource>:*" wildcard
* for a known resource.
*/
public static function isValid(string $scope): bool
{
$scope = trim($scope);
if ($scope === '' || $scope === '*') {
return $scope !== '';
}
if (str_ends_with($scope, ':*')) {
$prefix = substr($scope, 0, -2);
foreach (self::all() as $concrete) {
if (str_starts_with($concrete, $prefix . ':')) {
return true;
}
}
return false;
}
return in_array($scope, self::all(), true);
}
}
@@ -1,380 +0,0 @@
<?php
namespace classes;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use Throwable;
/**
* Customer rule "auto-send invoice on the 3rd business day each month" (TRU-70 / DRIFT 9).
*
* The toggle lives in the existing `customer_attributes` table under the
* `autoSendInvoiceThirdBusinessDay` attribute. A daily cron task delegates to
* {@see autoSendInvoicesThirdBusinessDay()} which is a no-op on every day
* except the 3rd business day of the month, where it auto-queues ready
* collected invoices for the opted-in customers.
*
* "Business day" is computed against a locale-aware weekend (default
* Saturday + Sunday). Public Danish holidays are supported via a small
* override hook so the unit tests can pin the result without depending on
* the wall clock.
*/
class auto_send_invoice_third_business_day_service
{
public const ATTRIBUTE = 'autoSendInvoiceThirdBusinessDay';
public const DEFAULT_WEEKEND_DAYS = [6, 7]; // ISO-8601: 6 = Saturday, 7 = Sunday
/** @var list<string>|null */
private ?array $holidayCache = null;
/** @var callable|null */
private $holidayProviderOverride = null;
/** @var callable|null */
private $nowProviderOverride = null;
/** @var callable|null */
private $timeZoneProviderOverride = null;
public function isThirdBusinessDay(?DateTimeImmutable $date = null): bool
{
$timezone = $this->resolveTimezone();
$reference = $date ?? $this->resolveNow()->setTimezone($timezone);
$reference = $reference->setTimezone($timezone)->setTime(0, 0, 0);
$businessDay = 0;
$cursor = $reference->setDate(
(int)$reference->format('Y'),
(int)$reference->format('n'),
1
);
$today = $reference;
while ($cursor <= $today) {
if ($this->isBusinessDay($cursor)) {
$businessDay++;
if ($businessDay === 3) {
return $cursor->format('Y-m-d') === $today->format('Y-m-d');
}
}
$cursor = $cursor->modify('+1 day');
}
return false;
}
public function thirdBusinessDayOfMonth(int $year, int $month): DateTimeImmutable
{
if ($year < 1970 || $year > 9999) {
throw new InvalidArgumentException("Year must be between 1970 and 9999, got {$year}");
}
if ($month < 1 || $month > 12) {
throw new InvalidArgumentException("Month must be between 1 and 12, got {$month}");
}
$timezone = $this->resolveTimezone();
$cursor = (new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $timezone))
->setTimezone($timezone);
$businessDay = 0;
while (true) {
if ($this->isBusinessDay($cursor)) {
$businessDay++;
if ($businessDay === 3) {
return $cursor->setTime(0, 0, 0);
}
}
$cursor = $cursor->modify('+1 day');
}
}
public function isBusinessDay(DateTimeImmutable $date): bool
{
$weekday = (int)$date->format('N');
if (in_array($weekday, self::DEFAULT_WEEKEND_DAYS, true)) {
return false;
}
$holidayKey = $date->format('Y-m-d');
foreach ($this->resolveHolidays() as $holiday) {
if ($holiday === $holidayKey) {
return false;
}
}
return true;
}
/**
* Auto-queue every ready collected invoice for customers with the
* `autoSendInvoiceThirdBusinessDay` attribute. Returns a summary of
* what was queued (or an empty `scanned` count when invoked on a
* non-trigger day).
*
* @return array{
* triggered: bool,
* trigger_date: ?string,
* customers: int,
* collections_scanned: int,
* jobs_enqueued: int,
* skipped_already_queued: int,
* errors: list<array{customer_number:int,collection_id:int,message:string}>
* }
*/
public function runOnce(?DateTimeImmutable $now = null): array
{
$timezone = $this->resolveTimezone();
$today = ($now ?? $this->resolveNow())->setTimezone($timezone)->setTime(0, 0, 0);
$summary = [
'triggered' => false,
'trigger_date' => null,
'customers' => 0,
'collections_scanned' => 0,
'jobs_enqueued' => 0,
'skipped_already_queued' => 0,
'errors' => [],
];
if (!$this->isThirdBusinessDay($today)) {
return $summary;
}
$summary['triggered'] = true;
$summary['trigger_date'] = $today->format('Y-m-d');
$customerNumbers = $this->loadEligibleCustomerNumbers();
$summary['customers'] = count($customerNumbers);
if ($customerNumbers === []) {
return $summary;
}
$collections = $this->loadReadyInvoiceCollections($customerNumbers);
$summary['collections_scanned'] = count($collections);
if ($collections === []) {
return $summary;
}
$queue = $this->createTransferQueue();
foreach ($collections as $collection) {
$collectionId = (int)($collection['id'] ?? 0);
if ($collectionId < 1) {
continue;
}
try {
$enqueued = $this->enqueueCollectionExport($queue, $collectionId);
} catch (Throwable $throwable) {
$summary['errors'][] = [
'customer_number' => (int)($collection['customer_number'] ?? 0),
'collection_id' => $collectionId,
'message' => $throwable->getMessage(),
];
continue;
}
if ($enqueued === 'queued') {
$summary['jobs_enqueued']++;
} elseif ($enqueued === 'already_queued') {
$summary['skipped_already_queued']++;
}
// 'unavailable' is intentionally silent: the queue is optional
// and the next cron tick will pick up the collections.
}
return $summary;
}
public function setHolidayProviderOverride(callable $provider): void
{
$this->holidayProviderOverride = $provider;
}
public function setNowProviderOverride(callable $provider): void
{
$this->nowProviderOverride = $provider;
}
public function setTimeZoneProviderOverride(callable $provider): void
{
$this->timeZoneProviderOverride = $provider;
}
public function clearOverrides(): void
{
$this->holidayProviderOverride = null;
$this->nowProviderOverride = null;
$this->timeZoneProviderOverride = null;
$this->holidayCache = null;
}
/** @return list<int> */
public function loadEligibleCustomerNumbers(): array
{
global $db;
$attribute = $db->escape_string(self::ATTRIBUTE);
$result = $db->query(
"SELECT DISTINCT CAST(u.customer_number AS UNSIGNED) AS customer_number
FROM customer_attributes ca
INNER JOIN users u ON u.id = ca.user_id
WHERE ca.attribute = '{$attribute}'
AND u.customer_number IS NOT NULL
AND u.customer_number <> 0
AND u.deleted_at IS NULL
ORDER BY customer_number ASC"
);
if (!$result) {
return [];
}
$customerNumbers = [];
while ($row = $result->fetch_assoc()) {
$number = (int)($row['customer_number'] ?? 0);
if ($number > 0) {
$customerNumbers[] = $number;
}
}
return $customerNumbers;
}
/**
* @param list<int> $customerNumbers
* @return list<array<string,mixed>>
*/
public function loadReadyInvoiceCollections(array $customerNumbers): array
{
$customerNumbers = array_values(array_filter(array_map('intval', $customerNumbers), static fn(int $n): bool => $n > 0));
if ($customerNumbers === []) {
return [];
}
global $db;
$in = implode(',', $customerNumbers);
// A collection is "ready" when:
// - it belongs to one of the opted-in customers
// - it has at least one order linked to it
// - it has not been booked yet (no booked_at)
// - it has not been closed yet (no closed_at) — closures are reserved
// for already-booked/manual-approval flows
// - it has not been deleted
$result = $db->query(
"SELECT c.id, c.customer_number
FROM collected_order_invoices c
WHERE c.customer_number IN ({$in})
AND c.deleted_at IS NULL
AND (c.booked_at IS NULL OR c.booked_at = '0000-00-00 00:00:00')
AND (c.closed_at IS NULL OR c.closed_at = '0000-00-00 00:00:00')
AND EXISTS (
SELECT 1 FROM orders o
WHERE o.invoice_collection_id = c.id
AND o.deleted_at IS NULL
)
ORDER BY c.customer_number ASC, c.id ASC"
);
if (!$result) {
return [];
}
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = [
'id' => (int)($row['id'] ?? 0),
'customer_number' => (int)($row['customer_number'] ?? 0),
];
}
return $rows;
}
/**
* Hook for unit tests: when the production economic_transfer_queue is
* not available the cron task should still succeed with no jobs.
*/
protected function createTransferQueue(): ?economic_transfer_queue
{
if (!class_exists(economic_transfer_queue::class)) {
return null;
}
try {
return new economic_transfer_queue();
} catch (Throwable) {
return null;
}
}
/**
* @return 'queued'|'already_queued'|'unavailable'
*/
private function enqueueCollectionExport(?economic_transfer_queue $queue, int $collectionId): string
{
if ($queue === null) {
return 'unavailable';
}
try {
$job = $queue->enqueue(
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT,
[
'collected_invoice_id' => $collectionId,
'send_as_is' => false,
'requested_by' => 0,
'auto_send_third_business_day' => true,
],
0
);
} catch (Throwable $throwable) {
// The queue is designed to refuse duplicate enqueue by
// surfacing a domain-specific message — treat that as
// "already_queued" rather than a hard failure.
if (str_contains(strtolower($throwable->getMessage()), 'already')) {
return 'already_queued';
}
throw $throwable;
}
return is_array($job) && !empty($job['id']) ? 'queued' : 'already_queued';
}
/** @return list<string> */
private function resolveHolidays(): array
{
if ($this->holidayCache !== null) {
return $this->holidayCache;
}
if ($this->holidayProviderOverride !== null) {
$value = ($this->holidayProviderOverride)();
$holidays = is_array($value) ? array_values(array_filter(array_map('strval', $value))) : [];
$this->holidayCache = $holidays;
return $holidays;
}
// Default: no hard-coded public holidays. Production can plug a
// concrete provider through setHolidayProviderOverride() once the
// holiday calendar is finalised. The Saturday/Sunday weekend
// logic is sufficient for the 3rd-business-day computation as
// long as the cron task runs on every weekday.
$this->holidayCache = [];
return $this->holidayCache;
}
private function resolveNow(): DateTimeImmutable
{
if ($this->nowProviderOverride !== null) {
$value = ($this->nowProviderOverride)();
if ($value instanceof DateTimeImmutable) {
return $value;
}
}
return new DateTimeImmutable('now');
}
private function resolveTimezone(): DateTimeZone
{
if ($this->timeZoneProviderOverride !== null) {
$value = ($this->timeZoneProviderOverride)();
if ($value instanceof DateTimeZone) {
return $value;
}
if (is_string($value) && $value !== '') {
return new DateTimeZone($value);
}
}
return new DateTimeZone('Europe/Copenhagen');
}
}
@@ -54,7 +54,6 @@ class customer_rule_product_restriction_service
'showPricesOnBookingPage',
'usePONumbers',
'exemptFromAdministrationFee',
'autoSendInvoiceThirdBusinessDay',
];
public function __construct()
@@ -1,92 +0,0 @@
<?php
namespace classes;
/**
* Centralized selection of e-conomic invoice layout numbers.
*
* This class is the **skeleton** introduced by TRU-197. It exposes the two
* layout numbers that the backend should use for the two invoice variants:
*
* - `LAYOUT_WITHOUT_DISCOUNTS` — clean invoice, no discount clutter
* - `LAYOUT_WITH_DISCOUNTS` — invoice with itemized discount line(s)
*
* The constants below are placeholders for the layout numbers that the
* e-conomic account admin must pick in e-conomic (Settings → Design and
* Layouts) and write into the module-config DB variables
* `invoiceLayoutNumber` and `invoiceDiscountLayoutNumber`. The numbers
* themselves are intentionally left as `0` in this skeleton — they are
* resolved at runtime from the module-config variables by the two existing
* call sites:
*
* - `services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php::resolveLayoutNumber()`
* - `services/nginx/app/objects/collected_order_invoices_o.php::resolveInvoiceLayoutNumber()`
*
* Wiring those call sites to read from this selector (instead of from the
* module-config variables directly) is intentionally **out of scope** for
* TRU-197. See `documentation/economic/invoice-template-audit.md` for the
* full audit and follow-up plan.
*
* Constants in this class are the *single source of truth* for the
* env-var-style aliases:
*
* - `LAYOUT_WITHOUT_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS`
* - `LAYOUT_WITH_DISCOUNTS` ⇄ `ECONOMIC_LAYOUT_WITH_DISCOUNTS`
*/
class economic_layout_selector
{
/**
* Layout number for invoices WITHOUT itemized discount lines.
*
* Intent: a clean invoice — no "Rabat" line, no discount column, just
* the line items and totals.
*
* @var int
*/
public const LAYOUT_WITHOUT_DISCOUNTS = 0;
/**
* Layout number for invoices WITH itemized discount lines.
*
* Intent: an invoice that visibly itemizes the negative `Rabat`
* (product `TotDiscount`) line so the customer can see the discount
* broken out instead of folded into per-product `discountPercentage`.
*
* @var int
*/
public const LAYOUT_WITH_DISCOUNTS = 0;
/**
* Module-config variable name for the without-discounts layout.
*
* @var string
*/
public const CONFIG_VAR_WITHOUT_DISCOUNTS = 'invoiceLayoutNumber';
/**
* Module-config variable name for the with-discounts layout.
*
* @var string
*/
public const CONFIG_VAR_WITH_DISCOUNTS = 'invoiceDiscountLayoutNumber';
/**
* Friendly alias for `LAYOUT_WITHOUT_DISCOUNTS` (env-var-style name).
*
* @return string
*/
public static function nameWithoutDiscounts(): string
{
return 'ECONOMIC_LAYOUT_WITHOUT_DISCOUNTS';
}
/**
* Friendly alias for `LAYOUT_WITH_DISCOUNTS` (env-var-style name).
*
* @return string
*/
public static function nameWithDiscounts(): string
{
return 'ECONOMIC_LAYOUT_WITH_DISCOUNTS';
}
}
@@ -9,13 +9,6 @@ class system_search_economic_customer_index
{
public const TABLE = 'system_search_economic_customer_index';
/**
* FULLTEXT key name used by TRU-62 customer-search performance fix.
* The column already exists (TEXT NULL `search_text`) — we just need
* the index. See documentation/perf/customer-search-slow-investigation.md.
*/
public const FULLTEXT_INDEX = 'ft_sseci_search_text';
private static bool $initialized = false;
public static function ensureTable(): void
@@ -57,14 +50,6 @@ class system_search_economic_customer_index
'economic_barred',
"ALTER TABLE `" . self::TABLE . "` ADD COLUMN `economic_barred` TINYINT(1) NULL AFTER `economic_mobile_phone`"
);
// TRU-62: ensure the FULLTEXT index used by the customer-search fast
// path. Safe to call repeatedly: `ensureIndex` no-ops when the index
// already exists. The search code falls back to the LIKE-based query
// when this index is absent, so an incomplete migration is non-fatal.
self::ensureIndex(
self::FULLTEXT_INDEX,
"ALTER TABLE `" . self::TABLE . "` ADD FULLTEXT INDEX `" . self::FULLTEXT_INDEX . "` (`search_text`)"
);
self::$initialized = true;
}
@@ -377,39 +362,6 @@ class system_search_economic_customer_index
}
}
/**
* Ensure an index (FULLTEXT or otherwise) exists on the table.
* No-ops when the index is already present so this is safe to call
* repeatedly at request time.
*/
private static function ensureIndex(string $indexName, string $alterSql): void
{
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return;
}
try {
$result = $db->query(
"SHOW INDEX FROM `" . self::TABLE . "` WHERE `Key_name` = '"
. $db->escape_string($indexName) . "'"
);
} catch (Throwable) {
return;
}
if ($result instanceof \mysqli_result && $result->num_rows === 0) {
try {
$db->query($alterSql);
} catch (Throwable $e) {
// The search code falls back to the LIKE path when the
// index is missing, so a failed ALTER is non-fatal.
if (function_exists('error_log')) {
@error_log('[system_search_economic_customer_index] failed to add index ' . $indexName . ': ' . $e->getMessage());
}
}
}
}
private static function barredStatus(?bool $barred): string
{
return match ($barred) {
@@ -720,18 +720,52 @@ class system_search_service
$customerFilter = ' AND u.customer_number IN (' . implode(',', array_map('intval', $customerNumbers)) . ')';
}
// TRU-62: prefer the FULLTEXT path against the denormalized
// `system_search_economic_customer_index.search_text` column. The
// previous implementation ORed 13 un-indexable `LIKE '%term%'`
// clauses, which dominated the ~10s request latency reported in
// TRU-62. We only fall back to that LIKE path when the FULLTEXT
// index is missing (e.g. migration not yet applied) or returns
// zero rows for the query.
$rows = $this->searchCustomersWithFulltext($terms, $customerFilter);
if ($rows === null) {
$rows = $this->searchCustomersWithLike($terms, $customerFilter);
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
...$this->joinTemporalSelectFields('users', 'u'),
];
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
$selectFields = [
...$selectFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$searchFields = [
...$searchFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
}
$rows = $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields,
$terms,
'1=1' . $customerFilter
);
return array_map(function (array $row) use ($terms, $entityBoost) {
$title = trim((string)($row['economic_name'] ?? ''));
if ($title === '') {
@@ -784,166 +818,6 @@ class system_search_service
}, $rows);
}
/**
* TRU-62 — FULLTEXT path for customer search.
*
* Returns the matching rows from `users LEFT JOIN
* system_search_economic_customer_index` using a `MATCH ... AGAINST`
* query against the denormalized `search_text` column. This replaces
* the 13-clause `LIKE '%term%'` OR chain that previously caused
* ~10s customer-search latency. Returns `null` when the FULLTEXT
* path is not available (index missing) or the boolean query is
* empty (terms too short for the FULLTEXT minimum word length);
* callers should then fall back to {@see searchCustomersWithLike()}.
*
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>|null
*/
private function searchCustomersWithFulltext(array $terms, string $customerFilter): ?array
{
if (empty($terms)) {
return [];
}
if (!$this->isFulltextCustomerIndexAvailable()) {
return null;
}
$booleanQuery = $this->buildBooleanFullTextQuery($terms);
if ($booleanQuery === null) {
// One or more terms are too short for the FULLTEXT minimum
// word length. The LIKE path is the only viable option.
return null;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return null;
}
$escaped = $db->escape_string($booleanQuery);
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$fromClause = 'users u LEFT JOIN `'
. system_search_economic_customer_index::TABLE
. '` sci ON sci.customer_number = u.customer_number';
$sql = "SELECT " . implode(', ', $selectFields)
. " FROM " . $fromClause
. " WHERE 1=1" . $customerFilter
. " AND MATCH(sci.search_text) AGAINST ('" . $escaped . "' IN BOOLEAN MODE)"
. " LIMIT " . $this->defaultEntityFetchLimit;
$rows = $this->runSelectRows($sql);
if (empty($rows)) {
// FULLTEXT is in use but the row set is empty. We could fall
// back to LIKE here, but a fully-empty FULLTEXT result for a
// customer-tab query usually means "no match" (the boolean
// query already required all terms to be present). Avoid the
// extra full-table scan and return an empty result set.
return [];
}
return $rows;
}
/**
* TRU-62 — original LIKE-based fallback for customer search. Kept
* verbatim so that deployments which have not yet applied the
* FULLTEXT migration still get correct results, just slowly.
*
* @param array<int, string> $terms
* @return array<int, array<string, mixed>>
*/
private function searchCustomersWithLike(array $terms, string $customerFilter): array
{
$selectFields = [
'u.id',
'u.customer_number',
'u.display_name',
'u.email',
'u.phone',
...$this->joinTemporalSelectFields('users', 'u'),
];
$searchFields = ['u.id', 'u.customer_number', 'u.display_name', 'u.email', 'u.phone'];
$fromClause = 'users u';
if ($this->isEconomicCustomerIndexAvailable()) {
$fromClause .= ' LEFT JOIN `' . system_search_economic_customer_index::TABLE . '` sci ON sci.customer_number = u.customer_number';
$selectFields = [
...$selectFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
$searchFields = [
...$searchFields,
'sci.economic_name',
'sci.economic_address',
'sci.economic_city',
'sci.economic_zip',
'sci.economic_email',
'sci.economic_cvr',
'sci.economic_mobile_phone',
'sci.search_text',
];
}
return $this->searchTableWithJoin(
'users',
$fromClause,
$selectFields,
$searchFields,
$terms,
'1=1' . $customerFilter
);
}
/**
* True when the `system_search_economic_customer_index` table exists
* AND the `ft_sseci_search_text` FULLTEXT index is present. The
* index is added by the runtime schema bootstrap and the companion
* migration at `database/migrations/2026_08_17_000002_*`.
*/
private function isFulltextCustomerIndexAvailable(): bool
{
if (!$this->isEconomicCustomerIndexAvailable()) {
return false;
}
global $db;
if (!is_object($db) || !method_exists($db, 'query')) {
return false;
}
try {
$indexName = $db->escape_string(system_search_economic_customer_index::FULLTEXT_INDEX);
$result = $db->query(
"SHOW INDEX FROM `" . system_search_economic_customer_index::TABLE
. "` WHERE `Key_name` = '" . $indexName . "'"
);
if (!($result instanceof \mysqli_result)) {
return false;
}
return $result->num_rows > 0;
} catch (Throwable) {
return false;
}
}
private function searchEmployees(array $terms, int $entityBoost, bool $ownOnly, ?int $ownCustomerNumber): array
{
$rows = $this->searchTableWithJoin(
+1 -75
View File
@@ -689,38 +689,6 @@ function EconomicTransferQueueCron(): void
}
}
/**
* Customer rule "auto-send invoice on the 3rd business day each month"
* (TRU-70 / DRIFT 9).
*
* The handler is a no-op on every day except the 3rd business day of
* the current month (Europe/Copenhagen timezone). On that day it scans
* the customers with the `autoSendInvoiceThirdBusinessDay` attribute
* and enqueues every ready collected invoice for export via the
* existing `economic_transfer_queue` machinery. The actual e-conomic
* send is handled asynchronously by `EconomicTransferQueueCron`.
*/
function AutoSendInvoicesThirdBusinessDay(): void
{
try {
$service = new \classes\auto_send_invoice_third_business_day_service();
$summary = $service->runOnce();
if (!empty($summary['triggered'])) {
echo "[" . date('Y-m-d H:i:s') . "][CRON] AutoSendInvoicesThirdBusinessDay trigger_date="
. ($summary['trigger_date'] ?? '')
. " customers=" . (int)($summary['customers'] ?? 0)
. " collections=" . (int)($summary['collections_scanned'] ?? 0)
. " enqueued=" . (int)($summary['jobs_enqueued'] ?? 0)
. " already_queued=" . (int)($summary['skipped_already_queued'] ?? 0)
. " errors=" . count($summary['errors'] ?? [])
. "\n";
}
} catch (Throwable $e) {
warn('AutoSendInvoicesThirdBusinessDay failed: ' . $e->getMessage());
error_log('[cron-auto-send-third-business-day] failed: ' . $e->getMessage());
}
}
function PruneSystemSessionActivityCron(): void
{
try {
@@ -1421,49 +1389,7 @@ function GoalsProgressAlertsCron(): void
case Dest::SLACK:
$departments = (array)$goal->departments->value();
$sentToDept = false;
$internalDepartmentIds = [];
try {
$slackConfig = new Slack();
if (method_exists($slackConfig, 'get_internal_department_ids')) {
$internalDepartmentIds = array_map('intval', (array)$slackConfig->get_internal_department_ids());
}
} catch (Throwable $slackConfigError) {
// Ignore - falls back to per-department webhooks
$internalDepartmentIds = [];
}
$goalDeptIds = [];
foreach ($departments as $deptId) {
if (is_numeric($deptId)) {
$goalDeptIds[] = (int)$deptId;
}
}
$allInternal = count($goalDeptIds) > 0
&& count(array_diff($goalDeptIds, $internalDepartmentIds)) === 0;
if ($allInternal) {
// TRU-76: For internal departments (e.g. Taulov/Taastrup DHL daily
// goal), post to the dedicated internal goal progress webhook
// instead of per-department webhooks, which are typically empty
// for internal locations.
$internalWebhook = '';
try {
$slackInstance = new Slack();
if (method_exists($slackInstance, 'get_internal_department_goal_progress_webhook_url')) {
$internalWebhook = trim((string)$slackInstance->get_internal_department_goal_progress_webhook_url());
}
} catch (Throwable $internalWebhookError) {
$internalWebhook = '';
}
if ($internalWebhook !== '') {
(new Slack())->send_webhook_message((string)goals_progress_alert_renderer::render($criteria), $internalWebhook);
$sentToDept = true;
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " sent to internal goal progress webhook (departments: " . implode(',', $goalDeptIds) . ")\n";
} else {
echo "[" . date('Y-m-d H:i:s') . "][CRON] GoalsProgressAlertsCron: goal #" . $goalId . " has only internal departments but internal_department_goal_progress_webhook_url is empty; falling back to per-department webhooks\n";
}
}
if (!$sentToDept && count($departments) > 0) {
if (count($departments) > 0) {
foreach ($departments as $deptId) {
if (!is_numeric($deptId)) { continue; }
$dept = (new departments_o())->select((int)$deptId);
@@ -1,48 +0,0 @@
<?php
/**
* Migration: create_api_keys_table
* Issue: TRU-143 — [Backend] API key data model + storage schema
* Date: 2026-08-17
*
* NOTE: This codebase does not run a migration framework; the
* canonical DDL is applied idempotently at runtime by
* `classes/api_key_schema_bootstrap.php`. This file is the
* human-readable change record / source of truth for the schema.
*
* To apply manually:
* mysql -u <user> -p <database> < 2026_08_17_000001_create_api_keys_table.sql
*/
return [
'id' => '2026_08_17_000001_create_api_keys_table',
'issue' => 'TRU-143',
'table' => 'api_keys',
'engine' => 'InnoDB',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'up' => [
"CREATE TABLE IF NOT EXISTS api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
],
'down' => [
'DROP TABLE IF EXISTS api_keys',
],
];
@@ -1,42 +0,0 @@
<?php
/**
* Migration: add_fulltext_to_system_search_economic_customer_index
* Issue: TRU-62 — System is very slow - search on customer tab ~10s
* Date: 2026-08-17
*
* The `system_search_economic_customer_index.search_text` column is a
* denormalized blob containing all customer-name / address / email / phone
* data concatenated. The customer search currently runs
*
* `field LIKE '%term%'`
*
* for 13 fields, which forces a full table scan and dominates the
* ~10s request latency. A FULLTEXT index on the same column lets the
* same search run in tens of milliseconds.
*
* NOTE: This codebase does not run a migration framework; the canonical
* DDL is applied idempotently at runtime by
* `classes/system_search_economic_customer_index::ensureTable()`. This
* file is the human-readable change record / source of truth for the
* schema. See TRU-62 investigation doc at
* `documentation/perf/customer-search-slow-investigation.md`.
*
* To apply manually:
* mysql -u <user> -p <database> \
* -e "ALTER TABLE \`system_search_economic_customer_index\`
* ADD FULLTEXT INDEX \`ft_sseci_search_text\` (\`search_text\`);"
*/
return [
'id' => '2026_08_17_000002_add_fulltext_to_system_search_economic_customer_index',
'issue' => 'TRU-62',
'table' => 'system_search_economic_customer_index',
'up' => [
'ALTER TABLE `system_search_economic_customer_index`
ADD FULLTEXT INDEX `ft_sseci_search_text` (`search_text`)',
],
'down' => [
'ALTER TABLE `system_search_economic_customer_index`
DROP INDEX `ft_sseci_search_text`',
],
];
@@ -61,16 +61,4 @@ return [
'estimated_duration_ms' => 3000,
'priority' => 20,
],
[
'id' => 'economic.auto_send_invoices_third_business_day',
'legacy_name' => 'AutoSendInvoicesThirdBusinessDay',
'name' => 'Auto-send invoices on the 3rd business day each month',
'description' => 'For customers with the autoSendInvoiceThirdBusinessDay attribute, enqueue every ready collected invoice for export on the 3rd business day of the month. The handler is a no-op on every other day.',
'module' => 'economic',
'handler' => 'AutoSendInvoicesThirdBusinessDay',
'schedule' => ['type' => 'interval', 'seconds' => 86400],
'timeout_seconds' => 900,
'estimated_duration_ms' => 5000,
'priority' => 25,
],
];
@@ -76,14 +76,9 @@ class economicCustomers extends economic_m
// The discount is global, but e-conomic resolves it through a product-specific
// invoice-line template. For foreign-currency customers some templates can fail
// if that product has no price in the customer currency, so try a few products
// before falling back to zero. We log every swallowed currency-price failure so
// silently-missing discounts (e.g. bug #11 customer 35131752 "kd" 15%) become
// visible in the application log instead of vanishing into the void.
// before falling back to zero.
$products = $this->getCustomerProducts($customer_number, 10);
$attempted_products = 0;
$swallowed_errors = 0;
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
$attempted_products++;
try {
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
return (int)($discount->discountPercentage ?? 0);
@@ -91,24 +86,9 @@ class economicCustomers extends economic_m
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
throw $exception;
}
$swallowed_errors++;
error_log(sprintf(
'[economicCustomers] Swallowed missing-currency-price error while resolving discount for customer %d product %d: %s',
$customer_number,
$product_number,
$exception->getMessage()
));
}
}
if ($attempted_products > 0 && $swallowed_errors === $attempted_products) {
error_log(sprintf(
'[economicCustomers] All %d invoice-line template probes failed with missing currency prices for customer %d; falling back to 0%% discount. Verify "economic_customer_discount_percentage" in e-conomic for this customer.',
$attempted_products,
$customer_number
));
}
return 0;
}
@@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint
* @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array<int,int>}
* @throws Exception If the request fails
*/
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): array
public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array
{
$draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true);
$orders_with_invoice_lines = 0;
@@ -89,8 +89,8 @@ class economic_invoices_draft_endpoint
$orders_with_invoice_lines++;
// Add the transaction header (Timestamp, department, etc.)
$draftInvoice->addNewTransactionHeader($order);
// Add the order lines (including the customer-level e-conomic discount, if any).
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts, $customer_discount_percentage);
// Add the order lines
$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);
// Add an empty line, so the invoice is not empty
$draftInvoice->addTextLine('');
}
@@ -125,15 +125,10 @@ class economic_invoices_drafts_endpoint
$customer = (new economic())->getCustomer($customer_number);
// Set the recipient details
// Note: customer_* fields come from e-conomic itself (controlled input),
// but we sanitize them defensively to avoid 400s if e-conomic ever stores
// a value with chars e-conomic later rejects in the recipient block.
// Each field uses an appropriate length cap to match the corresponding
// e-conomic recipient field limits.
$customer_name = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getName() ?? 'Ukendt', 100);
$customer_address = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getAddress() ?? 'Ukendt', 250);
$customer_zip = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getZipCode() ?? 'Ukendt', 20);
$customer_city = \classes\economic_export_sanitizer::sanitizeTextLine($customer->getCity() ?? 'Ukendt', 100);
$customer_name = $customer->getName() ?? 'Ukendt';
$customer_address = $customer->getAddress() ?? 'Ukendt';
$customer_zip = $customer->getZipCode() ?? 'Ukendt';
$customer_city = $customer->getCity() ?? 'Ukendt';
$recipient = [
'name' => $customer_name,
'address' => $customer_address,
@@ -144,14 +139,9 @@ class economic_invoices_drafts_endpoint
],
];
$customer_ean = $customer->getEan();
if ($customer_ean !== null && $customer_ean !== '') {
// EAN should be digits only; sanitize to strip anything that slipped through
$recipient['ean'] = preg_replace('/[^0-9]/', '', $customer_ean);
if ($recipient['ean'] !== '') {
$recipient['nemHandelType'] = 'ean';
} else {
unset($recipient['ean']);
}
if ($customer_ean !== null) {
$recipient['ean'] = $customer_ean;
$recipient['nemHandelType'] = 'ean';
}
$public_entry_number = $customer->getPublicEntryNumber();
if ($public_entry_number !== null) {
@@ -386,26 +386,11 @@ class economic_invoice_draft
*/
public function addTextLine(string $text): void
{
// Defense in depth: sanitize ALL text lines at insertion time.
// This catches anything that wasn't pre-sanitized at the call site.
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
if ($sanitized === '') {
return; // Skip empty/whitespace-only lines
}
$this->draft_lines[] = [
'description' => $sanitized
'description' => $text
];
}
/**
* Get the current draft lines (read-only view).
* Used by integration tests; production code uses addLines() to send.
*/
public function getDraftLines(): array
{
return $this->draft_lines;
}
/**
* Add an order to the draft invoice
* @note The lines won't be saved until the addLines() method is called.
@@ -414,7 +399,7 @@ class economic_invoice_draft
* @throws Exception if the order is not found
* @throws Exception if the order is not valid
*/
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): void
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void
{
// Get the order items
$order_items = $order->getOrderItems($order->id);
@@ -433,25 +418,18 @@ class economic_invoice_draft
});
// Define the total discount applied to the order
$total_discount = 0;
// Normalize the customer discount percentage (clamp to 0..100)
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
// Force itemized discount mode when the customer has a global e-conomic discount
// so the discount is applied at the line level (e-conomic line API requires per-line
// discountPercentage; an aggregate TotDiscount line would be ignored when the
// customer does not have a per-line discount configured for the customer).
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 0;
// Loop through the order items
foreach ( $order_items as $order_item ) {
if ($this->shouldSkipOrderItemLine($order_item)) {
continue;
}
// Add the order item to the draft invoice
self::addOrderItemLine($order_item, $department, false, $effective_itemized_discounts, $customer_discount_percentage);
self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts);
// Add the line discount to the total discount
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
}
// If the total discount is greater than 0, add it to the invoice
if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0) {
if (!$use_itemized_discounts && $total_discount > 0) {
// Add the discount to the invoice
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
}
@@ -467,7 +445,7 @@ class economic_invoice_draft
* @throws Exception if the order item is not found
* @throws Exception if the order item is not valid
*/
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false, int $customer_discount_percentage = 0): void
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void
{
// Check if the order item is valid
if (!isset($order_item['id'])) {
@@ -481,18 +459,9 @@ class economic_invoice_draft
// Get the dimension id
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
$pricing = self::resolveOrderItemInvoicePricing($order_item);
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
// is applied at the line level. Combined with per-item discounts using max() so the
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
// an already-discounted per-item price.
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
$itemized_discount_percentage = $use_itemized_discount
$discount_percentage = $use_itemized_discount
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
: 0.0;
$discount_percentage = (float)max(
$itemized_discount_percentage,
(float)$customer_discount_percentage
);
: 0;
// Add the order item to the draft invoice
self::addProductLine(
(string)$order_item['product']['economic_product_id'],
@@ -656,10 +625,6 @@ class economic_invoice_draft
// Sanitize product identifier and description (defense in depth — also done at addLines())
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
// Skip if sanitization removed everything
if ($productNumber === '' || $description === '') {
return;
}
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
// Add a line to the invoice
$line = [
@@ -32,7 +32,7 @@ class email_template_stripe_invoice
<!-- Email template -->
<p>Kære <?= $this->name ?>,</p>
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig et betalingslink til din faktura.
<p>Tak for din bestilling hos Truck Wash. Vi har sendt dig en faktura på Stripe.
Du kan betale fakturaen ved at klikke på linket nedenfor:</p>
<p><a href="<?= $this->stripe_payment_link ?>">Betal faktura for ordre <?= $this->order_id ?></a></p>
<!-- End of the email template -->
@@ -725,52 +725,6 @@ class collected_order_invoices_o extends db
return false;
}
/**
* Resolve the e-conomic customer discount percentage that should be applied at the
* line level when building the invoice draft. Caches via Redis to avoid hammering
* the e-conomic templates endpoint on every draft sync.
*/
private static function resolveCustomerDiscountPercentageForDraft(int $customer_number): int
{
if ($customer_number <= 0) {
return 0;
}
$user = (new users_o())->getUserByCustomerNumber($customer_number);
$userId = (int)$user->id;
if ($userId > 0 && defined('redis')) {
try {
$cached = constant('redis')->get_economic_customer_discount_percentage($userId);
if ($cached !== null) {
return max(0, min(100, (int)$cached));
}
} catch (\Throwable $e) {
// Fall through to the live lookup.
}
}
try {
$discount = (int)(new \customers\economicCustomers())->getCustomerDiscountPercentage($customer_number);
} catch (\Throwable $e) {
error_log(sprintf(
'[collected_order_invoices_o] Failed to resolve e-conomic customer discount for customer %d: %s',
$customer_number,
$e->getMessage()
));
return 0;
}
if ($userId > 0 && defined('redis')) {
try {
constant('redis')->cache_economic_customer_discount_percentage($userId, $discount);
} catch (\Throwable $e) {
// Cache failures are non-fatal.
}
}
return max(0, min(100, $discount));
}
/**
* Require the invoice draft to not already exist
* @throws Exception If the request was not successful
@@ -1010,18 +964,7 @@ class collected_order_invoices_o extends db
break;
}
}
// Look up the customer-level e-conomic discount (e.g. bug #11 customer 35131752
// "kd" 15%). This is applied at the line level so the draft invoice carries the
// discount percentage that e-conomic expects for the customer.
$customer_discount_percentage = self::resolveCustomerDiscountPercentageForDraft((int)$this->customer_number->value());
$metrics = (new economic())->invoices->draft->add_orders(
$draft_id,
$order_objects,
$currency,
500,
$use_itemized_discounts,
$customer_discount_percentage
);
$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);
$this->last_economic_transfer_metrics = [
'draft_invoice_id' => $draft_id,
'currency' => (string)$currency,
-62
View File
@@ -906,68 +906,6 @@ class orders_o extends db
return (bool)$count;
}
/**
* Get the timestamp of the most recent completed wash for a license plate.
* Used by the front page to show the "last washed" hint when a plate is
* scanned (DHL trailer pick-up use case, TRU-78 / DRIFT 17).
*
* Only orders that have at least one non-deleted order item are
* considered (mirrors the contract used by customer_vehicles_o::
* getLastOrderByPlate() so the timestamp is always backed by a real wash).
*
* @param string $reg_1 The license plate to look up
* @return string|null MySQL datetime string of the most recent qualifying
* order's `created_at`, or null when the plate has
* never been washed.
*/
public function getLastWashTimestampForPlate(string $reg_1): ?string
{
$normalized_reg_1 = trim($reg_1);
if ($normalized_reg_1 === '') {
return null;
}
$orders = self::getFieldsWhere([
'reg_1' => $normalized_reg_1,
'deleted_at' => null,
], [
'id',
]);
// Walk the orders newest-first and return the first one that actually
// has at least one non-deleted order item.
$candidate_ids = array_reverse(array_map(static function ($row) {
return (int)($row['id'] ?? 0);
}, $orders));
foreach ($candidate_ids as $order_id) {
if ($order_id <= 0) {
continue;
}
$has_items = (new order_items_o())->getFieldsWhere([
'order_id' => $order_id,
'deleted_at' => null,
], ['id']);
if (count($has_items) === 0) {
continue;
}
$details = self::getFieldsWhere([
'id' => $order_id,
'deleted_at' => null,
], ['created_at']);
$created_at = $details[0]['created_at'] ?? null;
if (is_string($created_at) && $created_at !== '') {
return $created_at;
}
}
return null;
}
public function getFixedPricingTransactions(int $customer_number, false $asArray, string|null $dateFrom = null, string|null $dateTo = null): array
{
// Get the fixed pricing transactions for a customer
+5 -38
View File
@@ -10344,11 +10344,8 @@ paths:
post:
tags:
- Modules
summary: Create Stripe invoice (retired - TRU-74 / DRIFT 13)
description: |
Retired in favour of in-store card payments. Always returns HTTP 410
with `code: stripe_email_payment_disabled` so the POS can fall back
to the standard card-payment flow.
summary: Create Stripe invoice
description: Create an invoice in Stripe
operationId: createStripeInvoice
requestBody:
required: false
@@ -10356,41 +10353,11 @@ paths:
application/json:
schema: {}
responses:
'410':
description: Direct Stripe payment links by email are no longer available
'201':
description: Stripe invoice created successfully
content:
application/json:
schema:
type: object
properties:
code:
type: string
example: stripe_email_payment_disabled
message:
type: string
delete:
tags:
- Modules
summary: Cancel/clean up a legacy Stripe hosted invoice
description: |
Void a pre-existing Stripe hosted invoice that was created before
direct payment links were retired from POS (TRU-74 / DRIFT 13).
Card payments created via the new flow are not affected and use
the standard payment-intent lifecycle instead.
operationId: cancelLegacyStripeInvoice
parameters:
- name: order_id
in: query
required: true
schema:
type: integer
responses:
'200':
description: Legacy Stripe hosted invoice was voided
content:
application/json:
schema:
type: object
schema: {}
/modules/stripe/terminal/readers:
get:
@@ -1,32 +0,0 @@
[Unit]
Description=Truck Wash API cron worker (long-running scheduler)
After=network-online.target php8.2-fpm.service redis.service
Wants=network-online.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/copenhagentruckwash-api/services/nginx/app
ExecStart=/usr/bin/php /opt/copenhagentruckwash-api/services/nginx/app/index.php run cron-worker
ExecReload=/bin/kill -HUP $MAINPID
Restart=always
RestartSec=10
TimeoutStopSec=30
StandardOutput=journal
StandardError=journal
SyslogIdentifier=cron-worker
# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
ReadWritePaths=/opt/copenhagentruckwash-api/services/php/logs
# Resource limits
LimitNOFILE=65536
MemoryMax=512M
[Install]
WantedBy=multi-user.target
@@ -24,9 +24,6 @@ use objects\products_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class InvoicingPeriodRoute
{
use route_t;
@@ -1415,7 +1412,6 @@ class InvoicingPeriodRoute
public function run(): void
{
$this->post('/superuser/invoicing/period/object-tree/canary', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/object-tree/canary');
global $response;
$this->requirePermission('superuser');
self::requireParameters(['enabled']);
@@ -1467,7 +1463,6 @@ class InvoicingPeriodRoute
]);
$this->get('/superuser/invoicing/period', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1512,7 +1507,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/tree', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/tree');
global $response;
$this->requirePermission('superuser_invoicing_period');
$user = (new authentication())->get_user();
@@ -1552,7 +1546,6 @@ class InvoicingPeriodRoute
);
$this->post('/superuser/invoicing/period/flags', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags');
global $response;
$this->requirePermission('add_invoice_period_flag');
$user = (new authentication())->get_user();
@@ -1578,7 +1571,6 @@ class InvoicingPeriodRoute
);
$this->patch('/superuser/invoicing/period/flags/{id}/status', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags/{id}/status');
global $response;
$this->requirePermission('update_invoice_period_flag_status');
$user = (new authentication())->get_user();
@@ -1607,7 +1599,6 @@ class InvoicingPeriodRoute
);
$this->post('/superuser/invoicing/period/flags/automatic/status', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/invoicing/period/flags/automatic/status');
global $response;
$this->requirePermission('update_invoice_period_flag_status');
$user = (new authentication())->get_user();
@@ -1634,7 +1625,6 @@ class InvoicingPeriodRoute
$this->get('/superuser/invoicing/period/distribution/all', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/all');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1657,7 +1647,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/fixed-pricing', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/fixed-pricing');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1675,7 +1664,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/wash-subscriptions');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -1693,7 +1681,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/all', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/all');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1712,7 +1699,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/fixed-pricing');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1731,7 +1717,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/wash-subscriptions');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1750,7 +1735,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/customer-prices', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/customer-prices');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1769,7 +1753,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/v2/booked-department-75', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/v2/booked-department-75');
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
@@ -1788,7 +1771,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/customers/pricing-history', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/customers/pricing-history');
global $response;
$this->requirePermission('superuser_customer_pricing_history_v2');
self::requireParameters(['customer_number']);
@@ -1850,7 +1832,6 @@ class InvoicingPeriodRoute
);
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/invoicing/period/distribution/wash-subscriptions/historical');
// Require the user to be logged in
global $response;
$this->requirePermission('superuser_invoicing_period');
@@ -7,9 +7,6 @@ use classes\account_deletion_service;
use Throwable;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class accountDeletionRoute
{
use route_t;
@@ -17,7 +14,6 @@ class accountDeletionRoute
public function run(): void
{
$this->get('/account/deletion', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/account/deletion');
global $response;
try {
if (!account_deletion_service::apiEnabled()) {
@@ -35,7 +31,6 @@ class accountDeletionRoute
});
$this->post('/account/deletion', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/deletion');
global $response;
try {
if (!account_deletion_service::apiEnabled()) {
-4
View File
@@ -7,9 +7,6 @@ use classes\response;
use classes\customer_invoice_email_schema_bootstrap;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
/**
* Admin / ops endpoints. Currently exposes the schema health check.
*
@@ -29,7 +26,6 @@ class adminRoute
// and the cron job. Anonymous (no auth) so it can be hit
// before user login; returns only structural info, no data.
$this->get('/admin/schema-check', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/admin/schema-check');
global /** @var response $response */ $response;
// Self-heal: run all schema bootstraps first
if (class_exists(customer_invoice_email_schema_bootstrap::class)) {
@@ -6,9 +6,6 @@ use classes\attachment_store;
use classes\response;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class attachmentsRoute
{
use route_t;
@@ -16,11 +13,9 @@ class attachmentsRoute
public function run(): void
{
$this->get('/attachments/example', function (): never {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/attachments/example');
throw new \Exception('EXAMPLE ROUTE, SHOULD BE IMPLEMENTED IN THE INDIVIDUAL OBJECT ROUTES');
});
$this->post('/attachments/upload', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/attachments/upload');
global $response;
self::requireParameters(['base64_image']);
$base64_image = self::getParameter('base64_image');
@@ -28,9 +28,6 @@ use Throwable;
use traits\bird_route_helpers_t;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
/**
* Narrow integration boundary between Bird and Pleno Control Plane.
*
@@ -44,7 +41,6 @@ class birdControlPlaneRoute
public function run(): void
{
$this->get('/bird/health', function (): void {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/health');
global $response;
$this->requirePermission('modules_bird_health_read');
@@ -6,9 +6,6 @@ use classes\bird;
use traits\bird_route_helpers_t;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class birdNumbersRoute
{
use route_t, bird_route_helpers_t;
@@ -17,7 +14,6 @@ class birdNumbersRoute
{
// List owned numbers
$this->get('/bird/numbers', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/numbers');
global $response;
// Permission: list numbers via Bird
$this->requirePermission('modules_bird_numbers_list');
@@ -23,8 +23,6 @@ use bird\helpers\bird_voice_recording_update_payload;
use bird\helpers\bird_voice_recordings_create_payload;
use bird\helpers\bird_voice_recordings_list_query_payload;
use bird\helpers\bird_voice_say_payload;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use bird\helpers\bird_voice_test_outbound_payload;
use bird\helpers\bird_voice_update_call_payload;
use classes\bird;
@@ -39,10 +37,7 @@ class birdVoiceCallsRoute
public function run(): void
{
// List workspace call log
// TRU-149: scope check added to satisfy scope middleware contract
// (every protected route must have at least one requireScope call).
$this->get('/bird/voice/calls/log', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/calls/log');
$this->get('/bird/voice/calls/log', function () {
global $response;
$this->requirePermission('modules_bird_voice_calls_log_list');
@@ -16,8 +16,6 @@ use bird\helpers\bird_flash_list_query_payload;
use bird\helpers\bird_request_schemas;
use classes\bird;
use traits\bird_route_helpers_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use traits\bird_route_validation_t;
use traits\route_t;
@@ -29,7 +27,6 @@ class birdVoiceFlashCallsRoute
{
// Create a flash call
$this->post('/bird/voice/flash-calls', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/voice/flash-calls');
global $response;
$this->requirePermission('modules_bird_voice_flash_calls_create');
@@ -12,9 +12,6 @@ use objects\logs_o;
use objects\order_bookings_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class bookingsRoute
{
use route_t;
@@ -23,7 +20,6 @@ class bookingsRoute
{
/** All bookings */
$this->get('/bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings');
// Require the user to be logged in
global
/** @var response $response */
@@ -97,7 +93,6 @@ class bookingsRoute
);
/** Own bookings */
$this->get('/user/bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/user/bookings');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -141,7 +136,6 @@ class bookingsRoute
);
$this->put('/bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/bookings');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -210,7 +204,6 @@ class bookingsRoute
);
// Synchronize booking from the external system
$this->post('/admin/bookings/sync', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/sync');
// Require the user to be logged in
global $response;
$this->requirePermission('sync_bookings');
@@ -263,7 +256,6 @@ class bookingsRoute
// Get a departments unfulfilled bookings (count) for the day
$this->get('/admin/bookings/department/count', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/admin/bookings/department/count');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -304,7 +296,6 @@ class bookingsRoute
);
$this->post('/user/bookings/washcertificate/download', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/user/bookings/washcertificate/download');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -363,7 +354,6 @@ class bookingsRoute
);
$this->get('/bookings/download_pdf', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/bookings/download_pdf');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -426,7 +416,6 @@ class bookingsRoute
);
$this->post('/admin/bookings/delete', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/delete');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -460,7 +449,6 @@ class bookingsRoute
);
$this->post('/superuser/bookings/sync/all', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/superuser/bookings/sync/all');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -486,7 +474,6 @@ class bookingsRoute
);
$this->post('/admin/bookings/completeWashWithoutWashCertificate', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/admin/bookings/completeWashWithoutWashCertificate');
global /** @var response $response */
$response;
$response->error('Booking completion must be completed through POS desktop or mobile steps.', 410);
@@ -497,7 +484,6 @@ class bookingsRoute
);
$this->post('/user/bookings/delete', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/user/bookings/delete');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -7,9 +7,6 @@ use objects\categories_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class categoriesRoute
{
use route_t;
@@ -17,7 +14,6 @@ class categoriesRoute
public function run(): void
{
$this->get('/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/categories');
// Require the user to be logged in
global $response;
@@ -63,7 +59,6 @@ class categoriesRoute
);
$this->post('/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/categories');
// Require the user to be logged in
global $response;
@@ -101,7 +96,6 @@ class categoriesRoute
);
$this->put('/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/categories');
// Require the user to be logged in
global $response;
-10
View File
@@ -10,9 +10,6 @@ use objects\logs_o;
use Throwable;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class cronRoute
{
use route_t;
@@ -20,7 +17,6 @@ class cronRoute
public function run(): void
{
$this->get('/superuser/cron', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
@@ -30,7 +26,6 @@ class cronRoute
]);
$this->get('/superuser/cron/runs', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron/runs');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
@@ -44,7 +39,6 @@ class cronRoute
]);
$this->get('/superuser/cron/workers', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/superuser/cron/workers');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_view');
@@ -70,7 +64,6 @@ class cronRoute
]);
$this->post('/superuser/cron/workers/deploy', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/workers/deploy');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
@@ -91,7 +84,6 @@ class cronRoute
]);
$this->post('/superuser/cron/run', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/run');
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
@@ -117,7 +109,6 @@ class cronRoute
]);
$this->patch('/superuser/cron/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron/config');
global $response;
$this->requireClassicSuperuserPermission('superuser_cron_manage');
@@ -146,7 +137,6 @@ class cronRoute
]);
$this->post('/superuser/cron', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/superuser/cron');
global $response;
$this->requireClassicSuperuserPermission('SUPERUSER_RUN_CRON');
@@ -8,9 +8,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerAttributes
{
use route_t;
@@ -18,7 +15,6 @@ class customerAttributes
public function run(): void
{
$this->get('/customer/attributes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/attributes');
// Require the user to be logged in
global $response;
// Get the user object
@@ -66,7 +62,6 @@ class customerAttributes
);
$this->post('/customer/attributes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/attributes');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_attribute');
@@ -105,7 +100,6 @@ class customerAttributes
);
$this->delete('/customer/attributes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/attributes');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_attribute');
@@ -7,9 +7,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerCodeDepartmentRoute
{
use route_t;
@@ -17,7 +14,6 @@ class customerCodeDepartmentRoute
public function run(): void
{
$this->get('/admin/customer/code', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/admin/customer/code');
// Require the user to be logged in
global $response;
$this->requirePermission('get_customer_code');
@@ -54,7 +50,6 @@ class customerCodeDepartmentRoute
);
$this->post('/admin/customer/code', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/admin/customer/code');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_code');
@@ -9,9 +9,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerDefaultDepartmentRoute
{
use route_t;
@@ -19,7 +16,6 @@ class customerDefaultDepartmentRoute
public function run(): void
{
$this->get('/customer/department/default', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/department/default');
// Require the user to be logged in
global $response;
$this->requirePermission('get_customer_default_department');
@@ -61,7 +57,6 @@ class customerDefaultDepartmentRoute
);
$this->post('/customer/department/default', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/department/default');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_default_department');
@@ -118,7 +113,6 @@ class customerDefaultDepartmentRoute
);
$this->delete('/customer/department/default', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/department/default');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_default_department');
@@ -9,9 +9,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerFixedPricingRoute
{
use route_t;
@@ -19,7 +16,6 @@ class customerFixedPricingRoute
public function run(): void
{
$this->get('/customer/pricing/fixed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/pricing/fixed');
// Require the user to be logged in
global $response;
$this->requirePermission('get_customer_fixed_pricing');
@@ -61,7 +57,6 @@ class customerFixedPricingRoute
]
);
$this->post('/customer/pricing/fixed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/pricing/fixed');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_fixed_pricing');
@@ -140,7 +135,6 @@ class customerFixedPricingRoute
);
$this->delete('/customer/pricing/fixed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/pricing/fixed');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_fixed_pricing');
@@ -8,9 +8,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerNotes
{
use route_t;
@@ -18,7 +15,6 @@ class customerNotes
public function run(): void
{
$this->get('/customer/notes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/customer/notes');
// Require the user to be logged in
global $response;
$this->requirePermission('list_customer_notes');
@@ -56,7 +52,6 @@ class customerNotes
);
$this->post('/customer/notes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/notes');
// Require the user to be logged in
global $response;
$this->requirePermission('add_customer_note');
@@ -97,7 +92,6 @@ class customerNotes
);
$this->delete('/customer/notes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customer/notes');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_customer_note');
@@ -9,9 +9,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerSearchRoute
{
use route_t;
@@ -20,7 +17,6 @@ class customerSearchRoute
{
//TODO: Remove this, this is deprecated in favor of the new search endpoint
$this->post('/customers/search', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customers/search');
// Require the user to be logged in
global $response;
$this->requirePermission('search_customers');
@@ -150,7 +146,6 @@ class customerSearchRoute
);
$this->post('/customers/import', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/customers/import');
global $response;
$this->requirePermission('add_user');
@@ -11,9 +11,6 @@ use objects\departments_o;
use objects\product_options_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class customerTimeBookingsRoute
{
use route_t;
@@ -51,7 +48,6 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Departments -> GET */
$this->get('/department/timebookings/departments/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/departments/public');
global $response;
$departments = new departments_o();
@@ -101,7 +97,6 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Opening Hours -> GET */
$this->get('/department/timebookings/opening-hours/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours/public');
global $response;
$this->requirePublicTimeBookingsDepartment();
@@ -141,7 +136,6 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Types -> GET */
$this->get('/department/timebookings/types/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types/public');
global $response;
$this->requirePublicTimeBookingsDepartment();
@@ -176,7 +170,6 @@ class customerTimeBookingsRoute
);
/** Guest Time Bookings -> Entries -> GET */
$this->get('/department/timebookings/entries/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries/public');
/**
* @example Usage of this endpoint:
* GET /department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc
@@ -212,7 +205,6 @@ class customerTimeBookingsRoute
/** Guest Time Bookings -> Entries -> Add */
$this->post('/department/timebookings/entries/public', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries/public');
global $response;
self::requireParameters(['department', 'type', 'start']);
$department = $this->requirePublicTimeBookingsDepartment('department');
@@ -19,9 +19,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentDailyReportsRoute
{
use route_t;
@@ -31,7 +28,6 @@ class departmentDailyReportsRoute
public function run(): void
{
$this->get('/departments/daily-reports', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -88,7 +84,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/get', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/get');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -162,7 +157,6 @@ class departmentDailyReportsRoute
);
$this->post('/departments/daily-reports', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports');
// Require the user to be logged in
global $response;
$this->requirePermission('create_department_daily_reports');
@@ -267,7 +261,6 @@ class departmentDailyReportsRoute
);
$this->post('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('create_department_daily_report_complaints');
@@ -367,7 +360,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/complaints/customers', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/complaints/customers');
global $response;
$user = (new authentication())->get_user();
@@ -478,7 +470,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('list_department_daily_report_complaints');
@@ -540,7 +531,6 @@ class departmentDailyReportsRoute
);
$this->put('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('edit_department_daily_report_complaints');
@@ -674,7 +664,6 @@ class departmentDailyReportsRoute
);
$this->delete('/departments/daily-reports/complaints', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/complaints');
global $response;
$this->requirePermission('delete_department_daily_report_complaints');
@@ -715,7 +704,6 @@ class departmentDailyReportsRoute
);
$this->put('/departments/daily-reports', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports');
// Require the user to be logged in
global $response;
$this->requirePermission('update_department_daily_reports');
@@ -807,7 +795,6 @@ class departmentDailyReportsRoute
);
$this->get('/superuser/departments/{id}/overview', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/superuser/departments/{id}/overview');
global $response;
$this->requirePermission('superuser_fetch_department');
@@ -853,7 +840,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/overview', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/overview');
global $response;
$this->requirePermission('list_department_daily_reports');
$this->requirePermission('list_bookings');
@@ -900,7 +886,6 @@ class departmentDailyReportsRoute
);
$this->put('/departments/daily-reports/product-targets', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/daily-reports/product-targets');
global $response;
$this->requirePermission(self::SET_PRODUCT_TARGET_PERMISSION);
@@ -963,7 +948,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/product-count', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/product-count');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -1073,7 +1057,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/transaction-count', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/transaction-count');
// Require the user to be logged in
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -1170,7 +1153,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/outside-hours-trend', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/outside-hours-trend');
global $response;
$this->requirePermission('list_department_daily_reports');
@@ -1218,7 +1200,6 @@ class departmentDailyReportsRoute
);
$this->get('/departments/daily-reports/bookings-count', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/daily-reports/bookings-count');
// Require the user to be logged in
global $response;
$this->requirePermission('list_bookings');
@@ -12,9 +12,6 @@ use objects\departments_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentGatesRelaysRoute
{
use route_t;
@@ -22,7 +19,6 @@ class departmentGatesRelaysRoute
public function run(): void
{
$this->get('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('list_department_gates');
$user = (new authentication())->get_user();
@@ -60,7 +56,6 @@ class departmentGatesRelaysRoute
]);
$this->post('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('add_department_gate');
$user = (new authentication())->get_user();
@@ -104,7 +99,6 @@ class departmentGatesRelaysRoute
]);
$this->put('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('update_department_gate');
$user = (new authentication())->get_user();
@@ -163,7 +157,6 @@ class departmentGatesRelaysRoute
]);
$this->delete('/department/gates', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/gates');
global /** @var response $response */ $response;
$this->requirePermission('delete_department_gate');
$user = (new authentication())->get_user();
@@ -190,7 +183,6 @@ class departmentGatesRelaysRoute
]);
$this->get('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('list_department_relays');
$user = (new authentication())->get_user();
@@ -228,7 +220,6 @@ class departmentGatesRelaysRoute
]);
$this->post('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('add_department_relay');
$user = (new authentication())->get_user();
@@ -271,7 +262,6 @@ class departmentGatesRelaysRoute
]);
$this->put('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('update_department_relay');
$user = (new authentication())->get_user();
@@ -324,7 +314,6 @@ class departmentGatesRelaysRoute
]);
$this->delete('/department/relays', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/relays');
global /** @var response $response */ $response;
$this->requirePermission('delete_department_relay');
$user = (new authentication())->get_user();
@@ -14,9 +14,6 @@ use objects\departments_o;
use objects\department_goals_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentGoalsRoute
{
use route_t;
@@ -25,7 +22,6 @@ class departmentGoalsRoute
{
// List or get single department goal(s)
$this->get('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_list');
@@ -87,7 +83,6 @@ class departmentGoalsRoute
// Create a new department goal
$this->post('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_create');
@@ -142,7 +137,6 @@ class departmentGoalsRoute
// Update an existing department goal
$this->put('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_update');
@@ -242,7 +236,6 @@ class departmentGoalsRoute
// Delete a department goal (soft delete if supported)
$this->delete('/goals/department', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_delete');
@@ -282,7 +275,6 @@ class departmentGoalsRoute
// Send a test progress alert for a department goal
$this->post('/goals/department/progress-alert/test', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/goals/department/progress-alert/test');
global /** @var response $response */ $response;
$this->requirePermission('goals_department_progress_alert_test');
@@ -14,9 +14,6 @@ use objects\department_selfserve_tasks_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentLanesRoute
{
use route_t;
@@ -26,7 +23,6 @@ class departmentLanesRoute
public function run(): void
{
$this->get('/department/lanes/status-toggles', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/status-toggles');
global $response;
$this->requirePermission('list_department_lanes');
@@ -56,7 +52,6 @@ class departmentLanesRoute
);
$this->get('/department/lanes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes');
// Require the user to be logged in
global $response;
@@ -129,7 +124,6 @@ class departmentLanesRoute
);
$this->get('/department/lanes/relay-options', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/relay-options');
global $response;
$this->requirePermission('list_department_lanes');
@@ -189,7 +183,6 @@ class departmentLanesRoute
* Response: image/png
*/
$this->get('/department/lanes/dynamic-image', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/lanes/dynamic-image');
global $response;
// Reuse listing permission; viewing image is tied to lane visibility
// Authenticated user and department access validation
@@ -342,7 +335,6 @@ class departmentLanesRoute
]);
$this->post('/department/lanes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/lanes');
// Require the user to be logged in
global $response;
@@ -408,7 +400,6 @@ class departmentLanesRoute
);
$this->put('/department/lanes', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/lanes');
// Require the user to be logged in
global $response;
@@ -9,9 +9,6 @@ use objects\department_notification_sms_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentNotificationSmsRoute
{
use route_t;
@@ -25,7 +22,6 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Get */
$this->get('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_get');
$user = (new authentication())->get_user();
@@ -64,7 +60,6 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Add */
$this->post('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_add');
$user = (new authentication())->get_user();
@@ -108,7 +103,6 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Update */
$this->put('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_update');
$user = (new authentication())->get_user();
@@ -163,7 +157,6 @@ class departmentNotificationSmsRoute
/** Department Notification SMS -> Delete */
$this->delete('/department/notification/sms', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/notification/sms');
global $response;
$this->requirePermission('department_notification_sms_delete');
$user = (new authentication())->get_user();
@@ -13,9 +13,6 @@ use objects\department_selfserve_condition_rules_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveConditionRulesRoute
{
use route_t;
@@ -26,7 +23,6 @@ class departmentSelfserveConditionRulesRoute
* List department self-serve condition rules
*/
$this->get('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('list_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -101,7 +97,6 @@ class departmentSelfserveConditionRulesRoute
* Add a department self-serve condition rule
*/
$this->post('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('add_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -154,7 +149,6 @@ class departmentSelfserveConditionRulesRoute
* Update a department self-serve condition rule
*/
$this->put('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('update_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -227,7 +221,6 @@ class departmentSelfserveConditionRulesRoute
* Delete a department self-serve condition rule
*/
$this->delete('/department/selfserve/condition/rules', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/condition/rules');
global $response;
$this->requirePermission('delete_department_selfserve_condition_rules');
$user = (new authentication())->get_user();
@@ -12,9 +12,6 @@ use objects\department_selfserve_conditions_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveConditionsRoute
{
use route_t;
@@ -25,7 +22,6 @@ class departmentSelfserveConditionsRoute
* List department self-serve conditions
*/
$this->get('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/conditions');
global $response;
$this->requirePermission('list_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -101,7 +97,6 @@ class departmentSelfserveConditionsRoute
* Add a department self-serve condition
*/
$this->post('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
global $response;
$this->requirePermission('add_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -161,7 +156,6 @@ class departmentSelfserveConditionsRoute
* Update a department self-serve condition
*/
$this->put('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
global $response;
$this->requirePermission('update_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -226,7 +220,6 @@ class departmentSelfserveConditionsRoute
* Delete a department self-serve condition
*/
$this->delete('/department/selfserve/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/conditions');
global $response;
$this->requirePermission('delete_department_selfserve_conditions');
$user = (new authentication())->get_user();
@@ -10,9 +10,6 @@ use modules\selfserve\classes\selfserve_config_versioning;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveConfigVersionsRoute
{
use route_t;
@@ -20,7 +17,6 @@ class departmentSelfserveConfigVersionsRoute
public function run(): void
{
$this->get('/department/selfserve/config/versions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/versions');
global $response;
$this->requirePermission('list_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -45,7 +41,6 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->get('/department/selfserve/config/history', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/history');
global $response;
$this->requirePermission('list_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -68,7 +63,6 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->get('/department/selfserve/config/active', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/config/active');
global $response;
$this->requirePermission('list_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -104,7 +98,6 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/draft', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/draft');
global $response;
$this->requirePermission('edit_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -129,7 +122,6 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/validate', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/validate');
global $response;
$this->requirePermission('edit_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -150,7 +142,6 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/publish', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/publish');
global $response;
$this->requirePermission('publish_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -175,7 +166,6 @@ class departmentSelfserveConfigVersionsRoute
]);
$this->post('/department/selfserve/config/rollback', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/config/rollback');
global $response;
$this->requirePermission('rollback_department_selfserve_config_versions');
$user = (new authentication())->get_user();
@@ -10,9 +10,6 @@ use objects\logs_o;
use objects\selfserve_machine_types_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveMachineTypesRoute
{
use route_t;
@@ -20,7 +17,6 @@ class departmentSelfserveMachineTypesRoute
public function run(): void
{
$this->get('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('list_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -49,7 +45,6 @@ class departmentSelfserveMachineTypesRoute
]);
$this->post('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('add_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -75,7 +70,6 @@ class departmentSelfserveMachineTypesRoute
]);
$this->put('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('update_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -108,7 +102,6 @@ class departmentSelfserveMachineTypesRoute
]);
$this->delete('/department/selfserve/machine-types', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/machine-types');
global $response;
$this->requirePermission('delete_department_selfserve_machine_types');
$user = (new authentication())->get_user();
@@ -12,9 +12,6 @@ use objects\department_selfserve_questions_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveQuestionsRoute
{
use route_t;
@@ -25,7 +22,6 @@ class departmentSelfserveQuestionsRoute
* List department self-serve questions
*/
$this->get('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/questions');
global $response;
$this->requirePermission('list_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -97,7 +93,6 @@ class departmentSelfserveQuestionsRoute
* Add a department self-serve question
*/
$this->post('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
global $response;
$this->requirePermission('add_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -152,7 +147,6 @@ class departmentSelfserveQuestionsRoute
* Update a department self-serve question
*/
$this->put('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
global $response;
$this->requirePermission('edit_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -214,7 +208,6 @@ class departmentSelfserveQuestionsRoute
* Delete a department self-serve question
*/
$this->delete('/department/selfserve/questions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/questions');
global $response;
$this->requirePermission('delete_department_selfserve_questions');
$user = (new authentication())->get_user();
@@ -9,9 +9,6 @@ use modules\selfserve\classes\selfserve_wash_flow;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveStudioRoute
{
use route_t;
@@ -19,7 +16,6 @@ class departmentSelfserveStudioRoute
public function run(): void
{
$this->get('/department/selfserve/studio/graph', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/studio/graph');
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -35,7 +31,6 @@ class departmentSelfserveStudioRoute
]);
$this->put('/department/selfserve/studio/graph', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/graph');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -56,7 +51,6 @@ class departmentSelfserveStudioRoute
]);
$this->put('/department/selfserve/studio/layout', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/layout');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'layout']);
@@ -76,7 +70,6 @@ class departmentSelfserveStudioRoute
]);
$this->put('/department/selfserve/studio/virtual-hardware', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/virtual-hardware');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'operation']);
@@ -101,7 +94,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/validate', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/validate');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -116,7 +108,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/simulate', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/simulate');
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department', 'lane_id', 'reg']);
@@ -142,7 +133,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/path-outcomes/stream', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-outcomes/stream');
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
$departmentId = (int)self::getParameter('department');
@@ -183,7 +173,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/path-outcomes', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-outcomes');
global $response;
$user = $this->requireStudioUser('list_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -207,7 +196,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/path-confirmations', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/path-confirmations');
global $response;
$user = $this->requireStudioUser('edit_department_selfserve_config_versions');
self::requireParameters(['department', 'path_signature']);
@@ -231,7 +219,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/publish', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/publish');
global $response;
$user = $this->requireStudioUser('publish_department_selfserve_config_versions');
self::requireParameters(['department']);
@@ -253,7 +240,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/rollback', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/rollback');
global $response;
$user = $this->requireStudioUser('rollback_department_selfserve_config_versions');
self::requireParameters(['department', 'target_version_id']);
@@ -274,7 +260,6 @@ class departmentSelfserveStudioRoute
]);
$this->post('/department/selfserve/studio/gateway-action', function (): void {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/studio/gateway-action');
global $response;
$user = $this->requireStudioUser('modules_shelly_config');
self::requireParameters(['department', 'gateway_id', 'action']);
@@ -17,9 +17,6 @@ use modules\selfserve\helpers\selfserve_lane_services;
use modules\selfserve\helpers\selfserve_task_gate_type;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveTasksRoute
{
use route_t;
@@ -30,7 +27,6 @@ class departmentSelfserveTasksRoute
* List department self-serve tasks
*/
$this->get('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks');
global $response;
$this->requirePermission('list_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -116,7 +112,6 @@ class departmentSelfserveTasksRoute
* Add a department self-serve task
*/
$this->post('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
global $response;
$this->requirePermission('add_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -276,7 +271,6 @@ class departmentSelfserveTasksRoute
* Update a department self-serve task
*/
$this->put('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
global $response;
$this->requirePermission('edit_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -443,7 +437,6 @@ class departmentSelfserveTasksRoute
* Delete a department self-serve task
*/
$this->delete('/department/selfserve/tasks', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks');
global $response;
$this->requirePermission('delete_department_selfserve_tasks');
$user = (new authentication())->get_user();
@@ -477,7 +470,6 @@ class departmentSelfserveTasksRoute
* Download an attachment for a department self-serve task
*/
$this->get('/department/selfserve/tasks/attachments/download', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks/attachments/download');
global $response;
$this->requirePermission('download_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -528,7 +520,6 @@ class departmentSelfserveTasksRoute
* List attachments for a department self-serve task
*/
$this->get('/department/selfserve/tasks/attachments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/tasks/attachments');
global $response;
$this->requirePermission('list_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -566,7 +557,6 @@ class departmentSelfserveTasksRoute
* Upload an attachment for a department self-serve task
*/
$this->post('/department/selfserve/tasks/attachments/upload', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks/attachments/upload');
global $response;
$this->requirePermission('add_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -613,7 +603,6 @@ class departmentSelfserveTasksRoute
* Delete an attachment for a department self-serve task
*/
$this->delete('/department/selfserve/tasks/attachments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/tasks/attachments');
global $response;
$this->requirePermission('delete_department_selfserve_task_attachments');
$user = (new authentication())->get_user();
@@ -19,9 +19,6 @@ use objects\logs_o;
use objects\selfserve_wash_sessions_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentSelfserveVehicleConditionsRoute
{
use route_t;
@@ -32,7 +29,6 @@ class departmentSelfserveVehicleConditionsRoute
* List department self-serve vehicle conditions
*/
$this->get('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/vehicle/conditions');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -122,7 +118,6 @@ class departmentSelfserveVehicleConditionsRoute
* Check whether self-serve is allowed for a specific vehicle and lane
*/
$this->get('/department/selfserve/vehicle/allowed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/vehicle/allowed');
global $response;
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
@@ -170,7 +165,6 @@ class departmentSelfserveVehicleConditionsRoute
* Get self-serve wash summary
*/
$this->get('/department/selfserve/washes/summary', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/selfserve/washes/summary');
global $response;
[$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST);
@@ -255,7 +249,6 @@ class departmentSelfserveVehicleConditionsRoute
* Add a department self-serve vehicle condition
*/
$this->post('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
global $response;
[$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal();
$own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD);
@@ -318,7 +311,6 @@ class departmentSelfserveVehicleConditionsRoute
* Update a department self-serve vehicle condition
*/
$this->put('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -416,7 +408,6 @@ class departmentSelfserveVehicleConditionsRoute
* Delete a department self-serve vehicle condition
*/
$this->delete('/department/selfserve/vehicle/conditions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/department/selfserve/vehicle/conditions');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -12,9 +12,6 @@ use objects\logs_o;
use objects\product_options_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentTimeBookingsRoute
{
use route_t;
@@ -28,7 +25,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> List */
$this->get('/department/timebookings/opening-hours', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/opening-hours');
global $response;
$this->requirePermission('department_timebookings_opening_hours_get');
$user = (new authentication())->get_user();
@@ -78,7 +74,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Update */
$this->put('/department/timebookings/opening-hours', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/opening-hours');
global $response;
$this->requirePermission('department_timebookings_opening_hours_put');
$user = (new authentication())->get_user();
@@ -129,7 +124,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types */
$this->get('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_get');
$user = (new authentication())->get_user();
@@ -175,7 +169,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types -> Add */
$this->post('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_post');
$user = (new authentication())->get_user();
@@ -211,7 +204,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types -> Update */
$this->put('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_put');
$user = (new authentication())->get_user();
@@ -247,7 +239,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Types -> Delete */
$this->delete('/department/timebookings/types', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/types');
global $response;
$this->requirePermission('department_timebookings_types_delete');
$user = (new authentication())->get_user();
@@ -278,7 +269,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Entries */
$this->get('/department/timebookings/entries', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/department/timebookings/entries');
global $response;
$this->requirePermission('department_timebookings_entries_get');
$user = (new authentication())->get_user();
@@ -334,7 +324,6 @@ class departmentTimeBookingsRoute
/** Department Time Bookings -> Entries -> Add */
$this->post('/department/timebookings/entries', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/department/timebookings/entries');
global $response;
$this->requirePermission('department_timebookings_entries_post');
$user = (new authentication())->get_user();
@@ -16,9 +16,6 @@ use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class departmentsRoute
{
use route_t;
@@ -86,7 +83,6 @@ class departmentsRoute
public function run(): void
{
$this->get('/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments');
// Require the user to be logged in
global $response;
$this->requirePermission('list_departments');
@@ -177,7 +173,6 @@ class departmentsRoute
);
$this->post('/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments');
// Require the user to be logged in
global $response;
$this->requirePermission('add_department');
@@ -214,7 +209,6 @@ class departmentsRoute
);
$this->put('/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments');
// Require the user to be logged in
global $response;
$this->requirePermission('edit_department');
@@ -271,7 +265,6 @@ class departmentsRoute
);
$this->get('/departments/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/categories');
// Require the user to be logged in
global $response;
$auth = new authentication();
@@ -330,7 +323,6 @@ class departmentsRoute
);
$this->get('/departments/self-serve/enabled', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/departments/self-serve/enabled');
// Require the user to be logged in
global $response;
$this->requirePermission('view_department_selfserve_enabled');
@@ -370,7 +362,6 @@ class departmentsRoute
);
$this->put('/departments/self-serve/enabled', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/self-serve/enabled');
// Require the user to be logged in
global $response;
$this->requirePermission('edit_department_selfserve_enabled');
@@ -422,7 +413,6 @@ class departmentsRoute
);
$this->post('/departments/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/categories');
// Require the user to be logged in
global $response;
$this->requirePermission('add_department_category');
@@ -475,7 +465,6 @@ class departmentsRoute
);
$this->delete('/departments/categories', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/departments/categories');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_department_category');
@@ -15,9 +15,6 @@ use objects\orders_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicInvoiceRoute
{
use route_t;
@@ -29,7 +26,6 @@ class economicInvoiceRoute
$router, $response;
$this->post('/economic/invoice/draft/export', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
@@ -103,7 +99,6 @@ class economicInvoiceRoute
]);
$this->delete('/economic/invoice/draft/delete', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/delete');
global /** @var response $response */
$response;
$this->requirePermission('economic_invoice_draft_delete');
@@ -144,7 +139,6 @@ class economicInvoiceRoute
]);
$this->post('/economic/invoice/export', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
@@ -229,7 +223,6 @@ class economicInvoiceRoute
]);
$this->get('/economic/invoice/draft/export/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/draft/export/status');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
@@ -255,7 +248,6 @@ class economicInvoiceRoute
]);
$this->post('/economic/invoice/draft/export/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/draft/export/retry');
global $response;
$this->requirePermission('economic_invoice_draft_export');
$user = (new authentication())->get_user();
@@ -290,7 +282,6 @@ class economicInvoiceRoute
]);
$this->get('/economic/invoice/export/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/invoice/export/status');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
@@ -316,7 +307,6 @@ class economicInvoiceRoute
]);
$this->post('/economic/invoice/export/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/invoice/export/retry');
global $response;
$this->requirePermission('economic_invoice_export');
$user = (new authentication())->get_user();
@@ -9,9 +9,6 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicLayoutsRoute
{
use route_t;
@@ -24,7 +21,6 @@ class economicLayoutsRoute
$this->get('/economic/layouts', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/layouts');
global $response;
$this->requirePermission('economic_layouts');
$user = (new authentication())->get_user();
@@ -9,9 +9,6 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class economicPaymentTermsRoute
{
use route_t;
@@ -24,7 +21,6 @@ class economicPaymentTermsRoute
$this->get('/economic/payment-terms', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/economic/payment-terms');
global $response;
$this->requirePermission('economic_payment_terms');
$user = (new authentication())->get_user();
@@ -13,8 +13,6 @@ use objects\departments_o;
use objects\order_items_o;
use objects\orders_o;
use objects\products_o;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use objects\users_o;
use traits\route_t;
@@ -25,7 +23,6 @@ class exampleRoute
public function run(): void
{
$this->get('/example', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/example');
global $response;
$response->success(['message' => 'Hello World!']);
});
@@ -7,9 +7,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class intimidateRoute
{
use route_t;
@@ -17,7 +14,6 @@ class intimidateRoute
public function run(): void
{
$this->post('/su/intimidate', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/su/intimidate');
// Get the post data
global $response;
// Make sure the user has the SUPERUSER_INTIMIDATE permission
@@ -12,9 +12,6 @@ use objects\orders_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class invoicesRoute
{
use route_t;
@@ -22,7 +19,6 @@ class invoicesRoute
public function run(): void
{
$this->get('/invoices/draft', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/invoices/draft');
// Require the user to be logged in
global $response;
$this->requirePermission('get_invoice_draft');
@@ -61,7 +57,6 @@ class invoicesRoute
);
$this->post('/invoices/draft/close', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/invoices/draft/close');
// Require the user to be logged in
global $response;
$this->requirePermission('close_invoice_draft');
@@ -99,7 +94,6 @@ class invoicesRoute
);
$this->get('/invoices/pdf', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/invoices/pdf');
// Require the user to be logged in
global $response;
$this->requirePermission('get_invoice_pdf');
@@ -8,9 +8,6 @@ use classes\limited_backoffice_login_grant_service;
use classes\limited_backoffice_service;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
require_once WD . '/classes/limited_backoffice_login_grant_service.php';
class limitedBackofficeRoute
@@ -20,7 +17,6 @@ class limitedBackofficeRoute
public function run(): void
{
$this->get('/limited-backoffice/departments', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limited-backoffice/departments');
$this->withLimitedBackoffice(function (limited_backoffice_service $service, $user): array {
$this->requirePermission(limited_backoffice_service::PERMISSION_ACCESS);
return $service->departmentsForUser($user);
@@ -11,9 +11,6 @@ use objects\logs_o;
use objects\plate_scanners_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class machineButtonPressRoute
{
use route_t;
@@ -21,7 +18,6 @@ class machineButtonPressRoute
public function run(): void
{
$handler = function () {
ScopeMiddleware::requireScope(Scope::SUBUSER_WRITE, '/relay/button/press/post');
global $response;
self::requirePlateScannerAuth();
@@ -7,9 +7,6 @@ use objects\logs_o;
use objects\module_action_logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleActionLogsRoute
{
use route_t;
@@ -18,7 +15,6 @@ class moduleActionLogsRoute
{
/** Modules > Action Logs > List */
$this->get('/modules/action-logs', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/action-logs');
global $response;
$this->requirePermission('modules_action_logs_view');
@@ -10,9 +10,6 @@ use objects\logs_o;
use Throwable;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleBackupsRoute
{
use route_t;
@@ -24,7 +21,6 @@ class moduleBackupsRoute
$router, $response;
$this->get('/modules/backup/backups', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/backups');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_list');
try {
@@ -42,7 +38,6 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_create');
$user_id = $this->actorUserId();
@@ -64,7 +59,6 @@ class moduleBackupsRoute
]);
$this->get('/modules/backup/jobs/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/jobs/{id}');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_list');
$job_id = (int)$this->fromRoute('id');
@@ -78,7 +72,6 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups/{backup_uuid}/verify', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/verify');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_verify');
try {
@@ -94,7 +87,6 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups/{backup_uuid}/restore/preview', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/restore/preview');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
@@ -110,7 +102,6 @@ class moduleBackupsRoute
]);
$this->post('/modules/backup/backups/{backup_uuid}/restore', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/backup/backups/{backup_uuid}/restore');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
@@ -137,7 +128,6 @@ class moduleBackupsRoute
]);
$this->get('/modules/backup/restore-audit', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/backup/restore-audit');
global $response;
$this->requireClassicSuperuserPermission('modules_backup_restore');
try {
@@ -19,9 +19,6 @@ use classes\workfeed;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleConfigRoute
{
use route_t;
@@ -35,7 +32,6 @@ class moduleConfigRoute
/** Economic config > GET */
$this->get('/economic/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/economic/config');
global $response;
$this->requirePermission('economic_config');
$user = (new authentication())->get_user();
@@ -56,7 +52,6 @@ class moduleConfigRoute
/** Economic config > POST */
$this->post('/economic/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/economic/config');
global $response;
$this->requirePermission('economic_config');
$user = (new authentication())->get_user();
@@ -77,7 +72,6 @@ class moduleConfigRoute
/** reCAPTCHA config > GET */
$this->get('/reCAPTCHA/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/reCAPTCHA/config');
global $response;
$this->requirePermission('recaptcha_config');
$user = (new authentication())->get_user();
@@ -98,7 +92,6 @@ class moduleConfigRoute
/** reCAPTCHA config > POST */
$this->post('/reCAPTCHA/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/reCAPTCHA/config');
global $response;
$this->requirePermission('recaptcha_config');
$user = (new authentication())->get_user();
@@ -119,7 +112,6 @@ class moduleConfigRoute
/** Email config > GET */
$this->get('/email/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/email/config');
global $response;
$this->requirePermission('email_config');
$user = (new authentication())->get_user();
@@ -140,7 +132,6 @@ class moduleConfigRoute
/** Email config > POST */
$this->post('/email/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/email/config');
global $response;
$this->requirePermission('email_config');
$user = (new authentication())->get_user();
@@ -161,7 +152,6 @@ class moduleConfigRoute
/** Email config > TEST */
$this->post('/email/config/test', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/email/config/test');
global $response;
$this->requirePermission('email_config');
$user = (new authentication())->get_user();
@@ -193,7 +183,6 @@ class moduleConfigRoute
/** Slack config > GET */
$this->get('/slack/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/slack/config');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -214,7 +203,6 @@ class moduleConfigRoute
/** Slack config > POST */
$this->post('/slack/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -235,7 +223,6 @@ class moduleConfigRoute
/** Slack config > TEST */
$this->post('/slack/config/test', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/test');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -265,7 +252,6 @@ class moduleConfigRoute
/** Slack internal department goal progress config > TEST */
$this->post('/slack/config/internal-department-goal-progress/test', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/internal-department-goal-progress/test');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -295,7 +281,6 @@ class moduleConfigRoute
/** Slack internal department goal progress config > GET */
$this->get('/slack/config/internal-department-goal-progress', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/slack/config/internal-department-goal-progress');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -316,7 +301,6 @@ class moduleConfigRoute
/** Slack internal department goal progress config > POST */
$this->post('/slack/config/internal-department-goal-progress', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/slack/config/internal-department-goal-progress');
global $response;
$this->requirePermission('slack_config');
$user = (new authentication())->get_user();
@@ -350,7 +334,6 @@ class moduleConfigRoute
);
$this->get('/backups/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/backups/config');
global $response;
$this->requirePermission('backups_config');
$user = (new authentication())->get_user();
@@ -370,7 +353,6 @@ class moduleConfigRoute
);
$this->post('/backups/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/backups/config');
global $response;
$this->requirePermission('backups_config');
$user = (new authentication())->get_user();
@@ -390,7 +372,6 @@ class moduleConfigRoute
);
$this->get('/failover/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/failover/config');
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
@@ -410,7 +391,6 @@ class moduleConfigRoute
);
$this->post('/failover/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/failover/config');
global $response;
$this->requirePermission('modules_failover_config');
$user = (new authentication())->get_user();
@@ -430,7 +410,6 @@ class moduleConfigRoute
);
$this->get('/coolify/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/coolify/config');
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
@@ -450,7 +429,6 @@ class moduleConfigRoute
);
$this->post('/coolify/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/coolify/config');
global $response;
$this->requirePermission('superuser_coolify_manage');
$user = (new authentication())->get_user();
@@ -471,7 +449,6 @@ class moduleConfigRoute
/** Bird config > GET */
$this->get('/bird/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/bird/config');
global $response;
$this->requirePermission('modules_bird_config');
$user = (new authentication())->get_user();
@@ -492,7 +469,6 @@ class moduleConfigRoute
/** Bird config > POST */
$this->post('/bird/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/bird/config');
global $response;
$this->requirePermission('modules_bird_config');
$user = (new authentication())->get_user();
@@ -513,7 +489,6 @@ class moduleConfigRoute
/** MotorAPI config > GET */
$this->get('/motorapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/motorapi/config');
global $response;
$this->requirePermission('motorapi_config');
$user = (new authentication())->get_user();
@@ -534,7 +509,6 @@ class moduleConfigRoute
/** MotorAPI config > POST */
$this->post('/motorapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/motorapi/config');
global $response;
$this->requirePermission('motorapi_config');
$user = (new authentication())->get_user();
@@ -555,7 +529,6 @@ class moduleConfigRoute
/** Stripe config > GET */
$this->get('/stripe/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/stripe/config');
global $response;
$this->requirePermission('stripe_config');
$user = (new authentication())->get_user();
@@ -576,7 +549,6 @@ class moduleConfigRoute
/** Stripe config > POST */
$this->post('/stripe/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/stripe/config');
global $response;
$this->requirePermission('stripe_config');
$user = (new authentication())->get_user();
@@ -597,7 +569,6 @@ class moduleConfigRoute
/** FXRatesAPI config > GET */
$this->get('/fxratesapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/fxratesapi/config');
global $response;
$this->requirePermission('fxratesapi_config');
$user = (new authentication())->get_user();
@@ -617,7 +588,6 @@ class moduleConfigRoute
);
/** FXRatesAPI config > POST */
$this->post('/fxratesapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/fxratesapi/config');
global $response;
$this->requirePermission('fxratesapi_config');
$user = (new authentication())->get_user();
@@ -637,7 +607,6 @@ class moduleConfigRoute
);
/** GatewayAPI config > GET */
$this->get('/gatewayapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/gatewayapi/config');
global $response;
$this->requirePermission('gatewayapi_config');
$user = (new authentication())->get_user();
@@ -657,7 +626,6 @@ class moduleConfigRoute
);
/** GatewayAPI config > POST */
$this->post('/gatewayapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/gatewayapi/config');
global $response;
$this->requirePermission('gatewayapi_config');
$user = (new authentication())->get_user();
@@ -678,7 +646,6 @@ class moduleConfigRoute
/** WeatherAPI config > GET */
$this->get('/weatherapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/weatherapi/config');
global $response;
$this->requirePermission('weatherapi_config');
$user = (new authentication())->get_user();
@@ -698,7 +665,6 @@ class moduleConfigRoute
);
/** WeatherAPI config > POST */
$this->post('/weatherapi/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/weatherapi/config');
global $response;
$this->requirePermission('weatherapi_config');
$user = (new authentication())->get_user();
@@ -719,7 +685,6 @@ class moduleConfigRoute
/** n8n config > GET */
$this->get('/n8n/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/n8n/config');
global $response;
$this->requirePermission('modules_n8n_config');
$user = (new authentication())->get_user();
@@ -739,7 +704,6 @@ class moduleConfigRoute
);
/** n8n config > POST */
$this->post('/n8n/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/n8n/config');
global $response;
$this->requirePermission('modules_n8n_config');
$user = (new authentication())->get_user();
@@ -760,7 +724,6 @@ class moduleConfigRoute
/** Workfeed config > GET */
$this->get('/workfeed/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/workfeed/config');
global $response;
$this->requirePermission('modules_workfeed_config');
$user = (new authentication())->get_user();
@@ -780,7 +743,6 @@ class moduleConfigRoute
);
/** Workfeed config > POST */
$this->post('/workfeed/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/workfeed/config');
global $response;
$this->requirePermission('modules_workfeed_config');
$user = (new authentication())->get_user();
@@ -801,7 +763,6 @@ class moduleConfigRoute
/** XLVask config > GET */
$this->get('/xlvask/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/xlvask/config');
global $response;
$this->requirePermission('xlvask_config');
$user = (new authentication())->get_user();
@@ -821,7 +782,6 @@ class moduleConfigRoute
);
/** XLVask config > POST */
$this->post('/xlvask/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/xlvask/config');
global $response;
$this->requirePermission('xlvask_config');
$user = (new authentication())->get_user();
@@ -842,7 +802,6 @@ class moduleConfigRoute
/** Entra config > GET */
$this->get('/entra/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/entra/config');
global $response;
$this->requirePermission('entra_config');
$user = (new authentication())->get_user();
@@ -862,7 +821,6 @@ class moduleConfigRoute
);
/** Entra config > POST */
$this->post('/entra/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/entra/config');
global $response;
$this->requirePermission('entra_config');
$user = (new authentication())->get_user();
@@ -882,7 +840,6 @@ class moduleConfigRoute
);
/** Limble config > GET */
$this->get('/limble/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/limble/config');
global $response;
$this->requirePermission('modules_limble_config');
$user = (new authentication())->get_user();
@@ -902,7 +859,6 @@ class moduleConfigRoute
);
/** Limble config > POST */
$this->post('/limble/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/limble/config');
global $response;
$this->requirePermission('modules_limble_config');
$user = (new authentication())->get_user();
@@ -922,7 +878,6 @@ class moduleConfigRoute
);
/** OcrSpace config > GET */
$this->get('/ocrspace/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/ocrspace/config');
global $response;
$this->requirePermission('modules_ocrspace_config');
$user = (new authentication())->get_user();
@@ -942,7 +897,6 @@ class moduleConfigRoute
);
/** OcrSpace config > POST */
$this->post('/ocrspace/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/ocrspace/config');
global $response;
$this->requirePermission('modules_ocrspace_config');
$user = (new authentication())->get_user();
@@ -962,7 +916,6 @@ class moduleConfigRoute
);
/** OpenAI config > GET */
$this->get('/openai/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/openai/config');
global $response;
$this->requirePermission('modules_openai_config');
$user = (new authentication())->get_user();
@@ -982,7 +935,6 @@ class moduleConfigRoute
);
/** OpenAI config > POST */
$this->post('/openai/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/openai/config');
global $response;
$this->requirePermission('modules_openai_config');
$user = (new authentication())->get_user();
@@ -1002,7 +954,6 @@ class moduleConfigRoute
);
/** LicensePlateRecognizer config > GET */
$this->get('/licenseplaterecognizer/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/licenseplaterecognizer/config');
global $response;
$this->requirePermission('modules_licenseplaterecognizer_config');
$user = (new authentication())->get_user();
@@ -1022,7 +973,6 @@ class moduleConfigRoute
);
/** LicensePlateRecognizer config > POST */
$this->post('/licenseplaterecognizer/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/licenseplaterecognizer/config');
global $response;
$this->requirePermission('modules_licenseplaterecognizer_config');
$user = (new authentication())->get_user();
@@ -1042,7 +992,6 @@ class moduleConfigRoute
);
/** Virkdata config > GET */
$this->get('/virkdata/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/virkdata/config');
global $response;
$this->requirePermission('modules_virkdata_config');
$user = (new authentication())->get_user();
@@ -1061,7 +1010,6 @@ class moduleConfigRoute
);
/** Virkdata config > POST */
$this->post('/virkdata/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/virkdata/config');
global $response;
$this->requirePermission('modules_virkdata_config');
$user = (new authentication())->get_user();
@@ -1081,7 +1029,6 @@ class moduleConfigRoute
);
/** Shelly config -> GET */
$this->get('/shelly/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/shelly/config');
global $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
@@ -1099,7 +1046,6 @@ class moduleConfigRoute
]
);
$this->post('/shelly/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/shelly/config');
global $response;
$this->requirePermission('modules_shelly_config');
$user = (new authentication())->get_user();
@@ -1114,7 +1060,6 @@ class moduleConfigRoute
});
/** Self-Serve config -> GET */
$this->get('/selfserve/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/selfserve/config');
global $response;
$this->requirePermission('modules_selfserve_config');
$user = (new authentication())->get_user();
@@ -1132,7 +1077,6 @@ class moduleConfigRoute
]
);
$this->post('/selfserve/config', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/selfserve/config');
global $response;
$this->requirePermission('modules_selfserve_config');
$user = (new authentication())->get_user();
@@ -9,9 +9,6 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleEconomicCustomerRoute
{
use route_t;
@@ -25,7 +22,6 @@ class moduleEconomicCustomerRoute
/** Modules > Economic > Customer > Get customer */
$this->get('/modules/economic/customer', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/economic/customer');
global $response;
$this->requirePermission('modules_economic_customer_get');
$user = (new authentication())->get_user();
@@ -49,7 +45,6 @@ class moduleEconomicCustomerRoute
/** Modules > Economic > Customer > Create customer */
$this->post('/modules/economic/customer', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/economic/customer');
global $response;
$this->requirePermission('modules_economic_customer_create');
$user = (new authentication())->get_user();
@@ -11,9 +11,6 @@ use objects\logs_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleEconomicRoute
{
use route_t;
@@ -27,7 +24,6 @@ class moduleEconomicRoute
/** Economic > Customers > Import customer */
$this->post('/economic/customers/import', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/economic/customers/import');
global $response;
$this->requirePermission('economic_import_customer');
$user = (new authentication())->get_user();
@@ -8,9 +8,6 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleEntraRoute
{
use route_t;
@@ -24,7 +21,6 @@ class moduleEntraRoute
/** Modules > Entra > Users > GET */
$this->get('/modules/entra/users', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/entra/users');
global $response;
$this->requirePermission('modules_entra_users');
$user = (new authentication())->get_user();
@@ -9,8 +9,6 @@ use classes\router;
use objects\currency_conversion_rates_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleFxRatesAPIRoute
{
@@ -25,7 +23,6 @@ class moduleFxRatesAPIRoute
/** Modules > FXRatesAPI > conversion rate > GET */
$this->get('/modules/fxratesapi/rate', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/fxratesapi/rate');
global $response;
$this->requirePermission('modules_fxratesapi_rate');
$user = (new authentication())->get_user();
@@ -10,9 +10,6 @@ use limble\helpers\limble_tasks_pagination;
use limble\helpers\limble_webhook_payload_task;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleLimbleRoute
{
use route_t;
@@ -23,7 +20,6 @@ class moduleLimbleRoute
/** @var router $router */
$router, $response;
$this->post('/modules/limble/webhook/task', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/limble/webhook/task');
global $response;
$slack = new slack();
$this->requirePermission('modules_limble_webhooks_task');
@@ -45,7 +41,6 @@ class moduleLimbleRoute
);
$this->get('/modules/limble/tasks', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/limble/tasks');
global $response;
$slack = new slack();
$slack->send_message('Limble Tasks Endpoint Triggered', 'Limble Tasks');
@@ -9,9 +9,6 @@ use classes\router;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleMotorAPIRoute
{
use route_t;
@@ -25,7 +22,6 @@ class moduleMotorAPIRoute
/** Modules > MotorAPI > Lookup > GET */
$this->get('/modules/motorapi/lookup', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/motorapi/lookup');
global $response;
$this->requirePermission('modules_motorapi_lookup');
$user = (new authentication())->get_user();
@@ -10,9 +10,6 @@ use objects\logs_o;
use stdClass;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleN8nRoute
{
use route_t;
@@ -24,7 +21,6 @@ class moduleN8nRoute
$router, $response;
$this->get('/modules/n8n/workflows', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/workflows');
global $response;
$this->requirePermission('modules_n8n_workflows_view');
$user = (new authentication())->get_user();
@@ -50,7 +46,6 @@ class moduleN8nRoute
]);
$this->get('/modules/n8n/workflows/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/workflows/{id}');
global $response;
$this->requirePermission('modules_n8n_workflows_view');
$user = (new authentication())->get_user();
@@ -67,7 +62,6 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/workflows', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -86,7 +80,6 @@ class moduleN8nRoute
]);
$this->put('/modules/n8n/workflows/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -105,7 +98,6 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/workflows/{id}/publish', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}/publish');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -124,7 +116,6 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/workflows/{id}/deactivate', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/workflows/{id}/deactivate');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -140,7 +131,6 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/webhooks/trigger', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/webhooks/trigger');
global $response;
$this->requirePermission('modules_n8n_workflows_run');
$user = (new authentication())->get_user();
@@ -162,7 +152,6 @@ class moduleN8nRoute
]);
$this->get('/modules/n8n/executions', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/executions');
global $response;
$this->requirePermission('modules_n8n_executions_view');
$user = (new authentication())->get_user();
@@ -187,7 +176,6 @@ class moduleN8nRoute
]);
$this->get('/modules/n8n/executions/{id}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/n8n/executions/{id}');
global $response;
$this->requirePermission('modules_n8n_executions_view');
$user = (new authentication())->get_user();
@@ -203,7 +191,6 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/executions/{id}/retry', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/executions/{id}/retry');
global $response;
$this->requirePermission('modules_n8n_workflows_run');
$user = (new authentication())->get_user();
@@ -224,7 +211,6 @@ class moduleN8nRoute
]);
$this->post('/modules/n8n/executions/{id}/stop', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_WRITE, '/modules/n8n/executions/{id}/stop');
global $response;
$this->requirePermission('modules_n8n_workflows_manage');
$user = (new authentication())->get_user();
@@ -6,9 +6,6 @@ use classes\licenseplaterecognizer;
use classes\response;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleScannerRoute
{
use route_t;
@@ -40,7 +37,6 @@ class moduleScannerRoute
$router, $response;
/** Modules > Scanner > License Plate Recognition > POST */
$this->post('/modules/scanner/lpr', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/scanner/lpr');
global $response;
$route_started_at = microtime(true);
$image_upload = self::getLPRImageUpload();
@@ -33,9 +33,6 @@ use objects\subusers_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleSelfServeRoute
{
use route_t;
@@ -53,7 +50,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Status */
$this->get('/modules/self-serve/lane/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/status');
global $response;
$this->requirePermission('modules_selfserve_lane_status_view');
$selfserve = new selfserve();
@@ -76,7 +72,6 @@ class moduleSelfServeRoute
);
$this->put('/modules/self-serve/lane/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/status');
global $response;
$this->requirePermission('modules_selfserve_lane_status_set');
@@ -132,7 +127,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Wash > In-progress details */
$this->get('/modules/self-serve/lane/wash/in-progress', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/wash/in-progress');
global $response;
self::requireParameters(['lane_id']);
$lane_id = (int)$this->getParameter('lane_id');
@@ -353,7 +347,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Wash > My active wash */
$this->get('/modules/self-serve/lane/wash/my-active-wash', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/wash/my-active-wash');
global $response;
$principal_scope = $this->requireMyActiveWashPrincipalScope();
@@ -374,7 +367,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Sessions */
$this->get('/modules/self-serve/sessions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/sessions');
global $response;
$this->requirePermission('modules_selfserve_sessions_view');
@@ -428,7 +420,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Session detail */
$this->get('/modules/self-serve/sessions/{id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/sessions/{id}');
global $response;
$this->requirePermission('modules_selfserve_sessions_view');
$session_id = (int)$this->fromRoute('id');
@@ -448,7 +439,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > Stop */
$this->post('/modules/self-serve/lane/force/stop', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/stop');
global $response;
$this->requirePermission('modules_selfserve_sessions_force_stop');
self::requireParameters(['lane_id', 'bill']);
@@ -490,7 +480,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Command */
$this->post('/modules/self-serve/lane/command', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/command');
global $response;
$selfserve = new selfserve();
// Get the request user
@@ -660,7 +649,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Allowed services (derived from shown tasks) */
$this->post('/modules/self-serve/lane/services/allowed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/services/allowed');
global $response;
$selfserve = new selfserve();
// Validate parameters
@@ -768,7 +756,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Gate > Open (ENTRANCE/EXIT) */
$this->post('/modules/self-serve/lane/gate/open', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/gate/open');
global $response;
$this->requirePermission('modules_selfserve_lane_gate_open');
$selfserve = new selfserve();
@@ -818,7 +805,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Hardware > Batch status */
$this->post('/modules/self-serve/lane/hardware/batch/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/hardware/batch/status');
global $response;
$selfserve = new selfserve();
self::requireParameters(['lane_id']);
@@ -854,7 +840,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Hardware > Batch set/open */
$this->post('/modules/self-serve/lane/hardware/batch/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/hardware/batch/set');
global $response;
$selfserve = new selfserve();
self::requireParameters(['lane_id', 'commands']);
@@ -884,7 +869,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Hardware > Batch poll */
$this->get('/modules/self-serve/lane/hardware/batch/{batch_id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/hardware/batch/{batch_id}');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_status_view');
try {
@@ -898,7 +882,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER status */
$this->get('/modules/self-serve/lane/relay/machine_program_picker/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine_program_picker/status');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_view');
$selfserve = new selfserve();
@@ -921,7 +904,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_PROGRAM_PICKER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_program_picker/set');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_program_picker_status_set');
$selfserve = new selfserve();
@@ -953,7 +935,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER status */
$this->get('/modules/self-serve/lane/relay/machine_cleaner/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine_cleaner/status');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_view');
$selfserve = new selfserve();
@@ -976,7 +957,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE_CLEANER set on/off */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_cleaner/set');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_cleaner_status_set');
$selfserve = new selfserve();
@@ -1008,7 +988,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE status */
$this->get('/modules/self-serve/lane/relay/machine/status', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/self-serve/lane/relay/machine/status');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_status_view');
$selfserve = new selfserve();
@@ -1031,7 +1010,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > MACHINE set on/off */
$this->post('/modules/self-serve/lane/relay/machine/set', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine/set');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_machine_status_set');
$selfserve = new selfserve();
@@ -1080,7 +1058,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_PROGRAM_PICKER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_program_picker/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_program_picker/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_enable_machine_program_picker');
$selfserve = new selfserve();
@@ -1114,7 +1091,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE_CLEANER (manual) */
$this->post('/modules/self-serve/lane/relay/machine_cleaner/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine_cleaner/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_relay_enable_machine_cleaner');
$selfserve = new selfserve();
@@ -1148,7 +1124,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Relay > Enable MACHINE (manual, gated by allowed services) */
$this->post('/modules/self-serve/lane/relay/machine/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/relay/machine/enable');
global $response;
$selfserve = new selfserve();
// Validate parameters
@@ -1198,7 +1173,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER enable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_program_picker/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_program_picker_enable');
$selfserve = new selfserve();
@@ -1232,7 +1206,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_PROGRAM_PICKER disable */
$this->post('/modules/self-serve/lane/force/machine_program_picker/disable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_program_picker/disable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_program_picker_disable');
$selfserve = new selfserve();
@@ -1260,7 +1233,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER enable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_cleaner/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_cleaner_enable');
$selfserve = new selfserve();
@@ -1294,7 +1266,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE_CLEANER disable */
$this->post('/modules/self-serve/lane/force/machine_cleaner/disable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine_cleaner/disable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_cleaner_disable');
$selfserve = new selfserve();
@@ -1322,7 +1293,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE enable (simulate started wash) */
$this->post('/modules/self-serve/lane/force/machine/enable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine/enable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_enable');
$selfserve = new selfserve();
@@ -1397,7 +1367,6 @@ class moduleSelfServeRoute
/** Modules > Self Serve > Lane > Force > MACHINE disable (simulate started wash without machine) */
$this->post('/modules/self-serve/lane/force/machine/disable', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/modules/self-serve/lane/force/machine/disable');
global $response;
$this->requirePermission('modules_selfserve_lane_force_machine_disable');
$selfserve = new selfserve();
@@ -11,9 +11,6 @@ use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleStripeRoute
{
use route_t;
@@ -27,7 +24,6 @@ class moduleStripeRoute
/** Modules > Stripe > Customers > List */
$this->get('/modules/stripe/customers', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/customers');
global $response;
$this->requirePermission('modules_stripe_customers_list');
$user = (new authentication())->get_user();
@@ -47,7 +43,6 @@ class moduleStripeRoute
/** Modules > Stripe > Products > List */
$this->get('/modules/stripe/products', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/products');
global $response;
$this->requirePermission('modules_stripe_products_list');
$user = (new authentication())->get_user();
@@ -67,7 +62,6 @@ class moduleStripeRoute
/** Modules > Stripe > Prices > List */
$this->get('/modules/stripe/prices', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/modules/stripe/prices');
global $response;
$this->requirePermission('modules_stripe_prices_list');
$user = (new authentication())->get_user();
@@ -87,7 +81,6 @@ class moduleStripeRoute
/** Modules > Stripe > Retired direct payment-link creation */
$this->post('/modules/stripe/invoice', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/stripe/invoice');
global $response;
$this->requirePermission('modules_stripe_invoice_send');
$response->error([
@@ -102,7 +95,6 @@ class moduleStripeRoute
/** Modules > Stripe > Cancel Invoice */
$this->delete('/modules/stripe/invoice', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/modules/stripe/invoice');
global $response;
$this->requirePermission('modules_stripe_invoice_send');
$user = (new authentication())->get_user();
@@ -6,9 +6,6 @@ use classes\module_usage_service;
use Exception;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleUsageRoute
{
use route_t;
@@ -16,7 +13,6 @@ class moduleUsageRoute
public function run(): void
{
$this->get('/modules/usage/summary', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/usage/summary');
global $response;
$this->requirePermission('modules_usage_view');
@@ -31,7 +27,6 @@ class moduleUsageRoute
]);
$this->get('/modules/usage/{moduleKey}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/usage/{moduleKey}');
global $response;
$this->requirePermission('modules_usage_view');
@@ -46,7 +41,6 @@ class moduleUsageRoute
]);
$this->patch('/modules/quotas/{moduleKey}/{metricKey}', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/quotas/{moduleKey}/{metricKey}');
global $response;
$this->requirePermission('modules_quotas_manage');
@@ -11,9 +11,6 @@ use objects\currency_conversion_rates_o;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleVirkDataRoute
{
use route_t;
@@ -27,7 +24,6 @@ class moduleVirkDataRoute
/** Modules > VirkData > search > GET */
$this->get('/modules/virkdata/search', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/virkdata/search');
global $response;
$this->requirePermission('modules_virkdata_search');
$user = (new authentication())->get_user();
@@ -19,8 +19,6 @@ use objects\departments_o;
use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleWeatherAPIRoute
{
@@ -48,7 +46,6 @@ class moduleWeatherAPIRoute
$router, $response;
$this->get('/modules/weatherapi/current', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/weatherapi/current');
global $response;
$this->requirePermission('modules_weatherapi_current');
$user = (new authentication())->get_user();
@@ -9,9 +9,6 @@ use classes\workfeed;
use objects\logs_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleWorkfeedRoute
{
use route_t;
@@ -23,7 +20,6 @@ class moduleWorkfeedRoute
$router, $response;
$this->get('/modules/workfeed/employees', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/workfeed/employees');
global $response;
$this->requirePermission('modules_workfeed_employees_view');
$user = (new authentication())->get_user();
@@ -39,7 +35,6 @@ class moduleWorkfeedRoute
]);
$this->get('/modules/workfeed/employees/{id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/workfeed/employees/{id}');
global $response;
$this->requirePermission('modules_workfeed_employees_view');
$user = (new authentication())->get_user();
@@ -55,7 +50,6 @@ class moduleWorkfeedRoute
]);
$this->get('/modules/workfeed/shifts', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/workfeed/shifts');
global $response;
$this->requirePermission('modules_workfeed_shifts_view');
$user = (new authentication())->get_user();
@@ -100,7 +94,6 @@ class moduleWorkfeedRoute
]);
$this->get('/modules/workfeed/shifts/{id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/workfeed/shifts/{id}');
global $response;
$this->requirePermission('modules_workfeed_shifts_view');
$user = (new authentication())->get_user();
@@ -116,7 +109,6 @@ class moduleWorkfeedRoute
]);
$this->get('/modules/workfeed/departments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/modules/workfeed/departments');
global $response;
$this->requirePermission('modules_workfeed_departments_view');
$user = (new authentication())->get_user();
@@ -10,9 +10,6 @@ use objects\orders_o;
use objects\xlvask_customers_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class moduleXLVaskRoute
{
use route_t;
@@ -23,7 +20,6 @@ class moduleXLVaskRoute
/** @var router $router */
$router, $response;
$this->get('/modules/xlvask/usageLog', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/usageLog');
global $response;
$this->requirePermission('modules_xlvask_usageLog');
$params = [
@@ -52,7 +48,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/vehicles', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/vehicles');
global $response;
$this->requirePermission('modules_xlvask_vehicles');
$params = [
@@ -79,7 +74,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/customers', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/customers');
global $response;
$this->requirePermission('modules_xlvask_customers');
$params = [
@@ -108,7 +102,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/internal/vehicle-types', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/internal/vehicle-types');
global $response;
$this->requirePermission('modules_xlvask_internal_vehicle_types');
$user = (new authentication())->get_user();
@@ -129,7 +122,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/related-orders', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/related-orders');
global $response;
$this->requirePermission('modules_xlvask_related_orders');
self::requireParameters(['washIds']);
@@ -164,7 +156,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/tasks/sync-users', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/tasks/sync-users');
global $response;
$this->requirePermission('modules_xlvask_sync_users');
// Create the xlvask tasks object
@@ -183,7 +174,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/tasks/sync-usage', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/tasks/sync-usage');
global $response;
$this->requirePermission('modules_xlvask_sync_usage');
// Create the xlvask tasks object
@@ -202,7 +192,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/tasks/import-customers', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/tasks/import-customers');
global $response;
$this->requirePermission('modules_xlvask_import_customers');
// Create the xlvask_customers_o object
@@ -219,7 +208,6 @@ class moduleXLVaskRoute
);
$this->get('/modules/xlvask/tasks/import-vehicles', function () {
ScopeMiddleware::requireScope(Scope::SUPERUSER_READ, '/modules/xlvask/tasks/import-vehicles');
global $response;
$this->requirePermission('modules_xlvask_import_vehicles');
// Create the xlvask_vehicles_o object
@@ -7,9 +7,6 @@ use objects\logs_o;
use objects\notifications_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class notificationsRoute
{
use route_t;
@@ -17,7 +14,6 @@ class notificationsRoute
public function run(): void
{
$this->get('/notifications', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/notifications');
// Require the user to be logged in
global $response;
$this->requirePermission('list_notifications');
@@ -81,7 +77,6 @@ class notificationsRoute
);
$this->post('/notifications', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/notifications');
// Require the user to be logged in
global $response;
$this->requirePermission('add_notification');
@@ -135,7 +130,6 @@ class notificationsRoute
$this->delete('/notifications', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/notifications');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_own_notifications');
@@ -20,9 +20,6 @@ use objects\products_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
require_once WD . '/classes/security_policy_service.php';
class orderBookingRoute
@@ -32,7 +29,6 @@ class orderBookingRoute
public function run(): void
{
$this->post('/order-bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/order-bookings');
// Require the user to be logged in
global $response;
/**
@@ -110,7 +106,6 @@ class orderBookingRoute
);
$this->get('/order-bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/order-bookings');
// Require the user to be logged in
global $response;
/**
@@ -188,7 +183,6 @@ class orderBookingRoute
);
$this->get('/order-bookings/counts', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_READ, '/order-bookings/counts');
global $response;
$auth = new authentication();
@@ -260,7 +254,6 @@ class orderBookingRoute
);
$this->put('/order-bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/order-bookings');
// Require the user to be logged in
global $response;
/**
@@ -344,7 +337,6 @@ class orderBookingRoute
);
$this->delete('/order-bookings', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/order-bookings');
// Require the user to be logged in
global $response;
/**
@@ -386,7 +378,6 @@ class orderBookingRoute
);
$this->post('/order-bookings/booking-confirmation/resend', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/order-bookings/booking-confirmation/resend');
global $response;
$object = self::getTargetObject();
@@ -410,7 +401,6 @@ class orderBookingRoute
);
$this->post('/order-bookings/completion-confirmation/resend', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/order-bookings/completion-confirmation/resend');
global $response;
$object = self::getTargetObject();
@@ -442,7 +432,6 @@ class orderBookingRoute
);
$this->post('/order-bookings/complete', function () {
ScopeMiddleware::requireScope(Scope::BOOKING_WRITE, '/order-bookings/complete');
// Require the user to be logged in
global $response;
/**
@@ -25,9 +25,6 @@ use objects\orders_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class orderInvoicesRoute
{
use route_t;
@@ -40,7 +37,6 @@ class orderInvoicesRoute
/** Collected order invoices > GET */
$this->get('/collected-invoices', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices');
global $response;
$this->requirePermission('list_collected_invoices');
$user = (new authentication())->get_user();
@@ -146,7 +142,6 @@ class orderInvoicesRoute
/** Collected order invoices > Compare with E-conomic > GET */
$this->get('/collected-invoices/economic/compare', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/compare');
global $response;
$this->requirePermission('compare_collected_invoice_economic');
//$user = (new authentication())->get_user();
@@ -246,7 +241,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-conomic V2 details > GET */
$this->get('/collected-invoices/economic/v2/details', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/v2/details');
global $response;
$this->requirePermission('view_collected_invoice_economic_v2_details');
$collected_invoice_id = $this->requireCollectedInvoiceId();
@@ -261,7 +255,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-conomic PDF > GET */
$this->get('/collected-invoices/economic/pdf', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/pdf');
global $response;
$this->requirePermission('download_collected_invoice_economic_pdf');
$collected_invoice_id = $this->requireCollectedInvoiceId();
@@ -282,7 +275,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-conomic V2 compare > GET */
$this->get('/collected-invoices/economic/v2/compare', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/v2/compare');
global $response;
$this->requirePermission('compare_collected_invoice_economic_v2');
$collected_invoice_id = $this->requireCollectedInvoiceId();
@@ -311,7 +303,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-conomic V2 compare bulk > POST */
$this->post('/collected-invoices/economic/v2/compare/bulk', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/v2/compare/bulk');
global $response;
$this->requirePermission('compare_collected_invoice_economic_v2_bulk');
self::requireParameters(['collected_invoice_ids']);
@@ -379,7 +370,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-conomic V2 revenue statistics > GET */
$this->get('/collected-invoices/economic/v2/revenue-statistics', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/v2/revenue-statistics');
global $response;
$this->requirePermission('view_collected_invoice_economic_v2_revenue_statistics');
@@ -423,7 +413,6 @@ class orderInvoicesRoute
/** Collected order invoices > Ready to invoice > GET */
$this->get('/collected-invoices/ready-to-invoice', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/ready-to-invoice');
global $response;
$this->requirePermission('list_collected_invoices');
$user = (new authentication())->get_user();
@@ -460,7 +449,6 @@ class orderInvoicesRoute
/** Collected order invoices > POST */
$this->post('/collected-invoices', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices');
global $response;
$this->requirePermission('add_collected_invoice');
$user = (new authentication())->get_user();
@@ -535,7 +523,6 @@ class orderInvoicesRoute
/** Collected order invoices > Move to customer > POST */
$this->post('/collected-invoices/move-to-customer', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/move-to-customer');
global $response;
$this->requirePermission('move_collected_invoice_customer');
$user = (new authentication())->get_user();
@@ -579,7 +566,6 @@ class orderInvoicesRoute
/** Collected order invoices > Split > POST */
$this->post('/collected-invoices/split', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/split');
global $response;
$this->requirePermission('split_collected_invoice');
$user = (new authentication())->get_user();
@@ -610,7 +596,6 @@ class orderInvoicesRoute
/** Collected order invoices > Split by month > POST */
$this->post('/collected-invoices/split-by-month', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/split-by-month');
global $response, $db;
$this->requirePermission('split_collected_invoice');
$user = (new authentication())->get_user();
@@ -753,7 +738,6 @@ class orderInvoicesRoute
/** Collected order invoices > Bulk action preview > POST */
$this->post('/collected-invoices/bulk-actions/preview', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/bulk-actions/preview');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -819,7 +803,6 @@ class orderInvoicesRoute
/** Collected order invoices > Bulk action apply > POST */
$this->post('/collected-invoices/bulk-actions/apply', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/bulk-actions/apply');
global $response;
$user = (new authentication())->get_user();
if (!$user) {
@@ -877,7 +860,6 @@ class orderInvoicesRoute
);
$this->post('/superuser/invoicing/period/tree-actions/preview', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/superuser/invoicing/period/tree-actions/preview');
global $response;
$this->requirePermission('superuser_invoicing_period');
$user = (new authentication())->get_user();
@@ -933,7 +915,6 @@ class orderInvoicesRoute
);
$this->post('/superuser/invoicing/period/tree-actions/apply', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/superuser/invoicing/period/tree-actions/apply');
global $response;
$this->requirePermission('superuser_invoicing_period');
$user = (new authentication())->get_user();
@@ -974,7 +955,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-Conomic > POST (queued) */
$this->post('/collected-invoices/economic', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1069,7 +1049,6 @@ class orderInvoicesRoute
);
$this->get('/collected-invoices/economic/queue', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/queue');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1107,7 +1086,6 @@ class orderInvoicesRoute
);
$this->get('/collected-invoices/economic/queue/status', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/queue/status');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1127,7 +1105,6 @@ class orderInvoicesRoute
);
$this->get('/collected-invoices/economic/queue/monitor', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/queue/monitor');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1151,7 +1128,6 @@ class orderInvoicesRoute
);
$this->post('/collected-invoices/economic/queue/retry', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/queue/retry');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1186,7 +1162,6 @@ class orderInvoicesRoute
);
$this->post('/collected-invoices/economic/queue/dismiss', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/queue/dismiss');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1223,7 +1198,6 @@ class orderInvoicesRoute
);
$this->post('/collected-invoices/economic/queue/dismiss-terminal', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/queue/dismiss-terminal');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1249,7 +1223,6 @@ class orderInvoicesRoute
);
$this->post('/collected-invoices/economic/queue/run', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/queue/run');
global $response;
$this->requirePermission('add_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1292,7 +1265,6 @@ class orderInvoicesRoute
/** Collected order invoices > Move multiple > Registration numbers > POST */
$this->post('/collected-invoices/move-multiple/registration-numbers', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/move-multiple/registration-numbers');
global $response;
$this->requirePermission('move_collected_invoice');
$user = (new authentication())->get_user();
@@ -1367,7 +1339,6 @@ class orderInvoicesRoute
});
/** Collected order invoices > Move multiple > POST */
$this->post('/collected-invoices/move-multiple', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/move-multiple');
// Used to move multiple orders, to a new collected order invoice, in one request, instead of having to move each order one by one
global $response;
$this->requirePermission('move_collected_invoice');
@@ -1431,7 +1402,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-Conomic > Unlink and clear cached data > POST */
$this->post('/collected-invoices/economic/unlink', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/unlink');
global $response;
$this->requirePermission('unlink_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1463,7 +1433,6 @@ class orderInvoicesRoute
/** Collected order invoices > E-Conomic > Remove special arrangements, and set all items to be included in the invoice > POST */
$this->post('/collected-invoices/remove-special-arrangements', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/remove-special-arrangements');
global $response;
$this->requirePermission('reset_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1497,7 +1466,6 @@ class orderInvoicesRoute
);
/** Collected order invoices > E-Conomic > Reset prices of items not included in the invoice > POST */
$this->post('/collected-invoices/reset-prices-of-items-not-included-in-invoice', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/reset-prices-of-items-not-included-in-invoice');
global $response;
$this->requirePermission('reset_collected_invoice_economic');
$user = (new authentication())->get_user();
@@ -1530,7 +1498,6 @@ class orderInvoicesRoute
/** Collected order invoices > Stripe > BOOK > POST */
$this->post('/collected-invoices/stripe/book', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/stripe/book');
global $response;
$this->requirePermission('add_collected_invoice_stripe');
$user = (new authentication())->get_user();
@@ -1613,7 +1580,6 @@ class orderInvoicesRoute
/** Collected order invoices > Vehicle subscriptions > POST */
$this->post('/collected-invoices/vehicle-subscriptions', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/vehicle-subscriptions');
global $response;
$this->requirePermission('add_collected_invoice_vehicle_subscriptions');
$user = (new authentication())->get_user();
@@ -1645,7 +1611,6 @@ class orderInvoicesRoute
/** Collected order invoices > Vehicle subscriptions > POST */
$this->post('/collected-invoices/fixed-price', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/fixed-price');
global $response;
$this->requirePermission('add_collected_invoice_fixed_price');
$user = (new authentication())->get_user();
@@ -1686,7 +1651,6 @@ class orderInvoicesRoute
/** Collected order invoices > Vehicle subscriptions > POST */
$this->post('/collected-invoices/vehicle-subscriptions/custom', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/vehicle-subscriptions/custom');
global $response;
$this->requirePermission('add_collected_invoice_vehicle_subscriptions');
$user = (new authentication())->get_user();
@@ -1753,7 +1717,6 @@ class orderInvoicesRoute
/** Collected order invoices > Open > GET Customers */
$this->get('/collected-invoices/customers', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/customers');
global $response;
$this->requirePermission('list_collected_invoices');
$user = (new authentication())->get_user();
@@ -1836,7 +1799,6 @@ class orderInvoicesRoute
);
$this->get('/collected-invoices/customers/invoicePerOrder', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/customers/invoicePerOrder');
global $response;
$this->requirePermission('list_collected_invoices');
$user = (new authentication())->get_user();
@@ -1880,7 +1842,6 @@ class orderInvoicesRoute
);
$this->get('/collected-invoices/customers/invoicePerMonth', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/customers/invoicePerMonth');
global $response;
$this->requirePermission('list_collected_invoices');
$user = (new authentication())->get_user();
@@ -1925,7 +1886,6 @@ class orderInvoicesRoute
$this->post('/collected-invoices/customers/invoiceTotals', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/customers/invoiceTotals');
// This is a superuser-only route
global $response;
$this->requirePermission('list_collected_invoices');
@@ -2013,7 +1973,6 @@ class orderInvoicesRoute
});
$this->get('/collected-invoices/economic/overview', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_READ, '/collected-invoices/economic/overview');
global $response;
$this->requirePermission('list_collected_invoices_economic_overview');
$user = (new authentication())->get_user();
@@ -2095,7 +2054,6 @@ class orderInvoicesRoute
);
$this->post('/collected-invoices/economic/run/check-drafts', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/run/check-drafts');
global $response;
$this->requirePermission('module_economic_run_check_drafts');
$user = (new authentication())->get_user();
@@ -2124,7 +2082,6 @@ class orderInvoicesRoute
);
$this->post('/collected-invoices/economic/run/check-errors', function () {
ScopeMiddleware::requireScope(Scope::INVOICE_WRITE, '/collected-invoices/economic/run/check-errors');
global $response;
$this->requirePermission('module_economic_run_check_errors');
$user = (new authentication())->get_user();
@@ -11,9 +11,6 @@ use objects\orders_o;
use objects\products_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class orderItemsRoute
{
use route_t;
@@ -21,7 +18,6 @@ class orderItemsRoute
public function run(): void
{
$this->post('/order/items', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/order/items');
// Require the user to be logged in
global $response;
$this->requirePermission('add_order_items');
@@ -158,7 +154,6 @@ class orderItemsRoute
);
$this->get('/order/items', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/order/items');
// Require the user to be logged in
global $response;
// Check if the user is requesting their own order items
@@ -214,7 +209,6 @@ class orderItemsRoute
);
$this->delete('/order/items', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/order/items');
// Require the user to be logged in
global $response, $db;
$this->requirePermission('delete_order_items');
@@ -268,7 +262,6 @@ class orderItemsRoute
);
$this->put('/order/items', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/order/items');
// Require the user to be logged in
global $response, $db;
$this->requirePermission('edit_order_items');
-6
View File
@@ -8,9 +8,6 @@ use objects\logs_o;
use objects\orders_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class orderRoute
{
use route_t;
@@ -18,7 +15,6 @@ class orderRoute
public function run(): void
{
$this->get('/order', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/order');
// Require the user to be logged in
global /** @var response $response */
$response;
@@ -59,7 +55,6 @@ class orderRoute
);
$this->post('/order/wash-certificate', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/order/wash-certificate');
// Require the user to be logged in and have permission
global /** @var response $response */
$response;
@@ -128,7 +123,6 @@ class orderRoute
/**
* $this->put('/order', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/order');
*
* // Require the user to be logged in
* global $response;
-20
View File
@@ -25,9 +25,6 @@ use objects\stripe_module_orders_o;
use objects\stripe_payment_intents_o;
use objects\users_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
use modules\subusers\helpers\subusers_permission_node_key;
class ordersRoute
@@ -39,7 +36,6 @@ class ordersRoute
public function run(): void
{
$this->get('/orders/reference-suggestions', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/orders/reference-suggestions');
global $response;
$auth = new authentication();
$user = $auth->get_user();
@@ -86,7 +82,6 @@ class ordersRoute
);
$this->get('/orders', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/orders');
// Require the user to be logged in
global $response;
/** Authentication */
@@ -155,7 +150,6 @@ class ordersRoute
$this->post('/orders', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders');
// Require the user to be logged in
global $response;
$this->requirePermission('add_order');
@@ -259,7 +253,6 @@ class ordersRoute
);
$this->put('/order', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/order');
self::updateOrder();
},
[
@@ -268,7 +261,6 @@ class ordersRoute
);
$this->put('/orders', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders');
self::updateOrder();
},
[
@@ -277,7 +269,6 @@ class ordersRoute
);
$this->delete('/orders', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_order');
@@ -352,7 +343,6 @@ class ordersRoute
);
$this->get('/orders/attachments/download', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/orders/attachments/download');
global $response;
$context = $this->requireOrderAttachmentDownloadContext();
$downloadLink = $context['store'] instanceof pdf_store
@@ -368,7 +358,6 @@ class ordersRoute
);
$this->get('/orders/attachments/content', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/orders/attachments/content');
global $response;
$context = $this->requireOrderAttachmentDownloadContext();
@@ -412,7 +401,6 @@ class ordersRoute
);
$this->get('/orders/attachments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/orders/attachments');
// Require the user to be logged in
global $response;
// Get the user object
@@ -468,7 +456,6 @@ class ordersRoute
);
$this->post('/orders/attachments/upload', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/attachments/upload');
// Require the user to be logged in
global $response;
$this->requirePermission('add_order_attachments');
@@ -522,7 +509,6 @@ class ordersRoute
);
$this->delete('/orders/attachments', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/attachments');
// Require the user to be logged in
global $response;
$this->requirePermission('delete_order_attachments');
@@ -570,7 +556,6 @@ class ordersRoute
);
$this->post('/orders/mark_as_completed', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/mark_as_completed');
// Require the user to be logged in
global $response;
$this->requirePermission('mark_order_as_completed');
@@ -611,7 +596,6 @@ class ordersRoute
);
$this->post('/orders/module/stripe/payment_intent', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/module/stripe/payment_intent');
global $response;
$this->requirePermission('charge_order');
$user = (new authentication())->get_user();
@@ -784,7 +768,6 @@ class ordersRoute
);
$this->get('/orders/module/stripe/payment_intent', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/orders/module/stripe/payment_intent');
global $response;
$this->requirePermission('get_payment_intent');
$user = (new authentication())->get_user();
@@ -875,7 +858,6 @@ class ordersRoute
);
$this->delete('/orders/module/stripe/payment_intent', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/module/stripe/payment_intent');
global $response;
$this->requirePermission('charge_order');
$user = (new authentication())->get_user();
@@ -929,7 +911,6 @@ class ordersRoute
);
$this->post('/orders/module/stripe/payment_intent/capture', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/module/stripe/payment_intent/capture');
global $response;
$this->requirePermission('confirm_payment_intent');
$user = (new authentication())->get_user();
@@ -1019,7 +1000,6 @@ class ordersRoute
);
$this->post('/orders/module/stripe/debug/simulate_payment', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/orders/module/stripe/debug/simulate_payment');
// Require the user to be logged in
global $response;
$this->requirePermission('debug_simulate_payment_intent');
@@ -7,9 +7,6 @@ use objects\logs_o;
use objects\passkeys_o;
use traits\route_t;
use app\auth\Scope;
use app\auth\ScopeMiddleware;
class passkeysRoute
{
use route_t;
@@ -49,7 +46,6 @@ class passkeysRoute
{
// List passkeys for current authenticated user
$this->get('/account/security/passkeys', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_list');
@@ -84,7 +80,6 @@ class passkeysRoute
// Create/add a passkey (store after client-side WebAuthn attestation)
$this->post('/account/security/passkeys', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_create');
@@ -137,7 +132,6 @@ class passkeysRoute
// Rename a passkey
$this->patch('/account/security/passkeys/{id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys/{id}');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_rename');
@@ -168,7 +162,6 @@ class passkeysRoute
// Delete a passkey (soft delete)
$this->delete('/account/security/passkeys/{id}', function () {
ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/account/security/passkeys/{id}');
global $response;
$principal = $this->resolvePasskeyPrincipal('user_security_passkeys_delete');

Some files were not shown because too many files have changed in this diff Show More