Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 52 additions & 11 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pnpm run dev # shared, then backend (:3000) + frontend (:4200)
pnpm run build # shared -> backend -> frontend, in that order
pnpm test # backend + frontend unit tests
pnpm run test:e2e # backend e2e
pnpm run backfill:fx # lock ECB rates onto rows without one; dry run unless --apply
```

Both workspaces use **vitest** (Angular CLI 21 and Nest 12 both default to it now — not karma/jest).
Expand Down Expand Up @@ -415,7 +416,8 @@ Getting them confused is how the UI briefly offered an Approve button that alway
## Module map

**backend/** — `auth/` (argon2id + JWT, rotating refresh, guards), `expenses/`
(CRUD, search, the status state machine), `budgets/`, `reports/` (polled job for
(CRUD, search, the status state machine), `fx/` (ECB rates, the daily cache,
and the rate lock — no controller, deliberately), `budgets/`, `reports/` (polled job for
the cancellation demo), `tool-calls/` (audit log), `orgs/`, `config/`
(`GET /api/config` serves the Gemini model list so it is editable without a
rebuild), `supabase/` (client + repository seam), `common/` (rate limiting).
Expand Down Expand Up @@ -503,11 +505,7 @@ finished in both the source and the test count.

## Money: never add two currencies

There is no FX pass. `expenses.service.ts` writes `converted_amount` **only**
when the expense is already in the org's base currency, so it is `null` for
every foreign row — and the seed data has INR, USD and EUR.

So a row counts toward a total only when it has a base-currency value, and the
A row counts toward a total only when it has a base-currency value, and the
ones that do not are **counted and stated**, never dropped and never added:

- Frontend: `core/expense/amount.ts` — `isConverted()`, `sumSpend()` (returns
Expand All @@ -520,15 +518,58 @@ ones that do not are **counted and stated**, never dropped and never added:

The earlier code fell back to the raw `amount`, on the reasoning that a slightly
wrong number beat a bar reading zero. It was not slightly wrong: a $200 charge
was counted as ₹200. When a real FX pass starts filling `converted_amount`,
those rows re-enter every total with no code change.
was counted as ₹200. That rule is what let the FX pass land without touching a
line of `amount.ts`: foreign rows re-entered every total the moment
`converted_amount` started being filled.

## FX: the rate is locked at write time, from the ECB

`backend/src/fx/` converts a foreign expense **once**, at its own
`expense_date`, and records what it used. `ExpensesService.create` and
`.update` write three fields together — `converted_amount`, `fx_rate`,
`fx_rate_date` — because a converted amount with no rate cannot be defended and
a rate with no date cannot be reproduced.

**Rates come from the ECB via `api.frankfurter.dev`**, which is deliberately the
same publisher the embedded converter shows: Cambiaro calls it from the browser,
so the advisory widget and the ledger agree without the ledger depending on the
widget. Cambiaro itself is **not** a rate source and cannot be one — it is a
static client-side app with no HTTP API (`/api/*` all 404), so its only
programmatic surface is `document.modelContext`, which exists inside a browser
page. Reading a figure out of the frame remains forbidden for the reasons below.

Four things that look like details and are not:

- **`fx_rate_date` is not `expense_date`.** The ECB publishes once per working
day, so a Saturday expense locks Friday's rate. Both dates are stored, and
the UI prints the rate's own date — claiming the expense's date would name a
rate nobody published. Real seed data exercises this: AWS on 2026-08-29.
- **`FxService.rateOn` never throws.** A missing rate returns `null`, the three
fields are written as null, and the row is excluded and counted — the state
the rules above already handle. A currency API being down must not stop
someone filing an expense.
- **`fx_rates` is keyed on the date *asked for*, not the date the rate is
from.** Keyed the other way, every weekend lookup would miss the cache
forever. The one entry that can go stale is today's while it still stands in
for an earlier day, before the ~16:00 CET publication.
- **An edit re-locks, and `expenseDate` is the easy one to miss.** Amount,
currency *and* date each invalidate the lock; all three fields move together,
including when the new lock fails.

`backend/scripts/backfill-fx.mjs` (`pnpm run backfill:fx`, dry run unless
`--apply`) locks rates onto rows that have none, and with `--restate` onto rows
whose `converted_amount` has no rate behind it — which the demo seed's foreign
rows were, at hand-written rates nobody published. It lives under `backend/`
because pnpm hoists nothing: a root script cannot resolve `@nestjs/core`.

**The embedded converter does not change this, and must not.** `converter/`
frames a separate converter app on four surfaces (`/convert`, `/agent`, the
dashboard's excluded-rows notice, and expense rows in another currency). It is
advisory: a rate a person reads off another site is not the historical rate
locked at entry, so nothing it shows may reach `converted_amount`, `sumSpend()`,
`sumByCategory()`, or the `excludedNotice()` copy.
advisory: a rate a person reads off another site *today* is not the historical
rate locked at entry, so nothing it shows may reach `converted_amount`,
`sumSpend()`, `sumByCategory()`, or the `excludedNotice()` copy. The FX pass
makes this sharper, not softer — there is now a real locked rate for the frame's
number to contradict.

That is enforced structurally rather than by good intentions:
`CurrencyConverter` has **no `output()`, no `postMessage` listener, and never
Expand Down
108 changes: 56 additions & 52 deletions Progress.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Tracks every feature in the PRD against what is actually in the codebase.

**Last audited:** 2026-09-03 · **Baseline:** 9 shared · 65 backend unit · 34 backend e2e · 786 frontend
**Last audited:** 2026-09-03 · **Baseline:** 12 shared · 93 backend unit · 34 backend e2e · 803 frontend

Status is evidence-based, not aspirational. A row is `DONE` only when the code
exists, is reachable from the running app, and has a test. A file existing is not
Expand Down Expand Up @@ -143,29 +143,33 @@ the figure matches a hand-check against the expense rows.
**Verify:** submit as member, approve as owner, confirm the member cannot approve
their own expense.

## §6.5 Multi-currency — 🟡 ⚠️
## §6.5 Multi-currency —

| Item | Phase | Status | Notes |
|---|---|---|---|
| Original + converted amounts stored | 1 | ✅ | Columns exist. `core/expense/amount.ts` owns the rule: `sumSpend()` adds only base-currency rows and reports the rest |
| Live FX + daily cache | 1 | | No FX client, no cache, no rates table. The embedded converter does **not** count — see below |
| Historical rate lock | 1 | | No rate column |
| Live FX + daily cache | 1 | | `backend/src/fx/` reads ECB rates from `api.frankfurter.dev` and caches them in `fx_rates` (migration `0003`). Keyed on the date *asked for*, not the date the rate is from, or every weekend lookup would miss forever; the only entry that can go stale is today's while it still stands in for an earlier day. `FxService.rateOn` returns `null` rather than throwing, so an unreachable publisher cannot stop an expense being filed |
| Historical rate lock | 1 | | `expenses.fx_rate` + `fx_rate_date`, written with `converted_amount` at the expense's own date. **`fx_rate_date` is not `expense_date`** — the ECB publishes once per working day, so a Saturday expense locks Friday's rate, and the row prints the rate's own date. An edit re-locks on amount, currency *or* date. Verified live: AWS filed 2026-08-29 (a Saturday) carries the 2026-08-28 rate |
| Embedded converter (advisory) | 1 | ✅ | `converter/currency-converter.ts` frames a separate converter app on `/convert`, `/agent`, the dashboard notice and foreign-currency expense rows. One frame at a time, lazily mounted, `CONVERTER_URL` from `GET /api/config` |

> **Totals are now honest about what they exclude.** `convertedAmount` is still
> only set when the currency already equals the base currency, so foreign rows
> have no base-currency value. They used to be added at face value — a $200
> charge counted as ₹200. Now a row counts only when it has a converted value,
> and the ones that do not are **counted and stated**: `sumSpend()` returns
> `{total, excluded}`, `sumByCategory()` returns `unconverted`, and that surfaces
> as `BudgetStatus.unconvertedCount`, a muted line on the dashboard and budgets
> screens, and a field in the `get_budget_status` tool result so the Copilot can
> qualify the figure. Row labels follow the same rule — an unconverted $50 prints
> as `$50`, not `₹50`.
> **The exclusion rules were what made FX cheap to land.** `sumSpend()` returns
> `{total, excluded}` and `sumByCategory()` returns `unconverted` — a row counts
> only when it has a base-currency value, and the ones that do not are counted
> and stated rather than added at face value. That held, so foreign rows
> re-entered every total the moment `converted_amount` started being filled:
> **`core/expense/amount.ts` needed no logic change at all**, only a comment and
> a line of copy that had gone out of date. `unconvertedCount` still exists and
> still reaches `get_budget_status`; it is now normally 0, and means "no rate
> could be locked" rather than "FX does not exist".
>
> This is the honest interim, not the feature: real FX (live rates, daily cache,
> historical lock) is still ⬜, and the moment `converted_amount` starts being
> filled, those rows re-enter every total with no code change.
> **The seed's foreign rows were worse than excluded — they were quietly
> wrong.** They shipped with a `converted_amount` at hand-written rates (₹87/$
> for Figma, ₹94/€ for Sentry) that no publisher ever quoted, so they counted
> toward every total with nothing to explain them. `--restate` replaced all five
> with dated ECB rates, and picked up two real rows (AWS $200, Starbucks $45)
> that had no conversion at all. Applied 2026-09-03: 7 rows locked, 0 failures,
> books up ₹25,136, and the dashboard's 14-day figure hand-checks to ₹54,669
> against its rows.
>
> **The embedded converter does not change any of that, deliberately.** It is a
> reference a person reads, framed from a separate origin; it writes nothing,
Expand All @@ -178,7 +182,8 @@ their own expense.
> it, the change would be wrong.

**Verify:** file expenses in two currencies and confirm the dashboard total is
not a naive sum, and that it says how many rows it left out.
not a naive sum. A foreign row shows what it was converted from and on which
day's rate; a row on a weekend names the preceding working day.

## §6.6 Analytics — 🟡

Expand Down Expand Up @@ -281,60 +286,59 @@ and the offline banner appearing and clearing on the network events.
| Supabase call timeouts | ✅ | 8s deadline — a stall used to hang the request forever |
| Unit tests for tool `execute()` | ✅ | — |
| Structured logging / error tracking | ⬜ | Nest logger only; no Sentry-tier reporting |
| **CI** | 🟡 | `.github/workflows/ci.yml` runs the full Definition of Done gate on push and PR. Never executed by GitHub — verified by running its exact command sequence locally |
| **CI** | | `.github/workflows/ci.yml` runs the full Definition of Done gate on push and PR, and **has run on GitHub**five successful runs, most recently on PR #4 |
| Single-process deploy | ✅ | `server.mjs`; the routing contract it depends on is pinned by `routing-contract.e2e-spec.ts` |

## §12 Submission criteria —
## §12 Submission criteria — 🟡

| Item | Status | Notes |
|---|---|---|
| **Public deployed URL** | 🟡 | **Live at `https://actuo.onrender.com`** — `/api/health` returns 200. `server.mjs` composes Nest under `/api` with the Angular SSR handler from a committed `Dockerfile`; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README *Why a Dockerfile*). Still 🟡, not ✅, because the deployed site is **defective in two ways**: `/` does not server-render (see §8.5) and it served a literal `__PUBLIC_ORIGIN__` in `canonical`/`og:image`. The stamp half is fixed in `scripts/stamp-seo.mjs` and needs a redeploy; the SSR half needs `NG_ALLOWED_HOSTS` set on the service. `pnpm run verify:deploy <url>` reports both |
| **Public deployed URL** | | **Live and correct at `https://actuo.onrender.com`** — `pnpm run verify:deploy https://actuo.onrender.com` passes all six checks as of 2026-09-03: `/api/health` 200, `/` server-rendered (`ng-server-context` present), `/`, `sitemap.xml` and `robots.txt` all stamped, and `/api/config` reporting the converter. `server.mjs` composes Nest under `/api` with the Angular SSR handler from a committed `Dockerfile`; Firebase App Hosting was abandoned after three distinct buildpack failures against this workspace monorepo (see README *Why a Dockerfile*). The two earlier defects — CSR fallback and a literal `__PUBLIC_ORIGIN__` — are both gone |
| README | ✅ | Root `README.md`: what is WebMCP-specific and where, the flag setup, what works without it, and the deploy steps. Workspace READMEs are still starter boilerplate |
| Demo video | ⬜ | The script is the "What to look at" list in `README.md`. Worth filming only after the SSR fix lands, or it records the client-rendered site |
| Demo video | ⬜ | The last box left. The script is the "What to look at" list in `README.md`, and the SSR fix it was waiting on has landed — the deployed site server-renders, so a recording made now records the real thing |
| Source with clear tool definitions | ✅ | `shared/src/tools.ts` |

---

## What to fix next

Every Phase 0 row is green as of 2026-09-03. The deploy exists and is healthy;
what is left is making it *correct*, the video, and Phase 1–3 features.

1. **Make the live deploy correct.** It exists and is healthy at
`https://actuo.onrender.com`, but `/` is client-rendered and was shipping an
unstamped `__PUBLIC_ORIGIN__`. Two things remain, both on the Render service
rather than in this repo: set **`NG_ALLOWED_HOSTS`** so Angular stops falling
back to CSR, and set **`CONVERTER_URL`** so the cross-origin path runs. Then
redeploy — `PUBLIC_ORIGIN` is a build arg, so a restart cannot carry the stamp
fix. If the service was created by hand rather than from `render.yaml`, its
`envVars` were never applied, which would explain all of it.
*Verify:* `pnpm run verify:deploy https://actuo.onrender.com` passes every
check.
Every Phase 0 row is green, the deploy is live *and* correct, and real FX
landed on 2026-09-03. What is left is the rest of Phase 1, and the video.

1. **Prove cross-origin from the deployed site.** `CONVERTER_URL` is set and
`/api/config` serves it, so this is very likely already working and merely
unverified. Open `https://actuo.onrender.com/agent` in flag-enabled Chrome
151, confirm Cambiaro's seven tools are discovered across two public origins,
and ask the Copilot to convert €80. Ten minutes, and it turns §7's
cross-origin row from locally-proven into deploy-proven.
2. **Demo video** — the last §12 checkbox. The script is the "What to look at"
list in `README.md`.
3. **Real FX** — live rates, a daily cache, a historical lock at write time.
Totals are honest about the gap now, but they still exclude real spend.
4. **Editing an existing budget** — `POST /budgets` inserts and there is no
PATCH, so a budget can be set once and not changed. The form hides categories
that already have one rather than offering a guaranteed 409.
5. **Everything else is Phase 1–3**: receipt OCR, notifications, recurring
templates (the table is not in the migration), multi-step approval chains,
comment threads, teams, tags, CSV import, PDF export, session management,
org invite/switch, `/api/analytics/*`, and packaging the Copilot as a
standalone script.
3. **Budgets depth** — `POST /budgets` inserts and there is no PATCH, so a
budget can be set once and not changed; the form hides categories that
already have one rather than offering a guaranteed 409. Threshold alerts
(80%) and rollover-vs-reset are the other two §6.3 rows, and `budgets.spec.ts`
already guards the rollover checkbox against returning without its behaviour.
4. **`/api/analytics/*`** — no controller; the dashboard derives everything
client-side. Standalone spend-by-category and a month-over-month delta tile
are the visible half.
5. **Recurring expenses** — `recurring_templates` is in PRD §8.7 and **absent
from the migrations**, so it needs `0004`.
6. **Org invites** — the last Phase 1 row, and the only one needing an external
service (Resend, plus a `sync: false` secret in `render.yaml`).
7. **Everything else is Phase 2–3**: receipt OCR, notifications, multi-step
approval chains, comment threads, teams, tags, CSV import, PDF export,
session management, and packaging the Copilot as a standalone script.

### Known rough edges, deliberately not fixed here

- **The cross-origin path has not been run end to end from a *deployed* Actuo.**
It is verified locally against the deployed converter (see §6.8), but nobody
has yet loaded Actuo on Render, framed the converter from there, and watched
the Copilot call `convertCurrency` across two public origins. Two things remain:
the converter commits have to reach the deploy, and `CONVERTER_URL` has to be
set on the service. The converter's own `exposedTo` change is already merged
and live. Until then `/api/config` reports no converter and the surfaces show
the honest "not configured" state, which is correct but is not the demo.
- **CI has never run on GitHub.** The workflow was verified by running its exact
command sequence locally; `act` is not installed on this machine.
the Copilot call `convertCurrency` across two public origins. The two blockers
are gone — the converter commits are deployed and `CONVERTER_URL` is set, so
`/api/config` now serves `https://cambiaro.programmersingh.dev/`. All that is
left is opening `https://actuo.onrender.com/agent` in flag-enabled Chrome and
watching it work.
- **The Firebase App Hosting backend may still be connected** with auto-rollouts,
in which case it fails on every push. Deleting it is
`firebase apphosting:backends:delete actuo --project actuo-2f1f3`. App Hosting
Expand Down
Loading
Loading