78b11d0b79 fix(api): route invoices to correct Economic account per-customer (TRU-18) (#380)
## Summary

Fixes **TRU-18 / AUT-14** — `truckwash.io` invoices were being routed to
the wrong Economic (EC) account for some users.

## Root cause

`getUserByCustomerNumber()` in `services/nginx/app/objects/users_o.php`
trusted the **inverse Redis cache** (`customer_number → user_id`)
without verifying that the user it loaded actually owned the requested
EC customer_number in the local DB.

When that cache went stale — e.g. after a `customer_number` re-mapping
on a code path that did not clear the inverse-cache entry —
`getUserByCustomerNumber()` would silently return a **different user**
whose current `customer_number` no longer matched the one the caller
asked for. Downstream invoice export code (`getCustomerEcocomicData()` →
`$customer_economic->customer_number` →
`economic_invoice_draft->setCustomerNumber(...)`) then used that wrong
user's current EC customer_number, and the draft invoice was created
against the **wrong Economic account**.

Because this only manifests when the inverse cache is stale, it surfaces
as "some users" — exactly the symptom reported.

## Fix

Minimal change in `getUserByCustomerNumber()`:

1. After the Redis fast-path loads a user, read the actual
`customer_number` from the DB via `getObjectProperties()`.
2. **Verify** that it equals the requested `$customer_number`.
3. If not, the inverse cache is stale: clear it
(`clear_user_id_from_customer_number`) and re-fetch via the recursive
call, which now falls through to the authoritative `SELECT id FROM users
WHERE customer_number = ?` DB query.

The DB path was always correct (it filters by exact `customer_number`);
the bug was exclusively in the unchecked Redis fast-path.

## Regression test

`tests/Unit/Users/GetUserByCustomerNumberStaleCacheTest.php` — wiring
tests that assert the verification + cache-clear + recursive re-fetch
are present, plus that the DB lookup path is the source of truth.
Prevents the regression from reappearing silently.

## Test run

PHP is unavailable in the sandbox, so the new test has not been executed
locally. It is a pure wiring test (string assertions on the source file)
and will be verified by CI on PR open.

## Out of scope

- No change to `openclaw.json`, deployment config, or any other config
files.
- Auto-merge is intentionally **not** enabled — leaving that to the
existing auto-merge cron.
- Existing tests untouched.

## Linear

- TRU-18 will be moved to "In Review" with the PR URL in a follow-up
comment.

🤖 Generated with [MaxClaw](https://maxclaw.ai)

---------

Co-authored-by: TRU-18 backend bot <bot@truckwash.dev>
Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
2026-08-16 16:04:03 +02:00
2026-07-20 08:49:27 +02:00
2025-01-29 14:27:44 +01:00
2026-06-12 12:27:27 +02:00

Copenhagen Truck Wash API

Backend API for Copenhagen Truck Wash services.

Changes are published from a scoped feature branch through a pull request to master; direct default-branch pushes are not part of the release workflow. See default branch protection for the CI gate and emergency procedure.

Architecture & Stack

  • Edge Proxy: Traefik 2.11 (Handles TLS termination, routing, and rate limiting).
  • Web Server: Caddy 2.7 (Serves the PHP application via FastCGI).
  • PHP Runtime: PHP 8.2-FPM (Scale out with workers php1 to php5).
  • Cache/Queue: Redis 7.
  • Observability: Jaeger (Tracing), Prometheus (Metrics).

Getting Started

Prerequisites

  • Docker Desktop 4.x+
  • PHP 8.2 CLI (optional, for host-side testing)

Local Development

To bring up the minimal development stack (Traefik, Redis, MySQL debug DB, Caddy, and one PHP worker):

docker compose up -d traefik redis mysql-debug php1 caddy

The API is accessible at:

  • http://localhost
  • https://localhost (using Traefik default cert)
  • http(s)://localhost/api/ (proxied with /api prefix stripped)

Test Gateway Container

To run a real PHP edge agent as a disposable Dockerized test gateway against the local stack, first create an install token from the edge gateway admin UI, then start the helper:

.\scripts\test-gateway.ps1 start --install-token <token>
./scripts/test-gateway.sh start --install-token <token>

The helper will:

  • start the local compose dependencies if needed
  • claim a gateway through http://localhost/api/edge-agent/claim
  • write the generated config to .tmp/test-gateway/test-gateway.json
  • build services/edge-agent/Dockerfile.test-gateway
  • run the PHP agent container on the local compose network

Useful follow-up commands:

.\scripts\test-gateway.ps1 logs
.\scripts\test-gateway.ps1 status
.\scripts\test-gateway.ps1 stop

To start all services including multiple PHP workers and development tools (Jaeger, Portainer):

docker compose --profile dev up -d

Configuration

Configuration is primarily managed via environment variables.

  • The application root is located at services/nginx/app.
  • services/nginx/app/config.php loads configuration from the environment (requires USE_ENV=true).
  • php1 performs an automatic composer install on startup if AUTO_COMPOSER_INSTALL=true.

Environment Change Runbook

When updating .env values used by PHP containers (for example e-conomic tokens), recreate affected services so Docker applies the new env:

docker compose up -d --force-recreate php1 php2 php3 php4 php5 php-cron

Edge Broker Public URL

Set EDGE_PUBLIC_BROKER_URL to the public route that serves the edge broker, including the path prefix handled by the proxy. Local Traefik uses http://localhost/api/edge-broker; production routes use the public broker prefix, for example https://api.truckwash.dk/edge-broker.

The browser terminal connects to the exact advertised EDGE_PUBLIC_BROKER_URL plus /ws/browser-shell. That URL must be routable through the proxy to the edge-broker service. Do not rely on derived /api/edge-broker fallback paths outside the local Traefik setup.

Testing

The project now uses Pest as the primary test runner in services/nginx/app.

Running Tests

All commands are run from services/nginx/app:

composer test
composer test:unit
composer test:integration
composer test:api
composer test:coverage

For local Docker development, run the PHP suites inside php1:

docker exec php1 sh -lc "cd /var/www/html && composer test:unit"
docker exec php1 sh -lc "cd /var/www/html && composer test:integration"
docker exec php1 sh -lc "cd /var/www/html && composer test:api"
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge"
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge"

Integration tests are opt-in and should be run with required services available:

$env:RUN_INTEGRATION_TESTS='1'
composer test:integration

Edge Gateway Regression Coverage

The dedicated backend regression lane for the PHP edge gateway stack is split into:

  • API contract tests for operator, agent, and broker-facing routes
  • DB-backed integration tests for install sessions, heartbeats, tasks, logs, statistics, and shell persistence
  • a local dockerized smoke that runs the real PHP edge agent against the local backend and broker

Run the targeted PHP suites inside php1:

docker exec php1 sh -lc "cd /var/www/html && composer run-script test:api:edge"
docker exec php1 sh -lc "cd /var/www/html && composer run-script test:integration:edge"

Run the full local smoke from backend-php on the host:

node .\scripts\edge-gateway-e2e.mjs

The E2E smoke expects the local compose stack and Docker daemon to be available. It boots a disposable gateway container, waits for a real heartbeat, validates live operations and telemetry, and verifies browser shell transcript persistence.

Public Staging Edge-Gateway Smoke

api.truckwash.io:4433 is the public staging ingress. For Edge Gateways v2, the router must serve the canonical artifacts from services/nginx/app/resources/edge-gateway-agent, not from the legacy dist/agent.mjs output or a separate runtime mount.

To verify the public staging stack after a deploy, use a real installer token and run:

node .\scripts\staging-edge-gateway-smoke.mjs --install-token <token>
node ./scripts/staging-edge-gateway-smoke.mjs --install-token <token>

The smoke check fails unless all of these return 200 from the public domain:

  • /ping
  • /edge-agent/artifacts/agent.php
  • /edge-agent/artifacts/truckwash-edge-agent.service
  • /edge-agent/install.sh?token=<real token>

API Test Suite

The Api suite exercises real HTTP endpoints instead of calling route handlers in-process.

  • composer test:api enables RUN_API_TESTS=1 automatically.
  • By default the suite starts a temporary PHP server with php -S 127.0.0.1:18080 index.php and hits the app over HTTP.
  • The suite covers the real router, request parsing, auth headers, status codes, and JSON response envelopes.
  • Tests run serially and use explicit database/Redis fixtures plus reverse-order cleanup instead of transaction rollbacks.
  • The default phase-1 coverage includes /ping, auth session routes, departments, department categories, and orders CRUD including the legacy PUT /order alias.
  • API server logs are written to services/nginx/app/build/logs/api-server.out.log and services/nginx/app/build/logs/api-server.err.log.

To point the suite at an already-running base URL instead of the self-started PHP server:

$env:API_TEST_BASE_URL='http://127.0.0.1:18080'
composer test:api

The suite requires an initialized application schema. Redis-backed flows are used when Redis is configured, but the suite can still boot without caddy or the full reverse-proxy stack.

Run Tests Against A Cloned Live DB (Docker-Isolated)

Use the helper scripts in scripts/ to:

  1. Clone the configured live DB into a local MySQL Docker container.
  2. Start an isolated Redis container.
  3. Run tests in a separate temporary PHP Docker container pointed at that clone.

PowerShell:

powershell -ExecutionPolicy Bypass -File .\scripts\clone-live-db-and-test.ps1 -Force

Bash:

FORCE=1 ./scripts/clone-live-db-and-test.sh

Optional overrides:

  • Test command: -TestCommand "composer test:integration" or TEST_COMMAND="composer test:integration"
  • Keep containers after run: -KeepContainers or KEEP_CONTAINERS=1
  • Skip image build: -SkipBuild or SKIP_BUILD=1
  • Force image rebuild: -ForceBuild or FORCE_BUILD=1

Clone Live DB Into mysql-debug Service

To refresh the docker-compose debug DB from live credentials in .env:

PowerShell:

powershell -ExecutionPolicy Bypass -File .\scripts\clone-live-to-debug-db.ps1 -Force

Bash:

FORCE=1 ./scripts/clone-live-to-debug-db.sh

Test Layout

  • tests/Unit/*: isolated unit and route-level behavior tests.
  • tests/Integration/*: Redis/DB-backed tests intended for Docker/CI environments.
  • tests/Api/*: real HTTP endpoint tests that boot a temporary PHP server and assert full request/response behavior.
  • tests/Api/api_coverage_manifest.php: selected phase-1 endpoint manifest used by the API meta-test to enforce happy-path and failure coverage.
  • tests/<legacy-domain>/*: legacy procedural scripts retained during migration; keep them runnable until matching Pest coverage exists.

Logs & Monitoring

  • Logs: docker compose logs -f [service_name] (e.g., php1, caddy, traefik).
  • Traefik Dashboard: http://traefik.localhost (available in dev).
  • Jaeger (Tracing): http://localhost:16686 (when running with --profile dev).
  • Portainer: http://localhost:9000 (when running with --profile dev).

API Documentation

  • OpenAPI: The authoritative OpenAPI 3.0 contract is at openapi.yaml.
  • Self-Serve Module Guide: Implementation and API guide at services/nginx/app/modules/selfserve/selfserve.md.
  • Writerside: Documentation projects are located in /Writerside and /Writerside2.
  • Generated Writerside API Reference: The active Writerside project lives in /documentation and is generated from openapi.yaml.

Writerside OpenAPI Generation Workflow

Prerequisite:

  • Python 3 with PyYAML (pip install pyyaml)

Run from repository root:

python scripts/generate_writerside_openapi_docs.py generate
python scripts/generate_writerside_openapi_docs.py check

Contribution rule:

  1. Update openapi.yaml.
  2. Regenerate docs (python scripts/generate_writerside_openapi_docs.py generate).
  3. Verify (python scripts/generate_writerside_openapi_docs.py check).
S
Description
Staging repo for api (CI + promotion to GitHub)
Readme
40 MiB
Languages
PHP 93.5%
HTML 3.5%
JavaScript 1.9%
Shell 0.4%
Python 0.3%
Other 0.3%