diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..ec459e5
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,34 @@
+version: 2
+updates:
+ - package-ecosystem: "uv"
+ directory: "/backend"
+ schedule:
+ interval: "weekly"
+ groups:
+ backend-dependencies:
+ patterns:
+ - "*"
+
+ - package-ecosystem: "npm"
+ directory: "/frontend"
+ schedule:
+ interval: "weekly"
+ groups:
+ frontend-dependencies:
+ patterns:
+ - "*"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "docker"
+ directory: "/backend"
+ schedule:
+ interval: "weekly"
+
+ - package-ecosystem: "docker"
+ directory: "/frontend"
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml
new file mode 100644
index 0000000..ca67357
--- /dev/null
+++ b/.github/workflows/cd.yml
@@ -0,0 +1,131 @@
+name: CD
+
+# Root CLAUDE.md: push images to Azure Container Registry, run Alembic migrations, deploy to
+# Azure. Gated on green CI (the `ci` workflow already required on `main`) plus a manual approval
+# via the `production` GitHub Environment -- rollback is re-running the previous successful
+# workflow run, since every image is tagged with its commit SHA, never `:latest` (Container Apps
+# does not reliably re-pull a moved tag, learned the hard way during the MVP deploy).
+on:
+ workflow_run:
+ workflows: ["CI"]
+ branches: [main]
+ types: [completed]
+
+permissions:
+ id-token: write
+ contents: read
+
+env:
+ RESOURCE_GROUP: trueup-mvp-rg
+ REGISTRY: trueupmvpacr.azurecr.io
+ IMAGE_TAG: ${{ github.event.workflow_run.head_sha }}
+
+jobs:
+ build-and-push:
+ name: Build + push images
+ if: github.event.workflow_run.conclusion == 'success'
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.workflow_run.head_sha }}
+ - name: Azure login (OIDC)
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ - run: az acr login --name trueupmvpacr
+ - uses: docker/setup-buildx-action@v3
+ - name: Build + push backend (linux/amd64 -- Container Apps requirement)
+ uses: docker/build-push-action@v6
+ with:
+ context: backend
+ platforms: linux/amd64
+ push: true
+ tags: ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
+ - name: Build + push frontend (linux/amd64)
+ uses: docker/build-push-action@v6
+ with:
+ context: frontend
+ platforms: linux/amd64
+ push: true
+ tags: ${{ env.REGISTRY }}/trueup-frontend:${{ env.IMAGE_TAG }}
+
+ migrate:
+ name: Run Alembic migrations
+ needs: build-and-push
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: Azure login (OIDC)
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ # backend-migrate is a Container Apps Job (Manual trigger), matching ADR 13's own pattern:
+ # `az containerapp exec` needs a real TTY and cannot run non-interactively, so migrations
+ # run as a one-off Job instead of a `containerapp exec` step.
+ - name: Point the migration job at this build's image
+ run: |
+ az containerapp job update \
+ --name backend-migrate \
+ --resource-group ${{ env.RESOURCE_GROUP }} \
+ --image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
+ - name: Run migrations and wait for completion
+ run: |
+ EXECUTION=$(az containerapp job start \
+ --name backend-migrate \
+ --resource-group ${{ env.RESOURCE_GROUP }} \
+ --query name -o tsv)
+ echo "Execution: $EXECUTION"
+ for i in $(seq 1 30); do
+ STATUS=$(az containerapp job execution show \
+ --name backend-migrate \
+ --resource-group ${{ env.RESOURCE_GROUP }} \
+ --job-execution-name "$EXECUTION" \
+ --query properties.status -o tsv 2>/dev/null || echo "Unknown")
+ echo "status: $STATUS"
+ case "$STATUS" in
+ Succeeded) exit 0 ;;
+ Failed|Stopped) echo "::error::Migration job $STATUS" && exit 1 ;;
+ esac
+ sleep 10
+ done
+ echo "::error::Migration job did not complete within the wait window" && exit 1
+
+ deploy:
+ name: Deploy backend + frontend
+ needs: migrate
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: Azure login (OIDC)
+ uses: azure/login@v2
+ with:
+ client-id: ${{ secrets.AZURE_CLIENT_ID }}
+ tenant-id: ${{ secrets.AZURE_TENANT_ID }}
+ subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ - name: Deploy backend revision
+ run: |
+ az containerapp update \
+ --name backend \
+ --resource-group ${{ env.RESOURCE_GROUP }} \
+ --image ${{ env.REGISTRY }}/trueup-backend:${{ env.IMAGE_TAG }}
+ - name: Deploy frontend revision
+ run: |
+ az containerapp update \
+ --name frontend \
+ --resource-group ${{ env.RESOURCE_GROUP }} \
+ --image ${{ env.REGISTRY }}/trueup-frontend:${{ env.IMAGE_TAG }}
+ - name: Verify health
+ run: |
+ FQDN=$(az containerapp show --name frontend --resource-group ${{ env.RESOURCE_GROUP }} --query properties.configuration.ingress.fqdn -o tsv)
+ for i in $(seq 1 10); do
+ CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$FQDN/health/ready" || echo "000")
+ [ "$CODE" = "200" ] && echo "healthy" && exit 0
+ sleep 5
+ done
+ echo "::error::Deployed frontend never reported healthy" && exit 1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d73f736
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,266 @@
+name: CI
+
+# Root CLAUDE.md ("CI/CD (GitHub Actions)"): lint, unit + integration tests, API tests, UI
+# component tests, coverage thresholds; dependency and secret scanning, SAST, Docker build +
+# image vulnerability scan, Alembic migration check. Runs on every PR marked ready for review and
+# on main -- draft PRs stay cheap to iterate on locally.
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+ push:
+ branches: [main]
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ UV_VERSION: "0.5.11"
+
+jobs:
+ backend-quality:
+ name: Backend / lint, types, imports
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v3
+ with:
+ version: ${{ env.UV_VERSION }}
+ - run: uv sync --project backend --all-extras
+ # `working-directory: backend` on every step below, not just the last one: mypy and ruff
+ # both auto-discover `backend/pyproject.toml` (the `[tool.mypy]` overrides -- the pydantic
+ # plugin, the flask_*/alpaca/plaid ignore_missing_imports list) only when invoked from that
+ # directory. Running from the repo root silently drops all of it, which is a materially
+ # different, much noisier check (confirmed the hard way: passed locally under `cwd=backend`,
+ # failed in CI with 34 spurious errors before this fix).
+ - run: uv run --project backend ruff check app tests
+ working-directory: backend
+ - run: uv run --project backend mypy --strict app
+ working-directory: backend
+ - run: uv run --project backend lint-imports
+ working-directory: backend
+
+ backend-tests:
+ name: Backend / tests + coverage floor
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ # Real PostgreSQL and Redis, never SQLite/fakeredis -- backend/CLAUDE.md and S0 §11 depend on
+ # CHECK constraints, revoked grants, RLS, NUMERIC precision, FOR UPDATE SKIP LOCKED, and
+ # advisory locks. Ports match backend/tests/conftest.py's own defaults (5433/6380) exactly so
+ # no extra env vars are needed to point the suite at them.
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: trueup
+ ports:
+ - 5433:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d trueup"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6380:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v3
+ with:
+ version: ${{ env.UV_VERSION }}
+ - run: uv sync --project backend --all-extras
+ # Same role/database setup docker-compose mounts into Postgres's own init path locally --
+ # reused verbatim here since the container's init-on-first-boot hook only fires when the
+ # script is present at container creation, which the `services:` block above can't do.
+ - name: Create database roles and trueup_test
+ run: |
+ PGPASSWORD=postgres psql -h localhost -p 5433 -U postgres -d trueup -v ON_ERROR_STOP=1 -f docker/postgres/init.sql
+ - name: Run full test suite with coverage floor
+ run: uv run --project backend pytest --cov=app --cov-report=term-missing --cov-report=xml
+ working-directory: backend
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: backend-coverage
+ path: backend/coverage.xml
+
+ backend-dependency-audit:
+ name: Backend / dependency scan
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v3
+ with:
+ version: ${{ env.UV_VERSION }}
+ - run: uv sync --project backend --all-extras
+ - run: uv run --project backend pip-audit
+
+ backend-sast:
+ name: Backend / SAST (Bandit + Semgrep)
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v3
+ with:
+ version: ${{ env.UV_VERSION }}
+ - run: uvx bandit -r backend/app -ll
+ - uses: returntocorp/semgrep-action@v1
+ with:
+ config: p/python p/flask p/owasp-top-ten
+
+ migration-check:
+ name: Backend / Alembic migration check
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ # `app.config.Settings` (pydantic-settings) validates every required field eagerly at
+ # construction, even though `alembic/env.py` only reads `sqlalchemy_url_owner` from it --
+ # confirmed the hard way (a bare DATABASE_URL_OWNER alone fails with 6 missing fields). None
+ # of these are real credentials; the database is ephemeral and local to this job.
+ env:
+ DATABASE_URL: postgresql+psycopg://trueup_app:trueup_app@localhost:5433/trueup
+ DATABASE_URL_WORKER: postgresql+psycopg://trueup_worker:trueup_worker@localhost:5433/trueup
+ DATABASE_URL_OWNER: postgresql+psycopg://trueup_owner:trueup_owner@localhost:5433/trueup
+ DATABASE_URL_CHAT: postgresql+psycopg://trueup_chat_readonly:trueup_chat_readonly@localhost:5433/trueup
+ REDIS_URL: redis://localhost:6380/0
+ SECRET_KEY: ci-migration-check-not-a-real-secret-0123456789
+ FEE_RATE_PCT: "0.0"
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: trueup
+ ports:
+ - 5433:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d trueup"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+ steps:
+ - uses: actions/checkout@v4
+ - uses: astral-sh/setup-uv@v3
+ with:
+ version: ${{ env.UV_VERSION }}
+ - run: uv sync --project backend --all-extras
+ - name: Create database roles
+ run: |
+ PGPASSWORD=postgres psql -h localhost -p 5433 -U postgres -d trueup -v ON_ERROR_STOP=1 -f docker/postgres/init.sql
+ - name: Apply every migration to a clean database
+ run: uv run --project backend alembic upgrade head
+ working-directory: backend
+ # Fails if a model was changed without a matching migration -- autogenerate against the
+ # now-migrated database should produce an empty diff.
+ - name: Assert no un-generated schema drift
+ run: |
+ # `alembic revision --autogenerate` always writes a new (untracked) file, drift or not
+ # -- `git diff` never sees it, so the file's own content is what has to be inspected.
+ # No drift means both upgrade() and downgrade() contain only the generated `pass`.
+ uv run --project backend alembic revision --autogenerate -m "ci-drift-check" --rev-id ci_drift_check
+ # `git status --porcelain` always reports paths relative to the repo root, even when
+ # invoked from this step's `backend` working directory -- strip that prefix back off so
+ # the grep/rm below (which run with cwd=backend) resolve the same file.
+ GENERATED=$(git status --porcelain alembic/versions/ | awk '{print $2}' | sed 's|^backend/||')
+ if [ -z "$GENERATED" ]; then
+ echo "::error::Expected a generated migration file, found none" && exit 1
+ fi
+ if grep -qE '^\s*op\.' "$GENERATED"; then
+ echo "::error::Model changes are missing a migration:" && cat "$GENERATED" && exit 1
+ fi
+ rm -f "$GENERATED"
+ working-directory: backend
+
+ frontend-quality:
+ name: Frontend / lint, test, build
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: frontend/package-lock.json
+ - run: npm ci
+ working-directory: frontend
+ - run: npm run lint
+ working-directory: frontend
+ # `npm test` only exists once Phase 7 installs Vitest -- until then this step is a no-op
+ # guarded by the script's own presence so earlier phases' CI runs stay green honestly
+ # rather than via a fabricated always-pass step.
+ - name: Run component tests (if configured)
+ run: |
+ if npm run | grep -q " test$"; then npm run test -- --run; else echo "no test script yet"; fi
+ working-directory: frontend
+ - run: npm run build
+ working-directory: frontend
+
+ frontend-dependency-audit:
+ name: Frontend / dependency scan
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: "npm"
+ cache-dependency-path: frontend/package-lock.json
+ - run: npm ci
+ working-directory: frontend
+ - run: npm audit --audit-level=high
+ working-directory: frontend
+
+ secret-scan:
+ name: Secret scanning (Gitleaks)
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: gitleaks/gitleaks-action@v2
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ docker-build-and-scan:
+ name: Docker build + Trivy scan (${{ matrix.image }})
+ if: github.event.pull_request.draft != true
+ runs-on: ubuntu-latest
+ strategy:
+ # One leg's finding (or infra flake) must not hide the other's result -- confirmed the hard
+ # way: the default fail-fast cancelled the backend leg mid-scan ("The operation was
+ # canceled") the moment the frontend leg's Trivy findings failed first, so the backend leg's
+ # own actual pass/fail never got a chance to report.
+ fail-fast: false
+ matrix:
+ image: [backend, frontend]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: docker/setup-buildx-action@v3
+ - name: Build image
+ uses: docker/build-push-action@v6
+ with:
+ context: ${{ matrix.image }}
+ push: false
+ load: true
+ tags: trueup-${{ matrix.image }}:ci
+ - name: Scan image for vulnerabilities
+ uses: aquasecurity/trivy-action@v0.36.0
+ with:
+ image-ref: trueup-${{ matrix.image }}:ci
+ severity: CRITICAL,HIGH
+ exit-code: "1"
+ ignore-unfixed: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62877a2..c0b0dcf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,36 @@
## Unreleased
+- Wire four frontend domains to the real backend (portfolio, fees, chat, the admin fee panel),
+ replacing `mockClient` calls with real `apiClient` requests: `portfoliosApi`/`OrderForm` drop the
+ fabricated security-price table (customer now enters the reference price directly, since no
+ quote endpoint exists); `feesApi` maps the real `FeeSummaryResponse` shape and drops two fields
+ the mock invented (`month_to_date_gain`, `payment_method`) that the backend doesn't return;
+ `chatApi`/`useChatStream` replace the fake word-splitting reveal with a real `fetch` +
+ `ReadableStream` consumer of the existing chat SSE endpoint; `PaymentMethodForm` sends one of
+ Stripe's fixed test-mode payment-method tokens to the real `POST /payment-methods` endpoint — a
+ genuinely live sandbox call, not simulated. The three domains with no backend route yet (tax
+ lots, admin customer directory/detail/KYC-override, statement export) stay mocked, now with a
+ visible "Simulated" tag (`components/SimulatedBadge`, generalizing design-system.md §8.3's
+ pattern) rather than looking indistinguishable from the real screens.
+- Fix a real bug in `StripeBillingAdapter.attach_payment_method`, found live: it reused the
+ caller-supplied `payment_method_id` for the follow-up `Customer.modify()` call, but Stripe's
+ fixed test-mode tokens (`pm_card_visa` etc.) each materialize a *new* PaymentMethod object on
+ attach — the real id differs from the token. The contract test's own assertion had encoded the
+ same wrong assumption (only true for the fake adapter), silently masking the drift; loosened it
+ to what the port actually guarantees.
+- Parameterize `frontend/nginx.conf`'s backend proxy target (`nginx.conf.template` +
+ `${BACKEND_URL}`, rendered by nginx's own built-in template mechanism) instead of hardcoding
+ `http://backend:8000`, and fix two real nginx reverse-proxy bugs found deploying to Azure
+ Container Apps: missing `proxy_http_version 1.1`/an unforwarded `Connection` header (nginx's own
+ proxy module returned 426 Upgrade Required), and no `proxy_buffering off` on `/api/`, which
+ would have buffered the entire chat SSE response instead of streaming it token-by-token.
+- Deploy an MVP to Azure Container Apps (`trueup-mvp-rg`, `centralus`): Postgres Flexible Server,
+ a Key Vault backing real envelope encryption via the backend's managed identity, Redis as an
+ internal Container App, and the existing Dockerfiles built for `linux/amd64` and pushed to a new
+ Azure Container Registry. See `README.md`'s new Deployment section for the live URLs and the
+ explicit list of MVP-speed shortcuts (public Postgres access, no real-time SSE push, no new test
+ coverage this pass) tracked for the next hardening cycle.
- Fix three tracked, empty, load-bearing files found by a full spec/security/quality audit:
`backend/pytest.ini` (0 bytes — silently outranked `pyproject.toml`'s real pytest config even
empty), and `backend/Dockerfile`/`frontend/Dockerfile` (0 bytes — `docker build` could never
diff --git a/DECISION-LOG.md b/DECISION-LOG.md
index 2fd9fe2..69b609c 100644
--- a/DECISION-LOG.md
+++ b/DECISION-LOG.md
@@ -16,6 +16,54 @@ Newest first. Times are local (America/Los_Angeles).
## Decisions
+### 2026-09-06 01:30 — MVP-first pivot: wire four real domains, skip new tests/hardening, deploy to Azure
+
+- **User redirected mid-plan**: an exhaustive frontend-completion design had just been written
+ (all six mocked domains, a full ADR 20 SSE fan-out, real Stripe Elements, one test file per
+ component — `docs/superpowers/specs/2026-09-05-frontend-completion-design.md`). Instructed
+ instead to ship an MVP fast — "don't worry about security and tests right now" — then deploy.
+ Re-scoped rather than blindly complied: confirmed with the user that this meant *skip new*
+ hardening/tests, not remove any existing control (CSRF, RLS, session/auth, rate limits, ledger
+ immutability stay untouched — verified after the fact via `git diff --stat` showing zero
+ `backend/app/` changes from the frontend rewiring itself).
+- **Scope actually shipped**: portfolio, fees, chat, and the admin fee panel wired to the real
+ backend (the four domains whose routes already existed); lots, admin-customers, and statement
+ export stay mocked with a visible "Simulated" tag (a rubric honesty requirement, not new
+ hardening, so building it stayed in scope). Order pricing: customer enters the reference price
+ directly rather than inventing a quote source. Payment method: wired genuinely live using
+ Stripe's fixed test-mode tokens rather than building Stripe Elements — a real sandbox call, less
+ UI work.
+- **Two real bugs found only by actually deploying, not by any test that existed before this
+ session**: nginx's reverse-proxy config was missing `proxy_http_version 1.1`/cleared
+ `Connection` header (426 Upgrade Required on every proxied request) and `proxy_buffering off`
+ (would have broken SSE streaming silently — no error, just no incremental tokens); and
+ `StripeBillingAdapter.attach_payment_method` reused the input token instead of the real attached
+ PaymentMethod's id, which only a live Stripe call surfaces — the contract test's own assertion
+ had encoded the same wrong assumption, since it happened to match the fake adapter's echo
+ behavior. Both fixed and covered: the nginx fix by a 10/10-request live stability check plus a
+ full core-loop smoke test, the Stripe fix by loosening the contract test's over-specified
+ assertion and re-running it against the real sandbox (`requires_credentials`), green.
+- **Deployed to Azure Container Apps** (`trueup-mvp-rg`, `centralus` — `eastus2` was tried first
+ for the resource group/ACR, which worked, but Postgres Flexible Server creation was rejected
+ there as region-restricted for this subscription; `centralus` allowed it, so Postgres/Key
+ Vault/Container Apps environment/both apps all live there instead, resource group stays a
+ cross-region container). ACR Tasks (`az acr build`) were also blocked on this subscription
+ ("TasksOperationsNotAllowed") — built and pushed images from the local Docker daemon instead,
+ explicitly for `linux/amd64` (the first push was Apple Silicon's native arch, which Container
+ Apps rejected outright).
+- **Real Key Vault set up, not deferred**, despite the "MVP fast" framing: the backend defaults to
+ `FLASK_ENV=production`, and without Key Vault configured it refuses to boot at all rather than
+ silently falling back to `LocalDevCipher` (`app/__init__.py`'s own explicit guard, ADR 23). The
+ alternative — set `FLASK_ENV=development` to unblock — would have also disabled HSTS, forced
+ HTTPS, and the session cookie's `Secure` flag, directly violating "don't touch existing
+ controls." Standing up the vault (one RSA key, the backend's system-assigned managed identity
+ granted Key Vault Crypto User) was less work than that trade-off implied, and correctness won.
+- **Cut, explicitly, for this pass**: new automated tests (none added), the ADR 20 real-time SSE
+ push fan-out, Stripe Elements, the three still-missing S8 backend routes, and Postgres
+ VNet-integration (public access left open on the Flexible Server for MVP speed). All five listed
+ in `README.md`'s new Deployment section and the still-current design spec above, not silently
+ dropped.
+
### 2026-09-06 — Full spec/security/quality audit of S0-S11; remediation plan approved; two prior decisions reversed
- **Independent, three-way audit dispatched** against every design spec, every ADR,
diff --git a/README.md b/README.md
index 1fd15c4..3070497 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ Every slot is labelled honestly, per
| Trading calendar | Alpaca | **Planned live** |
| Identity / KYC | Stripe Identity | **Planned live** (test mode) |
| Bank linking / ACH | Plaid | **Planned live** (sandbox) |
-| Fee billing | Stripe Billing | **Planned live** (test mode) |
+| Fee billing | Stripe Billing | **Live** (test mode) — `POST /payment-methods` genuinely calls Stripe's API; verified both via the real-adapter contract test (`requires_credentials`) and a live request against the deployed MVP |
| Custodian file | Built by us | **Simulated** — a deliberate simulator that generates the awkward cases (FR-33) |
"Planned live" means the adapter and its port exist and the contract suite runs against both the
@@ -29,6 +29,38 @@ real adapter and its fake, but the sandbox credentials are not yet issued. Each
**live** once its keys are in place and its contract tests run green against the real sandbox.
Nothing is presented as live before that.
+## Deployment
+
+An MVP is deployed to Azure Container Apps (`trueup-mvp-rg`, `centralus`):
+
+- Frontend: `https://frontend.proudmeadow-36949862.centralus.azurecontainerapps.io`
+- Backend API: `https://backend.proudmeadow-36949862.centralus.azurecontainerapps.io` (external
+ ingress kept for webhook reachability; the frontend's own nginx proxies `/api` and `/health` to
+ it over the environment's internal network, not the public FQDN — see
+ `frontend/nginx.conf.template`)
+
+Resources: Postgres Flexible Server (`trueup-mvp-pg`, Burstable B1ms), Azure Container Registry
+(`trueupmvpacr`), a Key Vault (`trueup-mvp-kv`) backing real envelope encryption via the backend's
+system-assigned managed identity (no `LOCAL_CIPHER_KEY` in this environment), and Redis running as
+an internal-only Container App (ADR 13/15: sessions and rate limits only, never financial state).
+
+**Deliberate MVP-speed shortcuts, not silent gaps — tracked here until hardened:**
+- Postgres allows public access from any IP (`--public-access 0.0.0.0-255.255.255.255`) rather than
+ VNet-integrated private access.
+- Three domains are still mocked client-side, honestly labelled "Simulated" in the UI: tax lots,
+ the admin customer directory/detail/KYC-override, and statement export — S8's `/lots`,
+ `/admin/customers*`, and `/admin/kyc-overrides/` routes don't exist yet
+ (`docs/superpowers/specs/2026-09-05-frontend-completion-design.md` has the full design for
+ closing this).
+- No real-time SSE push (ADR 20/S12 §6) — every screen refetches on mount instead, the documented
+ fallback.
+- No new automated test coverage was added for this pass (explicit scope cut — ship fast, harden
+ after); the exhaustive frontend test suite is tracked in the same design doc above.
+- Alpaca and Plaid stay "Planned live" (unchanged this pass) — only Stripe Billing's
+ `POST /payment-methods` was verified genuinely live this deployment, via Stripe's fixed
+ test-mode payment-method tokens (`pm_card_visa` etc.), confirmed against the real Stripe test
+ API both by the `requires_credentials` contract test and a live request against the deployed URL.
+
## Architecture
- Design specs: [`docs/specs/`](docs/specs/) — S0 (foundation) through S12 (production operations).
diff --git a/backend/alembic/versions/20260906_0327-79c5ef5459fa_add_s12_3_indexing_strategy_indexes.py b/backend/alembic/versions/20260906_0327-79c5ef5459fa_add_s12_3_indexing_strategy_indexes.py
new file mode 100644
index 0000000..0799919
--- /dev/null
+++ b/backend/alembic/versions/20260906_0327-79c5ef5459fa_add_s12_3_indexing_strategy_indexes.py
@@ -0,0 +1,44 @@
+"""add S12 §3 indexing strategy indexes
+
+Revision ID: 79c5ef5459fa
+Revises: 38096a12c976
+Create Date: 2026-09-06 03:27:11.804112
+
+"""
+from collections.abc import Sequence
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = '79c5ef5459fa'
+down_revision: str | Sequence[str] | None = '38096a12c976'
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_index('ix_account_customer_role', 'account', ['customer_id', 'role'], unique=False)
+ op.create_index('ix_chat_message_session_created', 'chat_message', ['session_id', 'created_at'], unique=False)
+ op.create_index('ix_journal_entry_effective_date', 'journal_entry', ['effective_date'], unique=False)
+ op.create_index('ix_journal_entry_recorded_at', 'journal_entry', ['recorded_at'], unique=False)
+ op.create_index('ix_order_customer_status', 'order', ['customer_id', 'status'], unique=False)
+ op.create_index('ix_posting_account_id', 'posting', ['account_id'], unique=False)
+ op.create_index('ix_reconciliation_break_status_opened', 'reconciliation_break', ['status', 'opened_at'], unique=False)
+ op.create_index('ix_tax_lot_customer_security_acquired', 'tax_lot', ['customer_id', 'security_id', 'acquired_at'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('ix_tax_lot_customer_security_acquired', table_name='tax_lot')
+ op.drop_index('ix_reconciliation_break_status_opened', table_name='reconciliation_break')
+ op.drop_index('ix_posting_account_id', table_name='posting')
+ op.drop_index('ix_order_customer_status', table_name='order')
+ op.drop_index('ix_journal_entry_recorded_at', table_name='journal_entry')
+ op.drop_index('ix_journal_entry_effective_date', table_name='journal_entry')
+ op.drop_index('ix_chat_message_session_created', table_name='chat_message')
+ op.drop_index('ix_account_customer_role', table_name='account')
+ # ### end Alembic commands ###
diff --git a/backend/app/__init__.py b/backend/app/__init__.py
index d082785..15a5aad 100644
--- a/backend/app/__init__.py
+++ b/backend/app/__init__.py
@@ -17,6 +17,8 @@
from werkzeug.exceptions import HTTPException
from app.config import Settings, get_settings
+from app.controllers.admin.customers import admin_customers_bp
+from app.controllers.admin.kyc_overrides import admin_kyc_overrides_bp
from app.controllers.admin.rebalance import admin_rebalance_bp
from app.controllers.api.auth import auth_bp, init_auth
from app.controllers.api.breaks import breaks_bp
@@ -24,6 +26,7 @@
from app.controllers.api.fees import fees_bp
from app.controllers.api.funding import funding_bp
from app.controllers.api.identity import identity_bp
+from app.controllers.api.lots import lots_bp
from app.controllers.api.orders import orders_bp
from app.controllers.api.portfolios import portfolios_bp
from app.controllers.api.statements import statements_bp
@@ -44,6 +47,7 @@
from app.core.security import set_audit_sink
from app.extensions import init_engines, limiter, make_redis, talisman
from app.integrations.crypto.local_cipher import LocalDevCipher
+from app.jobs import register_cli
from app.services.ops.audit_sink import SqlAuditSink
log = get_logger(__name__)
@@ -100,6 +104,7 @@ def create_app(settings: Settings | None = None) -> Flask:
app.register_blueprint(health_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(orders_bp)
+ app.register_blueprint(lots_bp)
app.register_blueprint(valuation_bp)
app.register_blueprint(statements_bp)
app.register_blueprint(identity_bp)
@@ -108,10 +113,16 @@ def create_app(settings: Settings | None = None) -> Flask:
app.register_blueprint(chat_bp)
app.register_blueprint(portfolios_bp)
app.register_blueprint(admin_rebalance_bp)
+ app.register_blueprint(admin_customers_bp)
+ app.register_blueprint(admin_kyc_overrides_bp)
app.register_blueprint(stripe_identity_bp)
app.register_blueprint(plaid_webhooks_bp)
app.register_blueprint(fees_bp)
app.register_blueprint(stripe_billing_bp)
+ # `flask jobs ` (Makefile's `job` target; the Azure Container Apps Job the deployed
+ # scheduled jobs run as) has no other entrypoint -- confirmed missing: register_cli existed
+ # but nothing ever called it, so every job command was unreachable outside a direct unit test.
+ register_cli(app)
return app
diff --git a/backend/app/config.py b/backend/app/config.py
index 74b270e..6f8a559 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -142,6 +142,11 @@ class Settings(BaseSettings):
hours) between retry attempts -- a defensible engineering default, flagged for business review
before go-live, same posture as `KYC_MAX_ATTEMPTS`/the deposit caps."""
+ outbox_max_attempts: int = 5
+ """S0 §9: how many times `OutboxWorker` retries a `job_outbox` row (exponential backoff,
+ `2**(attempt-1)` seconds) before moving it to `dead_letter` for operator attention -- the same
+ tunable-with-a-default posture as `dunning_max_attempts` above."""
+
@model_validator(mode="before")
@classmethod
def _blank_is_unset(cls, data: Any) -> Any:
diff --git a/backend/app/controllers/admin/customers.py b/backend/app/controllers/admin/customers.py
new file mode 100644
index 0000000..5810d4b
--- /dev/null
+++ b/backend/app/controllers/admin/customers.py
@@ -0,0 +1,206 @@
+"""Admin customer directory/detail/fees routes (S8 §4 rows 1, 2, 6). Adviser/admin-only -- a
+customer session gets 403, never 404, matching `app/controllers/api/breaks.py`'s
+`@requires_role` pattern.
+
+`GET /admin/customers/` calls the *same* `ValuationService.value_book` code path
+`GET /valuation/balance` does, and `GET /admin/customers//fees` calls the *same*
+`FeeSummaryService.summarize` `GET /fees` does -- both on `AdminUnitOfWork`
+(`app/services/admin/uow.py`), never a second, independently-implemented aggregation that could
+report a different figure for the same customer (S8 §6 edge case 4).
+"""
+
+from __future__ import annotations
+
+import uuid
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING, Any
+
+from flask import Blueprint, jsonify, request
+from flask_login import current_user
+
+from app.core.clock import MarketClock
+from app.core.errors import NotFoundError, ValidationError
+from app.core.pagination import decode_cursor, normalize_limit, paginate
+from app.core.security import requires_role
+from app.core.uow import SessionRole
+from app.extensions import limiter
+from app.models.marketdata.trading_calendar import CachedTradingCalendar
+from app.services.admin.uow import AdminUnitOfWork
+from app.services.fees.fee_summary_service import FeeSummaryService
+from app.services.reconciliation.break_aging_service import BreakAgingService
+from app.services.valuation.valuation_service import ValuationService
+from app.views.admin_customers import (
+ AdminCustomerDetailResponse,
+ AdminCustomersListResponse,
+ AdminCustomerSummaryResponse,
+)
+from app.views.fees import (
+ DunningStateResponse,
+ FeeChargeResponse,
+ FeeSummaryResponse,
+ HighWaterMarkResponse,
+)
+from app.views.reconciliation import BreakResponse
+from app.views.valuation import BalanceResponse
+
+if TYPE_CHECKING:
+ from app.models.reconciliation.reconciliation_break import ReconciliationBreak
+
+admin_customers_bp = Blueprint(
+ "admin_customers", __name__, url_prefix="/api/v1/admin/customers"
+)
+
+_STAFF_ROLES = ("adviser", "admin")
+
+
+def _parse_optional_limit(raw: str | None) -> int | None:
+ if raw is None:
+ return None
+ try:
+ return int(raw)
+ except ValueError as exc:
+ raise ValidationError("limit must be an integer") from exc
+
+
+def _decode_customer_cursor(raw: str) -> tuple[str, uuid.UUID]:
+ decoded = decode_cursor(raw)
+ if len(decoded) != 2 or not isinstance(decoded[0], str) or not isinstance(decoded[1], str):
+ raise ValidationError("invalid pagination cursor")
+ try:
+ return decoded[0], uuid.UUID(decoded[1])
+ except ValueError as exc:
+ raise ValidationError("invalid pagination cursor") from exc
+
+
+def _break_to_response(break_row: ReconciliationBreak, *, now: datetime) -> BreakResponse:
+ age = BreakAgingService.age(break_row, now=now)
+ return BreakResponse(
+ id=break_row.id,
+ break_type=break_row.break_type.value,
+ customer_id=break_row.customer_id,
+ expected=break_row.expected,
+ actual=break_row.actual,
+ opened_at=break_row.opened_at,
+ age_seconds=int(age.total_seconds()),
+ status=break_row.status.value,
+ resolved_at=break_row.resolved_at,
+ resolved_by=break_row.resolved_by,
+ resolution_note=break_row.resolution_note,
+ )
+
+
+@admin_customers_bp.route("", methods=["GET"])
+@limiter.limit("60 per minute")
+@requires_role(*_STAFF_ROLES)
+def list_customers() -> Any:
+ query = (request.args.get("query") or "").strip()
+ if not query:
+ raise ValidationError("query is required")
+
+ limit = normalize_limit(_parse_optional_limit(request.args.get("limit")))
+ after: tuple[str, uuid.UUID] | None = None
+ raw_cursor = request.args.get("cursor")
+ if raw_cursor:
+ after = _decode_customer_cursor(raw_cursor)
+
+ with AdminUnitOfWork(customer_id=None, role=SessionRole(current_user.role)) as uow:
+ rows = uow.customers.search(query, limit=limit, after=after)
+ page = paginate(rows, limit=limit, cursor_key=lambda c: (c.email, str(c.id)))
+
+ view = AdminCustomersListResponse(
+ customers=[
+ AdminCustomerSummaryResponse(
+ id=str(customer.id),
+ email=customer.email,
+ kyc_status=customer.kyc_status.value,
+ account_approval_status=customer.account_approval_status.value,
+ )
+ for customer in page.items
+ ],
+ next_cursor=page.next_cursor,
+ )
+
+ return jsonify(view.model_dump(mode="json")), 200
+
+
+@admin_customers_bp.route("/", methods=["GET"])
+@limiter.limit("60 per minute")
+@requires_role(*_STAFF_ROLES)
+def get_customer(customer_id: uuid.UUID) -> Any:
+ with AdminUnitOfWork(customer_id=None, role=SessionRole(current_user.role)) as uow:
+ customer = uow.customers.get_by_id(customer_id)
+ if customer is None:
+ raise NotFoundError(f"customer {customer_id} not found")
+
+ clock = MarketClock(CachedTradingCalendar(uow.calendar_cache))
+ as_of_date = clock.market_date(datetime.now(UTC))
+ result = ValuationService(uow).value_book(customer_id, as_of_date)
+
+ now = datetime.now(UTC)
+ open_breaks = uow.reconciliation_breaks.list_open_for_customer(customer_id)
+
+ view = AdminCustomerDetailResponse(
+ id=str(customer.id),
+ email=customer.email,
+ kyc_status=customer.kyc_status.value,
+ account_approval_status=customer.account_approval_status.value,
+ balance=BalanceResponse(
+ total_value=result.total_value,
+ as_of_date=result.as_of_date,
+ completeness=result.completeness,
+ ),
+ open_reconciliation_breaks=[
+ _break_to_response(break_row, now=now) for break_row in open_breaks
+ ],
+ )
+
+ return jsonify(view.model_dump(mode="json")), 200
+
+
+@admin_customers_bp.route("//fees", methods=["GET"])
+@limiter.limit("30 per minute")
+@requires_role(*_STAFF_ROLES)
+def get_customer_fees(customer_id: uuid.UUID) -> Any:
+ with AdminUnitOfWork(customer_id=None, role=SessionRole(current_user.role)) as uow:
+ if uow.customers.get_by_id(customer_id) is None:
+ raise NotFoundError(f"customer {customer_id} not found")
+
+ summary = FeeSummaryService(uow).summarize(customer_id)
+
+ hwm = summary.high_water_mark
+ dunning = summary.dunning
+ view = FeeSummaryResponse(
+ accrual_to_date=summary.accrual_to_date,
+ high_water_mark=(
+ HighWaterMarkResponse(peak_value=hwm.peak_value, updated_at=hwm.updated_at)
+ if hwm is not None
+ else None
+ ),
+ charges=[
+ FeeChargeResponse(
+ id=str(charge.id),
+ billing_period_start=charge.billing_period_start,
+ billing_period_end=charge.billing_period_end,
+ total_accrued=charge.total_accrued,
+ status=charge.status.value,
+ stripe_charge_id=charge.stripe_charge_id,
+ )
+ for charge in summary.charges
+ ],
+ dunning=(
+ DunningStateResponse(
+ fee_charge_id=str(dunning.fee_charge_id),
+ attempt_number=dunning.attempt_number,
+ next_retry_at=dunning.next_retry_at,
+ max_attempts=dunning.max_attempts,
+ status=dunning.status.value,
+ )
+ if dunning is not None
+ else None
+ ),
+ )
+
+ return jsonify(view.model_dump(mode="json")), 200
+
+
+__all__ = ["admin_customers_bp"]
diff --git a/backend/app/controllers/admin/kyc_overrides.py b/backend/app/controllers/admin/kyc_overrides.py
new file mode 100644
index 0000000..d2ca7b5
--- /dev/null
+++ b/backend/app/controllers/admin/kyc_overrides.py
@@ -0,0 +1,86 @@
+"""`POST /api/v1/admin/kyc-overrides/` (S8 §4 row 5, S2 §9's own deferred reopening
+gate resolved here). Adviser/admin-only, `@audited` (a privileged action against another
+customer's regulatory state).
+
+**Structural note, matching `app/controllers/api/breaks.py`'s own `@audited` pattern exactly.**
+`@audited` needs a `UnitOfWork` among the *decorated function's own* arguments, but this
+controller (like every other one) opens `uow` inside a `with` block in the view body -- so the
+route opens `uow`, then calls a small inner function (`_apply_override`) taking `uow`/
+`customer_id` as explicit keyword arguments, itself `@audited`-decorated.
+
+**The reset never touches `kyc_session` rows** (S2 §3.2: that table is append-only, one row per
+attempt). Resetting `customer.kyc_status` back to `pending` is the whole fix -- S2 §9's own text
+("a new kyc_session row resets attempt_number's effective count"): the very next
+`KycService.start_verification` call is no longer blocked by the `kyc_status == rejected` half of
+its lock check (`app/services/identity/kyc_service.py`), since that check requires *both* halves.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+from flask import Blueprint, jsonify, request
+from flask_login import current_user
+from pydantic import BaseModel, Field
+from pydantic import ValidationError as PydanticValidationError
+
+from app.core.errors import NotFoundError, ValidationError
+from app.core.security import audited, requires_role
+from app.core.uow import SessionRole
+from app.extensions import limiter
+from app.models.identity.customer import KycStatus
+from app.services.identity.uow import IdentityUnitOfWork
+from app.views.identity import IdentityStatusResponse
+
+if TYPE_CHECKING:
+ import uuid
+
+ from app.models.identity.customer import Customer
+
+admin_kyc_overrides_bp = Blueprint(
+ "admin_kyc_overrides", __name__, url_prefix="/api/v1/admin/kyc-overrides"
+)
+
+_STAFF_ROLES = ("adviser", "admin")
+
+
+class _OverrideRequest(BaseModel):
+ reason: str = Field(min_length=1)
+
+
+@admin_kyc_overrides_bp.route("/", methods=["POST"])
+@limiter.limit("10 per minute")
+@requires_role(*_STAFF_ROLES)
+def override_kyc_lock(customer_id: uuid.UUID) -> Any:
+ try:
+ body = _OverrideRequest.model_validate(request.get_json(silent=True) or {})
+ except PydanticValidationError as exc:
+ raise ValidationError(str(exc)) from exc
+
+ with IdentityUnitOfWork(customer_id=None, role=SessionRole(current_user.role)) as uow:
+ customer = uow.customers.get_by_id(customer_id)
+ if customer is None:
+ raise NotFoundError(f"customer {customer_id} not found")
+
+ _apply_override(uow=uow, customer_id=customer_id, customer=customer, reason=body.reason)
+ uow.commit()
+
+ view = IdentityStatusResponse(
+ kyc_status=customer.kyc_status.value,
+ account_approval_status=customer.account_approval_status.value,
+ )
+
+ return jsonify(view.model_dump(mode="json")), 200
+
+
+@audited("identity.kyc.override")
+def _apply_override(
+ *, uow: IdentityUnitOfWork, customer_id: uuid.UUID, customer: Customer, reason: str
+) -> None:
+ """A small indirection purely so `@audited` can see `uow`/`customer_id` as this function's
+ own arguments -- see module docstring. `reason` is captured only in the audit log's payload
+ hash (via the request body `@audited` already hashes); it has no other effect."""
+ customer.kyc_status = KycStatus.pending
+
+
+__all__ = ["admin_kyc_overrides_bp"]
diff --git a/backend/app/controllers/api/fees.py b/backend/app/controllers/api/fees.py
index e06dadb..db137df 100644
--- a/backend/app/controllers/api/fees.py
+++ b/backend/app/controllers/api/fees.py
@@ -8,7 +8,6 @@
from __future__ import annotations
import uuid
-from datetime import UTC, datetime
from typing import Any
from flask import Blueprint, jsonify, request
@@ -19,10 +18,10 @@
from app.config import get_settings
from app.core.errors import ForbiddenError, UnauthenticatedError, ValidationError
-from app.core.money import Money
from app.core.uow import SessionRole
from app.extensions import DbRole, limiter
from app.integrations.stripe.billing_adapter import StripeBillingAdapter
+from app.services.fees.fee_summary_service import FeeSummary, FeeSummaryService
from app.services.fees.payment_method_service import CustomerNotFoundError, PaymentMethodService
from app.services.fees.uow import FeesUnitOfWork
from app.views.fees import (
@@ -85,28 +84,11 @@ def _build_billing_adapter() -> StripeBillingAdapter:
return StripeBillingAdapter(api_key=settings.stripe_secret_key.get_secret_value())
-@fees_bp.route("/fees", methods=["GET"])
-@limiter.limit("30 per minute")
-def get_fees() -> Any:
- customer_id = _resolve_customer_id_for_get(request.args.get("customer_id"))
- role, uow_customer_id = _session_role_and_customer_id(customer_id)
-
- with FeesUnitOfWork(customer_id=uow_customer_id, role=role, db_role=DbRole.APP) as uow:
- today = datetime.now(UTC).date()
- period_start = today.replace(day=1)
- open_period_accruals = uow.fee_accruals.list_for_period(
- customer_id, period_start=period_start, period_end=today
- )
- accrual_to_date = Money("0.00")
- for accrual in open_period_accruals:
- accrual_to_date += accrual.fee_amount
-
- hwm = uow.high_water_marks.get_by_customer(customer_id)
- charges = uow.fee_charges.list_for_customer(customer_id)
- dunning_states = uow.dunning_states.get_by_customer(customer_id)
-
- view = FeeSummaryResponse(
- accrual_to_date=accrual_to_date,
+def _summary_to_view(summary: FeeSummary) -> FeeSummaryResponse:
+ hwm = summary.high_water_mark
+ dunning = summary.dunning
+ return FeeSummaryResponse(
+ accrual_to_date=summary.accrual_to_date,
high_water_mark=(
HighWaterMarkResponse(peak_value=hwm.peak_value, updated_at=hwm.updated_at)
if hwm is not None
@@ -121,20 +103,32 @@ def get_fees() -> Any:
status=charge.status.value,
stripe_charge_id=charge.stripe_charge_id,
)
- for charge in charges
+ for charge in summary.charges
],
dunning=(
DunningStateResponse(
- fee_charge_id=str(dunning_states[0].fee_charge_id),
- attempt_number=dunning_states[0].attempt_number,
- next_retry_at=dunning_states[0].next_retry_at,
- max_attempts=dunning_states[0].max_attempts,
- status=dunning_states[0].status.value,
+ fee_charge_id=str(dunning.fee_charge_id),
+ attempt_number=dunning.attempt_number,
+ next_retry_at=dunning.next_retry_at,
+ max_attempts=dunning.max_attempts,
+ status=dunning.status.value,
)
- if dunning_states
+ if dunning is not None
else None
),
)
+
+
+@fees_bp.route("/fees", methods=["GET"])
+@limiter.limit("30 per minute")
+def get_fees() -> Any:
+ customer_id = _resolve_customer_id_for_get(request.args.get("customer_id"))
+ role, uow_customer_id = _session_role_and_customer_id(customer_id)
+
+ with FeesUnitOfWork(customer_id=uow_customer_id, role=role, db_role=DbRole.APP) as uow:
+ summary = FeeSummaryService(uow).summarize(customer_id)
+ view = _summary_to_view(summary)
+
return jsonify(view.model_dump(mode="json")), 200
diff --git a/backend/app/controllers/api/identity.py b/backend/app/controllers/api/identity.py
index a06ee63..b6ea978 100644
--- a/backend/app/controllers/api/identity.py
+++ b/backend/app/controllers/api/identity.py
@@ -33,7 +33,7 @@
from app.extensions import DbRole, limiter
from app.integrations.stripe.kyc_adapter import StripeKycAdapter
from app.services.identity.funding_uow import FundingUnitOfWork
-from app.services.identity.kyc_service import KycService
+from app.services.identity.kyc_service import KycLockedError, KycService
from app.views.identity import IdentityConfigResponse, IdentityStatusResponse, KycSessionResponse
if TYPE_CHECKING:
@@ -114,7 +114,10 @@ def start_kyc_session() -> Any:
service = KycService(
uow, kyc_port=_build_kyc_port(), max_attempts=settings.kyc_max_attempts
)
- handle = service.start_verification(data.customer_id)
+ try:
+ handle = service.start_verification(data.customer_id)
+ except KycLockedError as exc:
+ raise ValidationError(str(exc), code="kyc_locked") from exc
uow.commit()
view = KycSessionResponse(
diff --git a/backend/app/controllers/api/lots.py b/backend/app/controllers/api/lots.py
new file mode 100644
index 0000000..5cc356d
--- /dev/null
+++ b/backend/app/controllers/api/lots.py
@@ -0,0 +1,108 @@
+"""Tax lot routes (S8 §3, data owned by S5) -- `GET /api/v1/lots`: quantity, cost basis (original
+and wash-sale-adjusted), realized gains, and provisional flags for every lot a customer has ever
+opened.
+
+Every route is authenticated and tenant-scoped (root `CLAUDE.md`'s non-negotiable), following
+`app/controllers/api/statements.py`'s exact pattern: a `customer` session only ever sees its own
+lots; `adviser`/`admin` pass `customer_id` explicitly, gated by `@requires_role` plus RLS's
+role-aware policy underneath (ADR 17).
+
+**Never the raw pre-adjustment basis/gain.** `TaxLot.adjusted_basis` and
+`LotConsumption.realized_gain_loss` are S5's own mutate-in-place fields -- by the time a wash sale
+(S5 §5) has been applied, both already carry the adjusted figure, so this module only ever reads
+those two columns and never `original_cost_basis` for anything but the immutable "as originally
+purchased" reference figure (S5 §7 edge case 1's own warning, restated in S8 §3's route table).
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections import defaultdict
+from typing import Any
+
+from flask import Blueprint, jsonify, request
+from flask import session as flask_session
+from flask_login import current_user
+
+from app.core.errors import UnauthenticatedError, ValidationError
+from app.core.money import Money
+from app.core.security import requires_role
+from app.core.uow import SessionRole
+from app.extensions import limiter
+from app.services.lots.uow import LotsUnitOfWork
+from app.views.lots import LotResponse, LotsListResponse
+
+lots_bp = Blueprint("lots", __name__, url_prefix="/api/v1")
+
+_STAFF_ROLES = ("adviser", "admin")
+
+
+def _resolve_customer_id() -> uuid.UUID:
+ """A `customer` session always sees its own data; staff must name whose (FR-31). See
+ `app/controllers/api/valuation.py::_resolve_customer_id`'s own docstring for why this reads
+ `flask.session["_user_id"]` rather than `current_user.id`."""
+ if current_user.role == "customer":
+ raw_user_id = flask_session.get("_user_id")
+ if not raw_user_id:
+ raise UnauthenticatedError("No authenticated session")
+ return uuid.UUID(raw_user_id)
+ raw = request.args.get("customer_id")
+ if not raw:
+ raise ValidationError("customer_id is required for a staff session")
+ try:
+ return uuid.UUID(raw)
+ except ValueError as exc:
+ raise ValidationError("customer_id must be a UUID") from exc
+
+
+def _session_role() -> SessionRole:
+ return SessionRole(current_user.role)
+
+
+def _uow_customer_id(customer_id: uuid.UUID) -> uuid.UUID | None:
+ """`UnitOfWork` requires `customer_id=None` for an adviser/admin session (RLS's role branch,
+ not `customer_id`, is what admits their reads — `app/core/uow.py`)."""
+ return customer_id if _session_role() is SessionRole.CUSTOMER else None
+
+
+@lots_bp.route("/lots", methods=["GET"])
+@limiter.limit("60 per minute")
+@requires_role("customer", *_STAFF_ROLES)
+def list_lots() -> Any:
+ customer_id = _resolve_customer_id()
+ with LotsUnitOfWork(customer_id=_uow_customer_id(customer_id), role=_session_role()) as uow:
+ lots = uow.tax_lots.list_for_customer(customer_id)
+ consumptions_by_lot: dict[uuid.UUID, list[Any]] = defaultdict(list)
+ for consumption in uow.lot_consumptions.list_for_lots([lot.id for lot in lots]):
+ consumptions_by_lot[consumption.tax_lot_id].append(consumption)
+
+ items = []
+ for lot in lots:
+ lot_consumptions = consumptions_by_lot.get(lot.id, [])
+ realized_gain_loss = Money("0.00")
+ for consumption in lot_consumptions:
+ realized_gain_loss += consumption.realized_gain_loss
+ security = uow.securities.get_by_id(lot.security_id)
+
+ items.append(
+ LotResponse(
+ id=str(lot.id),
+ security_id=str(lot.security_id),
+ symbol=security.symbol if security is not None else "",
+ quantity_opened=lot.quantity_opened,
+ quantity_remaining=lot.quantity_remaining,
+ original_cost_basis=lot.original_cost_basis,
+ adjusted_basis=lot.adjusted_basis,
+ realized_gain_loss=realized_gain_loss,
+ is_provisional=any(c.is_provisional for c in lot_consumptions),
+ acquired_at=lot.acquired_at,
+ designation=lot.designation.value,
+ designation_window_closes_at=lot.designation_window_closes_at,
+ )
+ )
+
+ view = LotsListResponse(lots=items)
+ return jsonify(view.model_dump(mode="json")), 200
+
+
+__all__ = ["lots_bp"]
diff --git a/backend/app/controllers/api/statements.py b/backend/app/controllers/api/statements.py
index 483c519..71c44ef 100644
--- a/backend/app/controllers/api/statements.py
+++ b/backend/app/controllers/api/statements.py
@@ -8,11 +8,13 @@
from __future__ import annotations
+import csv
+import io
import uuid
from datetime import date, datetime
from typing import Any
-from flask import Blueprint, jsonify, request
+from flask import Blueprint, Response, jsonify, request
from flask import session as flask_session
from flask_login import current_user
@@ -129,4 +131,90 @@ def get_statement(period_start: str) -> Any:
return jsonify(view.model_dump(mode="json")), 200
+_EXPORT_CSV_HEADER = [
+ "symbol",
+ "quantity_consumed",
+ "original_cost_basis",
+ "adjusted_basis",
+ "realized_gain_loss",
+ "is_provisional",
+]
+
+
+@statements_bp.route("//export", methods=["GET"])
+@limiter.limit("30 per minute")
+@requires_role("customer", *_STAFF_ROLES)
+def export_statement(period_start: str) -> Any:
+ """`GET /api/v1/statements//export` (S8 §5, FR-36) -- a CSV of every lot consumption
+ realized within the as-published period, never the raw pre-wash-sale-adjustment figure (S5's
+ own explicit warning, S8 §3's route table). A period never published returns a clear
+ "not yet published" `404` rather than falling back to a live-derived export (S8 §6 case 1)."""
+ customer_id = _resolve_customer_id()
+ try:
+ parsed_period_start = date.fromisoformat(period_start)
+ except ValueError as exc:
+ raise ValidationError("period must be an ISO date (YYYY-MM-DD)") from exc
+
+ raw_watermark = request.args.get("publish_watermark")
+ watermark_dt: datetime | None = None
+ if raw_watermark:
+ try:
+ watermark_dt = datetime.fromisoformat(raw_watermark)
+ except ValueError as exc:
+ raise ValidationError("publish_watermark must be an ISO 8601 datetime") from exc
+
+ with RestatementUnitOfWork(
+ customer_id=_uow_customer_id(customer_id), role=_session_role()
+ ) as uow:
+ snapshot = (
+ uow.published_snapshots.at_watermark_for_period(
+ customer_id, parsed_period_start, watermark_dt
+ )
+ if watermark_dt is not None
+ else uow.published_snapshots.latest_for_period(customer_id, parsed_period_start)
+ )
+ if snapshot is None:
+ raise NotFoundError(
+ "This period has not been published yet; a tax export always reflects the "
+ "as-published statement, never a live-derived figure",
+ code="statement_not_published",
+ )
+
+ consumptions = uow.lot_consumptions.list_realized_in_period(
+ customer_id, period_start=snapshot.period_start, period_end=snapshot.period_end
+ )
+ lots_by_id = {
+ lot.id: lot
+ for lot in uow.tax_lots.list_by_ids([c.tax_lot_id for c in consumptions])
+ }
+ symbol_by_security_id: dict[uuid.UUID, str] = {}
+
+ buffer = io.StringIO()
+ writer = csv.writer(buffer)
+ writer.writerow(_EXPORT_CSV_HEADER)
+ for consumption in consumptions:
+ lot = lots_by_id[consumption.tax_lot_id]
+ if lot.security_id not in symbol_by_security_id:
+ security = uow.securities.get_by_id(lot.security_id)
+ symbol_by_security_id[lot.security_id] = (
+ security.symbol if security is not None else ""
+ )
+ writer.writerow(
+ [
+ symbol_by_security_id[lot.security_id],
+ str(consumption.quantity_consumed),
+ str(lot.original_cost_basis),
+ str(lot.adjusted_basis),
+ str(consumption.realized_gain_loss),
+ str(consumption.is_provisional).lower(),
+ ]
+ )
+
+ response = Response(buffer.getvalue(), mimetype="text/csv")
+ response.headers["Content-Disposition"] = (
+ f'attachment; filename="statement-{parsed_period_start.isoformat()}.csv"'
+ )
+ return response
+
+
__all__ = ["statements_bp"]
diff --git a/backend/app/integrations/stripe/billing_adapter.py b/backend/app/integrations/stripe/billing_adapter.py
index 562f9bb..525ce7e 100644
--- a/backend/app/integrations/stripe/billing_adapter.py
+++ b/backend/app/integrations/stripe/billing_adapter.py
@@ -49,16 +49,22 @@ def attach_payment_method(
email=customer_email, api_key=self._api_key
).id
- stripe.PaymentMethod.attach(
+ # Stripe's fixed test-mode tokens (`pm_card_visa` etc., docs.stripe.com/testing) are not
+ # themselves attachable ids -- `.attach()` materializes a *new* PaymentMethod object each
+ # call and returns it. The caller-supplied `payment_method_id` is only ever the right id
+ # to reuse for a real `pm_...` id already unique to one card; using it for the follow-up
+ # `Customer.modify()` call instead of the attached object's own `.id` fails Stripe's own
+ # "must be attached to the customer" check whenever a fixed test token was supplied.
+ attached = stripe.PaymentMethod.attach(
payment_method_id, customer=customer_id, api_key=self._api_key
)
stripe.Customer.modify(
customer_id,
- invoice_settings={"default_payment_method": payment_method_id},
+ invoice_settings={"default_payment_method": attached.id},
api_key=self._api_key,
)
return PaymentMethodHandle(
- stripe_customer_id=customer_id, stripe_payment_method_id=payment_method_id
+ stripe_customer_id=customer_id, stripe_payment_method_id=attached.id
)
def charge(
diff --git a/backend/app/jobs/__init__.py b/backend/app/jobs/__init__.py
index 06b4e16..8eb5cad 100644
--- a/backend/app/jobs/__init__.py
+++ b/backend/app/jobs/__init__.py
@@ -14,6 +14,8 @@
from flask import Flask
+ from app.integrations.ports import BrokerPort
+
jobs_cli = AppGroup("jobs")
@@ -147,6 +149,150 @@ def _fees_uow_factory() -> FeesUnitOfWork:
click.echo(outcome.value)
+@jobs_cli.command("outbox-worker")
+def outbox_worker_command() -> None:
+ """The always-on `job_outbox` drain process (S0 §9's "hot-path draining") -- Makefile's
+ `worker` target and the deployed worker Container App both invoke this exact command. Blocks
+ forever: `OutboxWorker.listen_forever` LISTENs for `job_outbox_ready` notifications, with a
+ periodic sweep on each timeout for recovery.
+
+ Routes each of the three outbox task types this codebase produces
+ (`grep -rn "outbox.enqueue(" app/services/`) to its handler:
+
+ - `submit_order_to_broker` (S3 §4) -- calls `BrokerPort.submit_order`, real Alpaca if
+ configured, the fake otherwise (never silently identical to production; logged loudly).
+ - `charge_fee` (S10 §5) -- `FeeChargeOutboxHandler`, the same one `dunning-retry` already uses.
+ - `process_inbound_event` (S0 §6) -- `InboundEventDispatcher`. ALPACA is the one source with
+ no synchronous webhook path (ADR 22: fills arrive over a websocket, not a request/response
+ call) -- registering its handler here is what actually lets a fill reach the ledger; before
+ this command existed, nothing ever called `dispatcher.register` at all, so a filled Alpaca
+ order updated nothing beyond Alpaca's own books. STRIPE/PLAID already apply their effect
+ synchronously inside the webhook controller (`stripe_billing.py`/`stripe_identity.py`/
+ `plaid.py`); their own `process_inbound_event` row is a durability record, not a second
+ required application of the same effect -- registered here as an explicit no-op so the
+ dispatcher's own "a source with no handler is a wiring gap" check does not retry-then-dead-
+ letter every single Stripe/Plaid webhook's outbox row as though it were a real failure.
+ """
+ import os
+ import uuid
+ from datetime import UTC
+ from datetime import datetime as dt
+ from typing import Any
+
+ from app.config import get_settings
+ from app.core.db import DbRole
+ from app.core.logging import get_logger
+ from app.core.uow import SessionRole
+ from app.models.ops import OpsUnitOfWork
+ from app.models.ops.inbound_event import InboundEventSource
+ from app.services.fees.fee_charge_outbox_handler import FeeChargeOutboxHandler
+ from app.services.fees.uow import FeesUnitOfWork
+ from app.services.intake.dispatch import InboundEventDispatcher
+ from app.services.ledger.cash_policy_service import CashPolicyService
+ from app.services.ops.outbox_task_router import OutboxTaskRouter
+ from app.services.orders.approval_hold_service import ApprovalHoldService
+ from app.services.orders.holds_provider import OrderHoldsProvider
+ from app.services.orders.order_service import OrderService
+ from app.services.orders.trade_update_handler import AlpacaTradeUpdateHandler
+ from app.services.orders.uow import OrdersUnitOfWork
+ from app.workers.outbox import OutboxWorker, create_listen_connection
+
+ log = get_logger(__name__)
+ settings = get_settings()
+
+ def _ops_uow_factory() -> OpsUnitOfWork:
+ return OpsUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.WORKER)
+
+ def _orders_uow_factory() -> OrdersUnitOfWork:
+ return OrdersUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.WORKER)
+
+ def _fees_uow_factory() -> FeesUnitOfWork:
+ return FeesUnitOfWork(customer_id=None, role=SessionRole.ADMIN, db_role=DbRole.WORKER)
+
+ dispatcher = InboundEventDispatcher()
+ dispatcher.register(
+ InboundEventSource.ALPACA,
+ AlpacaTradeUpdateHandler(
+ uow_factory=_orders_uow_factory, now=lambda: dt.now(UTC)
+ ).handle,
+ )
+ dispatcher.register(InboundEventSource.STRIPE, lambda payload: None)
+ dispatcher.register(InboundEventSource.PLAID, lambda payload: None)
+
+ if settings.has_alpaca_credentials:
+ from app.integrations.alpaca.broker_adapter import AlpacaBrokerAdapter
+
+ broker_port: BrokerPort = AlpacaBrokerAdapter(
+ api_key_id=settings.alpaca_api_key_id.get_secret_value(), # type: ignore[union-attr]
+ api_secret_key=settings.alpaca_api_secret_key.get_secret_value(), # type: ignore[union-attr]
+ )
+ else:
+ from app.integrations.fake.fake_broker import FakeBrokerAdapter
+
+ log.warning(
+ "outbox_worker_broker_credentials_missing",
+ detail="ALPACA_API_KEY_ID/ALPACA_API_SECRET_KEY unset -- submitting orders against "
+ "FakeBrokerAdapter, not a real (paper) broker",
+ )
+ broker_port = FakeBrokerAdapter()
+
+ fee_handler: FeeChargeOutboxHandler | None = None
+ if settings.stripe_secret_key is not None:
+ from app.integrations.stripe.billing_adapter import StripeBillingAdapter
+
+ fee_handler = FeeChargeOutboxHandler(
+ uow_factory=_fees_uow_factory,
+ payment_port=StripeBillingAdapter(api_key=settings.stripe_secret_key.get_secret_value()),
+ dunning_max_attempts=settings.dunning_max_attempts,
+ )
+ else:
+ log.warning(
+ "outbox_worker_stripe_credentials_missing",
+ detail="STRIPE_SECRET_KEY unset -- charge_fee outbox tasks will fail until it is set",
+ )
+
+ def _submit_order_to_broker(payload: dict[str, Any]) -> None:
+ with _orders_uow_factory() as uow:
+ service = OrderService(
+ uow,
+ hold_service=ApprovalHoldService(uow),
+ cash_policy=CashPolicyService(uow, holds_provider=OrderHoldsProvider(uow)),
+ approval_threshold_usd=settings.order_approval_threshold_usd,
+ )
+ service.submit_to_broker(
+ uuid.UUID(str(payload["order_id"])),
+ symbol=str(payload["symbol"]),
+ broker=broker_port,
+ )
+ uow.commit()
+
+ def _charge_fee(payload: dict[str, Any]) -> None:
+ if fee_handler is None:
+ raise RuntimeError("STRIPE_SECRET_KEY is required to process charge_fee outbox tasks")
+ fee_handler.handle(task="charge_fee", payload=payload)
+
+ def _process_inbound_event(payload: dict[str, Any]) -> None:
+ with _ops_uow_factory() as uow:
+ dispatcher.handle(task="process_inbound_event", payload=payload, uow=uow.inbound_events)
+ uow.commit()
+
+ router = OutboxTaskRouter(
+ routes={
+ "submit_order_to_broker": _submit_order_to_broker,
+ "charge_fee": _charge_fee,
+ "process_inbound_event": _process_inbound_event,
+ }
+ )
+ worker = OutboxWorker(
+ uow_factory=_ops_uow_factory,
+ handler=router,
+ worker_id=f"outbox-worker-{os.getpid()}",
+ max_attempts=settings.outbox_max_attempts,
+ )
+ connection = create_listen_connection(settings.database_url_worker.get_secret_value())
+ worker.listen_forever(connection)
+
+
def register_cli(app: Flask) -> None:
"""Called by the application factory when CLI wiring is enabled."""
app.cli.add_command(jobs_cli)
diff --git a/backend/app/models/chat/chat_message.py b/backend/app/models/chat/chat_message.py
index 6d40295..604f736 100644
--- a/backend/app/models/chat/chat_message.py
+++ b/backend/app/models/chat/chat_message.py
@@ -29,7 +29,7 @@
from enum import StrEnum
from typing import TYPE_CHECKING
-from sqlalchemy import DDL, DateTime, ForeignKey, Text, event, func
+from sqlalchemy import DDL, DateTime, ForeignKey, Index, Text, event, func
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -49,6 +49,10 @@ class ChatMessageRole(StrEnum):
class ChatMessage(Base):
__tablename__ = "chat_message"
+ __table_args__ = (
+ # S12 §3: S11 §6's message history query.
+ Index("ix_chat_message_session_created", "session_id", "created_at"),
+ )
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(
diff --git a/backend/app/models/chat/curated_views.py b/backend/app/models/chat/curated_views.py
index be51fb5..0d0033f 100644
--- a/backend/app/models/chat/curated_views.py
+++ b/backend/app/models/chat/curated_views.py
@@ -58,7 +58,7 @@
"OR {column} = NULLIF(current_setting('app.customer_id', true), '')::uuid"
)
-CREATE_CURATED_VIEWS_SQL = """
+_CURATED_VIEWS_SQL_TEMPLATE = """
CREATE OR REPLACE VIEW v_customer_balance AS
SELECT
a.customer_id,
@@ -177,7 +177,11 @@
published_at
FROM published_snapshot
WHERE ({tenant_ps});
-""".format(
+"""
+
+# Static owner-authored DDL: `.format()` substitutes only the fixed column literals below, never
+# request/user input -- see this module's docstring for the full tenant-predicate rationale.
+CREATE_CURATED_VIEWS_SQL = _CURATED_VIEWS_SQL_TEMPLATE.format( # nosec B608
tenant_a=_TENANT_PREDICATE.format(column="a.customer_id"),
tenant_tl=_TENANT_PREDICATE.format(column="tl.customer_id"),
tenant_p=_TENANT_PREDICATE.format(column="p.customer_id"),
diff --git a/backend/app/models/identity/repository.py b/backend/app/models/identity/repository.py
index a7cc5d1..05fcc8d 100644
--- a/backend/app/models/identity/repository.py
+++ b/backend/app/models/identity/repository.py
@@ -2,6 +2,8 @@
from typing import TYPE_CHECKING, Protocol
+from sqlalchemy import select, tuple_
+
from app.core.repository import BaseRepository
from app.models.identity.customer import Customer
from app.models.identity.staff import Staff
@@ -19,6 +21,9 @@ class CustomerRepository(Protocol):
def get_by_email(self, email: str) -> Customer | None: ...
def get_by_id(self, customer_id: uuid.UUID) -> Customer | None: ...
def add(self, customer: Customer) -> None: ...
+ def search(
+ self, query: str, *, limit: int, after: tuple[str, uuid.UUID] | None = None
+ ) -> list[Customer]: ...
class StaffRepository(Protocol):
@@ -55,6 +60,24 @@ def get_by_email(self, email: str) -> Customer | None:
def get_by_id(self, customer_id: uuid.UUID) -> Customer | None:
return self.session.query(Customer).filter_by(id=customer_id).first()
+ def search(
+ self, query: str, *, limit: int, after: tuple[str, uuid.UUID] | None = None
+ ) -> list[Customer]:
+ """Case-insensitive email substring match (S8 §4 row 1) -- `Customer` carries no name
+ field yet (`app/models/identity/customer.py`'s own schema), so email is the only match
+ target. Keyset-paginated on `(email, id)`, ordered the same way, so a caller resuming
+ from `after` sees a stable, gap-free continuation regardless of concurrent inserts.
+ Fetches `limit + 1` rows so `app/core/pagination.py`'s `paginate()` can derive `has_more`
+ for free."""
+ statement = select(Customer).where(Customer.email.ilike(f"%{query}%"))
+ if after is not None:
+ after_email, after_id = after
+ statement = statement.where(
+ tuple_(Customer.email, Customer.id) > (after_email, after_id)
+ )
+ statement = statement.order_by(Customer.email.asc(), Customer.id.asc()).limit(limit + 1)
+ return list(self.session.execute(statement).scalars().all())
+
class SqlStaffRepository(BaseRepository[Staff]):
def __init__(self, uow: UnitOfWork) -> None:
diff --git a/backend/app/models/ledger/account.py b/backend/app/models/ledger/account.py
index 2ce8ac3..8650738 100644
--- a/backend/app/models/ledger/account.py
+++ b/backend/app/models/ledger/account.py
@@ -13,7 +13,7 @@
from enum import StrEnum
from typing import TYPE_CHECKING
-from sqlalchemy import DDL, CheckConstraint, String, event
+from sqlalchemy import DDL, CheckConstraint, Index, String, event
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -86,6 +86,8 @@ class Account(Base):
name="role_dimension",
),
CheckConstraint("currency = 'USD'", name="currency_usd"),
+ # S12 §3: every cash-policy/balance query filters on (customer_id, role).
+ Index("ix_account_customer_role", "customer_id", "role"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
diff --git a/backend/app/models/ledger/journal_entry.py b/backend/app/models/ledger/journal_entry.py
index cadc4a9..0b5db34 100644
--- a/backend/app/models/ledger/journal_entry.py
+++ b/backend/app/models/ledger/journal_entry.py
@@ -21,7 +21,7 @@
from enum import StrEnum
from typing import TYPE_CHECKING
-from sqlalchemy import DDL, Date, DateTime, ForeignKey, String, event, exists, func, select
+from sqlalchemy import DDL, Date, DateTime, ForeignKey, Index, String, event, exists, func, select
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -48,6 +48,11 @@ class JournalEntryType(StrEnum):
class JournalEntry(Base):
__tablename__ = "journal_entry"
+ __table_args__ = (
+ # S12 §3: S1/ADR 1's bitemporal range queries filter/order by each independently.
+ Index("ix_journal_entry_effective_date", "effective_date"),
+ Index("ix_journal_entry_recorded_at", "recorded_at"),
+ )
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
entry_type: Mapped[JournalEntryType] = mapped_column(
diff --git a/backend/app/models/ledger/lot_consumption.py b/backend/app/models/ledger/lot_consumption.py
index 8385463..1939ff3 100644
--- a/backend/app/models/ledger/lot_consumption.py
+++ b/backend/app/models/ledger/lot_consumption.py
@@ -30,6 +30,8 @@
from app.models.ledger.wash_sale_adjustment import WashSaleAdjustment
if TYPE_CHECKING:
+ from collections.abc import Sequence
+
from app.core.uow import UnitOfWork
@@ -81,6 +83,34 @@ def list_for_closing_fill(self, execution_id: str) -> list[LotConsumption]:
.all()
)
+ def list_realized_in_period(
+ self, customer_id: uuid.UUID, *, period_start: date, period_end: date
+ ) -> list[LotConsumption]:
+ """Every consumption whose `sale_date` falls inside `[period_start, period_end]` -- the
+ tax-export's own realized-activity slice (S8 §5), joined through `tax_lot` the same way
+ `find_unadjusted_losses_in_window` does since this table carries no `customer_id` of its
+ own (module docstring)."""
+ statement = (
+ select(LotConsumption)
+ .join(TaxLot, TaxLot.id == LotConsumption.tax_lot_id)
+ .where(
+ TaxLot.customer_id == customer_id,
+ LotConsumption.sale_date >= period_start,
+ LotConsumption.sale_date <= period_end,
+ )
+ .order_by(LotConsumption.sale_date.asc())
+ )
+ return list(self.session.execute(statement).scalars().all())
+
+ def list_for_lots(self, tax_lot_ids: Sequence[uuid.UUID]) -> list[LotConsumption]:
+ """Every consumption row against any of the given lots -- `GET /api/v1/lots` (S8 §3)
+ aggregates a lot's realized gain and provisional status from its own consumptions
+ (S5 §7 edge case 1), fetched here in one query rather than N+1 per lot."""
+ if not tax_lot_ids:
+ return []
+ statement = select(LotConsumption).where(LotConsumption.tax_lot_id.in_(tax_lot_ids))
+ return list(self.session.execute(statement).scalars().all())
+
def find_unadjusted_losses_in_window(
self,
customer_id: uuid.UUID,
diff --git a/backend/app/models/ledger/posting.py b/backend/app/models/ledger/posting.py
index 4a71830..50e2f86 100644
--- a/backend/app/models/ledger/posting.py
+++ b/backend/app/models/ledger/posting.py
@@ -21,7 +21,7 @@
import uuid
from typing import TYPE_CHECKING
-from sqlalchemy import DDL, CheckConstraint, ForeignKey, event
+from sqlalchemy import DDL, CheckConstraint, ForeignKey, Index, event
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -41,6 +41,9 @@ class Posting(Base):
"(amount_money IS NULL AND quantity_units IS NOT NULL)",
name="exactly_one_dimension",
),
+ # S12 §3: S1's balance-summing queries filter on account_id (the FK Postgres does not
+ # index automatically).
+ Index("ix_posting_account_id", "account_id"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
diff --git a/backend/app/models/ledger/tax_lot.py b/backend/app/models/ledger/tax_lot.py
index 220ffc4..c891ff8 100644
--- a/backend/app/models/ledger/tax_lot.py
+++ b/backend/app/models/ledger/tax_lot.py
@@ -18,7 +18,17 @@
from enum import StrEnum
from typing import TYPE_CHECKING
-from sqlalchemy import DDL, CheckConstraint, Date, DateTime, ForeignKey, String, event, select
+from sqlalchemy import (
+ DDL,
+ CheckConstraint,
+ Date,
+ DateTime,
+ ForeignKey,
+ Index,
+ String,
+ event,
+ select,
+)
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -46,6 +56,8 @@ class TaxLot(Base):
__tablename__ = "tax_lot"
__table_args__ = (
CheckConstraint("quantity_remaining >= 0", name="quantity_remaining_non_negative"),
+ # S12 §3: S5 §4's FIFO ordering selects lots by this exact filter+order.
+ Index("ix_tax_lot_customer_security_acquired", "customer_id", "security_id", "acquired_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -174,6 +186,25 @@ def list_customers_holding(self, security_id: uuid.UUID) -> list[uuid.UUID]:
)
return list(self.session.execute(statement).scalars().all())
+ def list_for_customer(self, customer_id: uuid.UUID) -> list[TaxLot]:
+ """Every lot ever opened for this customer, oldest-acquired first -- `GET /api/v1/lots`
+ (S8 §3) is a tax-detail screen, so a fully-consumed lot (`quantity_remaining == 0`) still
+ belongs in it, unlike `lock_open_fifo`'s trading-time filter."""
+ statement = (
+ select(TaxLot)
+ .where(TaxLot.customer_id == customer_id)
+ .order_by(TaxLot.acquired_at.asc(), TaxLot.id.asc())
+ )
+ return list(self.session.execute(statement).scalars().all())
+
+ def list_by_ids(self, lot_ids: Sequence[uuid.UUID]) -> list[TaxLot]:
+ """Plain, non-locking bulk fetch -- unlike `lock_by_ids`, for a read-only consumer (the
+ statement export, S8 §5) that has no consuming transaction to hold a row lock for."""
+ if not lot_ids:
+ return []
+ statement = select(TaxLot).where(TaxLot.id.in_(lot_ids))
+ return list(self.session.execute(statement).scalars().all())
+
def total_remaining(self, customer_id: uuid.UUID, security_id: uuid.UUID) -> Units:
lots = self.lock_open_for_security(customer_id, security_id)
total = Units("0")
diff --git a/backend/app/models/orders/order.py b/backend/app/models/orders/order.py
index 7c337c5..8b82211 100644
--- a/backend/app/models/orders/order.py
+++ b/backend/app/models/orders/order.py
@@ -18,7 +18,7 @@
from enum import StrEnum
from typing import TYPE_CHECKING
-from sqlalchemy import DDL, DateTime, ForeignKey, String, event, func, select
+from sqlalchemy import DDL, DateTime, ForeignKey, Index, String, event, func, select
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -71,6 +71,10 @@ def derive_client_order_id(order_id: uuid.UUID) -> str:
class Order(Base):
__tablename__ = "order"
+ __table_args__ = (
+ # S12 §3: S3 §6's order list/status queries filter on both together.
+ Index("ix_order_customer_status", "customer_id", "status"),
+ )
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
customer_id: Mapped[uuid.UUID] = mapped_column(
diff --git a/backend/app/models/reconciliation/reconciliation_break.py b/backend/app/models/reconciliation/reconciliation_break.py
index e719155..734288f 100644
--- a/backend/app/models/reconciliation/reconciliation_break.py
+++ b/backend/app/models/reconciliation/reconciliation_break.py
@@ -23,7 +23,7 @@
from enum import StrEnum
from typing import TYPE_CHECKING, Any
-from sqlalchemy import DDL, CheckConstraint, DateTime, String, event
+from sqlalchemy import DDL, CheckConstraint, DateTime, Index, String, event
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -58,6 +58,8 @@ class ReconciliationBreak(Base):
"(status <> 'resolved') OR (resolved_by IS NOT NULL)",
name="resolved_break_requires_resolver",
),
+ # S12 §3: S7 §7's aged-break-list query filters on status, ordered by opened_at.
+ Index("ix_reconciliation_break_status_opened", "status", "opened_at"),
)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@@ -203,6 +205,16 @@ def list_open(self) -> list[ReconciliationBreak]:
.all()
)
+ def list_open_for_customer(self, customer_id: uuid.UUID) -> list[ReconciliationBreak]:
+ """S8 §6 edge case 2: `GET /admin/customers/` must surface a customer's own open
+ break prominently, oldest first (same ordering as `list_open`)."""
+ return list(
+ self.session.query(ReconciliationBreak)
+ .filter_by(status=ReconciliationBreakStatus.OPEN, customer_id=customer_id)
+ .order_by(ReconciliationBreak.opened_at.asc())
+ .all()
+ )
+
def resolve(
self,
break_row: ReconciliationBreak,
diff --git a/backend/app/services/admin/__init__.py b/backend/app/services/admin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/app/services/admin/uow.py b/backend/app/services/admin/uow.py
new file mode 100644
index 0000000..a35faad
--- /dev/null
+++ b/backend/app/services/admin/uow.py
@@ -0,0 +1,26 @@
+"""`AdminUnitOfWork` — S8's admin surface (`/admin/customers`, `/admin/customers/`,
+`/admin/customers//fees`) reads across S1 (ledger/balance), S2 (customer identity), S4 (market
+data, for `ValuationService`), S6 (published snapshots), S7 (reconciliation breaks), and S10
+(fees) — so this composes `FeesUnitOfWork` (which already carries `IdentityUnitOfWork` and
+`RestatementUnitOfWork`, itself `ValuationUnitOfWork` + `RestatementModelsUnitOfWork`) with S7's
+own model-only mixin, the identical multiple-inheritance shape every other cross-aggregate
+consumer in this codebase already uses (S0 §5's documented extension mechanism).
+
+Composing `FeesUnitOfWork` rather than duplicating its accessors is what lets
+`GET /admin/customers/` call the *same* `ValuationService.value_book` code path
+`GET /valuation/balance` does (S8 §6 edge case 4's no-drift requirement) and
+`GET /admin/customers//fees` call the *same* fee-summary assembly `GET /fees` does, on one
+`UnitOfWork` rather than two independently-implemented aggregations.
+"""
+
+from __future__ import annotations
+
+from app.models.reconciliation import ReconciliationUnitOfWork as ReconciliationModelsUnitOfWork
+from app.services.fees.uow import FeesUnitOfWork
+
+
+class AdminUnitOfWork(FeesUnitOfWork, ReconciliationModelsUnitOfWork):
+ pass
+
+
+__all__ = ["AdminUnitOfWork"]
diff --git a/backend/app/services/chat/read_only_sql_executor.py b/backend/app/services/chat/read_only_sql_executor.py
index 13ff637..e6f9f56 100644
--- a/backend/app/services/chat/read_only_sql_executor.py
+++ b/backend/app/services/chat/read_only_sql_executor.py
@@ -84,7 +84,7 @@ def execute(self, sql: str, *, customer_id: uuid.UUID) -> SqlToolOutcome:
# text (ADR 19 §4.3), not a value a placeholder could carry -- the security control here
# is the upstream validator + `chat_readonly`'s own grants, not query parameterization.
wrapped_sql = (
- f"SELECT * FROM ({validation.normalized_sql}) AS sub LIMIT {self._row_cap}" # noqa: S608
+ f"SELECT * FROM ({validation.normalized_sql}) AS sub LIMIT {self._row_cap}" # noqa: S608 # nosec B608
)
try:
with UnitOfWork(
diff --git a/backend/app/services/fees/fee_summary_service.py b/backend/app/services/fees/fee_summary_service.py
new file mode 100644
index 0000000..74c6bdc
--- /dev/null
+++ b/backend/app/services/fees/fee_summary_service.py
@@ -0,0 +1,58 @@
+"""`FeeSummaryService` (S10 §7) — the single place that assembles a customer's fee/dunning read
+model, so `GET /api/v1/fees` and `GET /api/v1/admin/customers//fees` (S8 §4 row 6) call the
+same code path rather than two independently-implemented aggregations that could drift apart (the
+same DRY/no-drift discipline S8 §6 edge case 4 states for balance, applied here to fees).
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import UTC, datetime
+from typing import TYPE_CHECKING
+
+from app.core.money import Money
+
+if TYPE_CHECKING:
+ import uuid
+
+ from app.models.fees.dunning_state import DunningState
+ from app.models.fees.fee_charge import FeeCharge
+ from app.models.fees.high_water_mark import HighWaterMark
+ from app.services.fees.uow import FeesUnitOfWork
+
+
+@dataclass(frozen=True, slots=True)
+class FeeSummary:
+ accrual_to_date: Money
+ high_water_mark: HighWaterMark | None
+ charges: list[FeeCharge]
+ dunning: DunningState | None
+
+
+class FeeSummaryService:
+ def __init__(self, uow: FeesUnitOfWork) -> None:
+ self._uow = uow
+
+ def summarize(self, customer_id: uuid.UUID) -> FeeSummary:
+ today = datetime.now(UTC).date()
+ period_start = today.replace(day=1)
+ open_period_accruals = self._uow.fee_accruals.list_for_period(
+ customer_id, period_start=period_start, period_end=today
+ )
+ accrual_to_date = Money("0.00")
+ for accrual in open_period_accruals:
+ accrual_to_date += accrual.fee_amount
+
+ hwm = self._uow.high_water_marks.get_by_customer(customer_id)
+ charges = self._uow.fee_charges.list_for_customer(customer_id)
+ dunning_states = self._uow.dunning_states.get_by_customer(customer_id)
+
+ return FeeSummary(
+ accrual_to_date=accrual_to_date,
+ high_water_mark=hwm,
+ charges=charges,
+ dunning=dunning_states[0] if dunning_states else None,
+ )
+
+
+__all__ = ["FeeSummary", "FeeSummaryService"]
diff --git a/backend/app/services/identity/kyc_service.py b/backend/app/services/identity/kyc_service.py
index 30b91eb..0c6cc0f 100644
--- a/backend/app/services/identity/kyc_service.py
+++ b/backend/app/services/identity/kyc_service.py
@@ -56,6 +56,15 @@ class KycPortNotConfiguredError(RuntimeError):
arrives through this port), so callers on that path may omit it entirely."""
+class KycLockedError(RuntimeError):
+ """`start_verification` is blocked: the customer's `kyc_status` locked to `rejected` after
+ exhausting `KYC_MAX_ATTEMPTS` (S2 §3.2/§9), and only an adviser's `POST
+ /admin/kyc-overrides/` (S8 §4 row 5) can reopen it. Deliberately narrower than
+ "kyc_status is rejected" alone -- a single canceled attempt (well below the cap) also sets
+ `kyc_status = rejected`, but S8 §6 edge case 3 only locks out resubmission once attempts are
+ actually exhausted, not on the customer's first cancellation."""
+
+
class KycService:
def __init__(
self,
@@ -71,16 +80,31 @@ def __init__(
self._now = now
def start_verification(self, customer_id: uuid.UUID) -> KycSessionHandle:
- """S2 §5.1/§6: `POST /identity/kyc-sessions`. Always opens a fresh attempt -- S2 §9 defers
- the locked-`rejected` reopening gate to an out-of-scope adviser action; this method only
- guarantees `attempt_number` keeps advancing so that action has something to reopen."""
+ """S2 §5.1/§6: `POST /identity/kyc-sessions`. Locked-`rejected` (S2 §9's own reopening
+ gate, now implemented by S8 §4 row 5's admin override) blocks a fresh attempt outright,
+ before the provider is ever called -- otherwise always opens a fresh attempt, advancing
+ `attempt_number` so the override action has something to reopen."""
+ customer = self._uow.customers.get_by_id(customer_id)
+ if customer is None:
+ raise CustomerNotFoundError(f"no customer found for id={customer_id!r}")
+
+ latest = self._uow.kyc_sessions.latest_for_customer(customer_id)
+ if (
+ customer.kyc_status is KycStatus.rejected
+ and latest is not None
+ and latest.attempt_number >= self._max_attempts
+ ):
+ raise KycLockedError(
+ f"customer {customer_id} is locked after {latest.attempt_number} KYC attempts; "
+ "contact support"
+ )
+
if self._kyc_port is None:
raise KycPortNotConfiguredError(
"start_verification requires a KycPort; this KycService was built without one"
)
handle = self._kyc_port.create_verification_session(customer_id=str(customer_id))
- latest = self._uow.kyc_sessions.latest_for_customer(customer_id)
attempt_number = 1 if latest is None else latest.attempt_number + 1
session_row = KycSession(
diff --git a/backend/app/services/ops/outbox_task_router.py b/backend/app/services/ops/outbox_task_router.py
new file mode 100644
index 0000000..9a2ffff
--- /dev/null
+++ b/backend/app/services/ops/outbox_task_router.py
@@ -0,0 +1,40 @@
+"""`OutboxTaskRouter` -- the composition point that fans a claimed `job_outbox` row out to the
+handler for its `task`, matching `OutboxTaskHandler`'s shape (`app/workers/outbox.py`) so an
+`OutboxWorker` can be constructed with exactly one handler regardless of how many task types this
+codebase's services actually enqueue (`grep -rn "outbox.enqueue(" app/services/` currently finds
+three: `submit_order_to_broker`, `charge_fee`, `process_inbound_event`).
+
+Deliberately separate from the CLI wiring that constructs its routes (`app/jobs/__init__.py`'s
+`outbox-worker` command): that wiring needs real database/broker/payment credentials and cannot be
+unit tested without them, the same way `daily_valuation_command`'s own credential gating is
+untested inline CLI logic elsewhere in this file -- but *which task routes to which callable* is
+ordinary, DB-free logic with no reason to share that untestability, so it lives here instead.
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from collections.abc import Callable, Mapping
+
+
+class UnregisteredOutboxTaskError(RuntimeError):
+ """Raised rather than silently dropping a claimed row whose task has no route -- an outbox
+ row with no handler is a wiring gap, not a no-op (the same reasoning
+ `InboundEventDispatcher.UnregisteredSourceError` already states for its own, narrower case)."""
+
+
+class OutboxTaskRouter:
+ def __init__(self, *, routes: Mapping[str, Callable[[dict[str, Any]], None]]) -> None:
+ self._routes = dict(routes)
+
+ def handle(self, *, task: str, payload: dict[str, Any]) -> None:
+ """Matches `OutboxTaskHandler`'s shape (`app/workers/outbox.py`)."""
+ route = self._routes.get(task)
+ if route is None:
+ raise UnregisteredOutboxTaskError(f"no outbox route for task {task!r}")
+ route(payload)
+
+
+__all__ = ["OutboxTaskRouter", "UnregisteredOutboxTaskError"]
diff --git a/backend/app/views/admin_customers.py b/backend/app/views/admin_customers.py
new file mode 100644
index 0000000..bc946e6
--- /dev/null
+++ b/backend/app/views/admin_customers.py
@@ -0,0 +1,50 @@
+"""Response schemas for `app/controllers/admin/customers.py` (S8 §4 rows 1/2/6).
+
+Explicit allow-lists only, never a raw entity (`app/views/` may not import `app/models/`,
+`lint-imports`'s `views-are-not-entities` contract). `AdminCustomerDetailResponse.balance` reuses
+`BalanceResponse` verbatim (never a second, independently-shaped balance field) so the JSON this
+route emits is structurally identical to `GET /valuation/balance`'s -- the same "one code path,
+one figure" guarantee S8 §6 edge case 4 requires, made a type-level fact rather than a convention
+two authors could let drift.
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+from app.views.reconciliation import BreakResponse # noqa: TC001
+from app.views.valuation import BalanceResponse # noqa: TC001
+
+
+class AdminCustomerSummaryResponse(BaseModel):
+ """One row of `GET /api/v1/admin/customers` (S8 §4 row 1)."""
+
+ id: str
+ email: str
+ kyc_status: str
+ account_approval_status: str
+
+
+class AdminCustomersListResponse(BaseModel):
+ customers: list[AdminCustomerSummaryResponse]
+ next_cursor: str | None
+
+
+class AdminCustomerDetailResponse(BaseModel):
+ """`GET /api/v1/admin/customers/` (S8 §4 row 2) -- `open_reconciliation_breaks` is
+ non-empty exactly when S8 §6 edge case 2 applies, surfacing it directly on the aggregated
+ view rather than only on the dedicated breaks screen."""
+
+ id: str
+ email: str
+ kyc_status: str
+ account_approval_status: str
+ balance: BalanceResponse
+ open_reconciliation_breaks: list[BreakResponse]
+
+
+__all__ = [
+ "AdminCustomerDetailResponse",
+ "AdminCustomerSummaryResponse",
+ "AdminCustomersListResponse",
+]
diff --git a/backend/app/views/lots.py b/backend/app/views/lots.py
new file mode 100644
index 0000000..13e7c17
--- /dev/null
+++ b/backend/app/views/lots.py
@@ -0,0 +1,43 @@
+"""Response schemas for `app/controllers/api/lots.py` (S8 §3, data owned by S5).
+
+Explicit allow-lists only, never a raw entity (`app/views/` may not import `app/models/`,
+`lint-imports`'s `views-are-not-entities` contract). `adjusted_basis`/`realized_gain_loss` are
+always the wash-sale-**adjusted** figures (S5 §5's mutate-in-place design) -- this module has no
+field for the pre-adjustment amount at all, so there is no raw figure a caller could reach for by
+mistake (S5's own explicit warning against ever surfacing it).
+"""
+
+from __future__ import annotations
+
+from datetime import date, datetime # noqa: TC003 -- Pydantic resolves field annotations eagerly.
+
+from pydantic import BaseModel
+
+from app.core.money import Money, Units # noqa: TC001
+
+
+class LotResponse(BaseModel):
+ """One `tax_lot` row (S5 §3.1), enriched with its own `lot_consumption` rows' aggregated
+ realized gain and provisional status (S5 §7 edge case 1: a lot's consumptions are summed,
+ while each one's provisional flag is preserved for display via `is_provisional` here being
+ true if *any* of them still is)."""
+
+ id: str
+ security_id: str
+ symbol: str
+ quantity_opened: Units
+ quantity_remaining: Units
+ original_cost_basis: Money
+ adjusted_basis: Money
+ realized_gain_loss: Money
+ is_provisional: bool
+ acquired_at: date
+ designation: str
+ designation_window_closes_at: datetime
+
+
+class LotsListResponse(BaseModel):
+ lots: list[LotResponse]
+
+
+__all__ = ["LotResponse", "LotsListResponse"]
diff --git a/backend/app/workers/outbox.py b/backend/app/workers/outbox.py
index 8c7f444..dfea592 100644
--- a/backend/app/workers/outbox.py
+++ b/backend/app/workers/outbox.py
@@ -8,13 +8,15 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
-from typing import TYPE_CHECKING, Any, Protocol
+from typing import TYPE_CHECKING, Any, Protocol, Self
import psycopg
from sqlalchemy.engine import make_url
if TYPE_CHECKING:
+ import uuid
from collections.abc import Callable, Iterator
+ from types import TracebackType
from app.models.ops.job_outbox import JobOutbox, JobOutboxStatus
@@ -22,12 +24,12 @@
class OutboxRepository(Protocol):
def claim_next(self, *, worker_id: str, now: datetime) -> JobOutbox | None: ...
- def complete(self, *, outbox_id: object, worker_id: str) -> None: ...
+ def complete(self, *, outbox_id: uuid.UUID, worker_id: str) -> None: ...
def retry_or_dead_letter(
self,
*,
- outbox_id: object,
+ outbox_id: uuid.UUID,
worker_id: str,
next_attempt_at: datetime,
max_attempts: int,
@@ -35,11 +37,26 @@ def retry_or_dead_letter(
class OutboxUnitOfWork(Protocol):
- outbox: OutboxRepository
-
- def __enter__(self) -> OutboxUnitOfWork: ...
-
- def __exit__(self, *args: object) -> None: ...
+ # A `@property` here, not a bare attribute annotation: every real UnitOfWork exposes its
+ # repositories as `@cached_property`, and a Protocol's bare-attribute form expects a settable
+ # instance attribute, which a read-only property/cached_property does not structurally satisfy
+ # under strict mypy (the same fix `event_intake.py`'s equivalent Protocol already applies).
+ @property
+ def outbox(self) -> OutboxRepository: ...
+
+ def __enter__(self) -> Self: ...
+
+ # The real 3-argument context-manager `__exit__` (matching `app.core.uow.UnitOfWork`'s own
+ # signature exactly, the same proven pattern `event_intake.py`'s equivalent Protocol uses) --
+ # a looser `*args: object` here structurally rejects any real `UnitOfWork` subclass under
+ # strict Protocol matching, since a subclass's narrower, concretely-typed `__exit__` cannot
+ # satisfy a Protocol that promises callers may pass arbitrary positional `object`s.
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ tb: TracebackType | None,
+ ) -> None: ...
def commit(self) -> None: ...
diff --git a/backend/tests/api/test_admin_customers.py b/backend/tests/api/test_admin_customers.py
new file mode 100644
index 0000000..25c99ca
--- /dev/null
+++ b/backend/tests/api/test_admin_customers.py
@@ -0,0 +1,358 @@
+"""`GET /api/v1/admin/customers`, `GET /api/v1/admin/customers/`,
+`GET /api/v1/admin/customers//fees` (S8 §4 rows 1/2/6) via the Flask test client: search,
+the aggregated detail view (balance agreeing with `/valuation/balance`, an open break surfaced
+prominently per S8 §6 case 2), the fees re-export, authn/authz, and throttling.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Iterator
+from datetime import UTC, date, datetime
+
+import pyotp
+import pytest
+from flask.testing import FlaskClient
+from sqlalchemy import Engine, text
+from sqlalchemy.orm import Session
+
+from app.core.money import Money
+from app.models.fees.dunning_state import DunningState
+from app.models.fees.fee_accrual import FeeAccrual
+from app.models.fees.fee_charge import FeeCharge
+from app.models.fees.high_water_mark import HighWaterMark
+from app.models.identity.staff import Staff, StaffRole
+from app.models.ledger.account import Account, AccountRole
+from app.models.ledger.customer_cash_lock import CustomerCashLock
+from app.models.ledger.journal_entry import JournalEntry, JournalEntryType
+from app.models.ledger.posting import Posting
+from app.models.ledger.settlement_obligation import SettlementObligation
+from app.models.marketdata.daily_close import DailyClose
+from app.models.marketdata.market_calendar_cache import MarketCalendarCache
+from app.models.marketdata.security import Security
+from app.models.marketdata.sub_period_return import SubPeriodReturn
+from app.models.marketdata.valuation_run import ValuationRun
+from app.models.ops.inbound_event import InboundEvent, InboundEventSource
+from app.models.reconciliation.reconciliation_break import (
+ ReconciliationBreak,
+ ReconciliationBreakType,
+)
+from app.services.identity.auth import hash_password
+from app.services.ledger.posting_service import PostingLeg, PostingService
+
+STAFF_EMAIL = "admin-customers-adviser@trueup.example"
+STAFF_PASSWORD = "another-strong-password"
+CUSTOMER_PASSWORD = "correct-horse-battery"
+
+ADMIN_CUSTOMERS_TABLES = [
+ InboundEvent.__table__,
+ Account.__table__,
+ JournalEntry.__table__,
+ Posting.__table__,
+ SettlementObligation.__table__,
+ CustomerCashLock.__table__,
+ Security.__table__,
+ MarketCalendarCache.__table__,
+ DailyClose.__table__,
+ ValuationRun.__table__,
+ SubPeriodReturn.__table__,
+ ReconciliationBreak.__table__,
+ HighWaterMark.__table__,
+ FeeAccrual.__table__,
+ FeeCharge.__table__,
+ DunningState.__table__,
+]
+
+
+@pytest.fixture(autouse=True)
+def _admin_customers_tables(owner_engine: Engine) -> Iterator[None]:
+ for table in ADMIN_CUSTOMERS_TABLES:
+ table.create(bind=owner_engine, checkfirst=True)
+ yield None
+ with owner_engine.begin() as connection:
+ for table in reversed(ADMIN_CUSTOMERS_TABLES):
+ connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE'))
+
+
+class _LedgerLikeUow:
+ def __init__(self, session: Session) -> None:
+ self.session = session
+
+ class _Repo:
+ def __init__(self, session: Session) -> None:
+ self._session = session
+
+ def add(self, obj: object) -> None:
+ self._session.add(obj)
+
+ self.journal_entries = _Repo(session)
+ self.postings = _Repo(session)
+
+
+def _register(client: FlaskClient, *, email: str, password: str = CUSTOMER_PASSWORD) -> uuid.UUID:
+ response = client.post("/api/v1/auth/register", json={"email": email, "password": password})
+ assert response.status_code == 201
+ return uuid.UUID(response.get_json()["id"])
+
+
+@pytest.fixture
+def staff_member(owner_engine: Engine) -> Iterator[Staff]:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ staff = Staff(
+ email=STAFF_EMAIL, password_hash=hash_password(STAFF_PASSWORD), role=StaffRole.adviser
+ )
+ session.add(staff)
+ session.commit()
+ yield staff
+ session.close()
+
+
+def _staff_login_with_mfa(client: FlaskClient) -> None:
+ login_response = client.post(
+ "/api/v1/auth/login", json={"email": STAFF_EMAIL, "password": STAFF_PASSWORD}
+ )
+ assert login_response.status_code == 200
+ pending_csrf = login_response.get_json()["csrf_token"]
+ secret = client.post(
+ "/api/v1/auth/mfa/enroll", headers={"X-CSRFToken": pending_csrf}
+ ).get_json()["secret"]
+ code = pyotp.TOTP(secret).now()
+ verify = client.post(
+ "/api/v1/auth/mfa/verify", json={"code": code}, headers={"X-CSRFToken": pending_csrf}
+ )
+ assert verify.status_code == 200
+
+
+def _deposit(owner_engine: Engine, customer_id: uuid.UUID, *, amount: Money, on: date) -> None:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ session.add(CustomerCashLock(customer_id=customer_id))
+ cash = Account.create(AccountRole.CASH, customer_id=customer_id)
+ equity = Account.create(AccountRole.CUSTOMER_EQUITY, customer_id=customer_id)
+ session.add_all([cash, equity])
+ session.flush()
+
+ event = InboundEvent(
+ source=InboundEventSource.CUSTODIAN_FILE,
+ source_event_id=str(uuid.uuid4()),
+ payload={},
+ signature_verified=True,
+ )
+ session.add(event)
+ session.flush()
+
+ PostingService(_LedgerLikeUow(session)).post(
+ entry_type=JournalEntryType.DEPOSIT,
+ effective_date=on,
+ source_event_id=event.id,
+ legs=[
+ PostingLeg(account_id=cash.id, amount_money=amount),
+ PostingLeg(account_id=equity.id, amount_money=-amount),
+ ],
+ )
+ session.commit()
+ session.close()
+
+
+def _open_break(owner_engine: Engine, customer_id: uuid.UUID) -> None:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ session.add(
+ ReconciliationBreak(
+ break_type=ReconciliationBreakType.CASH_MISMATCH,
+ customer_id=customer_id,
+ expected={"cash": "100.00"},
+ actual={"cash": "90.00"},
+ opened_at=datetime(2026, 1, 1, tzinfo=UTC),
+ import_batch_id=uuid.uuid4(),
+ )
+ )
+ session.commit()
+ session.close()
+
+
+# --- GET /api/v1/admin/customers ------------------------------------------------------------
+
+
+def test_list_customers_requires_authentication(api_client: FlaskClient) -> None:
+ response = api_client.get("/api/v1/admin/customers?query=a")
+
+ assert response.status_code == 401
+
+
+def test_list_customers_a_customer_session_gets_403_not_404(api_client: FlaskClient) -> None:
+ _register(api_client, email="plain-customer@trueup.example")
+ api_client.post(
+ "/api/v1/auth/login",
+ json={"email": "plain-customer@trueup.example", "password": CUSTOMER_PASSWORD},
+ )
+
+ response = api_client.get("/api/v1/admin/customers?query=a")
+
+ assert response.status_code == 403
+
+
+def test_list_customers_requires_a_query(
+ api_client: FlaskClient, staff_member: Staff
+) -> None:
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get("/api/v1/admin/customers")
+
+ assert response.status_code == 422
+
+
+def test_list_customers_matches_email_case_insensitively(
+ api_client: FlaskClient, staff_member: Staff
+) -> None:
+ _register(api_client, email="match-me@trueup.example")
+ _register(api_client, email="no-match@trueup.example")
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get("/api/v1/admin/customers?query=MATCH-ME")
+
+ assert response.status_code == 200
+ body = response.get_json()
+ assert [c["email"] for c in body["customers"]] == ["match-me@trueup.example"]
+ assert body["next_cursor"] is None
+
+
+def test_list_customers_is_throttled(api_client: FlaskClient, staff_member: Staff) -> None:
+ _staff_login_with_mfa(api_client)
+
+ last_response = None
+ for _ in range(61):
+ last_response = api_client.get("/api/v1/admin/customers?query=a")
+
+ assert last_response is not None
+ assert last_response.status_code == 429
+ assert "Retry-After" in last_response.headers
+
+
+# --- GET /api/v1/admin/customers/ -------------------------------------------------------
+
+
+def test_get_customer_requires_authentication(api_client: FlaskClient) -> None:
+ response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}")
+
+ assert response.status_code == 401
+
+
+def test_get_customer_a_customer_session_gets_403_not_404(api_client: FlaskClient) -> None:
+ _register(api_client, email="plain-customer-2@trueup.example")
+ api_client.post(
+ "/api/v1/auth/login",
+ json={"email": "plain-customer-2@trueup.example", "password": CUSTOMER_PASSWORD},
+ )
+
+ response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}")
+
+ assert response.status_code == 403
+
+
+def test_get_customer_not_found(api_client: FlaskClient, staff_member: Staff) -> None:
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}")
+
+ assert response.status_code == 404
+
+
+def test_get_customer_balance_agrees_with_the_valuation_endpoint(
+ api_client: FlaskClient, owner_engine: Engine, staff_member: Staff
+) -> None:
+ customer_id = _register(api_client, email="balance-check@trueup.example")
+ _deposit(owner_engine, customer_id, amount=Money("2500.00"), on=date(2026, 1, 5))
+ _staff_login_with_mfa(api_client)
+
+ admin_response = api_client.get(f"/api/v1/admin/customers/{customer_id}")
+ valuation_response = api_client.get(f"/api/v1/valuation/balance?customer_id={customer_id}")
+
+ assert admin_response.status_code == 200
+ assert valuation_response.status_code == 200
+ admin_body = admin_response.get_json()
+ valuation_body = valuation_response.get_json()
+ assert admin_body["balance"] == valuation_body
+ assert admin_body["open_reconciliation_breaks"] == []
+
+
+def test_get_customer_surfaces_an_open_reconciliation_break(
+ api_client: FlaskClient, owner_engine: Engine, staff_member: Staff
+) -> None:
+ customer_id = _register(api_client, email="broken-customer@trueup.example")
+ _deposit(owner_engine, customer_id, amount=Money("100.00"), on=date(2026, 1, 5))
+ _open_break(owner_engine, customer_id)
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get(f"/api/v1/admin/customers/{customer_id}")
+
+ assert response.status_code == 200
+ breaks = response.get_json()["open_reconciliation_breaks"]
+ assert len(breaks) == 1
+ assert breaks[0]["break_type"] == "cash_mismatch"
+ assert breaks[0]["status"] == "open"
+
+
+def test_get_customer_is_throttled(api_client: FlaskClient, staff_member: Staff) -> None:
+ _staff_login_with_mfa(api_client)
+
+ last_response = None
+ for _ in range(61):
+ last_response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}")
+
+ assert last_response is not None
+ assert last_response.status_code == 429
+ assert "Retry-After" in last_response.headers
+
+
+# --- GET /api/v1/admin/customers//fees --------------------------------------------------
+
+
+def test_get_customer_fees_requires_authentication(api_client: FlaskClient) -> None:
+ response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}/fees")
+
+ assert response.status_code == 401
+
+
+def test_get_customer_fees_a_customer_session_gets_403_not_404(api_client: FlaskClient) -> None:
+ _register(api_client, email="plain-customer-3@trueup.example")
+ api_client.post(
+ "/api/v1/auth/login",
+ json={"email": "plain-customer-3@trueup.example", "password": CUSTOMER_PASSWORD},
+ )
+
+ response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}/fees")
+
+ assert response.status_code == 403
+
+
+def test_get_customer_fees_not_found(api_client: FlaskClient, staff_member: Staff) -> None:
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}/fees")
+
+ assert response.status_code == 404
+
+
+def test_get_customer_fees_matches_the_fees_endpoint(
+ api_client: FlaskClient, staff_member: Staff
+) -> None:
+ customer_id = _register(api_client, email="fees-check@trueup.example")
+ _staff_login_with_mfa(api_client)
+
+ admin_response = api_client.get(f"/api/v1/admin/customers/{customer_id}/fees")
+ fees_response = api_client.get(f"/api/v1/fees?customer_id={customer_id}")
+
+ assert admin_response.status_code == 200
+ assert fees_response.status_code == 200
+ assert admin_response.get_json() == fees_response.get_json()
+
+
+def test_get_customer_fees_is_throttled(api_client: FlaskClient, staff_member: Staff) -> None:
+ _staff_login_with_mfa(api_client)
+
+ last_response = None
+ for _ in range(31):
+ last_response = api_client.get(f"/api/v1/admin/customers/{uuid.uuid4()}/fees")
+
+ assert last_response is not None
+ assert last_response.status_code == 429
+ assert "Retry-After" in last_response.headers
diff --git a/backend/tests/api/test_admin_kyc_overrides.py b/backend/tests/api/test_admin_kyc_overrides.py
new file mode 100644
index 0000000..3d4e859
--- /dev/null
+++ b/backend/tests/api/test_admin_kyc_overrides.py
@@ -0,0 +1,213 @@
+"""`POST /api/v1/admin/kyc-overrides/` (S8 §4 row 5) via the Flask test client:
+reopening a locked-`rejected` KYC status, validation, authz, and throttling. Confirms end-to-end
+that the override actually unblocks a subsequent `POST /identity/kyc-sessions` call
+(`app/services/identity/kyc_service.py`'s lock gate).
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Iterator
+
+import pyotp
+import pytest
+from flask.testing import FlaskClient
+from sqlalchemy import Engine, text
+from sqlalchemy.orm import Session
+
+from app.config import get_settings
+from app.integrations.fake.fake_kyc import FakeKycAdapter
+from app.models.identity.customer import Customer, KycStatus
+from app.models.identity.kyc_session import KycSession
+from app.models.identity.staff import Staff, StaffRole
+from app.models.ops.admin_audit_log import AdminAuditLog
+from app.services.identity.auth import hash_password
+
+CUSTOMER_EMAIL = "kyc-override-customer@trueup.example"
+CUSTOMER_PASSWORD = "correct-horse-battery"
+STAFF_EMAIL = "kyc-override-adviser@trueup.example"
+STAFF_PASSWORD = "another-strong-password"
+
+OVERRIDE_TABLES = [KycSession.__table__, AdminAuditLog.__table__]
+
+
+@pytest.fixture(autouse=True)
+def _override_tables(owner_engine: Engine) -> Iterator[None]:
+ for table in OVERRIDE_TABLES:
+ table.create(bind=owner_engine, checkfirst=True)
+ yield None
+ with owner_engine.begin() as connection:
+ for table in reversed(OVERRIDE_TABLES):
+ connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE'))
+
+
+@pytest.fixture(autouse=True)
+def _fake_kyc_port(monkeypatch: pytest.MonkeyPatch) -> None:
+ import app.controllers.api.identity as identity_controller
+
+ monkeypatch.setattr(identity_controller, "_build_kyc_port", lambda: FakeKycAdapter())
+
+
+def _register_and_login(
+ client: FlaskClient, *, email: str = CUSTOMER_EMAIL, password: str = CUSTOMER_PASSWORD
+) -> tuple[str, str]:
+ register_response = client.post(
+ "/api/v1/auth/register", json={"email": email, "password": password}
+ )
+ assert register_response.status_code == 201
+ customer_id = register_response.get_json()["id"]
+
+ login_response = client.post("/api/v1/auth/login", json={"email": email, "password": password})
+ assert login_response.status_code == 200
+ csrf_token = login_response.get_json()["csrf_token"]
+ return customer_id, csrf_token
+
+
+@pytest.fixture
+def staff_member(owner_engine: Engine) -> Iterator[Staff]:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ staff = Staff(
+ email=STAFF_EMAIL, password_hash=hash_password(STAFF_PASSWORD), role=StaffRole.adviser
+ )
+ session.add(staff)
+ session.commit()
+ yield staff
+ session.close()
+
+
+def _staff_login_with_mfa(client: FlaskClient) -> str:
+ login_response = client.post(
+ "/api/v1/auth/login", json={"email": STAFF_EMAIL, "password": STAFF_PASSWORD}
+ )
+ assert login_response.status_code == 200
+ pending_csrf = login_response.get_json()["csrf_token"]
+ secret = client.post(
+ "/api/v1/auth/mfa/enroll", headers={"X-CSRFToken": pending_csrf}
+ ).get_json()["secret"]
+ code = pyotp.TOTP(secret).now()
+ verify = client.post(
+ "/api/v1/auth/mfa/verify", json={"code": code}, headers={"X-CSRFToken": pending_csrf}
+ )
+ assert verify.status_code == 200
+ csrf_token: str = verify.get_json()["csrf_token"]
+ return csrf_token
+
+
+def _lock_out(
+ owner_engine: Engine, customer_id: str, *, api_client: FlaskClient, csrf_token: str
+) -> None:
+ first = api_client.post(
+ "/api/v1/identity/kyc-sessions",
+ json={"customer_id": customer_id},
+ headers={"X-CSRFToken": csrf_token},
+ )
+ assert first.status_code == 201
+
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ try:
+ kyc_session = (
+ session.query(KycSession).filter_by(customer_id=uuid.UUID(customer_id)).one()
+ )
+ kyc_session.attempt_number = get_settings().kyc_max_attempts
+ customer = session.get(Customer, uuid.UUID(customer_id))
+ assert customer is not None
+ customer.kyc_status = KycStatus.rejected
+ session.commit()
+ finally:
+ session.close()
+
+
+def test_requires_authentication(api_client: FlaskClient) -> None:
+ response = api_client.post(f"/api/v1/admin/kyc-overrides/{uuid.uuid4()}")
+
+ assert response.status_code == 400 # CSRFProtect rejects an unauthenticated POST first.
+
+
+def test_a_customer_session_gets_403_not_404(api_client: FlaskClient) -> None:
+ _, csrf_token = _register_and_login(api_client)
+
+ response = api_client.post(
+ f"/api/v1/admin/kyc-overrides/{uuid.uuid4()}",
+ json={"reason": "manual review"},
+ headers={"X-CSRFToken": csrf_token},
+ )
+
+ assert response.status_code == 403
+
+
+def test_missing_reason_is_rejected(api_client: FlaskClient, staff_member: Staff) -> None:
+ csrf_token = _staff_login_with_mfa(api_client)
+
+ response = api_client.post(
+ f"/api/v1/admin/kyc-overrides/{uuid.uuid4()}",
+ json={},
+ headers={"X-CSRFToken": csrf_token},
+ )
+
+ assert response.status_code == 422
+
+
+def test_unknown_customer_returns_404(api_client: FlaskClient, staff_member: Staff) -> None:
+ csrf_token = _staff_login_with_mfa(api_client)
+
+ response = api_client.post(
+ f"/api/v1/admin/kyc-overrides/{uuid.uuid4()}",
+ json={"reason": "manual review"},
+ headers={"X-CSRFToken": csrf_token},
+ )
+
+ assert response.status_code == 404
+
+
+def test_override_reopens_a_locked_customer_for_a_fresh_attempt(
+ api_client: FlaskClient, owner_engine: Engine, staff_member: Staff
+) -> None:
+ customer_id, customer_csrf = _register_and_login(api_client)
+ _lock_out(owner_engine, customer_id, api_client=api_client, csrf_token=customer_csrf)
+ locked_again = api_client.post(
+ "/api/v1/identity/kyc-sessions",
+ json={"customer_id": customer_id},
+ headers={"X-CSRFToken": customer_csrf},
+ )
+ assert locked_again.status_code == 422
+ assert locked_again.get_json()["code"] == "kyc_locked"
+ api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": customer_csrf})
+
+ staff_csrf = _staff_login_with_mfa(api_client)
+ override_response = api_client.post(
+ f"/api/v1/admin/kyc-overrides/{customer_id}",
+ json={"reason": "customer called support, ID confirmed manually"},
+ headers={"X-CSRFToken": staff_csrf},
+ )
+
+ assert override_response.status_code == 200
+ assert override_response.get_json()["kyc_status"] == "pending"
+
+ api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": staff_csrf})
+ relogin = api_client.post(
+ "/api/v1/auth/login", json={"email": CUSTOMER_EMAIL, "password": CUSTOMER_PASSWORD}
+ )
+ assert relogin.status_code == 200
+ customer_csrf_2 = relogin.get_json()["csrf_token"]
+ retry = api_client.post(
+ "/api/v1/identity/kyc-sessions",
+ json={"customer_id": customer_id},
+ headers={"X-CSRFToken": customer_csrf_2},
+ )
+ assert retry.status_code == 201
+
+
+def test_is_throttled(api_client: FlaskClient, staff_member: Staff) -> None:
+ csrf_token = _staff_login_with_mfa(api_client)
+
+ last_response = None
+ for _ in range(11):
+ last_response = api_client.post(
+ f"/api/v1/admin/kyc-overrides/{uuid.uuid4()}",
+ json={"reason": "manual review"},
+ headers={"X-CSRFToken": csrf_token},
+ )
+
+ assert last_response is not None
+ assert last_response.status_code == 429
+ assert "Retry-After" in last_response.headers
diff --git a/backend/tests/api/test_identity.py b/backend/tests/api/test_identity.py
index efb0e29..cd64d2b 100644
--- a/backend/tests/api/test_identity.py
+++ b/backend/tests/api/test_identity.py
@@ -4,14 +4,18 @@
from __future__ import annotations
+import uuid
from collections.abc import Iterator
import pytest
from flask.testing import FlaskClient
from sqlalchemy import Engine
+from sqlalchemy.orm import Session
from werkzeug.test import TestResponse
+from app.config import get_settings
from app.integrations.fake.fake_kyc import FakeKycAdapter
+from app.models.identity.customer import Customer, KycStatus
from app.models.identity.kyc_session import KycSession
CUSTOMER_EMAIL = "identity-customer@trueup.example"
@@ -102,6 +106,36 @@ def test_start_kyc_session_rejects_another_customers_id(api_client: FlaskClient)
assert response.status_code == 403
+def test_start_kyc_session_is_blocked_once_locked_rejected(
+ api_client: FlaskClient, owner_engine: Engine
+) -> None:
+ """S2 §9/S8 §6 case 3: exhausted attempts lock `kyc_status` to `rejected`, and a further
+ `POST /identity/kyc-sessions` is rejected with a clear error rather than opening another
+ attempt -- only the adviser override (`POST /admin/kyc-overrides/`) can reopen
+ it."""
+ customer_id, csrf_token = _register_and_login(api_client)
+ first = _start_kyc_session(api_client, customer_id=customer_id, csrf_token=csrf_token)
+ assert first.status_code == 201
+
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ try:
+ kyc_session = (
+ session.query(KycSession).filter_by(customer_id=uuid.UUID(customer_id)).one()
+ )
+ kyc_session.attempt_number = get_settings().kyc_max_attempts
+ customer = session.get(Customer, uuid.UUID(customer_id))
+ assert customer is not None
+ customer.kyc_status = KycStatus.rejected
+ session.commit()
+ finally:
+ session.close()
+
+ response = _start_kyc_session(api_client, customer_id=customer_id, csrf_token=csrf_token)
+
+ assert response.status_code == 422
+ assert response.get_json()["code"] == "kyc_locked"
+
+
def test_start_kyc_session_is_throttled(api_client: FlaskClient) -> None:
customer_id, csrf_token = _register_and_login(api_client)
diff --git a/backend/tests/api/test_lots.py b/backend/tests/api/test_lots.py
new file mode 100644
index 0000000..7143451
--- /dev/null
+++ b/backend/tests/api/test_lots.py
@@ -0,0 +1,246 @@
+"""`GET /api/v1/lots` (S8 §3, data owned by S5) via the Flask test client: happy path (including
+the wash-sale-adjusted figure and a provisional consumption's flag), authn/ownership, and
+throttling -- matching `test_fees.py`/`test_admin_rebalance_api.py`'s exact conventions.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import Iterator
+from datetime import UTC, date, datetime
+
+import pyotp
+import pytest
+from flask.testing import FlaskClient
+from sqlalchemy import Engine, text
+from sqlalchemy.orm import Session
+
+from app.core.money import Money, Units
+from app.models.identity.staff import Staff, StaffRole
+from app.models.ledger.lot_consumption import LotConsumption
+from app.models.ledger.tax_lot import LotDesignation, TaxLot
+from app.models.marketdata.security import Security, SecurityAssetClass
+from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id
+from app.models.orders.order_event import OrderEvent, OrderEventType
+from app.services.identity.auth import hash_password
+
+CUSTOMER_EMAIL = "lots-customer@trueup.example"
+OTHER_EMAIL = "lots-other@trueup.example"
+STAFF_EMAIL = "lots-adviser@trueup.example"
+STAFF_PASSWORD = "another-strong-password"
+PASSWORD = "correct-horse-battery"
+
+
+@pytest.fixture
+def staff_member(owner_engine: Engine) -> Iterator[Staff]:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ staff = Staff(
+ email=STAFF_EMAIL, password_hash=hash_password(STAFF_PASSWORD), role=StaffRole.adviser
+ )
+ session.add(staff)
+ session.commit()
+ yield staff
+ session.close()
+
+
+def _staff_login_with_mfa(client: FlaskClient) -> None:
+ login_response = client.post(
+ "/api/v1/auth/login", json={"email": STAFF_EMAIL, "password": STAFF_PASSWORD}
+ )
+ assert login_response.status_code == 200
+ pending_csrf = login_response.get_json()["csrf_token"]
+ secret = client.post(
+ "/api/v1/auth/mfa/enroll", headers={"X-CSRFToken": pending_csrf}
+ ).get_json()["secret"]
+ code = pyotp.TOTP(secret).now()
+ verify = client.post(
+ "/api/v1/auth/mfa/verify", json={"code": code}, headers={"X-CSRFToken": pending_csrf}
+ )
+ assert verify.status_code == 200
+
+
+LOTS_TABLES = [
+ Security.__table__,
+ Order.__table__,
+ OrderEvent.__table__,
+ TaxLot.__table__,
+ LotConsumption.__table__,
+]
+
+
+@pytest.fixture(autouse=True)
+def _lots_tables(owner_engine: Engine) -> Iterator[None]:
+ for table in LOTS_TABLES:
+ table.create(bind=owner_engine, checkfirst=True)
+ yield None
+ with owner_engine.begin() as connection:
+ for table in reversed(LOTS_TABLES):
+ connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE'))
+
+
+def _register_and_login(
+ client: FlaskClient, *, email: str = CUSTOMER_EMAIL, password: str = PASSWORD
+) -> tuple[str, str]:
+ register_response = client.post(
+ "/api/v1/auth/register", json={"email": email, "password": password}
+ )
+ assert register_response.status_code == 201
+ customer_id = register_response.get_json()["id"]
+
+ login_response = client.post("/api/v1/auth/login", json={"email": email, "password": password})
+ assert login_response.status_code == 200
+ csrf_token = login_response.get_json()["csrf_token"]
+ return customer_id, csrf_token
+
+
+def _seed_fill_order_event(
+ session: Session, *, customer_id: uuid.UUID, security_id: uuid.UUID, execution_id: str
+) -> None:
+ order = Order(
+ customer_id=customer_id,
+ security_id=security_id,
+ side=OrderSide.BUY,
+ quantity_requested=Units("10"),
+ status=OrderStatus.FILLED,
+ filled_quantity=Units("10"),
+ client_order_id=derive_client_order_id(uuid.uuid4()),
+ )
+ session.add(order)
+ session.flush()
+ session.add(
+ OrderEvent(
+ order_id=order.id, seq=1, event_type=OrderEventType.FILL,
+ execution_id=execution_id, payload={},
+ )
+ )
+ session.flush()
+
+
+def _seed_lot_with_wash_sale_and_provisional_sale(
+ owner_engine: Engine, customer_id: uuid.UUID
+) -> tuple[uuid.UUID, str]:
+ """One lot whose `adjusted_basis` has already absorbed a wash-sale-disallowed loss (S5 §5)
+ and carries one still-provisional sell consumption (S5 §3.2) -- the two figures `GET
+ /api/v1/lots` must never conflate with their pre-adjustment/locked counterparts."""
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ try:
+ security = Security(
+ symbol="WASH", name="Wash Sale Co.", asset_class=SecurityAssetClass.EQUITY
+ )
+ session.add(security)
+ session.flush()
+
+ _seed_fill_order_event(
+ session, customer_id=customer_id, security_id=security.id, execution_id="buy-1"
+ )
+ lot = TaxLot(
+ customer_id=customer_id,
+ security_id=security.id,
+ opening_fill_execution_id="buy-1",
+ quantity_opened=Units("10"),
+ quantity_remaining=Units("5"),
+ original_cost_basis=Money("1000.00"),
+ adjusted_basis=Money("550.00"),
+ acquired_at=date(2026, 1, 5),
+ designation=LotDesignation.UNSPECIFIED,
+ designation_window_closes_at=datetime(2026, 1, 8, 16, 0, tzinfo=UTC),
+ )
+ session.add(lot)
+ session.flush()
+
+ _seed_fill_order_event(
+ session, customer_id=customer_id, security_id=security.id, execution_id="sell-1"
+ )
+ session.add(
+ LotConsumption(
+ closing_fill_execution_id="sell-1",
+ tax_lot_id=lot.id,
+ quantity_consumed=Units("5"),
+ realized_gain_loss=Money("-50.00"),
+ is_provisional=True,
+ sale_date=date(2026, 1, 6),
+ )
+ )
+ session.commit()
+ return lot.id, str(security.id)
+ finally:
+ session.close()
+
+
+def test_requires_authentication(api_client: FlaskClient) -> None:
+ response = api_client.get("/api/v1/lots")
+
+ assert response.status_code == 401
+
+
+def test_customer_sees_own_lots_with_adjusted_basis_and_provisional_flag(
+ api_client: FlaskClient, owner_engine: Engine
+) -> None:
+ customer_id, _ = _register_and_login(api_client)
+ lot_id, security_id = _seed_lot_with_wash_sale_and_provisional_sale(
+ owner_engine, uuid.UUID(customer_id)
+ )
+
+ response = api_client.get("/api/v1/lots")
+
+ assert response.status_code == 200
+ body = response.get_json()
+ assert len(body["lots"]) == 1
+ lot = body["lots"][0]
+ assert lot["id"] == str(lot_id)
+ assert lot["security_id"] == security_id
+ assert lot["symbol"] == "WASH"
+ assert lot["original_cost_basis"] == "1000.0000"
+ # Never the raw pre-adjustment basis (S5 §5) -- only the wash-sale-adjusted figure.
+ assert lot["adjusted_basis"] == "550.0000"
+ assert lot["realized_gain_loss"] == "-50.0000"
+ assert lot["is_provisional"] is True
+ assert lot["designation"] == "unspecified"
+
+
+def test_a_customer_never_sees_another_customers_lots(
+ api_client: FlaskClient, owner_engine: Engine
+) -> None:
+ other_id, other_csrf = _register_and_login(api_client, email=OTHER_EMAIL)
+ _seed_lot_with_wash_sale_and_provisional_sale(owner_engine, uuid.UUID(other_id))
+ api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": other_csrf})
+ _register_and_login(api_client)
+
+ response = api_client.get("/api/v1/lots")
+
+ assert response.status_code == 200
+ assert response.get_json()["lots"] == []
+
+
+def test_staff_requires_a_customer_id(api_client: FlaskClient, staff_member: Staff) -> None:
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get("/api/v1/lots")
+
+ assert response.status_code == 422
+
+
+def test_staff_sees_a_named_customers_lots(
+ api_client: FlaskClient, owner_engine: Engine, staff_member: Staff
+) -> None:
+ customer_id, csrf_token = _register_and_login(api_client)
+ api_client.post("/api/v1/auth/logout", headers={"X-CSRFToken": csrf_token})
+ _seed_lot_with_wash_sale_and_provisional_sale(owner_engine, uuid.UUID(customer_id))
+ _staff_login_with_mfa(api_client)
+
+ response = api_client.get(f"/api/v1/lots?customer_id={customer_id}")
+
+ assert response.status_code == 200
+ assert len(response.get_json()["lots"]) == 1
+
+
+def test_is_throttled(api_client: FlaskClient) -> None:
+ _register_and_login(api_client)
+
+ last_response = None
+ for _ in range(61):
+ last_response = api_client.get("/api/v1/lots")
+
+ assert last_response is not None
+ assert last_response.status_code == 429
+ assert "Retry-After" in last_response.headers
diff --git a/backend/tests/api/test_statements_export.py b/backend/tests/api/test_statements_export.py
new file mode 100644
index 0000000..6e9c613
--- /dev/null
+++ b/backend/tests/api/test_statements_export.py
@@ -0,0 +1,206 @@
+"""`GET /api/v1/statements//export` (S8 §5, FR-36) via the Flask test client: the CSV
+export happy path (wash-sale-adjusted figures, a provisional row included), the "not yet
+published" edge case (S8 §6 case 1), authn/ownership, and throttling.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+import uuid
+from collections.abc import Iterator
+from datetime import UTC, date, datetime
+from decimal import Decimal
+
+import pytest
+from flask.testing import FlaskClient
+from sqlalchemy import Engine, text
+from sqlalchemy.orm import Session
+
+from app.core.money import Money, Units
+from app.models.ledger.lot_consumption import LotConsumption
+from app.models.ledger.tax_lot import LotDesignation, TaxLot
+from app.models.marketdata.security import Security, SecurityAssetClass
+from app.models.orders.order import Order, OrderSide, OrderStatus, derive_client_order_id
+from app.models.orders.order_event import OrderEvent, OrderEventType
+from app.models.restatement.published_snapshot import PublishedSnapshot
+
+CUSTOMER_EMAIL = "export-customer@trueup.example"
+PASSWORD = "correct-horse-battery"
+
+PERIOD_START = date(2026, 1, 1)
+PERIOD_END = date(2026, 1, 31)
+
+EXPORT_TABLES = [
+ Security.__table__,
+ Order.__table__,
+ OrderEvent.__table__,
+ TaxLot.__table__,
+ LotConsumption.__table__,
+ PublishedSnapshot.__table__,
+]
+
+
+@pytest.fixture(autouse=True)
+def _export_tables(owner_engine: Engine) -> Iterator[None]:
+ for table in EXPORT_TABLES:
+ table.create(bind=owner_engine, checkfirst=True)
+ yield None
+ with owner_engine.begin() as connection:
+ for table in reversed(EXPORT_TABLES):
+ connection.execute(text(f'DROP TABLE IF EXISTS "{table.name}" CASCADE'))
+
+
+def _register_and_login(client: FlaskClient) -> tuple[str, str]:
+ register_response = client.post(
+ "/api/v1/auth/register", json={"email": CUSTOMER_EMAIL, "password": PASSWORD}
+ )
+ assert register_response.status_code == 201
+ customer_id = register_response.get_json()["id"]
+
+ login_response = client.post(
+ "/api/v1/auth/login", json={"email": CUSTOMER_EMAIL, "password": PASSWORD}
+ )
+ assert login_response.status_code == 200
+ csrf_token = login_response.get_json()["csrf_token"]
+ return customer_id, csrf_token
+
+
+def _publish_snapshot(owner_engine: Engine, customer_id: uuid.UUID) -> None:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ try:
+ session.add(
+ PublishedSnapshot(
+ customer_id=customer_id,
+ period_start=PERIOD_START,
+ period_end=PERIOD_END,
+ publish_watermark=datetime(2026, 2, 1, tzinfo=UTC),
+ twr=Decimal("0.0100000000"),
+ balance=Money("5000.00"),
+ holdings_json={},
+ published_at=datetime(2026, 2, 1, tzinfo=UTC),
+ )
+ )
+ session.commit()
+ finally:
+ session.close()
+
+
+def _seed_realized_lot(owner_engine: Engine, customer_id: uuid.UUID) -> None:
+ session = Session(bind=owner_engine, expire_on_commit=False)
+ try:
+ security = Security(symbol="EXPT", name="Export Co.", asset_class=SecurityAssetClass.EQUITY)
+ session.add(security)
+ session.flush()
+
+ buy_order = Order(
+ customer_id=customer_id, security_id=security.id, side=OrderSide.BUY,
+ quantity_requested=Units("10"), status=OrderStatus.FILLED, filled_quantity=Units("10"),
+ client_order_id=derive_client_order_id(uuid.uuid4()),
+ )
+ session.add(buy_order)
+ session.flush()
+ session.add(
+ OrderEvent(
+ order_id=buy_order.id, seq=1, event_type=OrderEventType.FILL,
+ execution_id="buy-exp-1", payload={},
+ )
+ )
+ session.flush()
+
+ lot = TaxLot(
+ customer_id=customer_id,
+ security_id=security.id,
+ opening_fill_execution_id="buy-exp-1",
+ quantity_opened=Units("10"),
+ quantity_remaining=Units("5"),
+ original_cost_basis=Money("1000.00"),
+ adjusted_basis=Money("450.00"),
+ acquired_at=date(2026, 1, 3),
+ designation=LotDesignation.UNSPECIFIED,
+ designation_window_closes_at=datetime(2026, 1, 6, 16, 0, tzinfo=UTC),
+ )
+ session.add(lot)
+ session.flush()
+
+ sell_order = Order(
+ customer_id=customer_id, security_id=security.id, side=OrderSide.SELL,
+ quantity_requested=Units("5"), status=OrderStatus.FILLED, filled_quantity=Units("5"),
+ client_order_id=derive_client_order_id(uuid.uuid4()),
+ )
+ session.add(sell_order)
+ session.flush()
+ session.add(
+ OrderEvent(
+ order_id=sell_order.id, seq=1, event_type=OrderEventType.FILL,
+ execution_id="sell-exp-1", payload={},
+ )
+ )
+ session.flush()
+ session.add(
+ LotConsumption(
+ closing_fill_execution_id="sell-exp-1",
+ tax_lot_id=lot.id,
+ quantity_consumed=Units("5"),
+ realized_gain_loss=Money("-25.00"),
+ is_provisional=True,
+ sale_date=date(2026, 1, 15),
+ )
+ )
+ session.commit()
+ finally:
+ session.close()
+
+
+def test_requires_authentication(api_client: FlaskClient) -> None:
+ response = api_client.get(f"/api/v1/statements/{PERIOD_START.isoformat()}/export")
+
+ assert response.status_code == 401
+
+
+def test_unpublished_period_returns_a_clear_not_yet_published_error(
+ api_client: FlaskClient,
+) -> None:
+ _register_and_login(api_client)
+
+ response = api_client.get(f"/api/v1/statements/{PERIOD_START.isoformat()}/export")
+
+ assert response.status_code == 404
+ body = response.get_json()
+ assert body["code"] == "statement_not_published"
+
+
+def test_export_happy_path_includes_the_adjusted_basis_and_provisional_flag(
+ api_client: FlaskClient, owner_engine: Engine
+) -> None:
+ customer_id, _ = _register_and_login(api_client)
+ _publish_snapshot(owner_engine, uuid.UUID(customer_id))
+ _seed_realized_lot(owner_engine, uuid.UUID(customer_id))
+
+ response = api_client.get(f"/api/v1/statements/{PERIOD_START.isoformat()}/export")
+
+ assert response.status_code == 200
+ assert response.mimetype == "text/csv"
+ assert "attachment" in response.headers["Content-Disposition"]
+ rows = list(csv.reader(io.StringIO(response.get_data(as_text=True))))
+ assert rows[0] == [
+ "symbol",
+ "quantity_consumed",
+ "original_cost_basis",
+ "adjusted_basis",
+ "realized_gain_loss",
+ "is_provisional",
+ ]
+ assert rows[1] == ["EXPT", "5.000000", "1000.0000", "450.0000", "-25.0000", "true"]
+
+
+def test_is_throttled(api_client: FlaskClient) -> None:
+ _register_and_login(api_client)
+
+ last_response = None
+ for _ in range(31):
+ last_response = api_client.get(f"/api/v1/statements/{PERIOD_START.isoformat()}/export")
+
+ assert last_response is not None
+ assert last_response.status_code == 429
+ assert "Retry-After" in last_response.headers
diff --git a/backend/tests/api/test_webhooks_stripe.py b/backend/tests/api/test_webhooks_stripe.py
index dfa57a3..6e53b52 100644
--- a/backend/tests/api/test_webhooks_stripe.py
+++ b/backend/tests/api/test_webhooks_stripe.py
@@ -35,6 +35,9 @@
@pytest.fixture(autouse=True)
def _webhook_settings(monkeypatch: pytest.MonkeyPatch) -> None:
+ # `conftest.py`'s autouse `_default_settings_env` fixture already makes `get_settings()`
+ # constructible without a `.env` (every other required field); this fixture only adds the two
+ # webhook secrets that fixture has no reason to know about.
settings = get_settings()
monkeypatch.setattr(settings, "stripe_webhook_secret_identity", SecretStr(IDENTITY_SECRET))
monkeypatch.setattr(settings, "stripe_webhook_secret_billing", SecretStr(BILLING_SECRET))
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 9f2429e..c905400 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -69,6 +69,33 @@ def test_settings() -> Settings:
)
+@pytest.fixture(autouse=True)
+def _default_settings_env(monkeypatch: pytest.MonkeyPatch, test_settings: Settings) -> None:
+ """Most fixtures inject `test_settings` straight into `create_app(...)`, never touching
+ `os.environ` -- but several controllers and jobs (`grep -rn "get_settings()" app/` finds
+ ~20 call sites: every webhook, funding, fees, chat, identity, orders, admin/rebalance
+ controller, and every scheduled job) read the module-level `get_settings()` singleton
+ directly instead of the app's injected settings, and that singleton's first construction
+ still needs real env vars -- there is no test double for it otherwise. Without this, any test
+ that exercises one of those code paths depends on a local `backend/.env` existing, silently
+ contradicting this file's own docstring promise that `uv run pytest` works with none present
+ (confirmed the hard way: 22 unrelated API tests failed this way, across five files, the first
+ time this suite ran anywhere without a `.env` -- i.e. the first time it ran in CI at all).
+
+ `monkeypatch.setenv`, not a module-level `os.environ` mutation, precisely because it reverts
+ at each test's teardown -- `test_config.py`'s own `test_every_required_setting_is_required`
+ deliberately removes one of these same variables to prove `Settings` still rejects it as
+ missing, which a session-wide or import-time mutation would silently defeat.
+ """
+ monkeypatch.setenv("SECRET_KEY", test_settings.secret_key.get_secret_value())
+ monkeypatch.setenv("DATABASE_URL", test_settings.database_url.get_secret_value())
+ monkeypatch.setenv("DATABASE_URL_WORKER", test_settings.database_url_worker.get_secret_value())
+ monkeypatch.setenv("DATABASE_URL_OWNER", test_settings.database_url_owner.get_secret_value())
+ monkeypatch.setenv("DATABASE_URL_CHAT", test_settings.database_url_chat.get_secret_value())
+ monkeypatch.setenv("REDIS_URL", test_settings.redis_url)
+ monkeypatch.setenv("FEE_RATE_PCT", str(test_settings.fee_rate_pct))
+
+
@pytest.fixture(scope="session")
def owner_engine(test_settings: Settings) -> Iterator[Engine]:
"""Schema owner. Creates and drops schema; RLS does not constrain it."""
diff --git a/backend/tests/contract/test_payment_port.py b/backend/tests/contract/test_payment_port.py
index 627bfbc..bfe5405 100644
--- a/backend/tests/contract/test_payment_port.py
+++ b/backend/tests/contract/test_payment_port.py
@@ -22,7 +22,12 @@ def _assert_attach_and_charge_succeed(port: PaymentPort, *, customer_email: str)
payment_method_id="pm_card_visa",
)
assert method.stripe_customer_id
- assert method.stripe_payment_method_id == "pm_card_visa"
+ # Not asserted equal to the input: Stripe's fixed test tokens (`pm_card_visa` etc.) each
+ # materialize a genuinely new PaymentMethod object on attach, so the real adapter's returned
+ # id legitimately differs from what was passed in -- only the fake happens to echo it back.
+ # The behavior that actually matters -- chaining the attach result into charge() -- is
+ # exercised below regardless of which id came back.
+ assert method.stripe_payment_method_id
handle = port.charge(
stripe_customer_id=method.stripe_customer_id,
diff --git a/backend/tests/integration/test_cash_lock_serialization.py b/backend/tests/integration/test_cash_lock_serialization.py
index 5dd600c..d4a669a 100644
--- a/backend/tests/integration/test_cash_lock_serialization.py
+++ b/backend/tests/integration/test_cash_lock_serialization.py
@@ -111,43 +111,54 @@ def test_concurrent_deposit_and_withdrawal_never_interleave_their_check_and_writ
uow.settlement_obligations.confirm(obligation, confirmed_at=datetime.now(UTC))
uow.commit()
- events: list[str] = []
- lock = threading.Lock()
-
- def record(label: str) -> None:
- with lock:
- events.append(label)
+ # Two independent DB connections, not two Python threads racing a single lock, are what
+ # actually resolve this: when the deposit's transaction commits, Postgres releases the row
+ # lock and wakes the withdrawal connection's blocked query on its own backend process, then
+ # separately replies "COMMIT" to the deposit connection. Those two replies travel over two
+ # different sockets, so *which client-side Python thread notices first* is not ordered by
+ # anything -- comparing "deposit_committed" vs "withdrawal_acquired" as list-append order was
+ # a genuine two-connection race, not a Python thread-scheduling one, and no amount of
+ # `threading.Event` synchronization on the Python side removes it (confirmed: it still failed
+ # intermittently after adding one). The property that's actually deterministic per this
+ # test's own single thread is duration: `acquire()` cannot return until the deposit's
+ # transaction ends, so timing only the withdrawal thread's own blocking call -- against its
+ # own monotonic clock, no cross-connection comparison -- proves the block happened without
+ # racing anything.
+ hold_seconds = 0.4
+ deposit_acquired = threading.Event()
+ withdrawal_blocked_for: list[float] = []
def hold_the_lock_during_a_deposit() -> None:
with FundingUnitOfWork(
customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP
) as uow:
uow.cash_locks.acquire(customer_id)
- record("deposit_acquired")
- time.sleep(0.4)
- record("deposit_about_to_commit")
+ deposit_acquired.set()
+ time.sleep(hold_seconds)
uow.commit()
- record("deposit_committed")
def attempt_a_withdrawal() -> None:
- time.sleep(0.1) # let the deposit thread acquire the lock first
with FundingUnitOfWork(
customer_id=customer_id, role=SessionRole.CUSTOMER, db_role=DbRole.APP
) as uow:
- record("withdrawal_attempting_acquire")
+ started_at = time.monotonic()
uow.cash_locks.acquire(customer_id)
- record("withdrawal_acquired")
+ withdrawal_blocked_for.append(time.monotonic() - started_at)
uow.commit()
deposit_thread = threading.Thread(target=hold_the_lock_during_a_deposit)
- withdrawal_thread = threading.Thread(target=attempt_a_withdrawal)
deposit_thread.start()
+ assert deposit_acquired.wait(timeout=5), "deposit thread never acquired the cash lock"
+
+ withdrawal_thread = threading.Thread(target=attempt_a_withdrawal)
withdrawal_thread.start()
deposit_thread.join(timeout=5)
withdrawal_thread.join(timeout=5)
- assert events.index("deposit_committed") < events.index("withdrawal_acquired")
- assert events.index("withdrawal_attempting_acquire") < events.index("deposit_committed")
+ assert len(withdrawal_blocked_for) == 1, "withdrawal thread never completed"
+ # A generous tolerance below the full hold: proves `acquire()` genuinely waited out most of
+ # the deposit's hold rather than interleaving (which would return near-instantly, ~0s).
+ assert withdrawal_blocked_for[0] >= hold_seconds * 0.75
def test_withdrawal_counts_only_settled_cash_never_unsettled_deposit_proceeds(
diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py
index b77515e..f9d69fb 100644
--- a/backend/tests/unit/test_config.py
+++ b/backend/tests/unit/test_config.py
@@ -24,10 +24,18 @@
def build(**overrides: str | None) -> Settings:
- """Construct Settings from an explicit environment, ignoring any real .env on disk."""
+ """Construct Settings from an explicit environment, ignoring any real .env on disk *and* any
+ of `COMPLETE`'s own keys already present in the process environment -- `conftest.py`'s
+ autouse `_default_settings_env` fixture sets exactly these for every other test in the suite
+ (so controllers calling the module-level `get_settings()` singleton work without a `.env`),
+ which would otherwise silently satisfy a field this function's caller is deliberately trying
+ to prove `Settings` requires."""
env = {**COMPLETE, **overrides}
values = {k: v for k, v in env.items() if v is not None}
- return Settings(_env_file=None, **values) # type: ignore[arg-type]
+ with pytest.MonkeyPatch.context() as mp:
+ for key in COMPLETE:
+ mp.delenv(key, raising=False)
+ return Settings(_env_file=None, **values) # type: ignore[arg-type]
def test_complete_environment_builds_settings() -> None:
diff --git a/backend/tests/unit/test_kyc_service.py b/backend/tests/unit/test_kyc_service.py
index 70112fd..ef606a6 100644
--- a/backend/tests/unit/test_kyc_service.py
+++ b/backend/tests/unit/test_kyc_service.py
@@ -10,7 +10,7 @@
from app.integrations.fake.fake_kyc import FakeKycAdapter
from app.models.identity.customer import Customer, KycStatus
from app.models.identity.kyc_session import KycSession, KycSessionStatus, SqlKycSessionRepository
-from app.services.identity.kyc_service import CustomerNotFoundError, KycService
+from app.services.identity.kyc_service import CustomerNotFoundError, KycLockedError, KycService
MAX_ATTEMPTS = 3
@@ -148,3 +148,43 @@ def test_unknown_provider_session_id_raises() -> None:
service.apply_verification_verdict(
provider_session_id="vs_unknown", stripe_status="verified"
)
+
+
+def test_start_verification_is_blocked_once_locked_rejected() -> None:
+ """S2 §9/S8 §6 case 3: a customer whose `kyc_status` locked to `rejected` after exhausting
+ `KYC_MAX_ATTEMPTS` cannot start a fresh attempt -- only the admin override (S8 §4 row 5) may
+ reopen it."""
+ customer = _customer()
+ uow = _FakeUow(customer)
+ port = FakeKycAdapter()
+ service = _service(uow, port)
+ handle = service.start_verification(customer.id)
+ session_row = uow.kyc_sessions.get_by_provider_session_id(handle.provider_session_id)
+ assert session_row is not None
+ session_row.attempt_number = MAX_ATTEMPTS
+ service.apply_verification_verdict(
+ provider_session_id=handle.provider_session_id, stripe_status="requires_input"
+ )
+ assert customer.kyc_status is KycStatus.rejected
+
+ with pytest.raises(KycLockedError):
+ service.start_verification(customer.id)
+
+
+def test_start_verification_allows_a_retry_below_the_attempt_cap() -> None:
+ """A single canceled attempt also sets `kyc_status = rejected` (ADR 9), but well below the
+ attempt cap this must not lock out a normal resubmission -- only "exhausted attempts" does
+ (S8 §6 case 3's own distinction)."""
+ customer = _customer()
+ uow = _FakeUow(customer)
+ service = _service(uow, FakeKycAdapter())
+ handle = service.start_verification(customer.id)
+ service.apply_verification_verdict(
+ provider_session_id=handle.provider_session_id, stripe_status="canceled"
+ )
+ assert customer.kyc_status is KycStatus.rejected
+
+ service.start_verification(customer.id)
+
+ attempts = sorted(row.attempt_number for row in uow.kyc_sessions.rows.values())
+ assert attempts == [1, 2]
diff --git a/backend/tests/unit/test_outbox_task_router.py b/backend/tests/unit/test_outbox_task_router.py
new file mode 100644
index 0000000..acaef52
--- /dev/null
+++ b/backend/tests/unit/test_outbox_task_router.py
@@ -0,0 +1,54 @@
+"""`OutboxTaskRouter` (`app/services/ops/outbox_task_router.py`) -- pure routing logic, no
+database or provider needed, unlike the CLI wiring (`outbox-worker` in `app/jobs/__init__.py`)
+that constructs its real routes with live credentials."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.services.ops.outbox_task_router import OutboxTaskRouter, UnregisteredOutboxTaskError
+
+
+def test_routes_a_task_to_its_registered_callable() -> None:
+ received: list[dict[str, object]] = []
+ router = OutboxTaskRouter(routes={"submit_order_to_broker": received.append})
+
+ router.handle(task="submit_order_to_broker", payload={"order_id": "abc"})
+
+ assert received == [{"order_id": "abc"}]
+
+
+def test_routes_each_task_only_to_its_own_callable() -> None:
+ broker_calls: list[dict[str, object]] = []
+ fee_calls: list[dict[str, object]] = []
+ router = OutboxTaskRouter(
+ routes={
+ "submit_order_to_broker": broker_calls.append,
+ "charge_fee": fee_calls.append,
+ }
+ )
+
+ router.handle(task="charge_fee", payload={"fee_charge_id": "xyz"})
+
+ assert fee_calls == [{"fee_charge_id": "xyz"}]
+ assert broker_calls == []
+
+
+def test_an_unregistered_task_raises_rather_than_silently_dropping_the_row() -> None:
+ router = OutboxTaskRouter(routes={"charge_fee": lambda payload: None})
+
+ with pytest.raises(UnregisteredOutboxTaskError, match="mystery_task"):
+ router.handle(task="mystery_task", payload={})
+
+
+def test_a_handler_raising_propagates_to_the_caller() -> None:
+ """`OutboxWorker.drain_once` is what actually catches this (retry-then-dead-letter) --
+ `OutboxTaskRouter` itself must not swallow it first."""
+
+ def _boom(payload: dict[str, object]) -> None:
+ raise RuntimeError("provider unavailable")
+
+ router = OutboxTaskRouter(routes={"charge_fee": _boom})
+
+ with pytest.raises(RuntimeError, match="provider unavailable"):
+ router.handle(task="charge_fee", payload={})
diff --git a/docker-compose.yml b/docker-compose.yml
index 715f197..5648c86 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,29 @@ services:
timeout: 5s
retries: 10
+ # The always-on `job_outbox` drain process (S0 §9) -- without it, nothing ever submits an
+ # order to the broker, charges a fee, or applies an Alpaca fill to the ledger (`app.jobs
+ # .outbox_worker_command`'s own docstring has the full task-routing rationale). Same image as
+ # `backend`, different entrypoint -- reuses the built image rather than a second Dockerfile.
+ worker:
+ profiles: ["app"]
+ build:
+ context: ./backend
+ container_name: trueup-worker
+ command: ["flask", "--app", "wsgi", "jobs", "outbox-worker"]
+ env_file: .env
+ environment:
+ DATABASE_URL: postgresql+psycopg://trueup_app:trueup_app@db:5432/trueup
+ DATABASE_URL_WORKER: postgresql+psycopg://trueup_worker:trueup_worker@db:5432/trueup
+ DATABASE_URL_OWNER: postgresql+psycopg://trueup_owner:trueup_owner@db:5432/trueup
+ DATABASE_URL_CHAT: postgresql+psycopg://trueup_chat_readonly:trueup_chat_readonly@db:5432/trueup
+ REDIS_URL: redis://redis:6379/0
+ depends_on:
+ db:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+
frontend:
profiles: ["app"]
build:
diff --git a/docs/decisions/24-mcp-agent-surface-approval-queue.md b/docs/decisions/24-mcp-agent-surface-approval-queue.md
new file mode 100644
index 0000000..a4c579e
--- /dev/null
+++ b/docs/decisions/24-mcp-agent-surface-approval-queue.md
@@ -0,0 +1,172 @@
+# 24 — MCP agent surface: read-only tools plus a durable, adviser-approved write queue
+
+## Status
+
+Accepted
+
+## Context
+
+`docs/requirements/non-negotiables.md` #5 requires *"a working MCP surface, not a design for
+one"* — minimum three read tools and one write tool that lands in a human approval queue, plus a
+written list of operations never handed to an autonomous agent. Nothing satisfies this today.
+S11's chat assistant (ADR 18/19) is an HTTP+SSE endpoint that happens to use an LLM internally; it
+is not an MCP server, speaks no MCP transport, and has no write path at all — it cannot be
+stretched to meet this non-negotiable without becoming a different thing.
+
+This surface exists for a different consumer than S11's: an external MCP client (an operator's own
+agent tooling, e.g. Claude Desktop or a custom ops agent), not Trueup's own customer-facing chat.
+The two share a security posture — both let an LLM decide what to query against a database holding
+real money and PII — but not an implementation: S11's tool runs *inside* Trueup's own request
+cycle, authenticated by the caller's existing session; an MCP client is a separate process
+connecting over its own transport, with no session cookie to present.
+
+## Decision
+
+### Reuse ADR 19's read boundary; this ADR does not re-derive it
+
+Every read tool executes through the same `trueup_chat_readonly` role and the same eight curated
+views (`app/models/chat/curated_views.py`) ADR 19 already built and proved: **the database is the
+boundary, not a system prompt** — this ADR's read tools inherit that argument in full rather than
+restating it. A read tool is a thin translation from an MCP tool call's arguments to one of these
+views' existing `SELECT`s; no read tool executes an LLM-composed query the way S11's `execute_
+read_only_sql` does; there is nothing here as flexible as that to bound, since these three tools
+have fixed, named shapes (below), not free-text SQL.
+
+### Read tools (minimum three, per the non-negotiable)
+
+1. **`get_portfolio_summary(customer_id)`** — balance, holdings, and assigned model, via
+ `v_customer_balance`/`v_holdings` (the same views S11 already curates).
+2. **`get_transaction_history(customer_id, since)`** — via `v_transaction_history`.
+3. **`list_open_reconciliation_breaks()`** — the aged break queue (S7 §7), adviser-scope only (no
+ `customer_id` argument — this tool is inherently cross-tenant, so it authenticates as an
+ adviser-equivalent principal, never `trueup_chat_readonly`, which ADR 19 deliberately grants no
+ table access outside the eight per-customer curated views).
+
+### Write tools: propose, never execute (all three, per this project's own decision)
+
+An MCP write tool call never touches `order`, `customer.kyc_status`, or `reconciliation_break`
+directly. It validates its arguments, then inserts exactly one `agent_action_request` row
+(below) with `status = 'pending'` and returns that row's id. Nothing else happens until a human
+adviser acts on it through the approval UI (§"Approval flow"). This is the whole answer to "a
+write tool that lands in a human approval queue" — there is no code path, tested or
+untested, where an MCP tool call itself changes money-moving or compliance state.
+
+1. **`propose_reconciliation_break_resolution(break_id, resolution_note)`**
+2. **`propose_kyc_override(customer_id, reason)`**
+3. **`propose_rebalance(customer_id)`** — proposes *running* `DriftEvaluationService`'s existing
+ evaluation for one customer now, ahead of its monthly schedule; approval executes the same
+ `MonthlyRebalanceJob`-owned order-placement path (S9) that already exists, for that one
+ customer only. This tool never proposes a specific trade an LLM invented — the trade a human
+ approves is whatever the existing, already-tested drift-evaluation logic would itself produce.
+
+### `agent_action_request` (new table, append-only in spirit)
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | uuid, PK | |
+| `action` | enum | `resolve_break` \| `kyc_override` \| `rebalance` |
+| `arguments` | jsonb | The tool call's own arguments, exactly as received — never re-derived at approval time, so what an adviser reviews is what was actually proposed |
+| `requesting_agent` | text | Free-text client identity the MCP transport reports (whatever a client sends as its own name) — an operational label, not an authorization boundary; every write tool call still requires the caller to present a valid adviser/admin credential (below), same as any other privileged action |
+| `justification` | text | Required, non-empty — the tool call's own stated reason (e.g. a KYC override's `reason` argument) |
+| `status` | enum | `pending` \| `approved` \| `rejected` \| `executed` \| `execution_failed` |
+| `reviewed_by` | uuid, FK `staff.id`, nullable | Set on `approved`/`rejected` |
+| `reviewed_at` | timestamptz, nullable | |
+| `review_note` | text, nullable | The adviser's own note, distinct from the proposal's `justification` |
+| `executed_at` | timestamptz, nullable | Set only once the underlying service call actually completes |
+| `created_at` | timestamptz | |
+
+Rows are never deleted and never edited outside the single `pending → {approved, rejected}` and
+`approved → {executed, execution_failed}` transitions — the same single-transition-guard shape
+`kyc_session.resolve()` (S2 §3.2) and `reconciliation_break` already use in this codebase, applied
+here for the identical audit reason.
+
+### Approval flow
+
+1. A write tool call inserts a `pending` row (above). The MCP tool's own return value is just the
+ row's id and `status: "pending"` — an agent calling this tool never learns whether its proposal
+ was good, only that it was recorded.
+2. An adviser/admin session lists pending requests via a new `GET /api/v1/admin/agent-requests`
+ route (same `@requires_role("adviser", "admin")` shape every other admin route in this codebase
+ uses) and approves or rejects one via `POST /api/v1/admin/agent-requests//approve` /
+ `.../reject`, both `@audited` (the same `admin_audit_log` sink every other privileged write in
+ this codebase already commits through — an approval is a privileged action in its own right,
+ audited identically to resolving a break or overriding KYC directly).
+3. **Approval executes; it does not merely flip a status.** `approve` calls the *existing*,
+ already-tested service method for that action (`ReconciliationBreakService.resolve`,
+ `KycService`'s override path from S8/ADR-24's own §"KYC override" work, or
+ `DriftEvaluationService`/the rebalance order-placement path) inside the same transaction that
+ marks the row `executed`, using the row's own stored `arguments` — never re-prompting the
+ agent, never trusting anything the agent might say between proposal and approval. A failure
+ during execution marks the row `execution_failed` with the error recorded, never silently
+ `approved`-and-stuck.
+4. Rejecting a row is terminal — a rejected proposal is not retried automatically; a new proposal
+ is a new tool call and a new row.
+
+### Authentication: a scoped Trueup credential, not the MCP transport's own identity
+
+The MCP server accepts connections authenticated by a long-lived, adviser-scoped API token (a new
+`staff_api_token` table: `id`, `staff_id` FK, `token_hash`, `created_at`, `revoked_at`) presented
+as an MCP transport-level bearer credential — never a raw database credential, never Trueup's own
+session cookie (an MCP client is not a browser). Every read and write tool call resolves the
+calling token to a `staff_id`/role exactly as a decorated Flask route resolves `current_user`, and
+every write tool call's resulting `agent_action_request.requesting_agent` field additionally
+records that resolved identity, not just the client-reported name. A revoked token is rejected
+immediately (`revoked_at IS NULL` checked on every call, not just at connection time) — the same
+posture ADR 15 already takes toward session invalidation.
+
+### Transport and deployment
+
+The `mcp` package (already a transitive dependency of `openai-agents`, pinned explicitly by this
+ADR's implementation since it is now a direct one) runs its own ASGI server
+(`MCPServer.run_streamable_http_async`) — a separate process from the WSGI Flask app, the same
+"different process, same database, same layered `app/services/`" shape `outbox-worker` (ADR 13)
+already establishes for a different reason. Deployed as its own Container App
+(`mcp-server`), reusing the backend image with a different entrypoint, exactly as `worker` reuses
+it today.
+
+## Consequences
+
+- The non-negotiable is met with no new trust boundary beyond what ADR 19 already proved: reads
+ go through the identical curated-view/least-privilege-role perimeter; writes never execute
+ without a human in the loop, and when they do execute, they run through the same service code
+ every other path into that state change already uses and already tests — there is no
+ agent-only code path to a money-moving or compliance state.
+- A new attack surface is a new credential type (`staff_api_token`) that must be issued, rotated,
+ and revoked carefully — tracked as an operational runbook item, not designed further here.
+- Latency: approval is asynchronous by design (a human must act), so no write tool can be used in
+ a synchronous, low-latency agent loop — an accepted trade-off, since the entire point is that
+ nothing here is autonomous.
+
+## Alternatives considered
+
+- **Let the agent execute directly, audit afterward.** Rejected outright: this is exactly the
+ autonomy this non-negotiable exists to prevent, and root `CLAUDE.md`'s LLM Top 10 posture
+ ("treat model input and output as untrusted") forbids treating an LLM's tool call as
+ self-authorizing for a money-moving or compliance action.
+- **A generic "approve any JSON payload" queue**, one row shape for all three actions. Rejected:
+ a typed `action` enum with action-specific `arguments` validation at insert time catches a
+ malformed proposal before it ever reaches an adviser's queue, rather than surfacing a raw,
+ unvalidated JSON blob for a human to interpret under time pressure.
+- **Reuse S11's existing OpenAI Agent SDK tool-calling loop as the "MCP surface."** Rejected: it is
+ not an MCP server (no MCP transport, no external client can connect to it), and satisfying the
+ non-negotiable's literal text ("a working MCP surface") requires an actual MCP protocol
+ endpoint, not an internally-similar mechanism under a different name.
+
+## Written list: operations never handed to an autonomous agent (part of this non-negotiable)
+
+- **Moving money or units directly** — no tool executes `DepositService`/`WithdrawalService`/order
+ submission itself; `propose_rebalance` only ever proposes running the *existing*, human-designed
+ drift-evaluation policy, never a trade amount an LLM invented.
+- **Mutating an immutable ledger row** — no tool writes `journal_entry`/`posting` under any
+ circumstance; the ledger's append-only invariant (ADR 1) has no agent-facing exception.
+- **Publishing or restating a statement** — S6's snapshot/restatement mechanism stays
+ system/adviser-triggered only; a wrong statement is a customer-facing legal document, not a
+ reversible operational action.
+- **Approving its own or another agent's proposal** — approval requires a human adviser/admin
+ credential; no service account or agent identity is ever granted the `adviser`/`admin` role this
+ approval flow checks.
+- **Bypassing KYC/account-approval gates directly** — `propose_kyc_override` only ever creates a
+ proposal; only a human's `approve` call can change `customer.kyc_status`.
+- **Altering a customer's payment instrument or bank link** — no tool reads or writes
+ `payment_method`/`bank_link` at all, read or write; fee-payment and funding instruments are
+ entirely outside this surface's scope.
diff --git a/docs/decisions/README.md b/docs/decisions/README.md
index 994d351..c4bfbb0 100644
--- a/docs/decisions/README.md
+++ b/docs/decisions/README.md
@@ -30,6 +30,7 @@ consequences, and the alternatives it rejected.
| [21](21-alpaca-paper-trading-not-broker-api.md) | Alpaca Paper Trading API, not Broker API; simulated account-approval lifecycle | Accepted |
| [22](22-alpaca-trade-updates-websocket-intake.md) | Alpaca fills arrive over a `trade_updates` websocket, not an HTTP webhook | Accepted |
| [23](23-key-vault-envelope-encryption.md) | Azure Key Vault envelope encryption for field-level secrets at rest | Accepted |
+| [24](24-mcp-agent-surface-approval-queue.md) | MCP agent surface: read-only tools plus a durable, adviser-approved write queue | Accepted |
Still open, deferred to the S8 sub-project (see [`docs/architecture.md`](../architecture.md)):
customer surface (mobile vs. web) and adviser console vs. plain admin.
diff --git a/docs/specs/13-agent-surface.md b/docs/specs/13-agent-surface.md
new file mode 100644
index 0000000..ba3d9cb
--- /dev/null
+++ b/docs/specs/13-agent-surface.md
@@ -0,0 +1,175 @@
+# S13 — MCP Agent Surface: Design Spec
+
+Date: 2026-09-06
+Status: Draft, pending review
+Requirements covered: `docs/requirements/non-negotiables.md` #5 (this spec's own requirement —
+no FR/NFR in `requirements.md` names it, since the non-negotiables predate and sit outside that
+catalogue)
+Depends on: S0 (foundation — layering, `UnitOfWork`, `@audited`/`@requires_role`, RLS), S7
+(reconciliation breaks), S2 (KYC), S9 (rebalancing), ADR 17 (RLS), ADR 19 (curated-view read
+boundary — this spec's read tools reuse it directly rather than re-deriving it)
+Consumed by: nothing — this is an external-facing operational surface, not depended on by any
+other sub-project
+Decision record: `docs/decisions/24-mcp-agent-surface-approval-queue.md` — read that first; it
+carries the *why* for every mechanism this document only specifies the *how* of.
+
+## 1. Purpose
+
+Build a real MCP (Model Context Protocol) server an external agent client can connect to, exposing
+three read tools and three write tools, where every write is a proposal landing in a durable,
+adviser-approved queue rather than an executed action. ADR 24 is the full rationale; this spec is
+the concrete schema, routes, and module layout an implementer builds from.
+
+## 2. Non-goals
+
+- A second natural-language interface competing with S11's chat assistant — this surface has no
+ conversational UI of its own; it is a protocol endpoint for an external MCP client.
+- Any new business logic — every write tool's *approved* execution calls an existing, already-
+ tested service method (S7's break resolution, S2/S8's KYC override, S9's drift evaluation +
+ order placement). This spec adds no new financial logic, only the proposal/approval scaffolding
+ around calling what already exists.
+- Autonomous execution of any kind — see ADR 24's "written list" section, reproduced nowhere else;
+ that list is normative.
+
+## 3. Schema
+
+### 3.1 `agent_action_request`
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | uuid, PK, default `gen_random_uuid()` | |
+| `action` | enum `agent_action_type`: `resolve_break` \| `kyc_override` \| `rebalance` | |
+| `arguments` | jsonb, not null | Exactly the tool call's own arguments (§4.2 per-tool shapes) |
+| `requesting_agent` | text, not null | The resolved `staff_api_token` identity's own label (§3.2), not a client-reported string |
+| `justification` | text, not null, `CHECK (length(justification) > 0)` | |
+| `status` | enum `agent_action_status`: `pending` \| `approved` \| `rejected` \| `executed` \| `execution_failed`, default `pending` | |
+| `reviewed_by` | uuid, FK `staff.id`, nullable | |
+| `reviewed_at` | timestamptz, nullable | |
+| `review_note` | text, nullable | |
+| `execution_error` | text, nullable | Set only alongside `execution_failed` |
+| `executed_at` | timestamptz, nullable | |
+| `created_at` | timestamptz, not null, `server_default=now()` | |
+
+Constraints (mirroring `reconciliation_break`'s own `resolved_break_requires_resolver` pattern):
+
+- `CHECK ((status NOT IN ('approved','rejected')) OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL))`
+- `CHECK ((status <> 'executed') OR (executed_at IS NOT NULL))`
+- `CHECK ((status <> 'execution_failed') OR (execution_error IS NOT NULL))`
+- No RLS tenant-isolation policy: this table has no single owning customer (some rows reference
+ one via `arguments->>'customer_id'`, but the row itself is adviser/admin-scoped, matching
+ `reconciliation_break`'s own nullable-`customer_id` precedent) — access control is
+ `@requires_role("adviser", "admin")` on every route, the same as `admin_breaks`.
+- `REVOKE UPDATE, DELETE` from `trueup_app` is **not** applied here (unlike ledger tables) — the
+ single-transition status updates above are legitimate application writes, not ledger mutation;
+ `trueup_app` still cannot `DELETE` (append-only in spirit, per ADR 24).
+
+### 3.2 `staff_api_token`
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | uuid, PK | |
+| `staff_id` | uuid, FK `staff.id`, not null | |
+| `label` | text, not null | Human-readable, e.g. "ops laptop MCP client" — shown in the token-management UI, never the raw token |
+| `token_hash` | text, not null, unique | SHA-256 of the raw token; the raw value is shown exactly once, at creation, never stored or logged (same handling discipline as a password) |
+| `created_at` | timestamptz, not null | |
+| `revoked_at` | timestamptz, nullable | |
+
+Issuing a token is itself `@audited`; a new `POST /api/v1/admin/staff-api-tokens` route
+(adviser/admin, self-service for one's own `staff_id` — an adviser issues a token for themselves,
+never for another staff member) returns the raw token once. `DELETE
+/api/v1/admin/staff-api-tokens/` sets `revoked_at`.
+
+## 4. MCP server
+
+### 4.1 Module layout
+
+```
+app/mcp/
+ __init__.py # MCPServer construction, tool registration
+ auth.py # staff_api_token bearer resolution -> (staff_id, role)
+ read_tools.py # the 3 read tools, each a thin call into ADR 19's curated views
+ write_tools.py # the 3 write tools, each validating args then inserting one row
+ entrypoint.py # run_streamable_http_async() wiring, the process's own main()
+```
+
+Follows the layering rule (`app/mcp/` sits alongside `app/controllers/` — both are entry-point
+layers calling into `app/services/`, never the reverse); `import-linter` gets a new contract
+mirroring the existing `controllers -> services` one, extended to `app.mcp -> app.services`.
+
+### 4.2 Tool contracts
+
+**Read tools** (bearer token resolves to a `staff_id`; `trueup_chat_readonly`-equivalent DB access
+for the two customer-scoped tools, ADR 19's own role for `list_open_reconciliation_breaks`):
+
+- `get_portfolio_summary(customer_id: str) -> {balance, holdings, assigned_model}`
+- `get_transaction_history(customer_id: str, since: str | None) -> {transactions: [...]}`
+- `list_open_reconciliation_breaks() -> {breaks: [...]}` (adviser-scope; no `customer_id` arg)
+
+**Write tools** — each validates its own arguments (a malformed `customer_id`/`break_id` is
+rejected at the tool-call boundary, before any row exists — never accepted into the queue as
+"pending" only to fail review), then does exactly:
+```python
+request_id = uow.agent_action_requests.create(
+ action=..., arguments={...}, requesting_agent=staff_label, justification=justification,
+)
+uow.commit()
+return {"request_id": str(request_id), "status": "pending"}
+```
+
+- `propose_reconciliation_break_resolution(break_id: str, resolution_note: str) -> {request_id, status}`
+- `propose_kyc_override(customer_id: str, reason: str) -> {request_id, status}`
+- `propose_rebalance(customer_id: str) -> {request_id, status}`
+
+## 5. Approval API (`app/controllers/admin/agent_requests.py`)
+
+| Route | Method | Notes |
+| --- | --- | --- |
+| `/api/v1/admin/agent-requests` | GET | List, filterable by `status` (default `pending`), paginated (`app/core/pagination.py`) |
+| `/api/v1/admin/agent-requests/` | GET | One request's full detail |
+| `/api/v1/admin/agent-requests//approve` | POST | `@audited`; body: `{review_note: str | None}`; executes the underlying service call in the same transaction, sets `executed`/`execution_failed` |
+| `/api/v1/admin/agent-requests//reject` | POST | `@audited`; body: `{review_note: str}` (required — a rejection needs a stated reason) |
+
+All four `@requires_role("adviser", "admin")`, same shape as `app/controllers/admin/rebalance.py`.
+
+### 5.1 Execution mapping (approve only)
+
+| `action` | Calls |
+| --- | --- |
+| `resolve_break` | The same service `POST /admin/breaks//resolve` already calls, with `arguments.resolution_note` |
+| `kyc_override` | The same service `POST /admin/kyc-overrides/` calls (S8/Phase 2), with `arguments.reason` becoming the override's own reason |
+| `rebalance` | `DriftEvaluationService.evaluate` + the same order-placement path `MonthlyRebalanceJob` uses, for `arguments.customer_id` only |
+
+## 6. Edge and corner cases
+
+1. **A proposal references an entity that no longer exists by approval time** (a break already
+ resolved by someone else, a customer since deleted — customers are never hard-deleted in this
+ system, so realistically "already resolved") — the execution call's own existing not-found/
+ already-resolved error surfaces as `execution_failed` with that error recorded, never a silent
+ no-op and never a crash.
+2. **Two advisers approve the same request concurrently** — `approve`/`reject` both take a row
+ lock (`SELECT ... FOR UPDATE`, matching `OrderRepository.get_for_update`'s precedent) and the
+ `pending -> {approved,rejected}` single-transition `CHECK` makes the second call fail cleanly
+ (`ValidationError`/409, not a double-execution).
+3. **A revoked `staff_api_token` is used mid-session** — every tool call re-checks
+ `revoked_at IS NULL`, not just at MCP connection time (ADR 24).
+
+## 7. Testing strategy
+
+- Unit: `agent_action_request`'s status-transition guard (mirrors `reconciliation_break`'s own
+ transition tests); each write tool's argument validation.
+- Integration: the row-lock/concurrent-approval race (edge case 2); RLS/role behavior for
+ `list_open_reconciliation_breaks` under a non-adviser token.
+- API: the four approval routes — happy path, validation errors, authn/authz, the execution-
+ mapping table's three rows actually calling through to their real service and changing real
+ state (not mocked), execution failure recorded correctly.
+- Contract-style: a minimal MCP client (the `mcp` package ships a client for exactly this) calling
+ each of the six tools end-to-end against a running `app.mcp` server in a test — the one place
+ this spec's own transport actually gets exercised, not just the service calls underneath it.
+
+## 8. Open parameters (not blocking this spec)
+
+- Token rotation cadence and a bulk-revoke-on-offboarding runbook — operational policy, not a
+ schema or code concern this spec resolves.
+- Whether `list_open_reconciliation_breaks` needs its own pagination for a very large break queue
+ — deferred until real volume argues for it, consistent with S12 §2's own "add a new migration
+ when a real pattern demands it" posture.
diff --git a/docs/superpowers/specs/2026-09-05-frontend-completion-design.md b/docs/superpowers/specs/2026-09-05-frontend-completion-design.md
new file mode 100644
index 0000000..c02e978
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-05-frontend-completion-design.md
@@ -0,0 +1,225 @@
+# Frontend completion — design
+
+## Context
+
+The frontend (`frontend/src`) is not a greenfield build: five prior phases produced ~204 files
+across 12 customer routes and 4 admin routes, a 93-token design system with zero hardcoded-color
+violations, a clean `apiClient.js` (CSRF, idempotency keys, RFC 9457 error normalization), and a
+passing `npm run lint` / `npm run build`. What's missing is real: **zero test files** despite
+`frontend/CLAUDE.md`'s "no component is done without a test," and **six domains built against
+`mockClient.js` instead of the API** — committed under a commit literally titled "Phase 4 - mocked
+domains." `App.jsx`'s own comment names them: portfolio, lots, fees, chat, admin customers,
+statement export.
+
+A three-way audit (mock inventory, backend API surface, spec conformance) found the six mocks split
+two ways:
+
+- **Backend already has the route** (portfolio, fees, chat, admin customer fees) — pure frontend
+ wiring gaps.
+- **No backend route exists at all** (lots, admin customers directory/detail/KYC-override,
+ statement export) — these are exactly S8's F10 finding from the earlier backend audit: six
+ documented-but-unbuilt routes.
+
+Two more gaps surfaced beyond the six named mocks: `structure.md §7` mandates a `hooks/useSSE` for
+real-time push (order fill, KYC verdict, new break) built on `S12 §6`/`ADR 20`'s Redis Pub/Sub
+fan-out — neither the backend endpoint nor the frontend hook exists, only chat has SSE. And the fee
+payment-method form collects a card brand/last-4 as plain strings instead of tokenizing through
+Stripe Elements, despite `@stripe/stripe-js` already being a dependency and `GET /identity/config`
+already serving the publishable key needed to mount it.
+
+The user's directive — "nothing should be mocked, ensure everything is finished" — was tested
+against three scope questions, each decided in favor of the larger, more complete option:
+
+1. **Missing backend routes**: build them (not leave three domains mocked, not remove them).
+2. **Order-form pricing**: reuse `GET /portfolios/models` for the security picker; the customer
+ enters `reference_price` (already a required API field) rather than inventing a quote source.
+3. **Test depth**: exhaustive — one colocated test file per component, matching the literal text of
+ `frontend/CLAUDE.md` and `structure.md §8`, not a risk-prioritized subset.
+4. **Real-time push**: build the full fan-out (backend SSE endpoints + Redis Pub/Sub publish sites +
+ frontend `useSSE` + its three consumers), not defer it.
+5. **Stripe Billing**: wire real Stripe Elements, tokenizing in the browser so card data never
+ touches Trueup's servers.
+
+This spec is the design for closing all of it. It does not re-litigate those five decisions or the
+backend remediation plan already executed and merged (`carefully-and-thoroughly-read-joyful-riddle`
+plan, PRs #5–#6) — it starts from `main` at that point.
+
+## Scope
+
+**In scope:**
+- Six new/extended backend routes (S8 F10): `/lots`, `/statements//export`,
+ `/admin/customers`, `/admin/customers/`, `/admin/kyc-overrides/`,
+ `/admin/customers//fees` (the last already partially served by `GET /fees?customer_id=`).
+- Real-time push backend: `GET /api/v1/events/stream`, `GET /api/v1/admin/events/stream`, and the
+ three publish sites (`OrderService`, `KycService`/`AccountApprovalService`,
+ `ReconciliationService`), per `S12 §6`'s already-complete wire design.
+- Frontend: replace all six `mockClient`-backed `api/*.js` modules with real `apiClient` calls;
+ build `hooks/useSSE` and its three feature consumers; replace `useChatStream`'s fake token-split
+ with a real `fetch` + `ReadableStream` consumer of the existing chat SSE endpoint; replace
+ `PaymentMethodForm`'s plain-string capture with Stripe Elements.
+- Vitest + React Testing Library installed and wired into `npm test` and `Makefile`; one colocated
+ test file per component/hook/api module/route-guard, per `structure.md §8`.
+
+**Out of scope (explicitly deferred, not silently dropped):**
+- CI/CD wiring for the frontend (Phase 3 of the backend plan already covers this ground generally;
+ extending it to frontend jobs is a follow-up once tests exist to run).
+- Azure deployment of the frontend container (Dockerfile already exists from Phase 0; deploying it
+ is Phase 5 of the backend plan, unchanged).
+- Anything in `docs/specs/frontend/structure.md §9`'s still-open question 2 (`/admin/breaks/:id`
+ hydrating from the list vs. a dedicated fetch) — no new information changes that answer; current
+ hydrate-from-list behavior stands.
+
+## Architecture
+
+### A. Backend: the six S8 routes
+
+All five new endpoints follow the existing controller pattern exactly (Pydantic request/response
+views in `app/views/`, a thin controller in `app/controllers/api/` or `app/controllers/admin/`, a
+service method, `@requires_role`/inline ownership matching neighboring routes, `@audited` on every
+privileged write per `ADR 15`).
+
+- **`GET /api/v1/lots`** (customer route, `S8 §3`) — new `LotsService` read method returning open
+ and (optionally, `?include_closed=`) closed lots for the caller's `customer_id`, with
+ `is_provisional` computed from `designation_window_closes_at` vs. now, and wash-sale adjustment
+ fields surfaced per `TaxLot.adjusted_basis`. Reuses `TaxLotRepository` (`app/models/ledger/tax_lot.py`)
+ already built for S5 — no new repository method beyond a customer-scoped list query.
+
+- **`GET /api/v1/statements//export`** (`S8 §5`, FR-36) — a new controller action beside the
+ existing `statements.py` routes. Reads the published snapshot exactly as `get_statement` does
+ (never a live recompute — same "not yet published" 404 if absent, per spec edge case 1), plus the
+ period's realized lot consumptions (S5) including `is_provisional` and any
+ `wash_sale_adjustment` rows, and renders CSV (`text/csv`, `Content-Disposition: attachment`). No
+ PDF (spec names it out of scope for v1).
+
+- **`GET /api/v1/admin/customers`** (`S8 §4`) — paginated directory using the existing
+ `app/core/pagination.py` helper; `?query=` matches email case-insensitively (name matching is
+ deferred per the spec's own note — S2 carries no name field yet). `@requires_role("adviser",
+ "admin")`.
+
+- **`GET /api/v1/admin/customers/`** — aggregates existing read paths under the adviser RLS
+ branch: `ValuationService.value_book` (the *same* call `/valuation/balance` makes — spec edge case
+ 4's "must agree exactly" requirement, satisfied by literally not duplicating the aggregation), KYC
+ and account-approval status, and **any open reconciliation break for this customer**, surfaced as
+ a prominent field per spec edge case 2 — not a separate fetch the frontend has to remember to make.
+
+- **`POST /api/v1/admin/kyc-overrides/`** — the concrete route for S2 §9's open
+ parameter. Only valid when `customer.kyc_status == REJECTED` and the latest `KycSession.attempt_number
+ >= KYC_MAX_ATTEMPTS` (the existing locked-rejected condition already computed in
+ `kyc_service.py:47`, not a new column) — resets `kyc_status` to `pending` and allows a fresh
+ `KycSession`. `@audited("identity.kyc.override")`.
+
+- **`GET /api/v1/admin/customers//fees`** — thin adviser-scoped wrapper over the same
+ `FeeChargeService` read path `GET /fees?customer_id=` already uses; kept as its own route per the
+ spec's route table (adviser fee view vs. customer fee view are named separately in `S8 §4`), but
+ implemented as a one-line delegation, not a second aggregation.
+
+`/identity/status/` also gets a small extension here: distinguish "rejected, locked"
+from "rejected, resubmittable" in its response (spec edge case 3), using the same
+`attempt_number >= max_attempts` check the override route reads, rather than inventing a new status
+value the customer-facing copy has to interpret.
+
+### B. Backend: real-time push (S12 §6 / ADR 20)
+
+The wire design is already fully specified — this implements it, not designs it:
+
+- Two SSE routes: `GET /api/v1/events/stream` (customer, ownership-scoped) and
+ `GET /api/v1/admin/events/stream` (adviser, role-scoped) — long-lived generators identical in
+ shape to `chat.py`'s existing `send_message` SSE handler, subscribing to
+ `customer::events` or `adviser:events` via the existing `redis.Redis` client
+ (`app/extensions.py:make_redis`) on connection open, unsubscribing on disconnect.
+- Message shape fixed by spec: `{event_type, entity_id, summary}` — a pointer, never the entity
+ itself; the frontend's reaction is always a re-fetch of the relevant `GET` endpoint.
+- Three publish sites, each firing **after** its own commit (never inside the transaction, per
+ S0 §5's no-I/O-in-a-transaction rule, restated explicitly by S12 §6): `OrderService` on a fill or
+ terminal `order_event`; `KycService`/`AccountApprovalService` on a status change; `ReconciliationService`
+ on opening a `reconciliation_break`. Adding a fourth is not in scope — three matches the spec's
+ named list exactly.
+- No new infra: same Redis instance already used for sessions/rate limits (ADR 13/15's "never holds
+ financial state" is preserved — a Pub/Sub message is an ephemeral pointer, not a fact of record).
+
+### C. Frontend: mock removal
+
+Each of the six `api/*.js` modules is rewritten to call `apiClient` instead of `mockClient`, matching
+the request/response shapes the backend audit already pinned down exactly (money/units/price as
+fixed-point strings, `code`-only error bodies). Two need a field-mapping layer because names differ
+from what the mock invented:
+
+- `feesApi.js` — backend's `accrual_to_date` / `high_water_mark.peak_value` map onto the same UI
+ concepts the mock called `accrual.month_to_date_gain` etc.; the mapping lives in the api module,
+ not spread across components.
+- `chatApi.js` / `useChatStream.js` — replaced with a real `fetch(..., {method: 'POST'})` +
+ `response.body.getReader()` loop parsing `data: {...}\n\n` frames per the three documented shapes
+ (`token`, `completed`, `error`); `EventSource` cannot be used since the endpoint is a CSRF-bearing
+ POST.
+- `portfoliosApi.js` — drops the fabricated `getSecurities()` price table entirely;
+ `OrderForm` now sources its security picker from `GET /portfolios/models`'s
+ `target_weights[].{security_id, symbol}` and asks the customer for `reference_price` directly
+ (decision 2 above) — the field the API already requires, so no contract change.
+- `lotsApi.js`, `adminCustomersApi.js`, `statements/export` — become thin real clients once §A's
+ routes exist; no mapping needed, response shapes are authored to match the frontend's existing
+ expectations since both are built in this change.
+
+`mockClient.js` is deleted once its last consumer is migrated — not left as unreferenced dead code.
+
+### D. Frontend: `useSSE` and its consumers
+
+One hook (`src/hooks/useSSE.js`), one connection per authenticated session, opened once by
+`SessionContext` (mirroring how `useChatStream` already manages its own connection) and exposing a
+subscribe-by-`event_type` API so a feature registers a callback rather than the hook knowing about
+every feature. Three consumers, exactly as `structure.md §7` specifies:
+
+- `orders` feature: a `fill` push re-fetches and patches the matching row in place — no full list
+ refetch.
+- `SessionContext`: a KYC/approval push re-fetches `GET /auth/session` so onboarding gating updates
+ without a manual reload.
+- `admin-breaks` feature: a new-break push re-fetches the list's first page and prepends any row not
+ already present.
+
+No connection (tab backgrounded, disconnected) degrades to nothing — every screen's own `GET` on
+mount stays the source of truth, exactly as ADR 20 requires.
+
+### E. Frontend: real Stripe Elements for billing
+
+`PaymentMethodForm` mounts Stripe Elements (`@stripe/stripe-js`, already a dependency) using the
+publishable key from the existing `GET /identity/config`, collects the card via Stripe's own
+`CardElement` (never touching Trueup's servers with raw card data), and submits the resulting
+`payment_method_id` to the existing `POST /payment-methods`. This is the same integration pattern
+`OnboardingPage`'s Stripe Identity step already uses for the KYC provider key, applied to Billing.
+
+### F. Testing
+
+Vitest + `@testing-library/react` + `@testing-library/jest-dom` + `jsdom`, wired as `npm test` in
+`package.json` and a `frontend-test` target in the root `Makefile` (mirroring the backend's
+`test-unit`/`test-integration` pattern). Per `structure.md §8`, colocated one-file-per-unit:
+
+- **Generic `components/`** (13 dirs): render + interaction + keyboard/focus-visible tests — the
+ shared accessibility contract, tested once.
+- **Feature components** (~60 files across 12 features): render tests per data shape, including the
+ provisional/adjusted/dunning variant named per-domain in `design-system.md §8`.
+- **Feature hooks** (~20 files): success/error/idempotent-retry against a mocked `api/*.js`.
+- **api modules** (13 files): request-shape and error-mapping tests against a mocked `fetch`.
+- **Forms**: validation, submit-disabled-while-submitting, submit-failure-keeps-input-filled.
+- **`ChatWindow`/`useChatStream`**: mocked SSE stream, incremental token rendering,
+ `tool_calls` disclosure.
+- **Route guards** (`SessionContext`, `guards.jsx`): tested once at the router/context level.
+- **Loading/empty/error states**: the `structure.md §6` table is the checklist — each named cell
+ becomes an explicit test case, not an implicit "renders" assertion.
+
+## Data flow / error handling
+
+No change to the established contracts: `apiClient.js`'s CSRF/idempotency/error-normalization stays
+the single point of contact; every error still surfaces as `ApiError{status, code, ...}` and every
+new form follows the existing inline-error, keep-input-filled pattern already used by
+`DepositForm`/`WithdrawForm`. The two new backend surfaces (admin aggregation, SSE) both read
+already-existing services rather than introducing a second source of truth, satisfying spec edge
+case 4 (`/valuation/balance` and `/admin/customers/` must agree exactly).
+
+## Testing (verification for this change itself)
+
+- Backend: new routes get the same four-layer coverage (`tests/unit`, `tests/integration`,
+ `tests/api`, `tests/contract` where applicable) as every existing S8 route; `make check` green;
+ Alembic round-trip if any schema changes (none anticipated — all new routes read existing tables).
+- Frontend: `npm test` green, `npm run lint`, `npm run build` green; manual smoke of the full loop
+ (register → KYC → bank link → deposit → assign portfolio → order → SSE fill push → statement →
+ export) against a running `make up && make dev` backend.
+- `mockClient.js` has zero remaining importers before deletion — verified by grep, not assumption.
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index db46ca2..240e651 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -13,10 +13,25 @@ RUN npm ci
COPY . .
RUN npm run build
-FROM nginx:1.27-alpine AS runtime
+FROM nginx:1.29-alpine AS runtime
+
+# Alpine's own package repo ships CVE fixes faster than the upstream nginx image gets rebuilt --
+# confirmed the hard way (the pinned tag alone still failed CI's Trivy scan with 34 HIGH findings
+# that already have a fixed package version available). Upgrading at build time, straight from
+# Alpine's mirrors, tracks that faster cadence instead of waiting on Docker Hub's rebuild schedule.
+RUN apk update && apk upgrade --no-cache
COPY --from=builder /app/dist /usr/share/nginx/html
-COPY nginx.conf /etc/nginx/conf.d/default.conf
+# The official nginx image's own entrypoint (`/docker-entrypoint.d/20-envsubst-on-templates.sh`)
+# renders any `*.template` file here into `/etc/nginx/conf.d/`, substituting only variables that
+# are actually set in the container's environment -- nginx's own `$host`/`$remote_addr`/etc. are
+# never OS environment variables, so they pass through untouched automatically.
+COPY nginx.conf.template /etc/nginx/templates/nginx.conf.template
+
+# Default matches docker-compose's network; a deployed environment (e.g. Azure Container Apps)
+# overrides this to the backend's actual reachable address -- see the module comment in
+# nginx.conf.template for why this can't stay hardcoded.
+ENV BACKEND_URL=http://backend:8000
EXPOSE 8080
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
deleted file mode 100644
index 14efe1a..0000000
--- a/frontend/nginx.conf
+++ /dev/null
@@ -1,30 +0,0 @@
-# Same-origin proxy for /api and /health, matching the dev-time Vite proxy in vite.config.js.
-# Required because the session cookie is SameSite=Strict and no CORS is configured anywhere in
-# the backend (see docs/architecture.md) -- a separate origin would neither send nor receive it.
-
-server {
- listen 8080;
- server_name _;
-
- root /usr/share/nginx/html;
- index index.html;
-
- location /api/ {
- proxy_pass http://backend:8000;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
- location /health {
- proxy_pass http://backend:8000;
- proxy_set_header Host $host;
- }
-
- # SPA fallback -- react-router-dom owns client-side routing; any unmatched path serves the
- # app shell rather than a raw 404.
- location / {
- try_files $uri $uri/ /index.html;
- }
-}
diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template
new file mode 100644
index 0000000..196da1b
--- /dev/null
+++ b/frontend/nginx.conf.template
@@ -0,0 +1,51 @@
+# Same-origin proxy for /api and /health, matching the dev-time Vite proxy in vite.config.js.
+# Required because the session cookie is SameSite=Strict and no CORS is configured anywhere in
+# the backend (see docs/architecture.md) -- a separate origin would neither send nor receive it.
+#
+# ${BACKEND_URL} is filled in by nginx's own built-in template rendering at container start
+# (`/docker-entrypoint.d/20-envsubst-on-templates.sh`, since this file lives under
+# /etc/nginx/templates/ -- see Dockerfile), defaulting to http://backend:8000 for
+# docker-compose's network -- Container Apps' reachable backend address isn't that literal
+# hostname, so it must be configurable rather than hardcoded.
+
+server {
+ listen 8080;
+ server_name _;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location /api/ {
+ proxy_pass ${BACKEND_URL};
+ proxy_http_version 1.1;
+ # Cleared, not forwarded: the platform's own ingress-to-container hop can set
+ # Connection/Upgrade headers for its own protocol negotiation (observed returning
+ # nginx's own 426 Upgrade Required when blindly forwarded to a plain HTTP/1.1 upstream
+ # that never completes the requested upgrade). Neither /api nor /health ever needs a
+ # WebSocket upgrade, so clearing this is always correct here, not just a workaround.
+ proxy_set_header Connection "";
+ # No `Host` override: the backend has no Host-based logic (no CORS, no vhost routing),
+ # so nginx's default ($proxy_host, i.e. BACKEND_URL's own host) costs nothing; the
+ # original client host is still available via X-Forwarded-Host below.
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ # The chat endpoint (POST /api/v1/chat/sessions/:id/messages) is text/event-stream --
+ # without this, nginx buffers the whole response before forwarding it, so the client
+ # would see every token arrive at once instead of streaming incrementally.
+ proxy_buffering off;
+ }
+
+ location /health {
+ proxy_pass ${BACKEND_URL};
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ }
+
+ # SPA fallback -- react-router-dom owns client-side routing; any unmatched path serves the
+ # app shell rather than a raw 404.
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/frontend/src/components/SimulatedBadge/SimulatedBadge.jsx b/frontend/src/components/SimulatedBadge/SimulatedBadge.jsx
new file mode 100644
index 0000000..177a09a
--- /dev/null
+++ b/frontend/src/components/SimulatedBadge/SimulatedBadge.jsx
@@ -0,0 +1,15 @@
+import { Badge } from '../Badge'
+
+/**
+ * Design system §8.3's provenance-disclosure pattern, generalized past its one named case
+ * (simulated account approval): a neutral, non-semantic tag marking a screen or section as backed
+ * by mock data rather than a live backend route — never a health signal, so never a semantic
+ * color (rubric: "honest real-versus-simulated labelling").
+ */
+export function SimulatedBadge({ className }) {
+ return (
+
+ Simulated
+
+ )
+}
diff --git a/frontend/src/components/SimulatedBadge/index.js b/frontend/src/components/SimulatedBadge/index.js
new file mode 100644
index 0000000..e75ad1d
--- /dev/null
+++ b/frontend/src/components/SimulatedBadge/index.js
@@ -0,0 +1 @@
+export { SimulatedBadge } from './SimulatedBadge.jsx'
diff --git a/frontend/src/features/admin-customers/api/adminCustomersApi.js b/frontend/src/features/admin-customers/api/adminCustomersApi.js
index b29bf5b..ab1c876 100644
--- a/frontend/src/features/admin-customers/api/adminCustomersApi.js
+++ b/frontend/src/features/admin-customers/api/adminCustomersApi.js
@@ -1,5 +1,8 @@
-// MOCK — no backend endpoint exists yet (admin customer directory/detail/KYC-override). Replace
-// with a real fetch call once that spec ships.
+// MOCK — no backend endpoint exists yet for the admin customer directory/detail/KYC-override
+// (S8's still-missing GET /admin/customers, GET /admin/customers/, POST
+// /admin/kyc-overrides/). The fee panel on the customer-detail page is wired for
+// real separately (see `feesApi.getForCustomer`, used by `useCustomerFees`) since
+// `GET /fees?customer_id=` already exists.
import { mockClient } from '../../../services/mockClient.js'
const NAMESPACE = 'admin-customers'
@@ -60,9 +63,4 @@ export const adminCustomersApi = {
}
return mockClient.request(customer)
},
- getFees: () =>
- mockClient.request({
- accrual: { peak_value: '31000.00', month_to_date_gain: '400.00', month_to_date_fee: '80.00' },
- dunning: null,
- }),
}
diff --git a/frontend/src/features/admin-customers/components/CustomerFeesPanel.jsx b/frontend/src/features/admin-customers/components/CustomerFeesPanel.jsx
index 16c02c1..db91e4b 100644
--- a/frontend/src/features/admin-customers/components/CustomerFeesPanel.jsx
+++ b/frontend/src/features/admin-customers/components/CustomerFeesPanel.jsx
@@ -19,9 +19,8 @@ export function CustomerFeesPanel({ customerId }) {
return (
)
diff --git a/frontend/src/features/admin-customers/hooks/useCustomerFees.js b/frontend/src/features/admin-customers/hooks/useCustomerFees.js
index e34fb27..a6dc46c 100644
--- a/frontend/src/features/admin-customers/hooks/useCustomerFees.js
+++ b/frontend/src/features/admin-customers/hooks/useCustomerFees.js
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
-import { adminCustomersApi } from '../api/adminCustomersApi.js'
+import { feesApi } from '../../fees/api/feesApi.js'
const IDLE = { status: 'idle', accrual: null, dunning: null, error: null }
@@ -9,7 +9,7 @@ export function useCustomerFees(customerId) {
const refetch = useCallback(async () => {
setState((prev) => ({ ...prev, status: 'loading', error: null }))
try {
- const data = await adminCustomersApi.getFees(customerId)
+ const data = await feesApi.getForCustomer(customerId)
setState({ status: 'loaded', accrual: data.accrual, dunning: data.dunning, error: null })
} catch (error) {
setState({ status: 'error', accrual: null, dunning: null, error })
diff --git a/frontend/src/features/chat/api/chatApi.js b/frontend/src/features/chat/api/chatApi.js
index 73999cc..738ca83 100644
--- a/frontend/src/features/chat/api/chatApi.js
+++ b/frontend/src/features/chat/api/chatApi.js
@@ -1,70 +1,103 @@
-// MOCK — no backend endpoint exists yet (S11 NL query assistant). Replace with a real fetch call
-// once that spec ships. The real `POST /chat/sessions/:id/messages` is `text/event-stream`
-// (`structure.md` §5) — this build has no SSE at all (Phase 0 scope decision), so the mock
-// resolves the full reply and `useChatStream` simulates the token reveal client-side instead.
-import { mockClient } from '../../../services/mockClient.js'
+// S11 NL query assistant — POST/GET /chat/sessions, GET/POST /chat/sessions/:id/messages.
+// The send endpoint is `text/event-stream`, so it goes through a raw `fetch` + `ReadableStream`
+// reader rather than `apiClient` (which only ever does `response.json()`).
+import { apiClient, ApiError } from '../../../services/apiClient.js'
-const NAMESPACE = 'chat'
+const API_BASE = '/api/v1'
-function seed() {
- return { sessions: [] }
+function toMessage({ id, role, content, created_at }) {
+ return { id, role, text: content, created_at }
}
-function cannedReply(text) {
- const lower = text.toLowerCase()
- if (lower.includes('balance')) {
- return {
- text: 'Your account balance is $52,000.00 as of the latest close.',
- tool_calls: [{ tool: 'get_balance', arguments: {}, result: '{"total_value": "52000.00"}' }],
- }
- }
- if (lower.includes('return') || lower.includes('performance')) {
- return {
- text: 'Your month-to-date return is +2.35%.',
- tool_calls: [{ tool: 'get_returns', arguments: { period: 'mtd' }, result: '{"twr": "0.0235"}' }],
- }
- }
- // FR-54: a decline-to-answer renders as a normal assistant message, not an error.
- return {
- text: "I'm not able to help with that yet — try asking about your balance or recent returns.",
- tool_calls: [],
+async function parseErrorBody(response) {
+ const contentType = response.headers.get('content-type') || ''
+ if (!contentType.includes('json')) return null
+ try {
+ return await response.json()
+ } catch {
+ return null
}
}
export const chatApi = {
- createSession: () => {
- const store = mockClient.getStore(NAMESPACE, seed)
- const session = { id: mockClient.mockId(NAMESPACE, store.sessions.length + 1), created_at: new Date().toISOString(), messages: [] }
- store.sessions.push(session)
- return mockClient.request({ id: session.id, created_at: session.created_at })
+ createSession: async () => {
+ const data = await apiClient.post('/chat/sessions')
+ return { id: data.session_id }
},
- listSessions: () => {
- const store = mockClient.getStore(NAMESPACE, seed)
- return mockClient.request(store.sessions.map(({ id, created_at }) => ({ id, created_at })))
+ listSessions: async () => {
+ const data = await apiClient.get('/chat/sessions')
+ return data.sessions
},
- getMessages: (sessionId) => {
- const store = mockClient.getStore(NAMESPACE, seed)
- const session = store.sessions.find((candidate) => candidate.id === sessionId)
- return mockClient.request(session ? session.messages : [])
+ getMessages: async (sessionId) => {
+ const data = await apiClient.get(`/chat/sessions/${sessionId}/messages`)
+ return data.messages.map(toMessage)
},
- sendMessage: (sessionId, text) => {
- const store = mockClient.getStore(NAMESPACE, seed)
- const session = store.sessions.find((candidate) => candidate.id === sessionId)
- const reply = cannedReply(text)
- const userMessage = {
- id: mockClient.mockId(NAMESPACE, `${sessionId}-u-${session.messages.length}`),
- role: 'user',
- text,
- created_at: new Date().toISOString(),
+ /** Streams the assistant's reply, calling `onToken(accumulatedText)` as each token frame
+ * arrives, and resolving with the final assistant message once the `completed` frame lands. */
+ streamMessage: async (sessionId, text, { onToken } = {}) => {
+ let response
+ try {
+ response = await fetch(`${API_BASE}/chat/sessions/${sessionId}/messages`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream',
+ 'X-CSRFToken': apiClient.getCsrfToken(),
+ },
+ body: JSON.stringify({ content: text }),
+ })
+ } catch {
+ throw new ApiError({ status: 0, code: 'network_error', title: 'Network request failed' })
+ }
+
+ if (!response.ok) {
+ const data = await parseErrorBody(response)
+ throw new ApiError({
+ status: response.status,
+ code: data?.code,
+ title: data?.title,
+ type: data?.type,
+ correlationId: data?.correlation_id,
+ })
}
- const assistantMessage = {
- id: mockClient.mockId(NAMESPACE, `${sessionId}-a-${session.messages.length + 1}`),
- role: 'assistant',
- text: reply.text,
- tool_calls: reply.tool_calls,
- created_at: new Date().toISOString(),
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ let accumulator = ''
+
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+
+ let separatorIndex
+ // eslint-disable-next-line no-cond-assign
+ while ((separatorIndex = buffer.indexOf('\n\n')) !== -1) {
+ const rawEvent = buffer.slice(0, separatorIndex)
+ buffer = buffer.slice(separatorIndex + 2)
+
+ const dataLine = rawEvent.split('\n').find((line) => line.startsWith('data:'))
+ if (!dataLine) continue
+ const frame = JSON.parse(dataLine.slice('data:'.length).trim())
+
+ if (frame.type === 'token') {
+ accumulator += frame.text
+ onToken?.(accumulator)
+ } else if (frame.type === 'completed') {
+ return {
+ id: frame.message_id,
+ role: 'assistant',
+ text: accumulator,
+ tool_calls: frame.tool_calls,
+ }
+ } else if (frame.type === 'error') {
+ throw new Error(frame.message)
+ }
+ }
}
- session.messages.push(userMessage, assistantMessage)
- return mockClient.request(assistantMessage, { latency: 500 })
+
+ return { id: `local-${Date.now()}`, role: 'assistant', text: accumulator, tool_calls: [] }
},
}
diff --git a/frontend/src/features/chat/hooks/useChatStream.js b/frontend/src/features/chat/hooks/useChatStream.js
index 132d88b..da1859b 100644
--- a/frontend/src/features/chat/hooks/useChatStream.js
+++ b/frontend/src/features/chat/hooks/useChatStream.js
@@ -1,8 +1,8 @@
import { useCallback, useState } from 'react'
-import { mockClient } from '../../../services/mockClient.js'
import { chatApi } from '../api/chatApi.js'
-/** Exposes streaming text plus the final structured trace separately (`structure.md` §5) — the mock reveals an already-resolved reply word-by-word rather than opening a real stream. */
+/** Exposes streaming text plus the final structured trace separately (`structure.md` §5) — tokens
+ * arrive from the real SSE stream via `chatApi.streamMessage`'s `onToken` callback. */
export function useChatStream(sessionId) {
const [status, setStatus] = useState('idle')
const [streamingText, setStreamingText] = useState('')
@@ -14,14 +14,9 @@ export function useChatStream(sessionId) {
setStreamingText('')
setError(null)
try {
- const assistantMessage = await chatApi.sendMessage(sessionId, text)
- const words = assistantMessage.text.split(' ')
- let revealed = ''
- for (const word of words) {
- revealed = revealed ? `${revealed} ${word}` : word
- setStreamingText(revealed)
- await mockClient.delay(60)
- }
+ const assistantMessage = await chatApi.streamMessage(sessionId, text, {
+ onToken: setStreamingText,
+ })
setStatus('idle')
return assistantMessage
} catch (err) {
diff --git a/frontend/src/features/fees/api/feesApi.js b/frontend/src/features/fees/api/feesApi.js
index 191741f..7a1877f 100644
--- a/frontend/src/features/fees/api/feesApi.js
+++ b/frontend/src/features/fees/api/feesApi.js
@@ -1,56 +1,32 @@
-// MOCK — no backend endpoint exists yet (S10 performance fees). Replace with a real fetch call
-// once that spec ships. Field names match `docs/specs/10-performance-fees.md`'s
-// `high_water_mark`/`fee_accrual`/`fee_charge`/`dunning_state` tables.
-import { mockClient } from '../../../services/mockClient.js'
+// S10 performance fees — GET /fees, POST /payment-methods.
+import { apiClient } from '../../../services/apiClient.js'
-const NAMESPACE = 'fees'
-
-function seed() {
+// The real `FeeSummaryResponse` has no `month_to_date_gain` field (that would need a valuation
+// dollar-gain figure this endpoint doesn't compute) and no `payment_method` field (there is no GET
+// that returns the currently-attached method) — both were mock inventions. This maps only what the
+// backend actually returns rather than inventing either.
+function toAccrual(data) {
return {
- accrual: {
- peak_value: '52000.00',
- month_to_date_gain: '1200.00',
- month_to_date_fee: '240.00',
- },
- charges: [
- {
- id: 'charge-1',
- billing_period_start: '2026-07-01',
- billing_period_end: '2026-07-31',
- total_accrued: '210.00',
- status: 'succeeded',
- },
- {
- id: 'charge-2',
- billing_period_start: '2026-08-01',
- billing_period_end: '2026-08-31',
- total_accrued: '240.00',
- status: 'dunning',
- },
- ],
- dunning: {
- status: 'retrying',
- attempt_number: 2,
- max_attempts: 4,
- next_retry_at: '2026-09-10T00:00:00Z',
- },
- payment_method: null,
+ peak_value: data.high_water_mark?.peak_value ?? null,
+ updated_at: data.high_water_mark?.updated_at ?? null,
+ accrual_to_date: data.accrual_to_date,
}
}
export const feesApi = {
- get: () => {
- const store = mockClient.getStore(NAMESPACE, seed)
- return mockClient.request({
- accrual: store.accrual,
- charges: store.charges,
- dunning: store.dunning,
- payment_method: store.payment_method,
- })
+ get: async () => {
+ const data = await apiClient.get('/fees')
+ return { accrual: toAccrual(data), charges: data.charges, dunning: data.dunning }
},
- attachPaymentMethod: (paymentMethod) => {
- const store = mockClient.getStore(NAMESPACE, seed)
- store.payment_method = paymentMethod
- return mockClient.request(store.payment_method)
+ getForCustomer: async (customerId) => {
+ const data = await apiClient.get(`/fees?customer_id=${customerId}`)
+ return { accrual: toAccrual(data), charges: data.charges, dunning: data.dunning }
+ },
+ attachPaymentMethod: async (customerId, paymentMethodId) => {
+ const data = await apiClient.post('/payment-methods', {
+ customer_id: customerId,
+ payment_method_id: paymentMethodId,
+ })
+ return data
},
}
diff --git a/frontend/src/features/fees/components/AccrualSummary.jsx b/frontend/src/features/fees/components/AccrualSummary.jsx
index 86b41d3..b1a98ab 100644
--- a/frontend/src/features/fees/components/AccrualSummary.jsx
+++ b/frontend/src/features/fees/components/AccrualSummary.jsx
@@ -12,9 +12,8 @@ export function AccrualSummary({ accrual }) {
diff --git a/frontend/src/features/fees/components/PaymentMethodForm.jsx b/frontend/src/features/fees/components/PaymentMethodForm.jsx
index aac897c..5e63970 100644
--- a/frontend/src/features/fees/components/PaymentMethodForm.jsx
+++ b/frontend/src/features/fees/components/PaymentMethodForm.jsx
@@ -1,38 +1,40 @@
import { useState } from 'react'
import { Button } from '../../../components/Button'
-import { Input } from '../../../components/Input'
+import { Select } from '../../../components/Select'
import { useToast } from '../../../components/Toast'
import { getErrorMessage } from '../../../utils/apiErrorMessage.js'
import { usePaymentMethod } from '../hooks/usePaymentMethod.js'
import './PaymentMethodForm.css'
-// MOCK — no real Stripe Billing/Elements wiring exists yet for fees (S10). A brand/last-4 pair
-// stands in for a captured card rather than a fake Elements-styled form implying real capture.
-export function PaymentMethodForm({ currentPaymentMethod, onAttached }) {
- const { status, error, attach } = usePaymentMethod()
+// Stripe's fixed test-mode PaymentMethod ids (https://docs.stripe.com/testing) — attachable
+// directly to a Stripe Customer via the real API in test mode, no Elements/card-collection UI
+// needed. Each has a well-known test last-4 used only for the "Current" display below.
+const TEST_PAYMENT_METHODS = {
+ visa: { label: 'Visa', paymentMethodId: 'pm_card_visa', last4: '4242' },
+ mastercard: { label: 'Mastercard', paymentMethodId: 'pm_card_mastercard', last4: '4444' },
+ amex: { label: 'American Express', paymentMethodId: 'pm_card_amex', last4: '0005' },
+ discover: { label: 'Discover', paymentMethodId: 'pm_card_discover', last4: '1117' },
+}
+
+export function PaymentMethodForm({ customerId, currentPaymentMethod, onAttached }) {
+ const { status, error, attach } = usePaymentMethod(customerId)
const { showToast } = useToast()
- const [brand, setBrand] = useState('Visa')
- const [last4, setLast4] = useState('')
- const [validationMessage, setValidationMessage] = useState(null)
+ const [brandKey, setBrandKey] = useState('visa')
const isSubmitting = status === 'submitting'
const handleSubmit = (event) => {
event.preventDefault()
- setValidationMessage(null)
- if (!/^\d{4}$/.test(last4)) {
- setValidationMessage('Enter the last 4 digits of the card.')
- return
- }
- attach({ brand, last4 })
+ const chosen = TEST_PAYMENT_METHODS[brandKey]
+ attach(chosen.paymentMethodId)
.then(() => {
showToast({ message: 'Payment method saved.', tone: 'success' })
- onAttached?.()
+ onAttached?.({ brand: chosen.label, last4: chosen.last4 })
})
.catch(() => {})
}
- const displayError = validationMessage || (status === 'error' ? getErrorMessage(error) : null)
+ const displayError = status === 'error' ? getErrorMessage(error) : null
return (
)}
- setBrand(event.target.value)} required />
- setLast4(event.target.value)}
- required
- />
+ setBrandKey(event.target.value)}>
+ {Object.entries(TEST_PAYMENT_METHODS).map(([key, { label, last4 }]) => (
+
+ {label} ending in {last4} (Stripe test card)
+
+ ))}
+
{displayError && (
{displayError}
diff --git a/frontend/src/features/fees/hooks/useFees.js b/frontend/src/features/fees/hooks/useFees.js
index 460e67c..ba49f1f 100644
--- a/frontend/src/features/fees/hooks/useFees.js
+++ b/frontend/src/features/fees/hooks/useFees.js
@@ -10,14 +10,16 @@ export function useFees() {
setState((prev) => ({ ...prev, status: 'loading', error: null }))
try {
const data = await feesApi.get()
- setState({
+ // No GET returns the currently-attached payment method — carry forward whatever was set
+ // locally by a successful attach in this session rather than resetting it to null.
+ setState((prev) => ({
status: 'loaded',
accrual: data.accrual,
charges: data.charges,
dunning: data.dunning,
- paymentMethod: data.payment_method,
+ paymentMethod: prev.paymentMethod,
error: null,
- })
+ }))
} catch (error) {
setState({ status: 'error', accrual: null, charges: [], dunning: null, paymentMethod: null, error })
}
@@ -27,5 +29,9 @@ export function useFees() {
refetch()
}, [refetch])
- return { ...state, refetch }
+ const setPaymentMethod = useCallback((paymentMethod) => {
+ setState((prev) => ({ ...prev, paymentMethod }))
+ }, [])
+
+ return { ...state, refetch, setPaymentMethod }
}
diff --git a/frontend/src/features/fees/hooks/usePaymentMethod.js b/frontend/src/features/fees/hooks/usePaymentMethod.js
index 876d3b9..32952ad 100644
--- a/frontend/src/features/fees/hooks/usePaymentMethod.js
+++ b/frontend/src/features/fees/hooks/usePaymentMethod.js
@@ -1,23 +1,26 @@
import { useCallback, useState } from 'react'
import { feesApi } from '../api/feesApi.js'
-export function usePaymentMethod() {
+export function usePaymentMethod(customerId) {
const [status, setStatus] = useState('idle')
const [error, setError] = useState(null)
- const attach = useCallback(async (paymentMethod) => {
- setStatus('submitting')
- setError(null)
- try {
- const data = await feesApi.attachPaymentMethod(paymentMethod)
- setStatus('submitted')
- return data
- } catch (err) {
- setError(err)
- setStatus('error')
- throw err
- }
- }, [])
+ const attach = useCallback(
+ async (paymentMethodId) => {
+ setStatus('submitting')
+ setError(null)
+ try {
+ const data = await feesApi.attachPaymentMethod(customerId, paymentMethodId)
+ setStatus('submitted')
+ return data
+ } catch (err) {
+ setError(err)
+ setStatus('error')
+ throw err
+ }
+ },
+ [customerId],
+ )
return { status, error, attach }
}
diff --git a/frontend/src/features/lots/api/lotsApi.js b/frontend/src/features/lots/api/lotsApi.js
index 02ae679..8a8b09b 100644
--- a/frontend/src/features/lots/api/lotsApi.js
+++ b/frontend/src/features/lots/api/lotsApi.js
@@ -18,6 +18,10 @@ function seed() {
adjusted_basis: '2400.00',
acquired_at: '2026-06-15',
is_provisional: false,
+ // No quote endpoint exists — this domain is still mocked (S8's `/lots` route isn't built
+ // yet), so its own current-price figure is mock data local to this file, not sourced from
+ // the (now real) portfolio domain.
+ current_price: '265.40',
},
{
id: 'lot-2',
@@ -30,6 +34,7 @@ function seed() {
acquired_at: '2026-07-02',
is_provisional: true,
wash_sale_note: 'A prior loss sale on BND was disallowed and added to this lot’s basis (ADR 11).',
+ current_price: '72.85',
},
{
id: 'lot-3',
@@ -41,6 +46,7 @@ function seed() {
adjusted_basis: '448.00',
acquired_at: '2026-08-11',
is_provisional: false,
+ current_price: '58.20',
},
],
}
diff --git a/frontend/src/features/lots/components/LotTable.jsx b/frontend/src/features/lots/components/LotTable.jsx
index aa443ee..729a1ad 100644
--- a/frontend/src/features/lots/components/LotTable.jsx
+++ b/frontend/src/features/lots/components/LotTable.jsx
@@ -5,7 +5,6 @@ import { ErrorState } from '../../../components/ErrorState'
import { Skeleton } from '../../../components/Skeleton'
import { Table } from '../../../components/Table'
import { UnitsValue } from '../../../components/UnitsValue'
-import { useSecurities } from '../../portfolio/hooks/useSecurities.js'
import { getErrorMessage } from '../../../utils/apiErrorMessage.js'
import { formatMoney } from '../../../utils/format.js'
import { useLots } from '../hooks/useLots.js'
@@ -15,7 +14,6 @@ import './LotTable.css'
export function LotTable() {
const { status, lots, error, refetch } = useLots()
- const { securities } = useSecurities()
const [expandedId, setExpandedId] = useState(null)
if (status === 'idle' || status === 'loading') {
@@ -46,8 +44,7 @@ export function LotTable() {
{lots.map((lot) => {
- const security = securities.find((candidate) => candidate.security_id === lot.security_id)
- const currentValue = security ? new Decimal(lot.quantity_remaining).times(security.reference_price) : null
+ const currentValue = lot.current_price ? new Decimal(lot.quantity_remaining).times(lot.current_price) : null
const gainLoss = currentValue ? currentValue.minus(lot.adjusted_basis) : null
const isExpanded = expandedId === lot.id
diff --git a/frontend/src/features/orders/components/OrderForm.jsx b/frontend/src/features/orders/components/OrderForm.jsx
index d5f0e90..a9bb6b6 100644
--- a/frontend/src/features/orders/components/OrderForm.jsx
+++ b/frontend/src/features/orders/components/OrderForm.jsx
@@ -22,6 +22,7 @@ export function OrderForm() {
const [securityId, setSecurityId] = useState('')
const [side, setSide] = useState('buy')
const [quantity, setQuantity] = useState('')
+ const [referencePrice, setReferencePrice] = useState('')
const [validationMessage, setValidationMessage] = useState(null)
if (securitiesStatus === 'idle' || securitiesStatus === 'loading') {
@@ -32,10 +33,10 @@ export function OrderForm() {
const isSubmitting = status === 'submitting'
const notional =
- selectedSecurity && quantity && !Number.isNaN(Number(quantity))
+ quantity && referencePrice && !Number.isNaN(Number(quantity)) && !Number.isNaN(Number(referencePrice))
? (() => {
try {
- return new Decimal(quantity).times(selectedSecurity.reference_price).toFixed(2)
+ return new Decimal(quantity).times(referencePrice).toFixed(2)
} catch {
return null
}
@@ -54,12 +55,16 @@ export function OrderForm() {
setValidationMessage('Choose a security.')
return
}
+ if (!referencePrice || Number.isNaN(Number(referencePrice)) || Number(referencePrice) <= 0) {
+ setValidationMessage('Enter a valid reference price greater than zero.')
+ return
+ }
placeOrder({
securityId: selectedSecurity.security_id,
side,
quantity: parsedQuantity,
- referencePrice: selectedSecurity.reference_price,
+ referencePrice,
})
.then((order) => {
showToast({ message: 'Order placed.', tone: 'success' })
@@ -75,7 +80,7 @@ export function OrderForm() {
setSecurityId(event.target.value)}>
{securities.map((security) => (
- {security.symbol} — {formatMoney(security.reference_price)}
+ {security.symbol}
))}
@@ -92,6 +97,15 @@ export function OrderForm() {
placeholder="0"
required
/>
+ setReferencePrice(event.target.value)}
+ placeholder="0.00"
+ required
+ />
{notional && (
Estimated notional
diff --git a/frontend/src/features/portfolio/api/portfoliosApi.js b/frontend/src/features/portfolio/api/portfoliosApi.js
index d055542..a2f924d 100644
--- a/frontend/src/features/portfolio/api/portfoliosApi.js
+++ b/frontend/src/features/portfolio/api/portfoliosApi.js
@@ -1,74 +1,20 @@
-// MOCK — no backend endpoint exists yet (S9 rebalancing / model portfolios). Replace with a real
-// fetch call once that spec ships. Function signatures match what `GET /portfolios/models` and
-// `GET/POST /portfolios/assignment` would return.
-import { mockClient } from '../../../services/mockClient.js'
-
-const NAMESPACE = 'portfolio'
-
-function seed() {
- return {
- models: [
- {
- id: 'model-conservative',
- name: 'Conservative',
- description: 'Capital preservation first — a bond-heavy allocation with a small equity sleeve.',
- target_weights: [
- { security_id: 'sec-bnd', symbol: 'BND', target_weight: '0.6000' },
- { security_id: 'sec-vti', symbol: 'VTI', target_weight: '0.3000' },
- { security_id: 'sec-cash', symbol: 'CASH', target_weight: '0.1000' },
- ],
- },
- {
- id: 'model-balanced',
- name: 'Balanced',
- description: 'An even split between growth and stability.',
- target_weights: [
- { security_id: 'sec-vti', symbol: 'VTI', target_weight: '0.4500' },
- { security_id: 'sec-vxus', symbol: 'VXUS', target_weight: '0.1500' },
- { security_id: 'sec-bnd', symbol: 'BND', target_weight: '0.3500' },
- { security_id: 'sec-cash', symbol: 'CASH', target_weight: '0.0500' },
- ],
- },
- {
- id: 'model-growth',
- name: 'Growth',
- description: 'Mostly equities, a modest bond allocation to dampen volatility.',
- target_weights: [
- { security_id: 'sec-vti', symbol: 'VTI', target_weight: '0.6000' },
- { security_id: 'sec-vxus', symbol: 'VXUS', target_weight: '0.2500' },
- { security_id: 'sec-bnd', symbol: 'BND', target_weight: '0.1500' },
- ],
- },
- {
- id: 'model-aggressive',
- name: 'Aggressive',
- description: 'Maximum equity exposure for a long time horizon.',
- target_weights: [
- { security_id: 'sec-vti', symbol: 'VTI', target_weight: '0.7000' },
- { security_id: 'sec-vxus', symbol: 'VXUS', target_weight: '0.3000' },
- ],
- },
- ],
- // The orderable universe (S9's model composition, minus CASH — not a security you place an
- // order for). `reference_price` stands in for a market-data quote (no quote endpoint is
- // exposed to the frontend either), needed for OrderForm's notional preview and `POST /orders`.
- securities: [
- { security_id: 'sec-vti', symbol: 'VTI', reference_price: '265.40' },
- { security_id: 'sec-vxus', symbol: 'VXUS', reference_price: '58.20' },
- { security_id: 'sec-bnd', symbol: 'BND', reference_price: '72.85' },
- ],
- assignment: null,
- }
-}
+// S9 rebalancing / model portfolios — GET /portfolios/models, GET+POST /portfolios/assignment.
+import { apiClient } from '../../../services/apiClient.js'
export const portfoliosApi = {
- getModels: () => mockClient.request(mockClient.getStore(NAMESPACE, seed).models),
- getSecurities: () => mockClient.request(mockClient.getStore(NAMESPACE, seed).securities),
- getAssignment: () => mockClient.request(mockClient.getStore(NAMESPACE, seed).assignment),
- assign: (modelId) => {
- const store = mockClient.getStore(NAMESPACE, seed)
- const model = store.models.find((candidate) => candidate.id === modelId)
- store.assignment = { model_id: modelId, model_name: model?.name, assigned_at: new Date().toISOString() }
- return mockClient.request(store.assignment)
+ getModels: async () => {
+ const data = await apiClient.get('/portfolios/models')
+ return data.models
+ },
+ getAssignment: async () => {
+ const data = await apiClient.get('/portfolios/assignment')
+ return data.assignment
+ },
+ assign: async (customerId, modelPortfolioId) => {
+ const data = await apiClient.post('/portfolios/assignment', {
+ customer_id: customerId,
+ model_portfolio_id: modelPortfolioId,
+ })
+ return data
},
}
diff --git a/frontend/src/features/portfolio/components/HoldingsTable.jsx b/frontend/src/features/portfolio/components/HoldingsTable.jsx
index d5deb89..9d01e1d 100644
--- a/frontend/src/features/portfolio/components/HoldingsTable.jsx
+++ b/frontend/src/features/portfolio/components/HoldingsTable.jsx
@@ -27,7 +27,7 @@ export function HoldingsTable({ model }) {
{weight.symbol}
- {formatWeight(weight.target_weight)}
+ {formatWeight(weight.weight_pct)}
))}
diff --git a/frontend/src/features/portfolio/components/ModelCard.jsx b/frontend/src/features/portfolio/components/ModelCard.jsx
index 3cf8f74..ba8a481 100644
--- a/frontend/src/features/portfolio/components/ModelCard.jsx
+++ b/frontend/src/features/portfolio/components/ModelCard.jsx
@@ -7,7 +7,19 @@ function formatWeight(value) {
return `${new Decimal(value).times(100).toFixed(0)}%`
}
+// `GET /portfolios/models` carries no description field — this is fixed UI copy for the four
+// model portfolios the backend defines, not a fabricated financial figure. A model whose name
+// doesn't match falls back to no description rather than inventing one.
+const MODEL_DESCRIPTIONS = {
+ Conservative: 'Capital preservation first — a bond-heavy allocation with a small equity sleeve.',
+ Balanced: 'An even split between growth and stability.',
+ Growth: 'Mostly equities, a modest bond allocation to dampen volatility.',
+ Aggressive: 'Maximum equity exposure for a long time horizon.',
+}
+
export function ModelCard({ model, selected, onSelect }) {
+ const description = MODEL_DESCRIPTIONS[model.name]
+
return (
@@ -18,12 +30,12 @@ export function ModelCard({ model, selected, onSelect }) {
)}
- {model.description}
+ {description && {description}
}
{model.target_weights.map((weight) => (
{weight.symbol}
- {formatWeight(weight.target_weight)}
+ {formatWeight(weight.weight_pct)}
))}
diff --git a/frontend/src/features/portfolio/hooks/useAssignment.js b/frontend/src/features/portfolio/hooks/useAssignment.js
index f1eabba..1af58ee 100644
--- a/frontend/src/features/portfolio/hooks/useAssignment.js
+++ b/frontend/src/features/portfolio/hooks/useAssignment.js
@@ -4,7 +4,7 @@ import { portfoliosApi } from '../api/portfoliosApi.js'
const IDLE = { status: 'idle', assignment: null, error: null }
/** Covers both `GET /portfolios/assignment` (read) and `POST /portfolios/assignment` (assign) — the two lifecycles stay in separate merged states since they don't always change together. */
-export function useAssignment() {
+export function useAssignment(customerId) {
const [state, setState] = useState(IDLE)
const [assignStatus, setAssignStatus] = useState('idle')
const [assignError, setAssignError] = useState(null)
@@ -23,20 +23,23 @@ export function useAssignment() {
refetch()
}, [refetch])
- const assign = useCallback(async (modelId) => {
- setAssignStatus('submitting')
- setAssignError(null)
- try {
- const assignment = await portfoliosApi.assign(modelId)
- setState({ status: 'loaded', assignment, error: null })
- setAssignStatus('submitted')
- return assignment
- } catch (error) {
- setAssignError(error)
- setAssignStatus('error')
- throw error
- }
- }, [])
+ const assign = useCallback(
+ async (modelId) => {
+ setAssignStatus('submitting')
+ setAssignError(null)
+ try {
+ const assignment = await portfoliosApi.assign(customerId, modelId)
+ setState({ status: 'loaded', assignment, error: null })
+ setAssignStatus('submitted')
+ return assignment
+ } catch (error) {
+ setAssignError(error)
+ setAssignStatus('error')
+ throw error
+ }
+ },
+ [customerId],
+ )
return { ...state, refetch, assign, assignStatus, assignError }
}
diff --git a/frontend/src/features/portfolio/hooks/useSecurities.js b/frontend/src/features/portfolio/hooks/useSecurities.js
index d41a12b..cc87959 100644
--- a/frontend/src/features/portfolio/hooks/useSecurities.js
+++ b/frontend/src/features/portfolio/hooks/useSecurities.js
@@ -3,15 +3,29 @@ import { portfoliosApi } from '../api/portfoliosApi.js'
const IDLE = { status: 'idle', securities: [], error: null }
-/** The orderable universe — consumed cross-feature by `orders`' OrderForm (mock-data owner stays `portfolio`). */
+/** The orderable universe — consumed cross-feature by `orders`' OrderForm (mock-data owner stays
+ * `portfolio`). There is no dedicated securities/quote endpoint, so this derives the list from
+ * `GET /portfolios/models`'s `target_weights`, deduped by `security_id` — a customer enters the
+ * reference price themselves (no quote source exists to invent one from). */
export function useSecurities() {
const [state, setState] = useState(IDLE)
const refetch = useCallback(async () => {
setState((prev) => ({ ...prev, status: 'loading', error: null }))
try {
- const securities = await portfoliosApi.getSecurities()
- setState({ status: 'loaded', securities, error: null })
+ const models = await portfoliosApi.getModels()
+ const bySecurityId = new Map()
+ for (const model of models) {
+ for (const weight of model.target_weights) {
+ if (!bySecurityId.has(weight.security_id)) {
+ bySecurityId.set(weight.security_id, {
+ security_id: weight.security_id,
+ symbol: weight.symbol,
+ })
+ }
+ }
+ }
+ setState({ status: 'loaded', securities: [...bySecurityId.values()], error: null })
} catch (error) {
setState({ status: 'error', securities: [], error })
}
diff --git a/frontend/src/features/statements/components/StatementDetail.jsx b/frontend/src/features/statements/components/StatementDetail.jsx
index 4dcc03a..865fd5f 100644
--- a/frontend/src/features/statements/components/StatementDetail.jsx
+++ b/frontend/src/features/statements/components/StatementDetail.jsx
@@ -2,6 +2,7 @@ import { useSearchParams } from 'react-router-dom'
import { Badge } from '../../../components/Badge'
import { Card } from '../../../components/Card'
import { ErrorState } from '../../../components/ErrorState'
+import { SimulatedBadge } from '../../../components/SimulatedBadge'
import { Skeleton } from '../../../components/Skeleton'
import { getErrorMessage } from '../../../utils/apiErrorMessage.js'
import { formatDate, formatDateTime, formatMoney, formatPercent } from '../../../utils/format.js'
@@ -36,6 +37,7 @@ export function StatementDetail({ periodStart }) {
{publishWatermark && Viewing as originally published }
+
diff --git a/frontend/src/features/valuation/components/BalanceCard.jsx b/frontend/src/features/valuation/components/BalanceCard.jsx
index f7b71c3..563e192 100644
--- a/frontend/src/features/valuation/components/BalanceCard.jsx
+++ b/frontend/src/features/valuation/components/BalanceCard.jsx
@@ -1,15 +1,19 @@
import { Card } from '../../../components/Card'
+import { ErrorState } from '../../../components/ErrorState'
import { Skeleton } from '../../../components/Skeleton'
import { formatDate, formatMoney } from '../../../utils/format.js'
+import { getErrorMessage } from '../../../utils/apiErrorMessage.js'
import './BalanceCard.css'
/** Design system §1/§3.1: the one deliberately expressive typographic choice — Source Serif 4, `display` scale, used nowhere else. */
-export function BalanceCard({ status, totalValue, asOfDate }) {
+export function BalanceCard({ status, totalValue, asOfDate, error, onRetry }) {
return (
Account balance
{status === 'loading' || status === 'idle' ? (
+ ) : status === 'error' ? (
+
) : (
<>
{formatMoney(totalValue)}
diff --git a/frontend/src/features/valuation/components/ReturnCard.jsx b/frontend/src/features/valuation/components/ReturnCard.jsx
index bf0a86c..a7704a2 100644
--- a/frontend/src/features/valuation/components/ReturnCard.jsx
+++ b/frontend/src/features/valuation/components/ReturnCard.jsx
@@ -1,12 +1,14 @@
import Decimal from 'decimal.js'
import { Badge } from '../../../components/Badge'
import { Card } from '../../../components/Card'
+import { ErrorState } from '../../../components/ErrorState'
import { Skeleton } from '../../../components/Skeleton'
import { formatPercent } from '../../../utils/format.js'
+import { getErrorMessage } from '../../../utils/apiErrorMessage.js'
import './ReturnCard.css'
/** Design system §2.4: gains/losses use success/error text color on the figure itself, not a badge. */
-export function ReturnCard({ status, twr, isProvisional, label = 'Month-to-date return' }) {
+export function ReturnCard({ status, twr, isProvisional, error, onRetry, label = 'Month-to-date return' }) {
const isLoss = twr !== null && new Decimal(twr).isNegative()
return (
@@ -14,6 +16,8 @@ export function ReturnCard({ status, twr, isProvisional, label = 'Month-to-date
{label}
{status === 'loading' || status === 'idle' ? (
+ ) : status === 'error' ? (
+
) : (
{formatPercent(twr)}
diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx
index bbc2294..36145db 100644
--- a/frontend/src/pages/DashboardPage.jsx
+++ b/frontend/src/pages/DashboardPage.jsx
@@ -1,4 +1,5 @@
import { useState } from 'react'
+import { useSession } from '../contexts/SessionContext.jsx'
import { BalanceCard } from '../features/valuation/components/BalanceCard.jsx'
import { CompletenessBanner } from '../features/valuation/components/CompletenessBanner.jsx'
import { ReturnCard } from '../features/valuation/components/ReturnCard.jsx'
@@ -17,22 +18,37 @@ function monthToDateRange() {
}
export function DashboardPage() {
+ const { principal } = useSession()
const balance = useBalance()
// Computed once per mount, not per render — a stable dependency for `useReturns` below.
const [{ periodStart, periodEnd }] = useState(monthToDateRange)
const returns = useReturns(periodStart, periodEnd)
- const assignment = useAssignment()
+ const assignment = useAssignment(principal.id)
const { models } = useModels()
- const assignedModel = assignment.assignment ? models.find((model) => model.id === assignment.assignment.model_id) : null
+ const assignedModel = assignment.assignment
+ ? models.find((model) => model.id === assignment.assignment.model_portfolio_id)
+ : null
return (
Dashboard
{balance.completeness === 'partial' &&
}
-
-
+
+
Holdings
diff --git a/frontend/src/pages/FeesPage.jsx b/frontend/src/pages/FeesPage.jsx
index 52d55c6..2d1ab8a 100644
--- a/frontend/src/pages/FeesPage.jsx
+++ b/frontend/src/pages/FeesPage.jsx
@@ -1,6 +1,7 @@
import { Card } from '../components/Card'
import { ErrorState } from '../components/ErrorState'
import { Skeleton } from '../components/Skeleton'
+import { useSession } from '../contexts/SessionContext.jsx'
import { AccrualSummary } from '../features/fees/components/AccrualSummary.jsx'
import { ChargeHistory } from '../features/fees/components/ChargeHistory.jsx'
import { DunningBanner } from '../features/fees/components/DunningBanner.jsx'
@@ -10,7 +11,8 @@ import { getErrorMessage } from '../utils/apiErrorMessage.js'
import './PageLayout.css'
export function FeesPage() {
- const { status, accrual, charges, dunning, paymentMethod, error, refetch } = useFees()
+ const { principal } = useSession()
+ const { status, accrual, charges, dunning, paymentMethod, error, refetch, setPaymentMethod } = useFees()
if (status === 'idle' || status === 'loading') {
return (
@@ -41,7 +43,7 @@ export function FeesPage() {
Payment method
-
+
)
diff --git a/frontend/src/pages/LotsPage.jsx b/frontend/src/pages/LotsPage.jsx
index 5d093d0..345523d 100644
--- a/frontend/src/pages/LotsPage.jsx
+++ b/frontend/src/pages/LotsPage.jsx
@@ -1,10 +1,13 @@
+import { SimulatedBadge } from '../components/SimulatedBadge'
import { LotTable } from '../features/lots/components/LotTable.jsx'
import './PageLayout.css'
export function LotsPage() {
return (
-
Tax lots
+
+ Tax lots
+
)
diff --git a/frontend/src/pages/PortfolioPage.jsx b/frontend/src/pages/PortfolioPage.jsx
index e5d25bb..18acc9f 100644
--- a/frontend/src/pages/PortfolioPage.jsx
+++ b/frontend/src/pages/PortfolioPage.jsx
@@ -1,5 +1,6 @@
import { ErrorState } from '../components/ErrorState'
import { Skeleton } from '../components/Skeleton'
+import { useSession } from '../contexts/SessionContext.jsx'
import { AssignmentPrompt } from '../features/portfolio/components/AssignmentPrompt.jsx'
import { ModelDetail } from '../features/portfolio/components/ModelDetail.jsx'
import { useAssignment } from '../features/portfolio/hooks/useAssignment.js'
@@ -8,7 +9,8 @@ import { getErrorMessage } from '../utils/apiErrorMessage.js'
import './PageLayout.css'
export function PortfolioPage() {
- const assignment = useAssignment()
+ const { principal } = useSession()
+ const assignment = useAssignment(principal.id)
const { models } = useModels()
if (assignment.status === 'idle' || assignment.status === 'loading') {
@@ -30,7 +32,7 @@ export function PortfolioPage() {
}
const assignedModel = assignment.assignment
- ? models.find((model) => model.id === assignment.assignment.model_id)
+ ? models.find((model) => model.id === assignment.assignment.model_portfolio_id)
: null
return (
diff --git a/frontend/src/pages/admin/CustomerDetailPage.jsx b/frontend/src/pages/admin/CustomerDetailPage.jsx
index b068d21..d698f1c 100644
--- a/frontend/src/pages/admin/CustomerDetailPage.jsx
+++ b/frontend/src/pages/admin/CustomerDetailPage.jsx
@@ -2,6 +2,7 @@ import { useParams } from 'react-router-dom'
import { Card } from '../../components/Card'
import { DetailFields } from '../../components/DetailFields'
import { ErrorState } from '../../components/ErrorState'
+import { SimulatedBadge } from '../../components/SimulatedBadge'
import { Skeleton } from '../../components/Skeleton'
import { CustomerFeesPanel } from '../../features/admin-customers/components/CustomerFeesPanel.jsx'
import { KycOverrideForm } from '../../features/admin-customers/components/KycOverrideForm.jsx'
@@ -45,6 +46,9 @@ export function CustomerDetailPage() {
Customer detail
{customer.has_open_break && }
+
+ Profile
+
- KYC override
+
+ KYC override
+
diff --git a/frontend/src/pages/admin/CustomerDirectoryPage.jsx b/frontend/src/pages/admin/CustomerDirectoryPage.jsx
index 39278ad..76d2eec 100644
--- a/frontend/src/pages/admin/CustomerDirectoryPage.jsx
+++ b/frontend/src/pages/admin/CustomerDirectoryPage.jsx
@@ -1,10 +1,13 @@
+import { SimulatedBadge } from '../../components/SimulatedBadge'
import { CustomerDirectory } from '../../features/admin-customers/components/CustomerDirectory.jsx'
import '../PageLayout.css'
export function CustomerDirectoryPage() {
return (
-
Customers
+
+ Customers
+
)
diff --git a/frontend/src/services/mockClient.js b/frontend/src/services/mockClient.js
index a350605..3d310f6 100644
--- a/frontend/src/services/mockClient.js
+++ b/frontend/src/services/mockClient.js
@@ -1,7 +1,9 @@
-// Shared infrastructure for every domain with no live backend yet (portfolio, lots, fees, chat,
-// admin-customers, statement export — main's Phase 4 dispatch). A domain's `api/*.js` file uses
-// this instead of `services/apiClient.js`, and instead of hand-rolling its own latency/state
-// mechanism, so every mock behaves the same way and lives in one place to swap out later.
+// Shared infrastructure for the domains still awaiting a backend route: lots (S8's `/lots`) and
+// admin-customers' directory/detail/KYC-override (S8's `/admin/customers*`,
+// `/admin/kyc-overrides/`). Portfolio, fees, and chat were migrated onto `apiClient` once
+// their real routes existed — see each domain's own `api/*.js` file. A domain's `api/*.js` file
+// uses this instead of `services/apiClient.js`, and instead of hand-rolling its own latency/state
+// mechanism, so every remaining mock behaves the same way and lives in one place to swap out later.
const DEFAULT_LATENCY_MS = 300