Compare commits

..

96 Commits

Author SHA1 Message Date
Carlos Escalante
3d0547743f Log client details for API and agent requests
All checks were successful
Deploy to VPS / test (push) Successful in 1m42s
Deploy to VPS / deploy (push) Successful in 14s
2026-07-14 22:55:50 -06:00
Carlos Escalante
8ceed0ab2e Add individual salary deposit agent tool
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 27s
2026-07-14 22:41:53 -06:00
Carlos Escalante
ca3115e99c Add retained chat and API request diagnostics 2026-07-14 22:34:33 -06:00
Carlos Escalante
3518ff58c4 Read assistant model from deployment secret
All checks were successful
Deploy to VPS / test (push) Successful in 1m49s
Deploy to VPS / deploy (push) Successful in 8s
2026-07-14 22:03:53 -06:00
Carlos Escalante
352b77ea07 Regenerate API types for MAF upgrade
All checks were successful
Deploy to VPS / test (push) Successful in 1m44s
Deploy to VPS / deploy (push) Successful in 2m29s
2026-07-14 21:52:50 -06:00
Carlos Escalante
fbc0816be6 Add Playwright assistant regression coverage
Some checks failed
Deploy to VPS / test (push) Failing after 1m37s
Deploy to VPS / deploy (push) Has been skipped
2026-07-14 21:48:59 -06:00
Carlos Escalante
301a0b687a Use Responses API with Luna by default 2026-07-14 21:48:54 -06:00
Carlos Escalante
b57a899634 Upgrade agent and CopilotKit dependencies 2026-07-14 21:48:40 -06:00
Carlos Escalante
2d6a3f83eb Rollback assistant model to GPT-5.4 mini
All checks were successful
Deploy to VPS / test (push) Successful in 1m45s
Deploy to VPS / deploy (push) Successful in 15s
2026-07-14 19:03:27 -06:00
Carlos Escalante
00e841ad3f Use unique backend alias for assistant
All checks were successful
Deploy to VPS / test (push) Successful in 1m45s
Deploy to VPS / deploy (push) Successful in 15s
2026-07-14 18:50:28 -06:00
Carlos Escalante
c527b3d6d0 Fix production assistant backend DNS
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 8s
2026-07-14 18:46:04 -06:00
Carlos Escalante
1e6ebab2b2 Upgrade assistant model to GPT-5.6 Luna
All checks were successful
Deploy to VPS / test (push) Successful in 1m44s
Deploy to VPS / deploy (push) Successful in 16s
2026-07-14 18:42:06 -06:00
Carlos Escalante
17a0c63abe Add manual salary entry
All checks were successful
Deploy to VPS / test (push) Successful in 1m50s
Deploy to VPS / deploy (push) Successful in 1m37s
2026-07-14 18:25:13 -06:00
Carlos Escalante
47cb1826a8 Assistant: migrate to v2 runtime handler with Postgres-backed AgentRunner
All checks were successful
Deploy to VPS / test (push) Successful in 1m39s
Deploy to VPS / deploy (push) Successful in 22s
Replace the deprecated copilotRuntimeNextJSAppRouterEndpoint wrapper
(which was already the v2 single-route handler underneath) with
createCopilotRuntimeHandler from @copilotkit/runtime/v2, built once at
module scope, and plug in a custom PostgresAgentRunner:

- run() proxies the agent's event stream untouched — MAF persists every
  turn in chatthreadsnapshot, so the BFF records nothing
- connect() forwards to MAF's snapshot hydration (empty-messages run →
  RUN_STARTED → MESSAGES_SNAPSHOT → RUN_FINISHED, no LLM call), making
  the DB the only conversation store — correct across clears, restarts
  and future instances
- stop() aborts via a process-local live-run map (same pattern as the
  official sqlite-runner); isRunning() has no caller in the v2 SSE path

This deletes the whole workaround layer the in-memory runner forced:
the connect intercepts (URL + envelope forms) and Asistente's
client-side hydration run. The ask-box flow now awaits connectAgent
instead. Auth moves to a single Request rewrite (ws_token cookie →
Authorization Bearer) before the handler — the v2 path forwards
authorization + x-* headers to both run clones and runner.connect.
Stays in single-route mode: same envelope protocol the client already
auto-detected, no exposure to CopilotKit#4953, /threads* REST not
needed. Telemetry disabled via COPILOTKIT_TELEMETRY_DISABLED.

Verified full matrix in dev: fresh ask, reload replay via runner,
3 cleared convos + double navigation (only last survives), card renders
once and survives follow-up + reload, Inicio ask-box with history,
stale-tab persists only its new turn. 126 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:09:24 -06:00
Carlos Escalante
8595e74566 Assistant: document why the connect intercept is load-bearing
All checks were successful
Deploy to VPS / test (push) Successful in 1m35s
Deploy to VPS / deploy (push) Successful in 20s
Investigated whether CopilotKit offers an official thread flush instead
of the BFF connect intercept. It does have one (POST /threads/clear ->
runner.clearThreads()), but only on the fetch-router entrypoint — the
single-route adapter we mount (copilotRuntimeNextJSAppRouterEndpoint)
accepts exactly agent/run|connect|stop, info, transcribe at 1.62, so
threads/* is unreachable from our deployment (verified: 400 Unsupported
method). A custom DB-backed AgentRunner is the clean end-state, but the
AgentRunner base class isn't exported at 1.62. Note left in clearThread;
revisit both on the next CopilotKit upgrade.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:28:45 -06:00
Carlos Escalante
0d0d60600d Assistant: intercept connect on the single-route transport too
All checks were successful
Deploy to VPS / test (push) Successful in 1m35s
Deploy to VPS / deploy (push) Successful in 22s
The connect intercept only matched POST …/connect, but CopilotKit talks
to the runtime in single-route mode: everything POSTs to
/api/copilotkit with the operation in the body envelope
({method:"agent/connect"}). So the in-memory runner kept replaying the
BFF process's full event history on every CopilotChat mount — every
conversation since server start, across clears — which is exactly the
stacked resurrected conversations seen in prod after navigating away
and back. Now both transports get the immediately-completed stream.

Verified with the reported repro: three conversations with clears
between, navigate away and back → only the third remains; reload
hydration unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:23:05 -06:00
Carlos Escalante
700e1edbb4 Assistant: stop stale clients from resurrecting cleared threads
All checks were successful
Deploy to VPS / test (push) Successful in 1m37s
Deploy to VPS / deploy (push) Successful in 28s
Root cause of the "chunks" clear bug: conversation history is
server-owned, but MAF seeds a thread from whatever the client sends
when no snapshot exists. Any stale tab (open across a clear or from an
older session) still holds the old conversation in memory, and its next
question re-persisted the whole thing — clears appeared to peel off one
layer at a time.

The agent middleware now drops client-sent history for threads with no
stored snapshot, keeping only the last user turn (fresh conversations
and empty-messages hydration requests pass through untouched). Verified
live: simulated a cleared-thread stale tab, asked from it, and only the
new turn was persisted.

Also: clearThread surfaces DELETE failures as a toast instead of
silently reloading, and conftest sets a dummy OPENAI_API_KEY so tests
can import app.main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:06:35 -06:00
Carlos Escalante
15fa18411a Assistant: block "Nueva conversación" while a run is streaming
All checks were successful
Deploy to VPS / test (push) Successful in 1m28s
Deploy to VPS / deploy (push) Successful in 21s
MAF persists the thread at run END, so clearing mid-stream deleted the
row only for the finishing run to write the conversation right back —
the thread reappeared after the reload ("chunks" needing a second
clear). The button is now disabled while agent.isRunning.

(The chunked-clear reports on the prior build had a second cause fixed
in 0f34a64: clearing left the client's message copy alive and the next
question re-persisted it.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:53:15 -06:00
Carlos Escalante
3f4df6f16b Agent: answer like an analyst, not a calculator
All checks were successful
Deploy to VPS / test (push) Successful in 1m34s
Deploy to VPS / deploy (push) Successful in 14s
Aggregate answers were bare numbers ("Tu saldo neto hoy es -X."). The
prompt now requires showing the composition unprompted: net worth gets
the assets/liabilities split plus per-account table, day spend gets the
purchases behind the total, cycle/category totals get their top
components — one headline sentence, a compact table, and a closing
observation only when the data supports one. Also bans filler offers
("si quieres te puedo mostrar…") in place of just showing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:42:50 -06:00
Carlos Escalante
0f34a64e51 Assistant: reliable "Nueva conversación" reset
All checks were successful
Deploy to VPS / test (push) Successful in 1m33s
Deploy to VPS / deploy (push) Successful in 29s
Clearing in place (setMessages + remount) left the conversation
rendered — CopilotKit keeps chat state beyond agent.messages — so the
button now reloads the page after deleting the thread: a fresh boot
hydrates from the empty store and shows the welcome screen with
suggestions, which previously only appeared after a manual refresh.

Supporting changes:
- CopilotChat gets an explicit agentId="wealthysmart" (without it, it
  resolves DEFAULT_AGENT_ID and can bind a different client agent
  instance than useAgent's).
- The BFF answers CopilotChat's POST …/connect with an
  immediately-completed stream: the runtime's in-memory runner replays
  this PROCESS's event history, which resurrects cleared conversations
  and double-feeds hydration. The empty-messages hydration run against
  the DB store is the single restore path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:33:41 -06:00
Carlos Escalante
7b7c741ba2 Agent: resolve "today" in the browser's timezone, not hardcoded CR
The CR-hardcoded today broke two scenarios: the owner traveling, and
any future non-CR user. Now the provider sends the browser's IANA
timezone (X-Client-Timezone) with every CopilotKit request — the
runtime forwards x-* headers to the agent on its own — and the backend
binds it to a per-request ContextVar next to the DB session.
get_current_date and the future-cuota bounds use it; the tool also
reports the timezone so the model can echo it. Unknown or absent zones
(n8n posts, tests) fall back to Costa Rica; comma-joined duplicate
header values are tolerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:33:27 -06:00
Carlos Escalante
8f51e33fe0 Assistant: drop mid-run MESSAGES_SNAPSHOT instead of reconciling it
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 20s
Prod bug: earlier assistant replies vanished as new turns completed and
only came back on refresh. Debug capture showed why: with persistence
on, the run-end snapshot rebuilds history under STORE ids while the
client holds it under STREAMED ids (MAF re-mints post-tool-call text
ids, their #3619). ag-ui apply treats the snapshot as authoritative —
matching ids get replaced (streamed text wiped by the store's
empty-content toolCall entry) and the store's text copies were being
filtered out by ReconcileSnapshotMiddleware as presumed duplicates, so
prior answers had no surviving representation.

Reconciling id-mismatched snapshots is unwinnable; skip them instead.
During a normal run the client already built the turn from streamed
events and the snapshot adds nothing. Hydration replays (empty
input.messages) still pass through — there the snapshot IS the
conversation, and every reload converges the client to store ids.
ReconcileSnapshotMiddleware is replaced by the much simpler
DropRunMessagesSnapshotMiddleware.

Verified in dev: multi-turn history persists across runs, one card that
survives follow-ups, reload replays everything, no duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:56:37 -06:00
Carlos Escalante
965e30a2d2 Agent: add get_daily_spending for day-scoped questions
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 14s
Prod showed the failure mode after the date fix: get_current_date
returned the right day, but the model answered "¿cuánto he gastado
hoy?" from cycle-level aggregates (the only spend tools it had) and
concluded ₡0. Give it a day-grained tool — per-day CRC totals mirroring
/analytics/daily-spending (COMPRA only, no installment anchors, no
future cuotas) — and a prompt rule to match tool to time grain.
Verified: fresh thread now calls get_current_date →
get_daily_spending(2026-07-04, 2026-07-05) and answers correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:36:48 -06:00
Carlos Escalante
334d41bd9a Agent: fix date handling — Costa Rica timezone and future cuotas
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 14s
Two bugs from prod (server clock is UTC, user is UTC-6):

- get_recent_transactions returned future-dated Tasa Cero cuotas; it now
  excludes anything dated after the user's today, same rule as
  /transactions/recent.
- "¿Cuánto he gastado hoy?" answered for the wrong day: the prompt baked
  date.today() in at boot — frozen from startup AND in server-UTC (8 pm
  in Costa Rica is already tomorrow in UTC). The prompt no longer carries
  a date; a new get_current_date tool returns today in CR time plus the
  active billing cycle, and the prompt directs the model to call it for
  any relative date reference. today_cr() lives in timeutil (CR has no
  DST, fixed UTC-6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:27:46 -06:00
Carlos Escalante
eb400ab1e9 Assistant: suppress repeated render-tool calls
All checks were successful
Deploy to VPS / test (push) Successful in 1m31s
Deploy to VPS / deploy (push) Successful in 2m1s
After the client returns a render tool's result, the model sometimes
re-issues the identical call with a fresh id on the follow-up run,
painting a second copy of the spending-summary card (observed live on
MAF 1.10 / CK 1.62). DeduplicateToolCallMiddleware now tracks which
render tools already ran in the current user turn and drops repeats —
their streamed TOOL_CALL_* events and their entries in the run's
MESSAGES_SNAPSHOT. The system prompt also tells the model the card is
already displayed once its "ok" result is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:10:51 -06:00
Carlos Escalante
bbcfaa7808 Assistant: hydrate the persisted thread in the UI
- Asistente.tsx pins threadId "main" on CopilotChat (an explicit stable
  id stops the per-mount message wipe), runs a hydration pass on page
  load — an empty-messages run makes MAF replay the stored snapshot as
  MESSAGES_SNAPSHOT with no LLM call — and sends the Inicio ask-box
  question only after hydration so every normal run carries the full
  client-known history. "Nueva conversación" clears the stored thread
  behind a ConfirmDialog.
- server.ts: the BFF no longer injects context into empty-messages
  requests (that would defeat MAF's hydration trigger), and the snapshot
  reconciliation passes hydration replays through untouched and treats
  ids the client already sent as history rather than duplicates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:10:35 -06:00
Carlos Escalante
6bce5539f7 Assistant: persist the chat thread in Postgres
Back MAF's opt-in AG-UI snapshot persistence with a DB store so the
conversation survives page reloads and restarts:

- ChatThreadSnapshot: one upserted row per (scope, thread_id), JSONB
  messages/state/interrupt (migration 794baf50635b)
- PostgresSnapshotStore implements the AGUIThreadSnapshotStore protocol.
  It opens its own sessions (MAF saves during SSE streaming, after the
  request middleware has closed the ContextVar session) and sanitizes on
  save: camelCase toolCalls/toolCallId (the @ag-ui/client Zod schema
  strips snake_case, which would lose cards on replay), drop BFF ctx-*
  context messages, dedupe MAF's re-recorded multi-run turns, and drop
  orphaned tool results
- Endpoint mount gains snapshot_store + a constant scope resolver
  (single-user app; auth already enforced by agent_auth_and_session)
- DELETE /api/v1/chat/thread resets the conversation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:09:55 -06:00
Carlos Escalante
0e03284c95 Assistant: restore ReconcileSnapshotMiddleware
Retiring it in the middleware audit was premature: MAF 1.10 reuses the
streamed id for plain-text snapshots but deliberately mints a fresh UUID
for post-tool-call assistant text (their #3619), so tool+text turns
still rendered a duplicate assistant bubble (re-verified live). The
user-message duplication that motivated the retirement only fired on
a2ui retry runs, which no longer exist now that a2ui is disabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:10:12 -06:00
Carlos Escalante
4687fa9669 Assistant: drop a2ui surfaces, fall back to markdown tables
CK 1.60+ validates generated a2ui ops against a component catalog before
painting, and our agent's ops fail validation — surfaces hang at
"Building interface" forever (MAF also drops RunAgentInput.context, so
the catalog/guidelines never even reach the model). Disable a2ui end to
end until the pipeline matures:

- server.ts: a2ui { enabled: false }; RENDER_TOOLS no longer includes
  render_a2ui
- App.tsx: remove the a2ui provider prop
- agent.py: prompt now asks for clean markdown tables for lists and
  structured data; render_spending_summary card guidance unchanged

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:09:56 -06:00
Carlos Escalante
bbae121fec Middleware audit for CK 1.62 / MAF 1.10: retire ReconcileSnapshot, inject dropped AG-UI context
- ReconcileSnapshotMiddleware removed: MAF now reuses the streamed
  message id in MESSAGES_SNAPSHOT (verified in _agent_run.py at
  python-1.10.0 and live event logs); keeping the surgery duplicated
  user messages on a2ui follow-up runs.
- beforeRequestMiddleware now injects RunAgentInput.context as a system
  message: MAF declares the field but never feeds it to the model,
  which starves the model of CopilotKit's A2UI catalog/guidelines.
  Remove when MAF consumes context upstream.
- Workaround chain env-gated: CK_MIDDLEWARES=off runs bare for audits,
  CK_DEBUG_EVENTS=1 enables the event logger.
- Kept: SuppressRenderToolText, StripModelArtifacts,
  DeduplicateToolCall, pairOrphanToolCalls (behavior-verified).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:55:29 -06:00
Carlos Escalante
033a920746 Upgrade CopilotKit 1.56.4 -> 1.62.2, @ag-ui/client 0.0.52 -> 0.0.57
No API drift: v2 hooks, v1 useCopilotAction interop, runtime endpoint
and a2ui config all verified against the v1.62.2 source. Brings the
1.60.2 chat scroll stability fixes and 1.57.2 thread-state fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:30:54 -06:00
Carlos Escalante
0d24c7f9be Upgrade MAF 1.2.1 -> 1.10.0 (ag-ui rc7), pin instrumentation off
Agent/OpenAIChatCompletionClient/add_agent_framework_fastapi_endpoint
APIs verified unchanged against the python-1.10.0 source; 115 tests
green, pip check clean. MAF 1.6.0 turns OTel on by default —
ENABLE_INSTRUMENTATION=false in both compose files keeps prod logs
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:24:54 -06:00
Carlos Escalante
00f059ee91 Fix chat scroll: cap the shell at the viewport on /asistente
The shell revamp left SidebarInset unbounded (wrapper is min-h-svh), so
Asistente's flex chain resolved to content height — the chat's internal
scroller never engaged and long answers hid behind the composer.
Asistente now gets h-svh + overflow-hidden on the inset; every other
page keeps body scroll. Verified: inset == viewport on /asistente,
body scroll intact elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 15:16:05 -06:00
Carlos Escalante
254b4c751e Swap plugin-react-swc for plugin-react-oxc
All checks were successful
Deploy to VPS / test (push) Successful in 1m30s
Deploy to VPS / deploy (push) Successful in 1m32s
Unifies the transform pipeline on Oxc now that Rolldown does the
bundling. Dev server boot 353ms -> 188ms; build parity (2.03s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:52:45 -06:00
Carlos Escalante
f2cacedc20 Swap Rollup for Rolldown (rolldown-vite) in the production build
The last JS-based stage of the toolchain (dev was already SWC+esbuild).
Local build: 18.75s/1.83GB peak RSS -> 1.97s/1.27GB, output verified
identical in the browser.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:48:15 -06:00
Carlos Escalante
a9adb410d0 Raise frontend build heap cap to 2048MB
All checks were successful
Deploy to VPS / test (push) Successful in 1m27s
Deploy to VPS / deploy (push) Successful in 1m49s
The UI revamp pushed rollup's peak past the 1536MB cap and the prod
image build OOM'd. Verified locally: fails at 1536, builds at 2048.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:42:38 -06:00
Carlos Escalante
37c2b18300 Dev: derive AGENT_URL from BACKEND_URL so the assistant works locally
Some checks failed
Deploy to VPS / test (push) Successful in 1m31s
Deploy to VPS / deploy (push) Failing after 1m54s
The default pointed at the compose-internal 'backend' hostname, which
never resolves when the Hono runtime runs on the host — every local
agent run died with fetch failed/ENOTFOUND before reaching FastAPI.
Prod is unaffected (sets both URLs explicitly).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:16:25 -06:00
Carlos Escalante
ce7073cf24 Analytics: date-range filter + fix category endpoint Decimal crash
by-category and daily-spending accept start_date/end_date (range wins
over cycle); Analytics gets a two-month calendar range picker beside
the cycle selector. Fixes a latent float/Decimal TypeError that 500'd
spending_by_category whenever it had data — the category donut had
never rendered. Regression + range tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:06:58 -06:00
Carlos Escalante
5033348320 Inicio: hover-cards on stat tiles
Gasto del ciclo reveals the by-source breakdown, Próximas cuotas the
full active-plan list, Pensión last-period rendimientos per fund.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:00:59 -06:00
Carlos Escalante
061a49f3b1 Command palette (cmd+K): page nav, quick actions, ask-the-assistant
Header search trigger + global hotkey. Typing a question surfaces a
'Preguntar' item that lands in Asistente via the same auto-send flow
as the Inicio ask box. Also installs hover-card and calendar for the
remaining Phase 3 work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:58:33 -06:00
Carlos Escalante
3a258537fc P2 tail: cuota progress bars, Sync item rows, dialog polish
All checks were successful
Deploy to VPS / test (push) Successful in 1m29s
Deploy to VPS / deploy (push) Successful in 2m19s
Financiamientos shows cuota progress as Progress bars; Sincronización
uses a single Card of Item rows; Items Recurrentes table wrapped in a
Card like every other table; both window.confirm sites replaced with
the app's ConfirmDialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:22:15 -06:00
Carlos Escalante
41d4306e86 Standardize page headers and chart tokens across all pages
PageHeader on Budget, Proyecciones, Pensiones, Planificador,
Municipalidad, Sincronización, Financiamientos, Asistente. Pensiones
fund identity and Municipalidad meter/charge series now use the
validated chart tokens (color-mix for translucent fills). Planificador
stat cards on StatTile; deterministic chart rendering everywhere.
Municipalidad page title now matches its nav/breadcrumb label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:53:30 -06:00
Carlos Escalante
9907ff265e Budget donuts: sequential ramps + categorical slots, Paleta toggle retired
lib/charts.ts centralizes the color system: fixed categorical slots
(never cycled, >5 folds into Otros, 'Sin categoría' reads neutral) and
color-mix ramps that stay correct in dark mode. Animations off so
charts render deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:47:20 -06:00
Carlos Escalante
2cf19f1880 Analytics: validated palette, bars for daily spend, Empty states, es-CR
Categories fold into 'Otros' past 5 slots (fixed hue order, never
cycled); daily spending excludes future-dated Tasa Cero cuotas; rate
chart gets a legend; PageHeader with the cycle selector as action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:43:57 -06:00
Carlos Escalante
a95486481b UI foundations: PageHeader, StatTile, validated chart palette, registry primitives
- PageHeader + StatTile standardize the four header and three stat-tile
  variants found in the audit; Inicio and Salarios migrated as proof.
- chart tokens replaced with a teal-anchored categorical scale, CVD-
  ordered and validated (six checks, light + dark surfaces).
- Registry adds: empty, field, item, spinner, progress, toggle-group,
  input-group, popover, kbd (+ toggle dep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 12:23:21 -06:00
Carlos Escalante
10d9bd209f Fix dashboard data findings from browser verification
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 1m2s
- Próximas cuotas: headline is the upcoming billing batch (sum of every
  active plan's next cuota, using the rounding-absorbing last cuota
  when it's the pending one) — the in-cycle formula was ~always zero
  since cuotas bill on the 19th, day one of the next cycle.
- useAgent must name the 'wealthysmart' agent (default crashed the page).
- /transactions/recent excludes future-dated Tasa Cero cuotas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 11:02:26 -06:00
Carlos Escalante
b1c387c415 Add Inicio dashboard as homepage
Stat cards (gasto del ciclo, próximas cuotas Tasa Cero, pensión),
ask-the-assistant box that lands in Asistente and auto-sends via
agent.addMessage + runAgent, and últimas transacciones. Index route,
login redirects updated; Asistente stays at /asistente.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:50:18 -06:00
Carlos Escalante
7ba70ae524 Asistente: fill viewport via flex chain instead of vh math
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:47:26 -06:00
Carlos Escalante
d9039c76b9 Replace hand-rolled shell with shadcn sidebar + breadcrumb header
AppSidebar (icon-collapsible, sync warning badge, brand header),
NavUser footer (avatar + privacy/theme/logout dropdown), and a thin
header with SidebarTrigger, breadcrumbs, and quick toggles. Mobile
drawer and cmd+B come from the sidebar primitives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:46:34 -06:00
Carlos Escalante
d117fb79ea Add nav config module, budget-cycle helper, me/recent API helpers
navigation.ts becomes the single source of truth for sidebar and
breadcrumbs (Inicio entry added; '/' is exact-match).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:43:26 -06:00
Carlos Escalante
d2610b2eef Add shadcn sidebar/breadcrumb/collapsible/avatar/tooltip (base-nova)
Registry-installed Base UI flavor; existing ui components untouched.
Also drops the meaningless empty registries key from components.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 10:42:20 -06:00
Carlos Escalante
3d71234a61 Budget: unify transactions section into one card
Toolbar (source tabs, search, CSV, add) and a summary strip (billing
cycle range, count, per-currency total) now live inside the table card
instead of five loose control rows. Cycle range 18-to-18 is finally
visible. Stray English strings translated; back-button spacing fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 09:38:36 -06:00
Carlos Escalante
72a5840752 Proyecciones: clicking a month row opens that month in Budget
The clicked year/month now travel in the navigation state; Budget
seeds useBudget with them instead of always defaulting to today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:40:09 -06:00
Carlos Escalante
f832138b41 Pensions: compute current age from birthdate instead of hardcoded 30
FCL projection target defaults to the next 5-year withdrawal window
(CURRENT_AGE + 5) rather than a fixed 35.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:38:44 -06:00
Carlos Escalante
5296230416 Show net total of listed transactions above the table
Compras minus devoluciones, one figure per currency, excluding Tasa
Cero anchors (their cuotas are what count). Tooltip explains the math.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 20:17:55 -06:00
Carlos Escalante
f2bcce702a CI: pin dependency resolution with constraints.txt
All checks were successful
Deploy to VPS / test (push) Successful in 1m32s
Deploy to VPS / deploy (push) Successful in 2m56s
Fresh-venv installs started failing with pip ResolutionTooDeep because
requirements.txt is unpinned. Constrain CI and image builds to the
frozen, test-passing local set; requirements.txt stays the source of
intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 19:52:39 -06:00
Carlos Escalante
d3b5188b67 Tasa Cero: badges, convert dialog, Financiamientos page
Some checks failed
Deploy to VPS / test (push) Failing after 56m9s
Deploy to VPS / deploy (push) Has been skipped
Violet 'Tasa Cero'/'Cuota' badges (anchor amount struck through like
deferred), convert/edit action on CC purchases with a live cuota
preview, and a Financiamientos page mirroring the bank's tab (progress,
monto cuota, saldo inicial/faltante, totals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:18 -06:00
Carlos Escalante
c563618957 Tasa Cero: regenerate API types + installment-plan client helpers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:18 -06:00
Carlos Escalante
3cd2927b5f Tasa Cero: backend tests (rounding, scheduling, exclusion, lifecycle)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:00 -06:00
Carlos Escalante
3b147bf8be Tasa Cero: auto-detect 'CC ' vouchers + /installment-plans API
Ingestion auto-converts CC-prefixed credit-card purchases (default 3
cuotas, push note appended). CRUD endpoints cover manual convert (e.g.
ALMACENES SIMAN), edit-with-regeneration, and unconvert; anchor deletion
tears down the plan; cuotas cannot be deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:00 -06:00
Carlos Escalante
8b4961d257 Tasa Cero: exclude anchors from budget/analytics aggregates
The anchor stays visible in listings but only its cuotas count toward
actuals — future cuotas intentionally show up in future months as
committed spend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:00 -06:00
Carlos Escalante
088062f757 Tasa Cero: InstallmentPlan model, cuota service, migration
BAC zero-interest financing: an anchor purchase splits into N monthly
cuota transactions (truncate-to-0.10 rule, last cuota absorbs rounding;
one cuota per 18th-cut billing cycle on the 19th). Pinned against real
Financiamientos data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:00 -06:00
Carlos Escalante
248417dbab PROGRESS: deferred items and wishlist complete — nothing remains
All checks were successful
Deploy to VPS / test (push) Successful in 1m43s
Deploy to VPS / deploy (push) Successful in 2m20s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:07:31 -06:00
Carlos Escalante
fc3461c377 PWA installability + working service worker (wishlist #8)
manifest.webmanifest with the existing icons, apple-touch-icon, and SW
registration at startup. The old sw.js was a self-unregistering cleanup
stub — meaning serviceWorker.ready never resolved and web push has been
silently dead; the new worker handles push display and notification
clicks, and makes the app installable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:07:02 -06:00
Carlos Escalante
c3c14f54c5 What-if savings planner page (wishlist #7)
New Planificador page: the current savings pace (prefilled from the
live budget projection's monthly net, editable) versus a scenario with
extra monthly savings, both compounded at a configurable annual rate
over a 1-40 year horizon — side-by-side totals and growth curves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:07:02 -06:00
Carlos Escalante
84f0256b7c Rate history chart and pension contribution calculator
Analytics gains a 90-day USD/CRC buy/sell chart from the existing
history endpoint (wishlist: rate history). Pensiones gains a
Calculadora de Aporte: fund + target amount + target age → required
monthly contribution via the annuity future-value formula, using the
fund's live balance and configured rate (wishlist: contribution
calculator).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 16:59:29 -06:00
Carlos Escalante
fe62e50a85 Cosmetics: shared PeriodNavigator, breadcrumb, meter labels
One chevron component now drives year/month stepping in Budget and
Proyecciones (UX-03). Jumping from Proyecciones to a Budget month shows
a back affordance (UX-22). Water meters can be named (Casa, Cochera…)
via a settings-persisted dialog; the chart legend uses the names
(UX-24).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 15:27:06 -06:00
Carlos Escalante
7dfe2da2a9 OpenAPI type codegen with CI drift gate (plan 3.4, ARCH-16, FE-14)
Every dict-returning route the SPA consumes now declares a response
model (auth, bulk, sync-status, notifications). api-types.gen.ts is
generated from app.openapi() via openapi-typescript, and the 22 hand-
written read interfaces in api.ts are now aliases onto the generated
schemas — backend schema drift becomes a typecheck failure, and CI
regenerates the file and fails the deploy if it's stale. The aliasing
immediately caught two real drifts: RecurringItemType was missing
SAVINGS, and raw_charges (untyped JSON column) is now explicitly
shaped at the boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 07:53:00 -06:00
Carlos Escalante
3ea1ef0fa0 Record Phase 4 iteration 2 — improvement plan complete
All checks were successful
Deploy to VPS / test (push) Successful in 1m51s
Deploy to VPS / deploy (push) Successful in 1m51s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:15:17 -06:00
Carlos Escalante
8713eaa4d8 Pension assumptions editable + dirty-form guard + empty-state CTA
Per-fund contribution and annual-rate assumptions are now editable in a
Supuestos dialog on Pensiones and persisted server-side in UserSettings
so projections survive devices and reloads; saldo inicial keeps coming
from the latest snapshot (UX-12). The recurring-item dialog confirms
before discarding unsaved edits, with the dirty baseline computed from
the same values the populate effect sets (UX-06). Empty transaction
lists now offer an inline add button (UX-14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:14:44 -06:00
Carlos Escalante
0dfac6a285 Bulk selection UI and import-review modal
Transactions gain a checkbox column with select-all; a bulk bar offers
assign-category, defer/restore and delete (confirmed) via the new bulk
endpoint, with toasts and invalidation. The paste-import modal now
lists each skipped duplicate with the matched existing row and offers
'Importar duplicados de todos modos' — re-importing ONLY the skipped
lines so first-pass rows can't double-import (UX-20). Two real fixes
found en route: the mobile list ignored undo-pending deletes and used
en-US dates; the desktop date column also lost its en-US stray. Note:
UX-11 (no mobile layout) was a false positive — a card layout already
existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:00:19 -06:00
Carlos Escalante
ce3cc69846 Import dedup transparency + override; bulk transaction actions
Paste-import now returns each skipped duplicate WITH the matched
existing row (id, merchant, date, amount, source) instead of a bare
count, and accepts allow_duplicates to force-import false positives —
the practical fix for same-day identical purchases hashing together
(UX-20; supersedes the BE-08 hash change, which would have orphaned
every existing hash). POST /transactions/bulk applies
delete/set_category/set_deferred to up to 500 ids atomically. 82 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:46:23 -06:00
Carlos Escalante
5612f69f11 Record Phase 4 iteration 1 in PROGRESS.md
All checks were successful
Deploy to VPS / test (push) Successful in 1m47s
Deploy to VPS / deploy (push) Successful in 1m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:04:33 -06:00
Carlos Escalante
b3a7f5185a Polish: aria-pressed toggles, deferred row styling, override hint
Fund visibility toggles and the header privacy/theme buttons expose
aria-pressed (FE-16); deferred transactions render struck-through and
muted so the state is visible at a glance (UX-05); the balance-override
input documents its keyboard contract (UX-21). The Budget CSV button
rides in the transactions tab header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
629d1181ae Undo grace for transaction deletes (UX-10)
After confirming a delete the row disappears immediately but the API
call waits 5 seconds behind a Deshacer toast action; undo restores the
row without any server round-trip. A failed commit restores the row
and reports the error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
571428f5ac CSV export for transactions with download buttons (ARCH-18)
GET /api/v1/transactions/export streams a CSV (optional source/type/
date filters), cookie-authenticated so window.open downloads work.
Buttons on the Budget transactions tab (all) and Salarios (SALARY
only). Quoting and category resolution covered by tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
4ceb67564a Sync Status: per-source ingestion health page with nav warning badge
The n8n flows fail invisibly from the app's perspective (review UX-17,
the top trust gap). /api/v1/sync-status derives last-received + cadence
thresholds for BAC, salary, municipal, pensions and exchange rate from
existing data — no n8n integration needed. New Sincronización page
shows per-source freshness and a hint to check n8n; the sidebar item
gets an amber dot when any source is stale. Verified live against the
dev DB (correctly flags its stale BAC data at 43 days).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 18:03:55 -06:00
Carlos Escalante
8b7f3dff40 Localization sweep: shared es-CR dates module, no more en-US strays
src/lib/dates.ts is the single source for month names and date
formatting; the three per-page month arrays are gone (with an
off-by-one guard — the shared array is 1-indexed). formatDate and the
Analytics chart tooltips now speak es-CR, Analytics headings are
Spanish, and the cycle selector says 'Todo el período' (UX-19, FE-17,
UX-07).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:59:16 -06:00
Carlos Escalante
cdfa4ad90a Record Phase 3 completion in PROGRESS.md
All checks were successful
Deploy to VPS / test (push) Successful in 1m37s
Deploy to VPS / deploy (push) Successful in 2m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:31:17 -06:00
Carlos Escalante
b5bb2e8562 Auth probe retries network failures; lazy context init; CK repair logging
A thrown fetch in the auth probe is a network failure, not a 401 — it
now retries once before treating the user as logged out, and logs the
real cause (FE-15). Theme and privacy state initialize lazily from
localStorage so neither the wrong theme nor unmasked sensitive values
flash on reload (FE-07). The CopilotKit orphan-tool-call repair logs
when it has to skip instead of failing silently (FE-11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:30:26 -06:00
Carlos Escalante
2a68a05ffd Mutation feedback: toasts on transaction save/delete, confirm override clear
Transaction create/update/delete now confirm success or failure with a
toast (UX-01); save failures always show a user-visible message instead
of console-only (FE-06, with a typed error guard replacing the any
cast). Clearing a balance override — which recalculates every following
month — now requires confirmation like the other destructive actions
(UX-15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:28:21 -06:00
Carlos Escalante
7a1733bf7e Migrate Analytics, Salarios, Pensions, ServiciosMunicipales, Proyecciones to queries
Every page now goes through TanStack Query with cancellation,
placeholder data on filter changes, and a visible ErrorState with retry
instead of console.error/silent-empty rendering (FE-02, UX-02).
Analytics keeps the trend query on its own key so cycle changes don't
refetch it. Pensions/Servicios upload-triggered refreshes are bounded
by the api-level 30s timeout (FE-05); pension upload results cap at 10
rows with an overflow note (FE-19). Note: the Pensions ROI fallback
already guarded short chart data — FE-04 was already handled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:23:52 -06:00
Carlos Escalante
65dffdac06 Adopt TanStack Query: provider, timeouts, useBudget, Budget page
QueryClientProvider (30s staleTime, 1 retry) + Sonner Toaster at the
root. api.ts gains AbortSignal support and a default 30s timeout
(FE-24); query cancellation kills the stale-response races (FE-01,
FE-03). useBudget is now queries + mutations with targeted ['budget']
invalidation and success/error toasts on every mutation (ARCH-13,
UX-01); month navigation keeps the previous month visible while
fetching. Budget's transaction list is a cancellable query with
placeholder data; deferred-toggle gets feedback (UX-05); both panels
render a shared ErrorState with retry instead of failing silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:24:48 -06:00
Carlos Escalante
396c492f27 Record Phase 2 completion in PROGRESS.md
All checks were successful
Deploy to VPS / test (push) Successful in 1m40s
Deploy to VPS / deploy (push) Successful in 2m26s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:31:42 -06:00
Carlos Escalante
440da3e394 Consistency sweep: enum members, keyword status codes, SimpleCookie
analytics.py filters by TransactionType.COMPRA instead of the string
literal; budget.py uses keyword HTTPException args; the agent
middleware parses cookies with stdlib SimpleCookie instead of a regex
(ARCH-10); auth.py's lazy imports are hoisted — there was never an
actual auth/db cycle (ARCH-20); transactions.py hoists or_/and_.
Verified: dev container boots, agent auth paths return 401 for
missing/garbage cookies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:23:21 -06:00
Carlos Escalante
82f10a5d7c Error visibility and agent tool cleanup
Exchange-rate fetchers log every source failure instead of silently
passing (BE-18); the rate tool exposes fetched_at so staleness is
visible (BE-20). Agent: unbound-session failures raise a clear
RuntimeError (BE-12); net worth converts via get_crc_multipliers with
last-known fallbacks instead of a hardcoded 600 CRC / 1.08 EUR guess,
and reports accounts it cannot convert rather than inventing numbers
(BE-24); budget tool enforces MIN/MAX_YEAR (BE-16); municipal receipts
gain offset paging (BE-17); category analytics prefetches names; the
module docstring pins the read-only tool policy (SEC-09). Note: the
refresh loop never swallowed CancelledError (BaseException since 3.8) —
ARCH-08 was a false positive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:23:21 -06:00
Carlos Escalante
82a7bfc4c5 Harden PDF uploads: size cap, magic-byte check, non-blocking parse
read_pdf_upload() bounds reads at 10 MB and requires the %PDF magic
before anything reaches pdftotext (SEC-06); rejections land in the
per-file errors list with the filename. Parsing now runs via
asyncio.to_thread so a slow PDF no longer blocks the event loop for up
to 30s (BE-14). Review notes: temp files were already context-managed
(BE-09 overstated) and the pension batch already commits atomically
(BE-10 overstated). 69 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 15:00:49 -06:00
Carlos Escalante
3cdd7d9eab Constraint and query pass: FK delete rules, grouped queries, tiebreakers
- FK rules via migration 7884505b16b3 (verified on dev with a rolled-
  back probe): transactions/recurring items SET NULL on category delete
  (this also fixes the 500 that DELETE /categories raised for in-use
  categories), water readings CASCADE with their receipt. Models declare
  ondelete to stay in sync. (ARCH-09, BE-11)
- compute_actuals_by_source: 3 grouped queries replace 10 single-
  aggregate round-trips; behavior pinned by the existing cycle suite.
  (BE-21)
- compute_cc_by_category prefetches category names (ARCH-15); agent
  pension latest-per-fund resolved in SQL (BE-04); municipal water
  consumption batched into one grouped query (BE-05).
- Deterministic pagination: date DESC, id DESC on all transaction
  listings. (ARCH-12)
- New agent-tool tests (session binding, JSON-safe output, batched
  queries): 64 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:45:46 -06:00
Carlos Escalante
74886fbf98 Migrate all 29 money columns from float to NUMERIC/Decimal (BE-02)
Models use Money = Annotated[Decimal, PlainSerializer(float)] so storage
is exact NUMERIC(15,2) (rates 15,6) while JSON keeps emitting plain
numbers — the frontend contract is unchanged, pinned by
test_models_serialization.py. Service layers coerce to float at their
boundaries (projection math, rate multipliers, agent tool output, the
savings constants become Decimal) so no naive Decimal/float mixing can
raise. Migration 7f567c8bafdb applied to dev: per-row values and the
table sum byte-identical before/after; endpoints verified live on the
new schema. 59/59 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:38:28 -06:00
Carlos Escalante
881d879ed1 Gate deploys on backend tests and frontend typecheck; record Phase 1
All checks were successful
Deploy to VPS / test (push) Successful in 1m51s
Deploy to VPS / deploy (push) Successful in 1m4s
The new test job runs the 55-test pytest suite and pnpm typecheck
before the deploy job (needs: test) — a red suite now blocks prod.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:59:34 -06:00
Carlos Escalante
49b96ed49c Fix all 20 pre-existing type errors; typecheck now green
- ui/chart.tsx: recharts 3 stopped exposing tooltip/legend content props
  on its public types; declare the ChartPayloadItem shape we consume
- base-ui Select onValueChange passes string|null: guard null in
  BillingCycleSelector, PasteImportModal, TransactionModal
- type the untyped api.get calls (cycles, categories)
- push-notifications: back the VAPID key with an explicit ArrayBuffer
  so it satisfies BufferSource under TS 5.9

Unblocks gating CI on pnpm typecheck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:55:07 -06:00
Carlos Escalante
9a25c4baab Adopt Alembic: autogenerated baseline, stamp-or-upgrade startup
The baseline (c3da001a0eb3) was autogenerated against an empty Postgres
and schema-diffed against the live dev schema until column-identical
(two model fixes fell out: deferred_to_next_cycle now declares its
server_default, usersettings.data is JSONB on Postgres to match the
live type). Startup replaces init_db/run_migrations with
run_alembic_upgrade(): pre-Alembic databases that already match the
baseline are adopted via stamp; fresh databases build from the
migration. The section is serialized with a pg advisory lock because
prod uvicorn runs 2 workers, each executing the lifespan. Verified:
adoption + idempotent re-run on the dev DB, fresh 13-table build on an
empty DB, full container boot, 55/55 tests. (ARCH-02, BE-19)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:31:55 -06:00
Carlos Escalante
c725c1487d Add pytest harness and 55-test suite for core backend logic
Covers: billing-cycle characterization (the BE-06 convention, boundary
instants, year wraps, deferred transactions), projection engine
(recurring schedules, no-double-count, cumulative balances, overrides,
fresh start), auth (JWT roundtrip/expiry, API tokens, constant-time
creds, rate-limit window), fail-fast settings, and the BAC paste
parser. SQLite in-memory fixtures, exchange rates pinned — no network.
Also doubles as the float-behavior baseline for the Phase 2 Decimal
migration (plan 1.5). config.py moves to SettingsConfigDict to clear
the pydantic deprecation warning from test output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:46:54 -06:00
Carlos Escalante
996bd437a1 Replace deprecated datetime.utcnow with shared naive-UTC helper
All 29 call sites now use app.timeutil.utcnow(), which returns naive
UTC via the non-deprecated API. Semantics are unchanged on purpose:
every datetime column is TIMESTAMP WITHOUT TIME ZONE, so going aware
here would poison naive/aware comparisons. The TIMESTAMPTZ migration
(Phase 2) now has a single place to change. (BE-01)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:46:54 -06:00
Carlos Escalante
0a4c3e9436 Validate month input and document cycle labeling convention
Review finding BE-06 (suspected off-by-one in get_cycle_range) is a
FALSE POSITIVE: budget month M composes get_previous_cycle with
get_cycle_range to cover [(M-1)/18, M/18), matching both the code
comment and the frontend convention. Verified against prod data: May
2026 cycle-window SQL sum matches the API (95 txs exactly, refunds to
the colón, 0.13%% delta from one EUR tx). The docstring now spells out
the labeling convention, and month is validated 1-12 (BE-07).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:46:54 -06:00
Carlos Escalante
fa19ac1988 Document Phase 0 completion in codebase-review/PROGRESS.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:34:48 -06:00
160 changed files with 19822 additions and 3253 deletions

View File

@@ -23,4 +23,4 @@ VAPID_PUBLIC_KEY=
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
OPENAI_API_KEY=
AGENT_MODEL=gpt-5.4-mini
AGENT_MODEL=gpt-5.6-luna

View File

@@ -5,8 +5,35 @@ on:
branches: [main]
jobs:
test:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Backend tests
run: |
cd backend
python3 -m venv /tmp/test-venv
/tmp/test-venv/bin/pip install --quiet -r requirements.txt -r requirements-dev.txt -c constraints.txt
/tmp/test-venv/bin/python -m pytest tests/ -q
- name: Frontend typecheck + API type drift gate
run: |
cd backend
DATABASE_URL='sqlite://' SECRET_KEY='ci-only-0123456789abcdef0123456789abcdef0123456789abcdef' \
ADMIN_USERNAME=ci ADMIN_PASSWORD=ci-not-admin OPENAI_API_KEY=dummy \
/tmp/test-venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
cd ../frontend
corepack enable
pnpm install --frozen-lockfile
pnpm exec openapi-typescript openapi.json -o src/lib/api-types.gen.ts
git diff --exit-code src/lib/api-types.gen.ts
pnpm typecheck
deploy:
runs-on: self-hosted
needs: test
steps:
- uses: actions/checkout@v4

7
.gitignore vendored
View File

@@ -14,3 +14,10 @@ tech_docs/
# Claude Code local state
.claude/
.venv/
frontend/openapi.json
# playwright (demo artifacts are generated, not committed)
frontend/test-results/
frontend/playwright-report/
frontend/e2e-docs/

View File

@@ -17,6 +17,27 @@ The backend refuses to boot without `SECRET_KEY`, `ADMIN_USERNAME`, and
`ADMIN_PASSWORD` (no insecure defaults). Copy `.env.example` to `.env` and fill
it in; the dev docker-compose reads it.
## Database Migrations
Schema changes go through Alembic — never edit the schema by hand and never
reset the prod DB. Workflow:
```bash
cd backend
# 1. Edit app/models/models.py
# 2. Autogenerate against the dev DB, then REVIEW the generated file
DATABASE_URL='postgresql://wealthy_user:wealthy_pass@localhost:5433/wealthysmart' \
.venv/bin/alembic revision --autogenerate -m "describe the change"
# 3. Migrations apply automatically at app startup (run_alembic_upgrade)
```
## Tests
```bash
cd backend && .venv/bin/python -m pytest tests/ -q # backend (55+ tests)
cd frontend && pnpm typecheck # frontend
```
## Local Docker
```bash
@@ -47,3 +68,17 @@ Four automated flows on old-vps feed data into WealthySmart:
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
## API Type Generation
Frontend read types are generated from the backend OpenAPI spec. After changing
backend response models:
```bash
cd backend && DATABASE_URL='sqlite://' SECRET_KEY=$(openssl rand -hex 32) \
ADMIN_USERNAME=x ADMIN_PASSWORD=x-x OPENAI_API_KEY=dummy \
.venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
cd ../frontend && pnpm gen:api # regenerates src/lib/api-types.gen.ts
```
CI regenerates and fails the deploy if `api-types.gen.ts` is stale.

View File

@@ -1,7 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --pre -r requirements.txt
COPY requirements.txt constraints.txt ./
RUN pip install --no-cache-dir --pre -r requirements.txt -c constraints.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -1,7 +1,7 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt constraints.txt ./
RUN pip install --no-cache-dir -r requirements.txt -c constraints.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]

149
backend/alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

49
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,49 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
from app.config import settings
import app.models.models # noqa: F401 — register all tables on the metadata
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Single source of truth for the DB URL: the app settings.
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = SQLModel.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,29 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,35 @@
"""track agui run id in chat logs
Revision ID: 10f4a4f71964
Revises: ecb99a158fbf
Create Date: 2026-07-14 22:32:16.771655
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '10f4a4f71964'
down_revision: Union[str, Sequence[str], None] = 'ecb99a158fbf'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('chatrunlog', sa.Column('agui_run_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.create_index(op.f('ix_chatrunlog_agui_run_id'), 'chatrunlog', ['agui_run_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chatrunlog_agui_run_id'), table_name='chatrunlog')
op.drop_column('chatrunlog', 'agui_run_id')
# ### end Alembic commands ###

View File

@@ -0,0 +1,61 @@
"""fk delete rules
Revision ID: 7884505b16b3
Revises: 7f567c8bafdb
Create Date: 2026-06-10 13:50:39.153585
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '7884505b16b3'
down_revision: Union[str, Sequence[str], None] = '7f567c8bafdb'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add delete rules to the three FKs (review ARCH-09/BE-11).
Transactions and recurring items outlive their category (SET NULL);
water readings die with their receipt (CASCADE). Constraint names match
both pre-Alembic databases and fresh baseline builds (Postgres default
naming).
"""
op.drop_constraint("transaction_category_id_fkey", "transaction", type_="foreignkey")
op.create_foreign_key(
"transaction_category_id_fkey", "transaction", "category",
["category_id"], ["id"], ondelete="SET NULL",
)
op.drop_constraint("recurringitem_category_id_fkey", "recurringitem", type_="foreignkey")
op.create_foreign_key(
"recurringitem_category_id_fkey", "recurringitem", "category",
["category_id"], ["id"], ondelete="SET NULL",
)
op.drop_constraint("watermeterreading_receipt_id_fkey", "watermeterreading", type_="foreignkey")
op.create_foreign_key(
"watermeterreading_receipt_id_fkey", "watermeterreading", "municipalreceipt",
["receipt_id"], ["id"], ondelete="CASCADE",
)
def downgrade() -> None:
op.drop_constraint("watermeterreading_receipt_id_fkey", "watermeterreading", type_="foreignkey")
op.create_foreign_key(
"watermeterreading_receipt_id_fkey", "watermeterreading", "municipalreceipt",
["receipt_id"], ["id"],
)
op.drop_constraint("recurringitem_category_id_fkey", "recurringitem", type_="foreignkey")
op.create_foreign_key(
"recurringitem_category_id_fkey", "recurringitem", "category",
["category_id"], ["id"],
)
op.drop_constraint("transaction_category_id_fkey", "transaction", type_="foreignkey")
op.create_foreign_key(
"transaction_category_id_fkey", "transaction", "category",
["category_id"], ["id"],
)

View File

@@ -0,0 +1,45 @@
"""chat thread snapshot
Revision ID: 794baf50635b
Revises: 961802a2f50d
Create Date: 2026-07-04 17:49:14.914103
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '794baf50635b'
down_revision: Union[str, Sequence[str], None] = '961802a2f50d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chatthreadsnapshot',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('scope', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('messages', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
sa.Column('state', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('interrupt', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('scope', 'thread_id')
)
op.create_index(op.f('ix_chatthreadsnapshot_scope'), 'chatthreadsnapshot', ['scope'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chatthreadsnapshot_scope'), table_name='chatthreadsnapshot')
op.drop_table('chatthreadsnapshot')
# ### end Alembic commands ###

View File

@@ -0,0 +1,263 @@
"""money columns to numeric
Revision ID: 7f567c8bafdb
Revises: c3da001a0eb3
Create Date: 2026-06-10 13:37:26.281634
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '7f567c8bafdb'
down_revision: Union[str, Sequence[str], None] = 'c3da001a0eb3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('account', 'balance',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('account', 'next_payment',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=True)
op.alter_column('balanceoverride', 'override_balance',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('exchangerate', 'buy_rate',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=6),
existing_nullable=False)
op.alter_column('exchangerate', 'sell_rate',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=6),
existing_nullable=False)
op.alter_column('municipalreceipt', 'subtotal',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('municipalreceipt', 'interests',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('municipalreceipt', 'iva',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('municipalreceipt', 'total',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_anterior',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'aportes',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'rendimientos',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'retiros',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'traslados',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'comision',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'correccion',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'bonificacion',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_final',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('recurringitem', 'amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('savingsaccrual', 'memp_amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('savingsaccrual', 'mpat_amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('transaction', 'amount',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_previous',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_current',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'consumption_m3',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'agua_potable',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'serv_ambientales',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'alcant_sanitario',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
op.alter_column('watermeterreading', 'iva',
existing_type=sa.DOUBLE_PRECISION(precision=53),
type_=sa.Numeric(precision=15, scale=2),
existing_nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('watermeterreading', 'iva',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'alcant_sanitario',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'serv_ambientales',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'agua_potable',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'consumption_m3',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_current',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('watermeterreading', 'reading_previous',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('transaction', 'amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('savingsaccrual', 'mpat_amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('savingsaccrual', 'memp_amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('recurringitem', 'amount',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_final',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'bonificacion',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'correccion',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'comision',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'traslados',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'retiros',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'rendimientos',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'aportes',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('pensionsnapshot', 'saldo_anterior',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'total',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'iva',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'interests',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('municipalreceipt', 'subtotal',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('exchangerate', 'sell_rate',
existing_type=sa.Numeric(precision=15, scale=6),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('exchangerate', 'buy_rate',
existing_type=sa.Numeric(precision=15, scale=6),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('balanceoverride', 'override_balance',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
op.alter_column('account', 'next_payment',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=True)
op.alter_column('account', 'balance',
existing_type=sa.Numeric(precision=15, scale=2),
type_=sa.DOUBLE_PRECISION(precision=53),
existing_nullable=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""record chat client details
Revision ID: 86104b4ff6ae
Revises: 10f4a4f71964
Create Date: 2026-07-14 22:54:55.519380
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '86104b4ff6ae'
down_revision: Union[str, Sequence[str], None] = '10f4a4f71964'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('chatrunlog', sa.Column('client_ip', sa.Text(), nullable=True))
op.add_column('chatrunlog', sa.Column('user_agent', sa.Text(), nullable=True))
op.add_column('chatrunlog', sa.Column('browser', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('chatrunlog', 'browser')
op.drop_column('chatrunlog', 'user_agent')
op.drop_column('chatrunlog', 'client_ip')
# ### end Alembic commands ###

View File

@@ -0,0 +1,55 @@
"""installment plans (tasa cero)
Revision ID: 961802a2f50d
Revises: 7884505b16b3
Create Date: 2026-07-03 16:00:55.026771
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '961802a2f50d'
down_revision: Union[str, Sequence[str], None] = '7884505b16b3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('installmentplan',
sa.Column('num_installments', sa.Integer(), nullable=False),
sa.Column('first_installment_date', sa.DateTime(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=15, scale=2), nullable=False),
sa.Column('installment_amount', sa.Numeric(precision=15, scale=2), nullable=False),
# Reuse the existing 'currency' enum type — do NOT re-create it.
sa.Column('currency', postgresql.ENUM('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency', create_type=False), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('anchor_transaction_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['anchor_transaction_id'], ['transaction.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_installmentplan_anchor_transaction_id'), 'installmentplan', ['anchor_transaction_id'], unique=True)
op.add_column('transaction', sa.Column('installment_plan_id', sa.Integer(), nullable=True))
op.add_column('transaction', sa.Column('is_installment_anchor', sa.Boolean(), server_default='false', nullable=False))
op.create_index(op.f('ix_transaction_installment_plan_id'), 'transaction', ['installment_plan_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_transaction_installment_plan_id'), table_name='transaction')
op.drop_column('transaction', 'is_installment_anchor')
op.drop_column('transaction', 'installment_plan_id')
op.drop_index(op.f('ix_installmentplan_anchor_transaction_id'), table_name='installmentplan')
op.drop_table('installmentplan')
# ### end Alembic commands ###

View File

@@ -0,0 +1,221 @@
"""baseline
Revision ID: c3da001a0eb3
Revises:
Create Date: 2026-06-09 20:00:18.301439
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'c3da001a0eb3'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('account',
sa.Column('bank', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False),
sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False),
sa.Column('label', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('balance', sa.Float(), nullable=False),
sa.Column('account_type', sa.Enum('BANK', 'PENSION', 'CRYPTO', 'SAVINGS', 'LIABILITY', name='accounttype'), nullable=False),
sa.Column('next_payment', sa.Float(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('apitoken',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_apitoken_token_hash'), 'apitoken', ['token_hash'], unique=False)
op.create_table('balanceoverride',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('year', sa.Integer(), nullable=False),
sa.Column('month', sa.Integer(), nullable=False),
sa.Column('override_balance', sa.Float(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('year', 'month')
)
op.create_table('category',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('icon', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('auto_match_patterns', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_category_name'), 'category', ['name'], unique=True)
op.create_table('exchangerate',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('buy_rate', sa.Float(), nullable=False),
sa.Column('sell_rate', sa.Float(), nullable=False),
sa.Column('fetched_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('municipalreceipt',
sa.Column('receipt_date', sa.Date(), nullable=False),
sa.Column('due_date', sa.Date(), nullable=False),
sa.Column('period', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('account', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('finca', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('holder_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('holder_cedula', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('holder_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('subtotal', sa.Float(), nullable=False),
sa.Column('interests', sa.Float(), nullable=False),
sa.Column('iva', sa.Float(), nullable=False),
sa.Column('total', sa.Float(), nullable=False),
sa.Column('raw_charges', sa.JSON(), server_default='[]', nullable=False),
sa.Column('source_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('account', 'period')
)
op.create_table('pensionsnapshot',
sa.Column('fund', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False),
sa.Column('contract_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('period_start', sa.Date(), nullable=False),
sa.Column('period_end', sa.Date(), nullable=False),
sa.Column('saldo_anterior', sa.Float(), nullable=False),
sa.Column('aportes', sa.Float(), nullable=False),
sa.Column('rendimientos', sa.Float(), nullable=False),
sa.Column('retiros', sa.Float(), nullable=False),
sa.Column('traslados', sa.Float(), nullable=False),
sa.Column('comision', sa.Float(), nullable=False),
sa.Column('correccion', sa.Float(), nullable=False),
sa.Column('bonificacion', sa.Float(), nullable=False),
sa.Column('saldo_final', sa.Float(), nullable=False),
sa.Column('source_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('fund', 'period_start', 'period_end')
)
op.create_table('pushsubscription',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('endpoint', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('p256dh', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('auth', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('endpoint')
)
op.create_table('savingsaccrual',
sa.Column('year', sa.Integer(), nullable=False),
sa.Column('month', sa.Integer(), nullable=False),
sa.Column('memp_amount', sa.Float(), nullable=False),
sa.Column('mpat_amount', sa.Float(), nullable=False),
sa.Column('trigger_transaction_id', sa.Integer(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('applied_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('year', 'month')
)
op.create_table('usersettings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('data', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='{}', nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_usersettings_key'), 'usersettings', ['key'], unique=True)
op.create_table('recurringitem',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False),
sa.Column('item_type', sa.Enum('INCOME', 'EXPENSE', 'SAVINGS', name='recurringitemtype'), nullable=False),
sa.Column('frequency', sa.Enum('WEEKLY', 'MONTHLY', 'QUARTERLY', 'BIANNUAL', 'YEARLY', name='recurringfrequency'), nullable=False),
sa.Column('day_of_month', sa.Integer(), nullable=True),
sa.Column('month_of_year', sa.Integer(), nullable=True),
sa.Column('override_amounts', sa.JSON(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('transaction',
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False),
sa.Column('merchant', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('date', sa.DateTime(), nullable=False),
sa.Column('card_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('card_last4', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('authorization_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('reference', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('transaction_type', sa.Enum('COMPRA', 'DEVOLUCION', 'DEPOSITO', 'SALARY', name='transactiontype'), nullable=False),
sa.Column('source', sa.Enum('CREDIT_CARD', 'CASH', 'TRANSFER', name='transactionsource'), nullable=False),
sa.Column('bank', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('category_id', sa.Integer(), nullable=True),
sa.Column('deferred_to_next_cycle', sa.Boolean(), server_default='false', nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_transaction_reference'), 'transaction', ['reference'], unique=False)
op.create_table('watermeterreading',
sa.Column('meter_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('period', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('reading_previous', sa.Float(), nullable=False),
sa.Column('reading_current', sa.Float(), nullable=False),
sa.Column('consumption_m3', sa.Float(), nullable=False),
sa.Column('agua_potable', sa.Float(), nullable=False),
sa.Column('serv_ambientales', sa.Float(), nullable=False),
sa.Column('alcant_sanitario', sa.Float(), nullable=False),
sa.Column('iva', sa.Float(), nullable=False),
sa.Column('is_historical', sa.Boolean(), nullable=False),
sa.Column('receipt_id', sa.Integer(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['receipt_id'], ['municipalreceipt.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('meter_id', 'period', 'is_historical')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('watermeterreading')
op.drop_index(op.f('ix_transaction_reference'), table_name='transaction')
op.drop_table('transaction')
op.drop_table('recurringitem')
op.drop_index(op.f('ix_usersettings_key'), table_name='usersettings')
op.drop_table('usersettings')
op.drop_table('savingsaccrual')
op.drop_table('pushsubscription')
op.drop_table('pensionsnapshot')
op.drop_table('municipalreceipt')
op.drop_table('exchangerate')
op.drop_index(op.f('ix_category_name'), table_name='category')
op.drop_table('category')
op.drop_table('balanceoverride')
op.drop_index(op.f('ix_apitoken_token_hash'), table_name='apitoken')
op.drop_table('apitoken')
op.drop_table('account')
# ### end Alembic commands ###

View File

@@ -0,0 +1,58 @@
"""add chat run audit logs
Revision ID: ecb99a158fbf
Revises: 794baf50635b
Create Date: 2026-07-14 22:22:06.035643
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'ecb99a158fbf'
down_revision: Union[str, Sequence[str], None] = '794baf50635b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('chatrunlog',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('request_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('requested_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('response_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('user_prompt', sa.Text(), nullable=True),
sa.Column('tool_calls', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
sa.Column('assistant_response', sa.Text(), nullable=True),
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('error', sa.Text(), nullable=True),
sa.Column('started_at', sa.DateTime(), nullable=False),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_chatrunlog_expires_at'), 'chatrunlog', ['expires_at'], unique=False)
op.create_index(op.f('ix_chatrunlog_request_id'), 'chatrunlog', ['request_id'], unique=True)
op.create_index(op.f('ix_chatrunlog_requested_thread_id'), 'chatrunlog', ['requested_thread_id'], unique=False)
op.create_index(op.f('ix_chatrunlog_response_thread_id'), 'chatrunlog', ['response_thread_id'], unique=False)
op.create_index(op.f('ix_chatrunlog_status'), 'chatrunlog', ['status'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_chatrunlog_status'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_response_thread_id'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_requested_thread_id'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_request_id'), table_name='chatrunlog')
op.drop_index(op.f('ix_chatrunlog_expires_at'), table_name='chatrunlog')
op.drop_table('chatrunlog')
# ### end Alembic commands ###

View File

@@ -2,10 +2,8 @@
from __future__ import annotations
from datetime import date
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from agent_framework.openai import OpenAIChatClient
from app.config import settings
from app.agent.tools import TOOLS
@@ -22,19 +20,46 @@ Context you can rely on:
following month. When the user says "this month" or "last month" without
qualifiers, assume they mean the calendar month unless they mention
"cycle", "corte", or their credit card.
- Today's date is {today}. Use it when the user says "this month", "last
month", "last year", etc.
- You do NOT have a reliable clock, and the server runs in UTC while the
user may be anywhere (their browser reports their timezone per request).
Whenever the question involves a relative date — "hoy", "ayer", "este
mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
derive ranges from its answer.
- Amounts are stored as raw numbers in their native currency (see `currency`
field on transactions/accounts). Tools that return `total_crc` are already
converted; tools that return per-transaction amounts are NOT.
How to answer:
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
- Match the tool to the question's time grain: day-scoped questions ("hoy",
"ayer", "el martes", a specific date) need get_daily_spending with that
day's bounds — cycle or category summaries have NO per-day data, so never
answer a day question from them.
- For a question about a specific salary deposit or its amount (including
"cuánto recibí hoy"), call get_salary_deposits with the relevant date range;
get_salary_summary is aggregate-only and cannot answer it.
- Answer like an analyst, not a calculator. An aggregate number on its own
is not an answer — show what it is made of, right away, without waiting
to be asked:
· saldo neto / net worth → headline, then the assets-vs-liabilities
split and the accounts behind each side (get_accounts + get_net_worth).
· "cuánto gasté hoy/ayer/el martes" → the total, then the purchases
behind it (get_recent_transactions with the same date bounds:
merchant, amount, source).
· cycle or category totals → the top components and how much each
contributes.
- Structure: one headline sentence with the number, a compact markdown
table with the breakdown, and — only when the data genuinely supports
it — one closing observation (an unusually large item, a comparison to
the previous day/cycle). Never pad with filler or generic offers like
"si quieres te puedo mostrar…" when you could just show it.
- Call multiple tools in parallel when the question spans domains
(e.g. net worth + recent transactions).
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
USD (two decimals), € for EUR (two decimals).
- When showing lists, prefer markdown tables over prose.
- When showing lists or structured data (transactions, breakdowns, funds),
format them as clean markdown tables: right-align amounts, include a total
row when summing, keep merchant names as-is.
- If a tool returns no data, say so explicitly — do not fill in zeros.
- You are read-only in this version. If asked to create, edit or delete
anything, explain that write actions aren't available yet and offer to
@@ -47,28 +72,32 @@ Generative UI — render tools:
- When showing spending totals, cycle summaries, or category breakdowns →
call render_spending_summary. Source data: get_cycle_summary (by_source,
grand_total_crc) + get_analytics_by_category (by_category).
- When showing transaction lists or other structured data →
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
calls have returned. NEVER call render_a2ui in the same batch as any other
tool.
- Do NOT use markdown tables for data a render tool can display.
- CRITICAL RULE: When you call a render tool (render_spending_summary or
render_a2ui), that tool call MUST be the ONLY content in your message.
Do NOT include any text content alongside the tool call — no introduction,
no list, no explanation, nothing. The rendered card IS the complete
response. Any text you write in the same message as a render call will
appear as a duplicate below the card, which is wrong.
- Do NOT use markdown tables for data render_spending_summary can display.
- CRITICAL RULE: When you call render_spending_summary, that tool call MUST
be the ONLY content in your message. Do NOT include any text content
alongside the tool call — no introduction, no list, no explanation,
nothing. The rendered card IS the complete response. Any text you write in
the same message as a render call will appear as a duplicate below the
card, which is wrong.
- When a render_spending_summary tool result ("ok") is already present for
the current question, the card is already on screen. Do NOT call the tool
again and do NOT add text — end your response with no further output.
"""
def build_agent() -> Agent:
client = OpenAIChatCompletionClient(
# Use the Responses API for every configured model. It preserves one tool
# transport across model changes and supports Luna's tool-result turns.
client = OpenAIChatClient(
api_key=settings.OPENAI_API_KEY,
model=settings.AGENT_MODEL,
)
return Agent(
name="wealthysmart",
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
# No date baked in: build_agent() runs once at startup, so anything
# substituted here freezes at boot (and in server-UTC). The model
# gets "today" from the get_current_date tool instead.
instructions=SYSTEM_PROMPT,
client=client,
tools=TOOLS,
)

View File

@@ -0,0 +1,153 @@
"""Durable diagnostics for individual AG-UI agent runs."""
from __future__ import annotations
import asyncio
import contextvars
from datetime import timedelta
from typing import Any
from sqlmodel import Session, delete, select
from app.config import settings
from app.db import engine
from app.models.models import ChatRunLog
from app.timeutil import utcnow
CHAT_LOG_RETENTION_DAYS = 90
_chat_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
"chat_run_id", default=None
)
def set_current_chat_run(run_id: int) -> contextvars.Token:
return _chat_run_id.set(run_id)
def reset_current_chat_run(token: contextvars.Token) -> None:
_chat_run_id.reset(token)
def current_chat_run_id() -> int | None:
return _chat_run_id.get()
def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None:
for message in reversed(messages):
if message.get("role") == "user" and isinstance(message.get("content"), str):
return message["content"]
return None
def create_chat_run(
*,
request_id: str,
agui_run_id: str | None,
thread_id: str | None,
messages: list[dict[str, Any]],
client_ip: str | None = None,
user_agent: str | None = None,
browser: str | None = None,
) -> int:
now = utcnow()
with Session(engine) as session:
row = ChatRunLog(
request_id=request_id,
agui_run_id=agui_run_id,
requested_thread_id=thread_id,
model=settings.AGENT_MODEL,
client_ip=client_ip,
user_agent=user_agent,
browser=browser,
user_prompt=_last_user_prompt(messages),
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
)
session.add(row)
session.commit()
session.refresh(row)
assert row.id is not None
return row.id
def _tool_activity(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
calls: dict[str, dict[str, Any]] = {}
order: list[str] = []
for message in messages:
if message.get("role") == "assistant":
for call in message.get("toolCalls") or message.get("tool_calls") or []:
call_id = str(call.get("id") or "")
if not call_id or call_id in calls:
continue
function = call.get("function") or {}
calls[call_id] = {
"id": call_id,
"name": function.get("name"),
"arguments": function.get("arguments"),
}
order.append(call_id)
elif message.get("role") == "tool":
call_id = str(message.get("toolCallId") or message.get("tool_call_id") or "")
if call_id in calls:
calls[call_id]["result"] = message.get("content")
if message.get("error") is not None:
calls[call_id]["error"] = message["error"]
return [calls[call_id] for call_id in order]
def _last_assistant_response(messages: list[dict[str, Any]]) -> str | None:
for message in reversed(messages):
if message.get("role") == "assistant" and isinstance(message.get("content"), str):
return message["content"]
return None
def finalize_chat_run_in_session(
session: Session, *, run_id: int, response_thread_id: str, messages: list[dict[str, Any]]
) -> None:
row = session.get(ChatRunLog, run_id)
if row is None:
return
row.response_thread_id = response_thread_id
row.tool_calls = _tool_activity(messages)
row.assistant_response = _last_assistant_response(messages)
row.status = "completed"
row.completed_at = utcnow()
session.add(row)
def mark_chat_run_failed(run_id: int, error: str) -> None:
with Session(engine) as session:
row = session.get(ChatRunLog, run_id)
if row is None or row.status == "completed":
return
row.status = "failed"
row.error = error[:4000]
row.completed_at = utcnow()
session.add(row)
session.commit()
def finish_pending_chat_run(run_id: int) -> None:
with Session(engine) as session:
row = session.get(ChatRunLog, run_id)
if row is None or row.status != "started":
return
row.status = "completed"
row.completed_at = utcnow()
session.add(row)
session.commit()
def purge_expired_chat_runs() -> int:
with Session(engine) as session:
result = session.exec(delete(ChatRunLog).where(ChatRunLog.expires_at < utcnow()))
session.commit()
return result.rowcount or 0
async def purge_expired_chat_runs_periodically() -> None:
"""Purge at startup and then once per day for a bounded audit footprint."""
while True:
await asyncio.to_thread(purge_expired_chat_runs)
await asyncio.sleep(24 * 60 * 60)

View File

@@ -0,0 +1,194 @@
"""Postgres-backed AG-UI thread snapshot store.
Implements the AGUIThreadSnapshotStore protocol from agent-framework-ag-ui so
the assistant conversation survives page reloads: MAF calls `save` with the
full cumulative message list at each run end, and `get` on the next request
(server-side context rebuild, or a client hydration run with empty messages).
Two deliberate choices:
- Own sessions per call: MAF saves during SSE streaming, after the
agent_auth_and_session middleware has already closed the request-bound
ContextVar session. Sync SQLModel work runs in a thread so the event loop
keeps streaming.
- `save` normalizes assistant `tool_calls` -> `toolCalls`: @ag-ui/client's
Zod schema silently strips the snake_case field, so a replayed snapshot
would lose tool calls (and the spending-summary card) on the way to the
browser. MAF reads both casings back, so storing camelCase is safe.
"""
from __future__ import annotations
import asyncio
from typing import Any
from agent_framework_ag_ui import AGUIThreadSnapshot
from sqlmodel import Session, select
from app.db import engine
from app.agent.chat_audit import current_chat_run_id, finalize_chat_run_in_session
from app.models.models import ChatThreadSnapshot
from app.timeutil import utcnow
def _normalize_message(msg: dict[str, Any]) -> dict[str, Any]:
out = dict(msg)
tool_calls = out.pop("tool_calls", None)
if tool_calls is None:
tool_calls = out.pop("toolCalls", None)
else:
out.pop("toolCalls", None)
# MAF writes tool_calls: null on plain-text assistant messages; only a
# non-empty list is meaningful downstream.
if isinstance(tool_calls, list) and tool_calls:
out["toolCalls"] = tool_calls
if "tool_call_id" in out:
out.setdefault("toolCallId", out["tool_call_id"])
del out["tool_call_id"]
return out
def _clean_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sanitize the cumulative snapshot before storing it.
MAF's history merge re-records the resent suffix of multi-run turns
(frontend-tool roundtrips) without deduplicating, and the model
occasionally re-issues an identical render tool call on the follow-up
run. Left as-is, the snapshot grows duplicates that replay as repeated
bubbles/cards after a reload.
"""
# Pass 1: drop BFF-injected ctx-* system messages, duplicate message
# ids, and repeated identical tool calls (same function + arguments)
# within one user turn.
seen_ids: set[str] = set()
turn_calls: set[tuple[Any, Any]] = set()
kept_call_ids: set[str] = set()
first_pass: list[dict[str, Any]] = []
for raw in messages:
m = _normalize_message(raw)
mid = str(m.get("id", ""))
if mid.startswith("ctx-"):
continue
if mid and mid in seen_ids:
continue
role = m.get("role")
if role == "user":
turn_calls = set()
if role == "assistant" and isinstance(m.get("toolCalls"), list):
kept: list[dict[str, Any]] = []
for tc in m["toolCalls"]:
fn = tc.get("function") or {}
key = (fn.get("name"), fn.get("arguments"))
if key in turn_calls:
continue
turn_calls.add(key)
kept.append(tc)
if tc.get("id"):
kept_call_ids.add(str(tc["id"]))
if kept:
m["toolCalls"] = kept
else:
m.pop("toolCalls", None)
if not m.get("content"):
continue # nothing left worth replaying
if mid:
seen_ids.add(mid)
first_pass.append(m)
# Pass 2: one tool result per surviving call id. Results whose call was
# deduplicated away must go too — an orphan tool message in the stored
# history would 400 the next OpenAI request when MAF rebuilds context.
answered: set[str] = set()
out: list[dict[str, Any]] = []
for m in first_pass:
if m.get("role") == "tool":
tcid = str(m.get("toolCallId") or "")
if tcid and (tcid not in kept_call_ids or tcid in answered):
continue
if tcid:
answered.add(tcid)
out.append(m)
return out
class PostgresSnapshotStore:
"""One upserted row per (scope, thread_id); latest snapshot only."""
async def save(
self, *, scope: str, thread_id: str, snapshot: AGUIThreadSnapshot
) -> None:
def _save() -> None:
with Session(engine) as session:
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == scope,
ChatThreadSnapshot.thread_id == thread_id,
)
).first()
if row is None:
row = ChatThreadSnapshot(scope=scope, thread_id=thread_id)
row.messages = _clean_messages(snapshot.messages)
row.state = snapshot.state
row.interrupt = snapshot.interrupt
row.updated_at = utcnow()
session.add(row)
run_id = current_chat_run_id()
if run_id is not None:
finalize_chat_run_in_session(
session,
run_id=run_id,
response_thread_id=thread_id,
messages=row.messages,
)
session.commit()
await asyncio.to_thread(_save)
async def get(
self, *, scope: str, thread_id: str
) -> AGUIThreadSnapshot | None:
def _get() -> AGUIThreadSnapshot | None:
with Session(engine) as session:
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == scope,
ChatThreadSnapshot.thread_id == thread_id,
)
).first()
if row is None:
return None
return AGUIThreadSnapshot(
messages=row.messages or [],
state=row.state,
interrupt=row.interrupt,
)
return await asyncio.to_thread(_get)
async def delete(self, *, scope: str, thread_id: str) -> bool:
def _delete() -> bool:
with Session(engine) as session:
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == scope,
ChatThreadSnapshot.thread_id == thread_id,
)
).first()
if row is None:
return False
session.delete(row)
session.commit()
return True
return await asyncio.to_thread(_delete)
async def clear(self, *, scope: str | None = None) -> None:
def _clear() -> None:
with Session(engine) as session:
stmt = select(ChatThreadSnapshot)
if scope is not None:
stmt = stmt.where(ChatThreadSnapshot.scope == scope)
for row in session.exec(stmt).all():
session.delete(row)
session.commit()
await asyncio.to_thread(_clear)

View File

@@ -3,12 +3,17 @@ Read-only tools exposed to the MAF ChatAgent. Each tool is a thin wrapper
around existing SQLModel queries / service helpers — they do NOT duplicate
business logic. The active DB session is resolved via a ContextVar so tool
signatures stay clean for the LLM.
POLICY: every tool here must stay READ-ONLY. Transaction descriptions reach
the model from external emails (indirect prompt-injection surface), so a
write-capable tool would let crafted text mutate financial data. Any future
write tool requires an explicit user-confirmation step in the UI.
"""
from __future__ import annotations
import contextvars
from datetime import datetime
from datetime import datetime, time, timedelta
from typing import Annotated, Optional
from pydantic import Field
@@ -28,16 +33,22 @@ from app.models.models import (
WaterMeterReading,
)
from app.services.budget_projection import (
MAX_YEAR,
MIN_YEAR,
NOT_INSTALLMENT_ANCHOR,
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
get_cycle_range,
)
from app.services import exchange_rate as fx
from app.timeutil import client_tz, today_client
from app.services.exchange_rate import (
get_converted_amount_expr,
get_current_rate,
)
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
SALARY_TRANSACTION_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
def set_session(session: Session) -> contextvars.Token:
@@ -49,7 +60,13 @@ def reset_session(token: contextvars.Token) -> None:
def _s() -> Session:
try:
return _session_ctx.get()
except LookupError as exc:
raise RuntimeError(
"DB session not bound to agent context — tool called outside an "
"HTTP request (the AG-UI middleware binds it per request)"
) from exc
# ─── Tools ──────────────────────────────────────────────────────────────────
@@ -66,9 +83,9 @@ def get_accounts() -> list[dict]:
"bank": a.bank.value,
"label": a.label,
"currency": a.currency.value,
"balance": a.balance,
"balance": float(a.balance),
"account_type": a.account_type.value,
"next_payment": a.next_payment,
"next_payment": float(a.next_payment) if a.next_payment is not None else None,
}
for a in rows
]
@@ -77,26 +94,31 @@ def get_accounts() -> list[dict]:
def get_net_worth() -> dict:
"""Return total assets, liabilities and net worth in CRC (primary currency).
USD/EUR balances are converted at the latest exchange rate."""
accounts = _s().exec(select(Account)).all()
rate = get_current_rate(_s())
sell = rate.sell_rate if rate else 600.0
session = _s()
accounts = session.exec(select(Account)).all()
multipliers = fx.get_crc_multipliers(session)
assets_crc = 0.0
liabilities_crc = 0.0
excluded: list[str] = []
for a in accounts:
amt = a.balance
if a.currency.value == "USD":
amt = a.balance * sell
elif a.currency.value == "EUR":
amt = a.balance * sell * 1.08 # rough; real conversion is endpoint-side
mult = multipliers.get(a.currency.value)
if mult is None:
# No live or last-known rate: say so instead of inventing a number
excluded.append(f"{a.label} ({a.currency.value})")
continue
amt = float(a.balance) * float(mult)
if a.account_type.value == "LIABILITY":
liabilities_crc += amt
else:
assets_crc += amt
return {
result = {
"assets_crc": round(assets_crc, 2),
"liabilities_crc": round(liabilities_crc, 2),
"net_crc": round(assets_crc - liabilities_crc, 2),
}
if excluded:
result["excluded_accounts_no_rate"] = excluded
return result
def get_recent_transactions(
@@ -121,7 +143,11 @@ def get_recent_transactions(
q = select(Transaction).where(
col(Transaction.transaction_type).notin_(
[TransactionType.SALARY, TransactionType.DEPOSITO]
)
),
# Tasa Cero generates future-dated cuotas; "recent" means already
# billed (same rule as /transactions/recent). Bound is end of the
# user's today in their request timezone (CR fallback).
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
)
if source:
q = q.where(Transaction.source == TransactionSource(source))
@@ -139,7 +165,7 @@ def get_recent_transactions(
"id": t.id,
"date": t.date.isoformat(),
"merchant": t.merchant,
"amount": t.amount,
"amount": float(t.amount),
"currency": t.currency.value,
"source": t.source.value,
"transaction_type": t.transaction_type.value,
@@ -218,6 +244,8 @@ def get_budget_projection(
"""Budget projection. If month is omitted, returns the yearly rollup; if
given, returns the monthly detail with income items, expense items and
actuals by source."""
if not MIN_YEAR <= year <= MAX_YEAR:
return {"error": f"year must be between {MIN_YEAR} and {MAX_YEAR}"}
session = _s()
if month is None:
months_data = compute_yearly_projection_with_cumulative(session, year)
@@ -243,7 +271,7 @@ def list_recurring_items() -> list[dict]:
{
"id": r.id,
"name": r.name,
"amount": r.amount,
"amount": float(r.amount),
"currency": r.currency.value,
"item_type": r.item_type.value,
"frequency": r.frequency.value,
@@ -266,27 +294,38 @@ def get_pension_snapshots(
) -> list[dict]:
"""Pension fund snapshots. Each snapshot covers a period with balances,
contributions, returns, fees and the ending balance (saldo_final)."""
q = select(PensionSnapshot).order_by(col(PensionSnapshot.period_end).desc())
if latest_only:
# Latest snapshot per fund resolved in SQL instead of scanning all rows
latest = (
select(
PensionSnapshot.fund.label("fund"),
func.max(PensionSnapshot.period_end).label("period_end"),
)
.group_by(PensionSnapshot.fund)
.subquery()
)
q = select(PensionSnapshot).join(
latest,
(PensionSnapshot.fund == latest.c.fund)
& (PensionSnapshot.period_end == latest.c.period_end),
)
else:
q = select(PensionSnapshot)
q = q.order_by(col(PensionSnapshot.period_end).desc())
if fund:
q = q.where(PensionSnapshot.fund == fund)
rows = _s().exec(q).all()
if latest_only:
seen: dict[str, PensionSnapshot] = {}
for r in rows:
if r.fund.value not in seen:
seen[r.fund.value] = r
rows = list(seen.values())
return [
{
"fund": r.fund.value,
"period_start": r.period_start.isoformat(),
"period_end": r.period_end.isoformat(),
"saldo_anterior": r.saldo_anterior,
"aportes": r.aportes,
"rendimientos": r.rendimientos,
"retiros": r.retiros,
"comision": r.comision,
"saldo_final": r.saldo_final,
"saldo_anterior": float(r.saldo_anterior),
"aportes": float(r.aportes),
"rendimientos": float(r.rendimientos),
"retiros": float(r.retiros),
"comision": float(r.comision),
"saldo_final": float(r.saldo_final),
}
for r in rows
]
@@ -301,7 +340,7 @@ def get_salary_summary() -> dict:
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.SALARY)
).where(col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES))
).first()
count = row[0] if row else 0
total = float(row[1]) if row else 0.0
@@ -309,8 +348,47 @@ def get_salary_summary() -> dict:
return {"count": count, "total_crc": total, "latest_date": latest}
def get_salary_deposits(
limit: Annotated[int, Field(ge=1, le=100, description="How many deposits to return")] = 20,
start_date: Annotated[
Optional[str], Field(description="ISO date lower bound, inclusive")
] = None,
end_date: Annotated[
Optional[str], Field(description="ISO date upper bound, exclusive")
] = None,
) -> list[dict]:
"""Individual salary deposits, newest first. Use this for questions about
a specific salary or a day/range (for example, "cuánto recibí hoy").
Amounts remain in their recorded currency; use the currency field when
presenting them."""
q = select(Transaction).where(
col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES)
)
if start_date:
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
if end_date:
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).limit(limit)
return [
{
"id": t.id,
"date": t.date.isoformat(),
"amount": float(t.amount),
"currency": t.currency.value,
"merchant": t.merchant,
"source": t.source.value,
"bank": t.bank.value,
"transaction_type": t.transaction_type.value,
"reference": t.reference,
"notes": t.notes,
}
for t in _s().exec(q).all()
]
def get_municipal_receipts(
limit: Annotated[int, Field(ge=1, le=50)] = 12,
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
account: Annotated[
Optional[str], Field(description="Municipal account/contract id")
] = None,
@@ -320,13 +398,23 @@ def get_municipal_receipts(
q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc())
if account:
q = q.where(MunicipalReceipt.account == account)
q = q.limit(limit)
q = q.offset(offset).limit(limit)
rows = _s().exec(q).all()
# One grouped query for all receipts instead of one per receipt
consumption: dict[int, float] = {}
ids = [r.id for r in rows if r.id is not None]
if ids:
grouped = _s().exec(
select(
WaterMeterReading.receipt_id,
func.sum(WaterMeterReading.consumption_m3),
)
.where(col(WaterMeterReading.receipt_id).in_(ids))
.group_by(WaterMeterReading.receipt_id)
).all()
consumption = {rid: float(total) for rid, total in grouped}
out: list[dict] = []
for r in rows:
readings = _s().exec(
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
).all()
out.append(
{
"id": r.id,
@@ -334,11 +422,11 @@ def get_municipal_receipts(
"period": r.period,
"account": r.account,
"finca": r.finca,
"subtotal": r.subtotal,
"interests": r.interests,
"iva": r.iva,
"total": r.total,
"water_consumption_m3": sum(w.consumption_m3 for w in readings),
"subtotal": float(r.subtotal),
"interests": float(r.interests),
"iva": float(r.iva),
"total": float(r.total),
"water_consumption_m3": consumption.get(r.id, 0.0),
}
)
return out
@@ -366,13 +454,10 @@ def get_analytics_by_category(
q = q.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(q).all()
grand = sum(float(r[1]) for r in rows) or 1.0
names = {c.id: c.name for c in session.exec(select(Category)).all()}
out = []
for cat_id, total, count in rows:
name = "Uncategorized"
if cat_id:
cat = session.get(Category, cat_id)
if cat:
name = cat.name
name = names.get(cat_id, "Uncategorized") if cat_id else "Uncategorized"
out.append(
{
"category_id": cat_id,
@@ -436,9 +521,10 @@ def get_exchange_rate() -> dict:
if not rate:
return {"buy_rate": None, "sell_rate": None, "date": None}
return {
"buy_rate": rate.buy_rate,
"sell_rate": rate.sell_rate,
"buy_rate": float(rate.buy_rate),
"sell_rate": float(rate.sell_rate),
"date": rate.date.isoformat(),
"fetched_at": rate.fetched_at.isoformat() if rate.fetched_at else None,
}
@@ -450,7 +536,68 @@ def list_categories() -> list[dict]:
# Registered with the agent in agent.py
def get_daily_spending(
start_date: Annotated[str, Field(description="ISO date lower bound, inclusive")],
end_date: Annotated[str, Field(description="ISO date upper bound, exclusive")],
) -> list[dict]:
"""Spending per day in CRC (converted), COMPRA only, excluding future
Tasa Cero cuotas and installment anchors. THE tool for day-scoped
questions: 'cuánto gasté hoy / ayer / el martes'. Days with no spending
are simply absent from the result."""
session = _s()
amount_crc = get_converted_amount_expr(session)
rows = session.exec(
select(
func.date(Transaction.date).label("day"),
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
# Future-dated Tasa Cero cuotas are not money already spent.
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
)
.group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date))
).all()
return [
{"date": str(day), "total_crc": float(total), "count": count}
for day, total, count in rows
]
def get_current_date() -> dict:
"""Today's date in the user's own timezone (sent by their browser;
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
call this before resolving any relative date reference — 'hoy', 'ayer',
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
own assumptions about today are unreliable."""
today = today_client()
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
# the 18th we are still in the cycle that started last month.
if today.day >= 18:
cycle_year, cycle_month = today.year, today.month
elif today.month == 1:
cycle_year, cycle_month = today.year - 1, 12
else:
cycle_year, cycle_month = today.year, today.month - 1
start, end = get_cycle_range(cycle_year, cycle_month)
return {
"date": today.isoformat(),
"weekday": today.strftime("%A"),
"timezone": str(client_tz()),
"cycle_year": cycle_year,
"cycle_month": cycle_month,
"cycle_range": [start.date().isoformat(), end.date().isoformat()],
}
TOOLS = [
get_current_date,
get_daily_spending,
get_accounts,
get_net_worth,
get_recent_transactions,
@@ -459,6 +606,7 @@ TOOLS = [
list_recurring_items,
get_pension_snapshots,
get_salary_summary,
get_salary_deposits,
get_municipal_receipts,
get_analytics_by_category,
get_monthly_trend,

View File

@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.timeutil import utcnow
from app.models.models import Account, AccountCreate, AccountRead, AccountUpdate
router = APIRouter(prefix="/accounts", tags=["accounts"])
@@ -44,7 +45,7 @@ def update_account(
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(account, key, value)
account.updated_at = datetime.utcnow()
account.updated_at = utcnow()
session.add(account)
session.commit()
session.refresh(account)

View File

@@ -8,8 +8,9 @@ from sqlmodel import Session, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Category, Transaction
from app.services.budget_projection import get_cycle_range
from app.models.models import Category, Transaction, TransactionType
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
from app.timeutil import utcnow
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -42,6 +43,8 @@ class DailySpending(BaseModel):
def spending_by_category(
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
@@ -53,17 +56,26 @@ def spending_by_category(
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.category_id)
)
if cycle_year and cycle_month:
# Arbitrary date range takes precedence over the cycle filter.
if start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
elif cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
rows = session.exec(query).all()
grand_total = sum(r[1] for r in rows) or 1
grand_total = float(sum(float(r[1]) for r in rows)) or 1.0
results = []
for category_id, total, count in rows:
@@ -123,9 +135,10 @@ def monthly_trend(
),
)
.where(
Transaction.transaction_type == "COMPRA",
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
NOT_INSTALLMENT_ANCHOR,
)
).first()
@@ -160,6 +173,8 @@ def monthly_trend(
def daily_spending(
cycle_year: Optional[int] = None,
cycle_month: Optional[int] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
@@ -171,12 +186,23 @@ def daily_spending(
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")
.where(
Transaction.transaction_type == TransactionType.COMPRA,
NOT_INSTALLMENT_ANCHOR,
# Tasa Cero generates future-dated cuotas; daily spending is
# about money already spent.
Transaction.date <= utcnow(),
)
.group_by(func.date(Transaction.date))
.order_by(func.date(Transaction.date))
)
if cycle_year and cycle_month:
if start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
elif cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)

View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from fastapi.security import OAuth2PasswordRequestForm
from app.auth import (
@@ -11,7 +12,16 @@ from app.auth import (
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
class TokenResponse(BaseModel):
access_token: str
token_type: str
class MeResponse(BaseModel):
username: str
@router.post("/login", response_model=TokenResponse)
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
check_login_rate_limit(request)
verify_admin_credentials(form_data.username, form_data.password)
@@ -19,6 +29,6 @@ def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
return {"access_token": token, "token_type": "bearer"}
@router.get("/me")
@router.get("/me", response_model=MeResponse)
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
return {"username": username}

View File

@@ -7,6 +7,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.timeutil import utcnow
from app.models.models import (
BalanceOverride,
BalanceOverrideCreate,
@@ -247,9 +248,9 @@ def upsert_balance_override(
_user: str = Depends(get_current_user),
):
if year < MIN_YEAR or year > MAX_YEAR:
raise HTTPException(400, f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
raise HTTPException(status_code=400, detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
if year == FRESH_START_YEAR and month < FRESH_START_MONTH:
raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
raise HTTPException(status_code=400, detail=f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
existing = session.exec(
select(BalanceOverride).where(
@@ -259,7 +260,7 @@ def upsert_balance_override(
if existing:
existing.override_balance = data.override_balance
existing.updated_at = datetime.utcnow()
existing.updated_at = utcnow()
session.add(existing)
session.commit()
session.refresh(existing)
@@ -287,6 +288,6 @@ def delete_balance_override(
)
).first()
if not existing:
raise HTTPException(404, "No override found for this month")
raise HTTPException(status_code=404, detail="No override found for this month")
session.delete(existing)
session.commit()

View File

@@ -0,0 +1,41 @@
"""Assistant chat thread management.
The conversation itself is persisted by the AG-UI snapshot store
(app/agent/snapshot_store.py) under a single ("default", "main") thread;
this router only exposes the reset used by "Nueva conversación".
"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import ChatThreadSnapshot
router = APIRouter(prefix="/chat", tags=["chat"])
CHAT_SCOPE = "default"
CHAT_THREAD_ID = "main"
class ClearThreadResponse(BaseModel):
cleared: bool
@router.delete("/thread", response_model=ClearThreadResponse)
def clear_thread(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
row = session.exec(
select(ChatThreadSnapshot).where(
ChatThreadSnapshot.scope == CHAT_SCOPE,
ChatThreadSnapshot.thread_id == CHAT_THREAD_ID,
)
).first()
if row is None:
return {"cleared": False}
session.delete(row)
session.commit()
return {"cleared": True}

View File

@@ -26,12 +26,29 @@ class PasteImportRequest(BaseModel):
text: str = Field(max_length=1_000_000)
bank: Bank = Bank.BAC
source: TransactionSource = TransactionSource.CREDIT_CARD
# Override for false-positive dedup (legitimate same-day, same-amount
# repeat purchases hash identically) — review UX-20/BE-08.
allow_duplicates: bool = False
class SkippedDuplicate(BaseModel):
line: int
merchant: str
date: str
amount: float
currency: str
existing_id: int
existing_merchant: str
existing_date: str
existing_amount: float
existing_source: str
class PasteImportResult(BaseModel):
imported: int
duplicates: int
errors: list[str]
skipped: list[SkippedDuplicate] = []
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
@@ -103,6 +120,7 @@ def paste_import(
imported = 0
duplicates = 0
errors: list[str] = []
skipped: list[SkippedDuplicate] = []
lines = req.text.strip().splitlines()
@@ -120,12 +138,28 @@ def paste_import(
parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"]
)
# Check for duplicates
# Check for duplicates — and SHOW the user what matched instead of
# silently dropping the line (UX-20).
if not req.allow_duplicates:
existing = session.exec(
select(Transaction).where(Transaction.reference == ref_hash)
).first()
if existing:
duplicates += 1
skipped.append(
SkippedDuplicate(
line=i,
merchant=parsed["merchant"],
date=parsed["date"].isoformat(),
amount=parsed["amount"],
currency=parsed["currency"],
existing_id=existing.id or 0,
existing_merchant=existing.merchant,
existing_date=existing.date.isoformat(),
existing_amount=float(existing.amount),
existing_source=existing.source.value,
)
)
continue
tx = Transaction(
@@ -147,4 +181,6 @@ def paste_import(
if imported > 0:
session.commit()
return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors)
return PasteImportResult(
imported=imported, duplicates=duplicates, errors=errors, skipped=skipped
)

View File

@@ -0,0 +1,234 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlmodel import Session, col, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
InstallmentPlan,
InstallmentPlanCreate,
InstallmentPlanUpdate,
Transaction,
TransactionRead,
TransactionSource,
TransactionType,
)
from app.services.installments import (
compute_installment_amounts,
create_plan,
delete_plan,
regenerate_cuotas,
)
from app.timeutil import utcnow
router = APIRouter(prefix="/installment-plans", tags=["installments"])
MIN_INSTALLMENTS = 2
MAX_INSTALLMENTS = 48
class InstallmentPlanRead(BaseModel):
id: int
anchor_transaction_id: int
merchant: str
purchase_date: datetime
num_installments: int
first_installment_date: datetime
total_amount: float
installment_amount: float
last_installment_amount: float
currency: str
notes: Optional[str] = None
cuotas_billed: int
paid_amount: float
remaining_amount: float
next_cuota_date: Optional[datetime] = None
is_completed: bool
class InstallmentPlanDetail(InstallmentPlanRead):
cuotas: list[TransactionRead] = []
class InstallmentPlanListResponse(BaseModel):
plans: list[InstallmentPlanRead]
total_remaining: float
def _validate_num_installments(n: int) -> None:
if not MIN_INSTALLMENTS <= n <= MAX_INSTALLMENTS:
raise HTTPException(
status_code=400,
detail=f"num_installments must be {MIN_INSTALLMENTS}-{MAX_INSTALLMENTS}",
)
def _get_plan_and_anchor(
session: Session, plan_id: int
) -> tuple[InstallmentPlan, Transaction]:
plan = session.get(InstallmentPlan, plan_id)
if not plan:
raise HTTPException(status_code=404, detail="Installment plan not found")
anchor = session.get(Transaction, plan.anchor_transaction_id)
if not anchor:
raise HTTPException(status_code=404, detail="Anchor transaction not found")
return plan, anchor
def _to_read(
plan: InstallmentPlan, anchor: Transaction, cuotas: list[Transaction]
) -> InstallmentPlanRead:
now = utcnow()
billed = [c for c in cuotas if c.date <= now]
future = [c for c in cuotas if c.date > now]
paid = float(sum(c.amount for c in billed))
total = float(plan.total_amount)
amounts = compute_installment_amounts(
plan.total_amount, plan.num_installments
)
return InstallmentPlanRead(
id=plan.id,
anchor_transaction_id=plan.anchor_transaction_id,
merchant=anchor.merchant,
purchase_date=anchor.date,
num_installments=plan.num_installments,
first_installment_date=plan.first_installment_date,
total_amount=total,
installment_amount=float(plan.installment_amount),
last_installment_amount=float(amounts[-1]),
currency=plan.currency.value,
notes=plan.notes,
cuotas_billed=len(billed),
paid_amount=paid,
remaining_amount=total - paid,
next_cuota_date=min((c.date for c in future), default=None),
is_completed=len(billed) == plan.num_installments,
)
def _cuotas_by_plan(
session: Session, plan_ids: list[int]
) -> dict[int, list[Transaction]]:
grouped: dict[int, list[Transaction]] = {pid: [] for pid in plan_ids}
if plan_ids:
rows = session.exec(
select(Transaction)
.where(col(Transaction.installment_plan_id).in_(plan_ids))
.order_by(col(Transaction.date))
).all()
for row in rows:
grouped[row.installment_plan_id].append(row)
return grouped
@router.get("/", response_model=InstallmentPlanListResponse)
def list_installment_plans(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
plans = session.exec(select(InstallmentPlan)).all()
anchors = {
t.id: t
for t in session.exec(
select(Transaction).where(
col(Transaction.id).in_([p.anchor_transaction_id for p in plans])
)
).all()
} if plans else {}
cuotas = _cuotas_by_plan(session, [p.id for p in plans])
reads = [
_to_read(p, anchors[p.anchor_transaction_id], cuotas[p.id])
for p in plans
if p.anchor_transaction_id in anchors
]
reads.sort(key=lambda r: r.purchase_date, reverse=True)
return InstallmentPlanListResponse(
plans=reads,
total_remaining=sum(r.remaining_amount for r in reads),
)
@router.get("/{plan_id}", response_model=InstallmentPlanDetail)
def get_installment_plan(
plan_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
plan, anchor = _get_plan_and_anchor(session, plan_id)
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
read = _to_read(plan, anchor, cuotas)
return InstallmentPlanDetail(**read.model_dump(), cuotas=cuotas)
@router.post("/", response_model=InstallmentPlanRead, status_code=201)
def create_installment_plan(
data: InstallmentPlanCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Convert an existing credit-card purchase into a Tasa Cero plan."""
_validate_num_installments(data.num_installments)
anchor = session.get(Transaction, data.transaction_id)
if not anchor:
raise HTTPException(status_code=404, detail="Transaction not found")
if anchor.source != TransactionSource.CREDIT_CARD:
raise HTTPException(
status_code=400, detail="Only credit card transactions can be financed"
)
if anchor.transaction_type != TransactionType.COMPRA:
raise HTTPException(
status_code=400, detail="Only COMPRA transactions can be financed"
)
if anchor.installment_plan_id:
raise HTTPException(
status_code=400, detail="A cuota cannot be converted to a plan"
)
if anchor.is_installment_anchor:
raise HTTPException(
status_code=409, detail="Transaction already has an installment plan"
)
plan = create_plan(
session, anchor, data.num_installments, data.first_installment_date
)
session.commit()
session.refresh(plan)
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
return _to_read(plan, anchor, cuotas)
@router.patch("/{plan_id}", response_model=InstallmentPlanRead)
def update_installment_plan(
plan_id: int,
data: InstallmentPlanUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Edit a plan; cuota rows are regenerated (per-cuota edits are lost)."""
plan, anchor = _get_plan_and_anchor(session, plan_id)
if data.num_installments is not None:
_validate_num_installments(data.num_installments)
if data.notes is not None:
plan.notes = data.notes
regenerate_cuotas(
session, plan, anchor, data.num_installments, data.first_installment_date
)
session.commit()
session.refresh(plan)
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
return _to_read(plan, anchor, cuotas)
@router.delete("/{plan_id}", status_code=204)
def delete_installment_plan(
plan_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Unconvert: remove the plan and its cuotas; the anchor becomes a normal
transaction again."""
plan, anchor = _get_plan_and_anchor(session, plan_id)
delete_plan(session, plan, anchor)
session.commit()

View File

@@ -2,11 +2,14 @@ from datetime import datetime
from dateutil.relativedelta import relativedelta
from typing import Optional
import asyncio
from fastapi import APIRouter, Depends, Query, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.uploads import read_pdf_upload
from app.db import get_session
from app.models.models import (
Category,
@@ -204,8 +207,9 @@ async def upload_municipal_receipt(
errors: list[str] = []
try:
pdf_bytes = await file.read()
data = extract_municipal_receipt(pdf_bytes, filename)
pdf_bytes = await read_pdf_upload(file)
# pdftotext runs as a subprocess; keep the event loop free
data = await asyncio.to_thread(extract_municipal_receipt, pdf_bytes, filename)
except ValueError as e:
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
except Exception as e:

View File

@@ -1,6 +1,7 @@
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pywebpush import WebPushException, webpush
from sqlmodel import Session, select
@@ -15,14 +16,22 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/notifications", tags=["notifications"])
@router.get("/vapid-public-key")
class VapidPublicKeyResponse(BaseModel):
publicKey: str
class SubscriptionStatusResponse(BaseModel):
status: str
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
def get_vapid_public_key(_user: str = Depends(get_current_user)):
if not settings.VAPID_PUBLIC_KEY:
raise HTTPException(status_code=503, detail="Push notifications not configured")
return {"publicKey": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe", status_code=201)
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
def subscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),
@@ -48,7 +57,7 @@ def subscribe(
return {"status": "subscribed"}
@router.delete("/unsubscribe")
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
def unsubscribe(
data: PushSubscriptionCreate,
session: Session = Depends(get_session),

View File

@@ -1,10 +1,13 @@
from datetime import date
import asyncio
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
from app.auth import get_current_user
from app.uploads import read_pdf_upload
from app.db import get_session
from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead
from app.services.pension_pdf import parse_pension_pdf
@@ -115,8 +118,11 @@ async def upload_pension_pdfs(
for file in files:
filename = file.filename or "unknown.pdf"
try:
pdf_bytes = await file.read()
fund_snapshots = parse_pension_pdf(pdf_bytes, filename)
pdf_bytes = await read_pdf_upload(file)
# pdftotext runs as a subprocess (up to 30s); keep the event loop free
fund_snapshots = await asyncio.to_thread(
parse_pension_pdf, pdf_bytes, filename
)
except ValueError as e:
errors.append(str(e))
continue

View File

@@ -31,7 +31,7 @@ def list_salarios(
query = (
select(Transaction)
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
.order_by(col(Transaction.date).desc())
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
.offset(offset)
.limit(limit)
)

View File

@@ -5,6 +5,7 @@ from sqlmodel import Session, col, select
from app.auth import get_current_user
from app.db import get_session
from app.timeutil import utcnow
from app.models.models import (
SavingsAccrual,
SavingsAccrualCreate,
@@ -44,7 +45,7 @@ def create_accrual(
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
)
accrual = SavingsAccrual.model_validate(data)
accrual.applied_at = datetime.utcnow()
accrual.applied_at = utcnow()
session.add(accrual)
session.commit()
session.refresh(accrual)

View File

@@ -5,6 +5,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.timeutil import utcnow
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -52,7 +53,7 @@ def update_settings(
settings = UserSettings(key="default", data=body.data)
else:
settings.data = body.data
settings.updated_at = datetime.utcnow()
settings.updated_at = utcnow()
session.add(settings)
session.commit()
session.refresh(settings)

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import Session
from app.auth import get_current_user
from app.db import get_session
from app.services.sync_status import compute_sync_status
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
class SyncSourceStatus(BaseModel):
key: str
label: str
description: str
last_received: str | None
age_days: float | None
warn_after_days: float
status: str # 'ok' | 'warning' | 'never'
class SyncStatusResponse(BaseModel):
sources: list[SyncSourceStatus]
warnings: int
@router.get("/", response_model=SyncStatusResponse)
def sync_status(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
sources = compute_sync_status(session)
return {
"sources": sources,
"warnings": sum(1 for s in sources if s["status"] in ("warning", "never")),
}

View File

@@ -8,6 +8,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user, hash_token
from app.db import get_session
from app.timeutil import utcnow
from app.models.models import APIToken, APITokenCreate, APITokenRead
router = APIRouter(prefix="/tokens", tags=["tokens"])
@@ -28,7 +29,7 @@ def create_token(
plaintext = secrets.token_urlsafe(32)
expires_at = None
if data.expires_days:
expires_at = datetime.utcnow() + timedelta(days=data.expires_days)
expires_at = utcnow() + timedelta(days=data.expires_days)
api_token = APIToken(
name=data.name,

View File

@@ -2,7 +2,9 @@ from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response as FastAPIResponse
from pydantic import BaseModel
from sqlalchemy import and_, or_
from sqlmodel import Session, col, func, select
from app.auth import get_current_user
@@ -19,8 +21,19 @@ from app.models.models import (
TransactionUpdate,
)
from app.services.budget_projection import get_cycle_range, get_previous_cycle
from app.services.budget_projection import (
NOT_INSTALLMENT_ANCHOR,
get_cycle_range,
get_previous_cycle,
)
from app.services.csv_export import build_transactions_csv
from app.services.exchange_rate import get_converted_amount_expr
from app.services.installments import (
DEFAULT_NUM_INSTALLMENTS,
create_plan,
is_tasa_cero_merchant,
teardown_plan_for_anchor,
)
router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -73,7 +86,6 @@ def list_transactions(
prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
from sqlalchemy import or_, and_
query = query.where(
or_(
and_(
@@ -93,10 +105,86 @@ def list_transactions(
Transaction.date >= datetime.fromisoformat(start_date),
Transaction.date < datetime.fromisoformat(end_date),
)
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
query = query.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).offset(offset).limit(limit)
return session.exec(query).all()
class BulkActionRequest(BaseModel):
ids: list[int]
action: str # 'delete' | 'set_category' | 'set_deferred'
category_id: Optional[int] = None # for set_category (None clears)
deferred: Optional[bool] = None # for set_deferred
class BulkActionResponse(BaseModel):
affected: int
@router.post("/bulk", response_model=BulkActionResponse)
def bulk_action(
req: BulkActionRequest,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Apply one action to many transactions atomically (review 4.4)."""
if not req.ids or len(req.ids) > 500:
raise HTTPException(status_code=400, detail="ids must contain 1-500 entries")
if req.action not in ("delete", "set_category", "set_deferred"):
raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}")
if req.action == "set_deferred" and req.deferred is None:
raise HTTPException(status_code=400, detail="deferred is required for set_deferred")
if req.action == "set_category" and req.category_id is not None:
if session.get(Category, req.category_id) is None:
raise HTTPException(status_code=404, detail="Category not found")
txs = session.exec(
select(Transaction).where(col(Transaction.id).in_(req.ids))
).all()
affected = 0
for tx in txs:
if req.action == "delete":
teardown_plan_for_anchor(session, tx)
session.delete(tx)
elif req.action == "set_category":
tx.category_id = req.category_id
session.add(tx)
elif req.action == "set_deferred":
if tx.installment_plan_id:
continue # cuotas of a plan cannot be deferred
tx.deferred_to_next_cycle = bool(req.deferred)
session.add(tx)
affected += 1
session.commit()
return {"affected": affected}
@router.get("/export")
def export_transactions_csv(
source: Optional[TransactionSource] = None,
transaction_type: Optional[TransactionType] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Download transactions as CSV (filters optional). Cookie-authenticated,
so a plain <a href> download works from the SPA."""
csv_text = build_transactions_csv(
session,
source=source,
transaction_type=transaction_type,
start_date=datetime.fromisoformat(start_date) if start_date else None,
end_date=datetime.fromisoformat(end_date) if end_date else None,
)
return FastAPIResponse(
content=csv_text,
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="wealthysmart-transactions.csv"'
},
)
@router.get("/cycles", response_model=list[BillingCycle])
def list_billing_cycles(
session: Session = Depends(get_session),
@@ -132,7 +220,9 @@ def list_billing_cycles(
# Count transactions in this cycle
count_result = session.exec(
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date < end
Transaction.date >= start,
Transaction.date < end,
NOT_INSTALLMENT_ANCHOR,
)
).first()
count = count_result[0] if count_result else 0
@@ -163,10 +253,16 @@ def recent_transactions(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
from app.timeutil import utcnow
query = (
select(Transaction)
.where(Transaction.source == TransactionSource.CREDIT_CARD)
.order_by(col(Transaction.date).desc())
.where(
Transaction.source == TransactionSource.CREDIT_CARD,
# Tasa Cero generates future-dated cuotas; "recent" means billed.
Transaction.date <= utcnow(),
)
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
.limit(limit)
)
return session.exec(query).all()
@@ -195,6 +291,20 @@ def create_transaction(
session.commit()
session.refresh(tx)
# Tasa Cero auto-detection: BAC prefixes financed purchases with "CC ".
# Default 3 cuotas starting on the purchase date; editable afterwards via
# /installment-plans.
tasa_cero_note = ""
if (
tx.source == TransactionSource.CREDIT_CARD
and tx.transaction_type == TransactionType.COMPRA
and is_tasa_cero_merchant(tx.merchant)
):
create_plan(session, tx, DEFAULT_NUM_INSTALLMENTS, tx.date)
session.commit()
session.refresh(tx)
tasa_cero_note = f" — Tasa Cero {DEFAULT_NUM_INSTALLMENTS} cuotas"
# Send push notification
symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""}
symbol = symbols.get(tx.currency, tx.currency.value)
@@ -205,7 +315,7 @@ def create_transaction(
send_push_to_all(
session,
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {label}",
body=f"{amount_str}{tx.bank.value} {label}{tasa_cero_note}",
url="/salarios" if is_income else "/budget",
)
@@ -227,6 +337,11 @@ def update_transaction(
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
update_data = data.model_dump(exclude_unset=True)
if update_data.get("deferred_to_next_cycle") is not None and tx.installment_plan_id:
raise HTTPException(
status_code=400,
detail="Cuotas of an installment plan cannot be deferred",
)
for key, value in update_data.items():
setattr(tx, key, value)
session.add(tx)
@@ -244,5 +359,6 @@ def delete_transaction(
tx = session.get(Transaction, transaction_id)
if not tx:
raise HTTPException(status_code=404, detail="Transaction not found")
teardown_plan_for_anchor(session, tx)
session.delete(tx)
session.commit()

View File

@@ -6,14 +6,17 @@ from app.api.v1.endpoints import (
auth,
budget,
categories,
chat,
exchange_rate,
import_transactions,
installments,
municipal_receipts,
notifications,
pensions,
salarios,
savings_accrual,
settings,
sync_status,
tokens,
transactions,
)
@@ -24,6 +27,7 @@ api_router.include_router(accounts.router)
api_router.include_router(categories.router)
api_router.include_router(transactions.router)
api_router.include_router(import_transactions.router)
api_router.include_router(installments.router)
api_router.include_router(exchange_rate.router)
api_router.include_router(tokens.router)
api_router.include_router(analytics.router)
@@ -34,3 +38,5 @@ api_router.include_router(salarios.router)
api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router)
api_router.include_router(sync_status.router)
api_router.include_router(chat.router)

View File

@@ -12,6 +12,9 @@ from jose import JWTError, jwt
from sqlmodel import Session, select
from app.config import settings
from app.db import get_session
from app.models.models import APIToken
from app.timeutil import utcnow
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
@@ -70,7 +73,7 @@ def verify_admin_credentials(username: str, password: str) -> None:
def create_access_token(subject: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
expire = utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
@@ -90,9 +93,6 @@ def _validate_token(token: str) -> str:
pass
# Fallback: check API token
from app.db import get_session
from app.models.models import APIToken
token_hash = hash_token(token)
with next(get_session()) as session:
api_token = session.exec(
@@ -102,7 +102,7 @@ def _validate_token(token: str) -> str:
)
).first()
if api_token:
if api_token.expires_at and api_token.expires_at < datetime.utcnow():
if api_token.expires_at and api_token.expires_at < utcnow():
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
return f"api:{api_token.name}"

View File

@@ -1,5 +1,5 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
OPENAI_API_KEY: str = ""
AGENT_MODEL: str = "gpt-5.4-mini"
AGENT_MODEL: str = "gpt-5.6-luna"
@property
def cors_origins_list(self) -> list[str]:
@@ -39,8 +39,7 @@ class Settings(BaseSettings):
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
return self
class Config:
env_file = ".env"
model_config = SettingsConfigDict(env_file=".env")
settings = Settings()

View File

@@ -1,79 +1,48 @@
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
from pathlib import Path
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect, text
from sqlmodel import Session, create_engine
from app.config import settings
engine = create_engine(settings.DATABASE_URL)
def init_db():
SQLModel.metadata.create_all(engine)
_MIGRATION_LOCK_KEY = 727274 # app-wide advisory lock id, arbitrary constant
def run_migrations():
"""Run idempotent schema migrations for columns added after initial create."""
def _alembic_config() -> Config:
backend_dir = Path(__file__).resolve().parent.parent
cfg = Config(str(backend_dir / "alembic.ini"))
cfg.set_main_option("script_location", str(backend_dir / "alembic"))
return cfg
def run_alembic_upgrade() -> None:
"""Bring the schema to head at startup.
Databases that predate Alembic (prod, existing dev volumes) already match
the baseline revision — the baseline was autogenerated from, and
schema-diffed against, exactly that schema — so they are adopted by
stamping instead of re-running DDL. Serialized with a Postgres advisory
lock because prod uvicorn runs 2 workers, each executing the lifespan.
"""
cfg = _alembic_config()
is_postgres = engine.dialect.name == "postgresql"
with engine.connect() as conn:
if is_postgres:
conn.execute(text(f"SELECT pg_advisory_lock({_MIGRATION_LOCK_KEY})"))
try:
insp = inspect(conn)
if not insp.has_table("alembic_version") and insp.has_table("transaction"):
command.stamp(cfg, "head")
command.upgrade(cfg, "head")
finally:
if is_postgres:
conn.execute(
text(
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
text(f"SELECT pg_advisory_unlock({_MIGRATION_LOCK_KEY})")
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS savingsaccrual (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
trigger_transaction_id INTEGER,
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
notes TEXT,
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
)
"""
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
VALUES
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
ON CONFLICT (year, month) DO NOTHING
"""
)
)
conn.commit()
except Exception:
conn.rollback()
def get_session():

31
backend/app/logging.py Normal file
View File

@@ -0,0 +1,31 @@
"""Structured, redacted application logging for container stdout."""
from __future__ import annotations
import json
from datetime import date, datetime
from enum import Enum
from typing import Any
class _JSONEncoder(json.JSONEncoder):
def default(self, value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Enum):
return value.value
return super().default(value)
def configure_structured_logging() -> None:
"""Reserved lifecycle hook for the structured stdout audit channel."""
def log_event(event: str, **fields: Any) -> None:
"""Write a single JSON line; callers must not pass credentials or bodies."""
# stdout is Docker's durable capture point. `flush=True` also makes this
# visible immediately in `docker compose logs` during an active SSE run.
print(
json.dumps({"event": event, **fields}, cls=_JSONEncoder, separators=(",", ":")),
flush=True,
)

View File

@@ -1,7 +1,8 @@
import asyncio
import json
import re
import uuid
from time import perf_counter
from http.cookies import SimpleCookie
from contextlib import asynccontextmanager
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
@@ -11,6 +12,15 @@ from jose import JWTError, jwt
from pydantic import BaseModel
from app.agent.agent import build_agent
from app.agent.chat_audit import (
create_chat_run,
finish_pending_chat_run,
mark_chat_run_failed,
purge_expired_chat_runs_periodically,
reset_current_chat_run,
set_current_chat_run,
)
from app.agent.snapshot_store import PostgresSnapshotStore
from app.agent.tools import reset_session, set_session
from app.api.v1.router import api_router
from app.auth import (
@@ -20,12 +30,49 @@ from app.auth import (
verify_admin_credentials,
)
from app.config import settings
from app.db import get_session, init_db, run_migrations
from app.db import get_session, run_alembic_upgrade
from app.seed import seed_db
from app.timeutil import reset_client_timezone, set_client_timezone
from app.services.exchange_rate import refresh_rates_periodically
from app.logging import configure_structured_logging, log_event
AGENT_PATH = "/api/v1/agent/agui"
MAX_USER_AGENT_LENGTH = 512
def _request_client_details(request: Request) -> tuple[str, str | None, str | None]:
"""Return the browser client details propagated through the frontend BFF.
Production nginx-proxy supplies X-Real-IP and the frontend forwards it to
FastAPI. For direct local development, fall back to X-Forwarded-For and
then the socket peer. The BFF is the only production path to the backend,
which is not publicly exposed.
"""
client_ip = request.headers.get("x-real-ip", "").strip()
if not client_ip:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = forwarded.split(",", 1)[0].strip()
if not client_ip:
client_ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None
if not user_agent:
return client_ip, None, None
ua = user_agent.lower()
if "edg/" in ua or "edgios/" in ua:
browser = "Edge"
elif "opr/" in ua or "opera" in ua:
browser = "Opera"
elif "firefox/" in ua or "fxios/" in ua:
browser = "Firefox"
elif "chrome/" in ua or "crios/" in ua:
browser = "Chrome"
elif "safari/" in ua:
browser = "Safari"
else:
browser = "Other"
return client_ip, user_agent, browser
def _pair_orphan_tool_calls(messages: list) -> list:
@@ -63,20 +110,60 @@ def _pair_orphan_tool_calls(messages: list) -> list:
return out
def _drop_stale_client_history(session, thread_id, messages: list) -> list:
"""Conversation history is server-owned (the AG-UI snapshot store).
A request that carries assistant/tool history for a thread with NO
stored snapshot comes from a stale client — typically a second tab that
was open when 'Nueva conversación' cleared the thread. Trusting it
would re-persist the cleared conversation ('chunks' resurrecting after
every clear). Keep only the last user turn; MAF starts the thread
fresh from there.
"""
if not thread_id or not messages:
return messages
has_history = any(m.get("role") in ("assistant", "tool") for m in messages)
if not has_history:
return messages
from sqlmodel import select
from app.models.models import ChatThreadSnapshot
row = session.exec(
select(ChatThreadSnapshot.id).where(
ChatThreadSnapshot.thread_id == str(thread_id)
)
).first()
if row is not None:
return messages # thread exists; history is legitimate
last_user = None
for i in range(len(messages) - 1, -1, -1):
if messages[i].get("role") == "user":
last_user = i
break
return messages[last_user:] if last_user is not None else []
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
run_migrations()
configure_structured_logging()
run_alembic_upgrade()
seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
chat_audit_cleanup_task = asyncio.create_task(purge_expired_chat_runs_periodically())
try:
yield
finally:
rate_refresh_task.cancel()
chat_audit_cleanup_task.cancel()
try:
await rate_refresh_task
except asyncio.CancelledError:
pass
try:
await chat_audit_cleanup_task
except asyncio.CancelledError:
pass
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
@@ -90,6 +177,67 @@ app.add_middleware(
)
async def log_api_request(request: Request, call_next):
"""Emit redacted, one-line diagnostics for API requests only.
This deliberately records no request/response bodies, headers, tokens,
passwords, or financial payloads. Full assistant content is retained only
in the bounded chat-run audit table.
"""
request_id = uuid.uuid4().hex
request.state.request_id = request_id
client_ip, user_agent, browser = _request_client_details(request)
request.state.client_ip = client_ip
request.state.user_agent = user_agent
request.state.browser = browser
started = perf_counter()
status_code = 500
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
def emit(error_type: str | None = None) -> None:
if not is_logged_api:
return
route = request.scope.get("route")
log_event(
"api_request",
request_id=request_id,
method=request.method,
path=getattr(route, "path", request.url.path),
status_code=status_code,
duration_ms=round((perf_counter() - started) * 1000, 1),
error_type=error_type,
client_ip=client_ip,
user_agent=user_agent,
browser=browser,
)
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
original_iterator = getattr(response, "body_iterator", None)
if original_iterator is None:
emit()
return response
async def logged_stream():
error_type: str | None = None
try:
async for chunk in original_iterator:
yield chunk
except Exception as exc:
error_type = type(exc).__name__
raise
finally:
emit(error_type)
response.body_iterator = logged_stream()
return response
except Exception as exc:
emit(type(exc).__name__)
raise
@app.middleware("http")
async def agent_auth_and_session(request: Request, call_next):
"""For the AG-UI route, validate the JWT, repair message history, and
@@ -106,10 +254,10 @@ async def agent_auth_and_session(request: Request, call_next):
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
else:
cookie_header = request.headers.get("cookie", "")
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
if m:
token = m.group(1)
cookies = SimpleCookie()
cookies.load(request.headers.get("cookie", ""))
if "ws_token" in cookies:
token = cookies["ws_token"].value
if not token:
return Response(status_code=401, content="Missing auth")
@@ -120,25 +268,94 @@ async def agent_auth_and_session(request: Request, call_next):
except JWTError:
return Response(status_code=401, content="Invalid token")
session_gen = get_session()
session = next(session_gen)
token_var = set_session(session)
chat_run_id: int | None = None
# Repair orphan tool_calls before the MAF agent sees the message history.
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
raw = await request.body()
try:
body = json.loads(raw)
if isinstance(body.get("messages"), list):
body["messages"] = _drop_stale_client_history(
session,
body.get("threadId") or body.get("thread_id"),
body["messages"],
)
body["messages"] = _pair_orphan_tool_calls(body["messages"])
raw = json.dumps(body).encode()
except Exception:
pass
log_event(
"agent_request",
request_id=request.state.request_id,
agui_run_id=body.get("runId"),
thread_id=body.get("threadId") or body.get("thread_id"),
message_count=len(body.get("messages") or []),
has_user_message=any(
message.get("role") == "user" for message in body.get("messages") or []
),
client_ip=request.state.client_ip,
user_agent=request.state.user_agent,
browser=request.state.browser,
)
# The BFF removes its `method: agent/run` envelope before
# forwarding to MAF. A non-empty user message is the reliable
# backend-side distinction between an agent run and hydration.
if any(message.get("role") == "user" for message in body.get("messages") or []):
chat_run_id = create_chat_run(
request_id=request.state.request_id,
agui_run_id=body.get("runId"),
thread_id=body.get("threadId") or body.get("thread_id"),
messages=body.get("messages") or [],
client_ip=request.state.client_ip,
user_agent=request.state.user_agent,
browser=request.state.browser,
)
except Exception as exc:
# Parsing/repair must not prevent the agent endpoint from serving;
# the access log will still capture a resulting error response.
log_event(
"chat_audit_start_failed",
request_id=request.state.request_id,
error_type=type(exc).__name__,
)
body = None
# Starlette caches the body; replace it so call_next sees the fixed bytes.
request._body = raw # type: ignore[attr-defined]
session_gen = get_session()
session = next(session_gen)
token_var = set_session(session)
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
# "today" wherever the user is (falls back to Costa Rica).
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
audit_token = set_current_chat_run(chat_run_id) if chat_run_id is not None else None
try:
return await call_next(request)
response = await call_next(request)
if chat_run_id is None:
return response
original_iterator = response.body_iterator
async def audit_stream():
try:
async for chunk in original_iterator:
yield chunk
except Exception as exc:
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
raise
else:
# MAF normally finalizes via PostgresSnapshotStore.save(). If
# it emitted no snapshot, do not leave a permanently-open row.
finish_pending_chat_run(chat_run_id)
response.body_iterator = audit_stream()
return response
except Exception as exc:
if chat_run_id is not None:
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
raise
finally:
if audit_token is not None:
reset_current_chat_run(audit_token)
reset_client_timezone(tz_token)
reset_session(token_var)
try:
next(session_gen)
@@ -146,11 +363,26 @@ async def agent_auth_and_session(request: Request, call_next):
pass
# FastAPI wraps function middlewares in reverse registration order. Register
# this after the AG-UI auth middleware so it observes every API response,
# including auth failures that return before calling the inner application.
app.middleware("http")(log_api_request)
# Register app routes
app.include_router(api_router)
# Mount the AG-UI agent endpoint.
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
# Mount the AG-UI agent endpoint. The snapshot store persists the chat
# thread across page reloads; scope is a constant because this is a
# single-user app and auth is already enforced by agent_auth_and_session
# (the scope resolver only sees the AGUIRequest, which carries no identity).
add_agent_framework_fastapi_endpoint(
app,
build_agent(),
AGENT_PATH,
snapshot_store=PostgresSnapshotStore(),
snapshot_scope_resolver=lambda _request: "default",
)
@app.get("/")

View File

@@ -1,10 +1,26 @@
import enum
from datetime import date, datetime
from typing import Optional
from decimal import Decimal
from typing import Annotated, Optional
from sqlalchemy import JSON, Column, UniqueConstraint
from pydantic import PlainSerializer
from sqlalchemy import JSON, Column, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel
from app.timeutil import utcnow
# Live DBs store usersettings.data as jsonb; keep plain JSON elsewhere and on
# SQLite (tests).
JSON_OR_JSONB = JSON().with_variant(JSONB(), "postgresql")
# Monetary values: exact NUMERIC storage, but serialized to JSON as plain
# numbers so the API contract (frontend expects `number`) is unchanged.
# Pinned by tests/test_models_serialization.py.
Money = Annotated[
Decimal, PlainSerializer(float, return_type=float, when_used="json")
]
class RecurringItemType(str, enum.Enum):
INCOME = "INCOME"
@@ -99,14 +115,14 @@ class AccountBase(SQLModel):
bank: Bank
currency: Currency
label: str
balance: float = 0.0
balance: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
account_type: AccountType = AccountType.BANK
next_payment: Optional[float] = None
next_payment: Optional[Money] = Field(default=None, max_digits=15, decimal_places=2)
class Account(AccountBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
updated_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=utcnow)
class AccountCreate(AccountBase):
@@ -119,16 +135,16 @@ class AccountRead(AccountBase):
class AccountUpdate(SQLModel):
balance: Optional[float] = None
balance: Optional[Money] = None
label: Optional[str] = None
next_payment: Optional[float] = None
next_payment: Optional[Money] = None
# --- Transaction ---
class TransactionBase(SQLModel):
amount: float
amount: Money = Field(max_digits=15, decimal_places=2)
currency: Currency = Currency.CRC
merchant: str
city: Optional[str] = None
@@ -141,14 +157,29 @@ class TransactionBase(SQLModel):
source: TransactionSource = TransactionSource.CREDIT_CARD
bank: Bank = Bank.BAC
notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False)
category_id: Optional[int] = Field(
default=None, foreign_key="category.id", ondelete="SET NULL"
)
deferred_to_next_cycle: bool = Field(
default=False, sa_column_kwargs={"server_default": "false"}
)
class Transaction(TransactionBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
category: Optional[Category] = Relationship(back_populates="transactions")
# Tasa Cero: set on generated cuota rows. Plain int (no DB FK) to avoid a
# circular transaction<->installmentplan dependency; cascades are handled
# explicitly in app.services.installments (SQLite tests don't enforce FKs
# anyway). Table-class only so TransactionCreate/n8n payloads can't set it.
installment_plan_id: Optional[int] = Field(default=None, index=True)
# Tasa Cero: True on the original purchase once converted to a plan; the
# anchor stays visible in listings but is excluded from budget/analytics
# aggregates (its generated cuotas are counted instead).
is_installment_anchor: bool = Field(
default=False, sa_column_kwargs={"server_default": "false"}
)
class TransactionCreate(TransactionBase):
@@ -159,10 +190,12 @@ class TransactionRead(TransactionBase):
id: int
created_at: datetime
category: Optional[CategoryRead] = None
installment_plan_id: Optional[int] = None
is_installment_anchor: bool = False
class TransactionUpdate(SQLModel):
amount: Optional[float] = None
amount: Optional[Money] = None
currency: Optional[Currency] = None
merchant: Optional[str] = None
city: Optional[str] = None
@@ -174,20 +207,53 @@ class TransactionUpdate(SQLModel):
deferred_to_next_cycle: Optional[bool] = None
# --- Installment Plan (Tasa Cero) ---
class InstallmentPlanBase(SQLModel):
num_installments: int
first_installment_date: datetime
total_amount: Money = Field(max_digits=15, decimal_places=2)
# All cuotas but the last; the last absorbs the rounding remainder.
installment_amount: Money = Field(max_digits=15, decimal_places=2)
currency: Currency = Currency.CRC
notes: Optional[str] = None
class InstallmentPlan(InstallmentPlanBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
anchor_transaction_id: int = Field(
foreign_key="transaction.id", ondelete="CASCADE", unique=True, index=True
)
created_at: datetime = Field(default_factory=utcnow)
class InstallmentPlanCreate(SQLModel):
transaction_id: int
num_installments: int = 3
first_installment_date: Optional[datetime] = None # default: anchor.date
class InstallmentPlanUpdate(SQLModel):
num_installments: Optional[int] = None
first_installment_date: Optional[datetime] = None
notes: Optional[str] = None
# --- Exchange Rate ---
class ExchangeRate(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
date: datetime
buy_rate: float
sell_rate: float
fetched_at: datetime = Field(default_factory=datetime.utcnow)
buy_rate: Decimal = Field(max_digits=15, decimal_places=6)
sell_rate: Decimal = Field(max_digits=15, decimal_places=6)
fetched_at: datetime = Field(default_factory=utcnow)
class ExchangeRateRead(SQLModel):
buy_rate: float
sell_rate: float
buy_rate: Money
sell_rate: Money
date: datetime
fetched_at: datetime
@@ -199,7 +265,7 @@ class APIToken(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
token_hash: str = Field(index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
expires_at: Optional[datetime] = None
is_active: bool = True
@@ -225,9 +291,9 @@ class UserSettings(SQLModel, table=True):
key: str = Field(index=True, unique=True, default="default")
data: dict = Field(
default_factory=dict,
sa_column=Column(JSON, nullable=False, server_default="{}"),
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="{}"),
)
updated_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=utcnow)
class UserSettingsRead(SQLModel):
@@ -245,7 +311,7 @@ class UserSettingsUpdate(SQLModel):
class RecurringItemBase(SQLModel):
name: str
amount: float
amount: Money = Field(max_digits=15, decimal_places=2)
currency: Currency = Currency.CRC
item_type: RecurringItemType
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
@@ -255,14 +321,16 @@ class RecurringItemBase(SQLModel):
default=None,
sa_column=Column(JSON, nullable=True),
)
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
category_id: Optional[int] = Field(
default=None, foreign_key="category.id", ondelete="SET NULL"
)
is_active: bool = True
notes: Optional[str] = None
class RecurringItem(RecurringItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
category: Optional[Category] = Relationship()
@@ -278,7 +346,7 @@ class RecurringItemRead(RecurringItemBase):
class RecurringItemUpdate(SQLModel):
name: Optional[str] = None
amount: Optional[float] = None
amount: Optional[Money] = None
currency: Optional[Currency] = None
item_type: Optional[RecurringItemType] = None
frequency: Optional[RecurringFrequency] = None
@@ -298,7 +366,7 @@ class PushSubscription(SQLModel, table=True):
endpoint: str = Field(unique=True)
p256dh: str
auth: str
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
class PushSubscriptionCreate(SQLModel):
@@ -314,15 +382,15 @@ class PensionSnapshotBase(SQLModel):
contract_number: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float
bonificacion: float
saldo_final: float
saldo_anterior: Money = Field(max_digits=15, decimal_places=2)
aportes: Money = Field(max_digits=15, decimal_places=2)
rendimientos: Money = Field(max_digits=15, decimal_places=2)
retiros: Money = Field(max_digits=15, decimal_places=2)
traslados: Money = Field(max_digits=15, decimal_places=2)
comision: Money = Field(max_digits=15, decimal_places=2)
correccion: Money = Field(max_digits=15, decimal_places=2)
bonificacion: Money = Field(max_digits=15, decimal_places=2)
saldo_final: Money = Field(max_digits=15, decimal_places=2)
source_filename: str
@@ -331,7 +399,7 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
UniqueConstraint("fund", "period_start", "period_end"),
)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
class PensionSnapshotRead(PensionSnapshotBase):
@@ -347,20 +415,20 @@ class BalanceOverride(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
year: int
month: int
override_balance: float
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
override_balance: Money = Field(max_digits=15, decimal_places=2)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
class BalanceOverrideCreate(SQLModel):
override_balance: float
override_balance: Money
class BalanceOverrideRead(SQLModel):
id: int
year: int
month: int
override_balance: float
override_balance: Money
updated_at: datetime
@@ -370,8 +438,8 @@ class BalanceOverrideRead(SQLModel):
class SavingsAccrualBase(SQLModel):
year: int
month: int
memp_amount: float = 200000.0
mpat_amount: float = 200000.0
memp_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
mpat_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
trigger_transaction_id: Optional[int] = None
notes: Optional[str] = None
@@ -379,7 +447,7 @@ class SavingsAccrualBase(SQLModel):
class SavingsAccrual(SavingsAccrualBase, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True)
applied_at: datetime = Field(default_factory=datetime.utcnow)
applied_at: datetime = Field(default_factory=utcnow)
class SavingsAccrualCreate(SavingsAccrualBase):
@@ -392,8 +460,8 @@ class SavingsAccrualRead(SavingsAccrualBase):
class SavingsAccrualUpdate(SQLModel):
memp_amount: Optional[float] = None
mpat_amount: Optional[float] = None
memp_amount: Optional[Money] = None
mpat_amount: Optional[Money] = None
notes: Optional[str] = None
@@ -409,10 +477,10 @@ class MunicipalReceiptBase(SQLModel):
holder_name: str
holder_cedula: str
holder_address: str
subtotal: float
interests: float
iva: float
total: float
subtotal: Money = Field(max_digits=15, decimal_places=2)
interests: Money = Field(max_digits=15, decimal_places=2)
iva: Money = Field(max_digits=15, decimal_places=2)
total: Money = Field(max_digits=15, decimal_places=2)
raw_charges: list[dict] = Field(
default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"),
@@ -423,7 +491,7 @@ class MunicipalReceiptBase(SQLModel):
class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt",
)
@@ -444,21 +512,23 @@ class MunicipalReceiptRead(MunicipalReceiptBase):
class WaterMeterReadingBase(SQLModel):
meter_id: str
period: str # "YYYY-MM"
reading_previous: float = 0
reading_current: float = 0
consumption_m3: float
agua_potable: float = 0
serv_ambientales: float = 0
alcant_sanitario: float = 0
iva: float = 0
reading_previous: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
reading_current: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
consumption_m3: Money = Field(max_digits=15, decimal_places=2)
agua_potable: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
serv_ambientales: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
alcant_sanitario: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
iva: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
receipt_id: Optional[int] = Field(
default=None, foreign_key="municipalreceipt.id", ondelete="CASCADE"
)
class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
created_at: datetime = Field(default_factory=utcnow)
receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings",
)
@@ -467,3 +537,67 @@ class WaterMeterReading(WaterMeterReadingBase, table=True):
class WaterMeterReadingRead(WaterMeterReadingBase):
id: int
created_at: datetime
# --- Assistant Chat Thread ---
class ChatThreadSnapshot(SQLModel, table=True):
"""Latest AG-UI thread snapshot per (scope, thread_id).
Written by app/agent/snapshot_store.py: MAF overwrites the full
cumulative message list at each run end — one row per thread, not an
append log.
"""
__table_args__ = (UniqueConstraint("scope", "thread_id"),)
id: Optional[int] = Field(default=None, primary_key=True)
scope: str = Field(index=True)
thread_id: str
messages: list = Field(
default_factory=list,
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
)
state: Optional[dict] = Field(
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
)
interrupt: Optional[list] = Field(
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
)
updated_at: datetime = Field(default_factory=utcnow)
# --- Assistant Chat Run Audit ---
class ChatRunLog(SQLModel, table=True):
"""Append-only diagnostic record for an AG-UI agent/run request.
Thread snapshots are mutable conversation state. This table instead keeps
the request, model, tool activity, and final emitted answer for a bounded
retention period so individual assistant runs can be investigated later.
"""
id: Optional[int] = Field(default=None, primary_key=True)
request_id: str = Field(index=True, unique=True)
agui_run_id: Optional[str] = Field(default=None, index=True)
requested_thread_id: Optional[str] = Field(default=None, index=True)
response_thread_id: Optional[str] = Field(default=None, index=True)
model: str
client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
tool_calls: list = Field(
default_factory=list,
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
)
assistant_response: Optional[str] = Field(
default=None, sa_column=Column(Text, nullable=True)
)
status: str = Field(default="started", index=True)
error: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
started_at: datetime = Field(default_factory=utcnow)
completed_at: Optional[datetime] = Field(default=None)
expires_at: datetime = Field(index=True)

View File

@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
# Income-like transaction types that should never be counted as expenses
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
# Tasa Cero: the original purchase ("anchor") is excluded from all budget
# aggregates — its generated cuota transactions are counted instead.
# See app/services/installments.py.
NOT_INSTALLMENT_ANCHOR = Transaction.is_installment_anchor == False # noqa: E712
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
@@ -31,7 +36,7 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
if freq == RecurringFrequency.MONTHLY:
if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)])
return item.amount
return float(item.amount)
if freq == RecurringFrequency.WEEKLY:
# Count occurrences of the weekday in this month
@@ -39,14 +44,14 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
weekday = item.day_of_month if item.day_of_month is not None else 0
cal = calendar.monthcalendar(year, month)
count = sum(1 for week in cal if week[weekday] != 0)
return item.amount * count
return float(item.amount) * count
if freq == RecurringFrequency.QUARTERLY:
# Active in months 3, 6, 9, 12 by default
if month % 3 == 0:
if item.override_amounts and str(month) in item.override_amounts:
return float(item.override_amounts[str(month)])
return item.amount
return float(item.amount)
return None
if freq == RecurringFrequency.BIANNUAL:
@@ -54,19 +59,25 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
base = item.month_of_year or 1
second = base + 6 if base <= 6 else base - 6
if month in (base, second):
return item.amount
return float(item.amount)
return None
if freq == RecurringFrequency.YEARLY:
if month == (item.month_of_year or 12):
return item.amount
return float(item.amount)
return None
return None
def _validate_month(month: int) -> None:
if not 1 <= month <= 12:
raise ValueError(f"month must be 1-12, got {month}")
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for a calendar month."""
_validate_month(month)
start = datetime(year, month, 1)
if month == 12:
end = datetime(year + 1, 1, 1)
@@ -76,7 +87,15 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
"""Return (start, end) of the billing cycle that STARTS on the 18th of
`month`: [month/18, month+1/18).
Note the labeling convention: budget month M covers the cycle that ENDS
on the 18th of M, so callers compose get_previous_cycle(year, M) with
this function — see compute_actuals_by_source. Pinned by
tests/test_cycle_math.py.
"""
_validate_month(month)
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
@@ -110,72 +129,58 @@ def compute_actuals_by_source(
amount_crc = get_converted_amount_expr(session)
def _grouped(*where) -> dict[tuple, tuple[float, int]]:
"""{(source, type): (total_crc, count)} for matching transactions."""
rows = session.exec(
select(
Transaction.source,
Transaction.transaction_type,
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.where(NOT_INSTALLMENT_ANCHOR, *where)
.group_by(Transaction.source, Transaction.transaction_type)
).all()
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
# Three grouped queries replace the previous ten single-aggregate ones.
cc_now = _grouped(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
cc_deferred = _grouped(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
non_cc = _grouped(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
)
results = {}
for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred)
compra_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == False, # noqa: E712
buckets = (cc_now, cc_deferred)
else:
buckets = (non_cc,)
compra = sum(
b.get((source, TransactionType.COMPRA), (0.0, 0))[0] for b in buckets
)
).one()
# Deferred from previous cycle
compra_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.deferred_to_next_cycle == True, # noqa: E712
devolucion = sum(
b.get((source, TransactionType.DEVOLUCION), (0.0, 0))[0]
for b in buckets
)
).one()
compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == False, # noqa: E712
count = sum(
cnt
for b in buckets
for (s, t), (_total, cnt) in b.items()
if s == source and t not in INCOME_TYPES
)
).one()
dev_deferred = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
devolucion = float(dev_normal) + float(dev_deferred)
count_normal = session.exec(
select(func.count()).where(
Transaction.date >= start,
Transaction.date < end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
).one()
count_deferred = session.exec(
select(func.count()).where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
).one()
count = count_normal + count_deferred
results[source.value] = {
"source": source.value,
"total_compra": compra,
@@ -183,42 +188,6 @@ def compute_actuals_by_source(
"net": compra - devolucion,
"count": count,
}
else:
# Cash / Transfer: calendar month, no deferred logic
compra = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.COMPRA,
)
).one()
devolucion = session.exec(
select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type == TransactionType.DEVOLUCION,
)
).one()
count = session.exec(
select(func.count()).where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
).one()
compra_val = float(compra)
devolucion_val = float(devolucion)
results[source.value] = {
"source": source.value,
"total_compra": compra_val,
"total_devolucion": devolucion_val,
"net": compra_val - devolucion_val,
"count": count,
}
return results
@@ -261,6 +230,7 @@ def compute_actuals_by_category(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -281,6 +251,7 @@ def compute_actuals_by_category(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -301,6 +272,7 @@ def compute_actuals_by_category(
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
@@ -342,6 +314,7 @@ def compute_cc_by_category(
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
@@ -360,24 +333,24 @@ def compute_cc_by_category(
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# Resolve category names
# Resolve category names in one query instead of one per category
from app.models.models import Category
names = {
c.id: c.name for c in session.exec(select(Category)).all()
}
result = []
for cat_id, amount in totals.items():
if amount <= 0:
continue
if cat_id is not None:
cat = session.get(Category, cat_id)
name = cat.name if cat else "Sin categoría"
else:
name = "Sin categoría"
name = names.get(cat_id, "Sin categoría") if cat_id is not None else "Sin categoría"
result.append({"category_name": name, "amount": round(amount, 2)})
return sorted(result, key=lambda x: x["amount"], reverse=True)
@@ -477,6 +450,7 @@ def compute_monthly_projection(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
@@ -492,6 +466,7 @@ def compute_monthly_projection(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
@@ -507,6 +482,7 @@ def compute_monthly_projection(
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
NOT_INSTALLMENT_ANCHOR,
)
.group_by(Transaction.transaction_type)
).all()
@@ -547,13 +523,13 @@ def _get_december_cumulative(session: Session, year: int) -> float:
)
).first()
if override:
return override.override_balance
return float(override.override_balance)
# Compute the full year to get December's cumulative
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
override_map = {o.month: float(o.override_balance) for o in overrides}
cumulative = 0.0
if year > FRESH_START_YEAR:
@@ -577,7 +553,7 @@ def compute_yearly_projection_with_cumulative(
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
override_map = {o.month: float(o.override_balance) for o in overrides}
# Determine January carryover
if year <= FRESH_START_YEAR:

View File

@@ -0,0 +1,57 @@
"""CSV export of transactions (review ARCH-18 / wishlist #4)."""
import csv
import io
from datetime import datetime
from typing import Optional
from sqlmodel import Session, col, select
from app.models.models import Transaction, TransactionSource, TransactionType
COLUMNS = [
"id", "date", "merchant", "city", "amount", "currency",
"transaction_type", "source", "bank", "category", "notes", "reference",
]
def build_transactions_csv(
session: Session,
*,
source: Optional[TransactionSource] = None,
transaction_type: Optional[TransactionType] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
) -> str:
q = select(Transaction)
if source:
q = q.where(Transaction.source == source)
if transaction_type:
q = q.where(Transaction.transaction_type == transaction_type)
if start_date:
q = q.where(Transaction.date >= start_date)
if end_date:
q = q.where(Transaction.date < end_date)
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(COLUMNS)
for tx in session.exec(q).all():
writer.writerow(
[
tx.id,
tx.date.isoformat(),
tx.merchant,
tx.city or "",
f"{tx.amount:.2f}",
tx.currency.value,
tx.transaction_type.value,
tx.source.value,
tx.bank.value,
tx.category.name if tx.category else "",
tx.notes or "",
tx.reference or "",
]
)
return buf.getvalue()

View File

@@ -8,6 +8,7 @@ from sqlalchemy import case
from sqlmodel import Session, col, select
from app.config import settings
from app.timeutil import utcnow
from app.db import engine
from app.models.models import ExchangeRate
@@ -60,8 +61,8 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
for datos in root.iter():
if datos.tag.endswith("NUM_VALOR"):
return float(datos.text.strip().replace(",", "."))
except Exception:
pass
except Exception as e:
logger.warning("BCCR rate fetch failed (indicator %s): %s", indicator, e)
return None
@@ -90,8 +91,8 @@ def _fetch_exchangerate_api() -> tuple[float, float] | None:
crc = data["rates"].get("CRC")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
pass
except Exception as e:
logger.warning("ExchangeRate-API fetch failed: %s", e)
return None
@@ -105,7 +106,8 @@ def _fetch_currency_api() -> tuple[float, float] | None:
crc = data.get("usd", {}).get("crc")
if crc:
return _mid_to_buy_sell(float(crc))
except Exception:
except Exception as e:
logger.warning("currency-api fetch failed (%s): %s", url, e)
continue
return None
@@ -119,8 +121,8 @@ def _fetch_floatrates() -> tuple[float, float] | None:
crc_data = data.get("crc")
if crc_data and "rate" in crc_data:
return _mid_to_buy_sell(float(crc_data["rate"]))
except Exception:
pass
except Exception as e:
logger.warning("FloatRates fetch failed: %s", e)
return None
@@ -136,7 +138,7 @@ def _fetch_rate_from_apis() -> tuple[float, float] | None:
def _remember(rate: ExchangeRate) -> ExchangeRate:
"""Store rate in both TTL cache and permanent last-known holder."""
global _last_known
_cache["current"] = (rate, datetime.utcnow())
_cache["current"] = (rate, utcnow())
_last_known = rate
return rate
@@ -147,11 +149,11 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
# 1. Fresh memory cache (< 1 hour)
cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
if cached and utcnow() - cached[1] < CACHE_TTL:
return cached[0]
# 2. Fresh DB rate (< 1 hour)
one_hour_ago = datetime.utcnow() - CACHE_TTL
one_hour_ago = utcnow() - CACHE_TTL
db_rate = session.exec(
select(ExchangeRate)
.where(ExchangeRate.fetched_at > one_hour_ago)
@@ -164,7 +166,7 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
result = _fetch_rate_from_apis()
if result is not None:
buy, sell = result
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
rate = ExchangeRate(date=utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
@@ -198,8 +200,8 @@ def _fetch_fiat_crc_mid(code: str) -> float | None:
x = data["rates"].get(code)
if crc and x:
return float(crc) / float(x)
except Exception:
pass
except Exception as e:
logger.warning("%s/CRC fiat rate fetch failed: %s", code, e)
return None
@@ -219,8 +221,8 @@ def _fetch_crypto_crc(code: str) -> float | None:
price = data.get(coin_id, {}).get("crc")
if price:
return float(price)
except Exception:
pass
except Exception as e:
logger.warning("CoinGecko %s/CRC fetch failed: %s", code, e)
return None
@@ -230,7 +232,7 @@ def get_crc_rate(code: str) -> float | None:
return 1.0
cached = _xcrc_cache.get(code)
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
if cached and utcnow() - cached[1] < CACHE_TTL:
return cached[0]
if code in _COINGECKO_IDS:
@@ -239,7 +241,7 @@ def get_crc_rate(code: str) -> float | None:
rate = _fetch_fiat_crc_mid(code)
if rate is not None:
_xcrc_cache[code] = (rate, datetime.utcnow())
_xcrc_cache[code] = (rate, utcnow())
_last_known_xcrc[code] = rate
return rate
@@ -254,7 +256,8 @@ def get_crc_multipliers(session: Session) -> dict[str, float]:
usd_rate = get_current_rate(session)
if usd_rate:
multipliers["USD"] = usd_rate.sell_rate
# sell_rate is Decimal when loaded from the DB; keep multipliers float
multipliers["USD"] = float(usd_rate.sell_rate)
for code in (c.value for c in Currency):
if code in multipliers:
@@ -293,7 +296,7 @@ def _refresh_usd_rate() -> bool:
return False
buy, sell = fetched
with Session(engine) as session:
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
rate = ExchangeRate(date=utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
@@ -309,7 +312,7 @@ def _refresh_other_rate(code: str) -> bool:
rate = _fetch_fiat_crc_mid(code)
if rate is None:
return False
_xcrc_cache[code] = (rate, datetime.utcnow())
_xcrc_cache[code] = (rate, utcnow())
_last_known_xcrc[code] = rate
return True
@@ -368,7 +371,7 @@ async def refresh_rates_periodically(
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
"""Get historical exchange rates."""
cutoff = datetime.utcnow() - timedelta(days=days)
cutoff = utcnow() - timedelta(days=days)
return list(
session.exec(
select(ExchangeRate)

View File

@@ -0,0 +1,187 @@
"""Tasa Cero (BAC zero-interest installment financing) plans.
A qualifying credit-card purchase ("anchor") is billed by the bank as N equal
monthly cuotas instead of one charge. Here the anchor transaction is flagged
(`is_installment_anchor`) and excluded from budget aggregates, and N real
cuota Transaction rows are generated so they flow through the existing
billing-cycle logic — including future-dated cuotas, which intentionally show
up in future months' budgets as committed spend.
Verified BAC mechanics (real Financiamientos data, 2026-05/07):
- cuota = total / N truncated to 0.10; the LAST cuota absorbs the remainder
(111,053.60/3 -> 37,017.80 x2 + 37,018.00).
- Cuotas post one per 18th-cut billing cycle, on the 19th, except the first
one whose date varies per plan (purchase date or a later 19th) — hence
`first_installment_date` is stored per plan and user-editable.
Known v1 gaps: paste-imported real "CUOTA:XX/YY" statement lines won't dedup
against the synthetic cuotas; editing the anchor's amount does not recompute
the plan (edit the plan instead).
Cascades are handled explicitly here in Python: tests run on SQLite without
FK enforcement, and transaction.installment_plan_id has no DB-level FK (it
would be circular with installmentplan.anchor_transaction_id).
"""
import re
from datetime import datetime
from decimal import ROUND_DOWN, Decimal
from typing import Optional
from sqlmodel import Session, select
from app.models.models import (
InstallmentPlan,
Transaction,
TransactionType,
)
from app.services.budget_projection import get_previous_cycle
# BAC prefixes Tasa Cero voucher merchants with "CC " (observed with a double
# space: "CC CONSTRUPLAZA"). Non-prefixed plans (e.g. ALMACENES SIMAN) are
# converted manually via POST /installment-plans/.
TASA_CERO_RE = re.compile(r"^CC\s", re.IGNORECASE)
DEFAULT_NUM_INSTALLMENTS = 3
def is_tasa_cero_merchant(merchant: str) -> bool:
return bool(TASA_CERO_RE.match(merchant))
def compute_installment_amounts(total: Decimal, n: int) -> list[Decimal]:
"""Split `total` into n cuotas using BAC's rule: truncate to 0.10, last
cuota absorbs the remainder. Invariant: sum(result) == total."""
per = (total / n).quantize(Decimal("0.1"), rounding=ROUND_DOWN)
return [per] * (n - 1) + [total - per * (n - 1)]
def _add_months(year: int, month: int, delta: int) -> tuple[int, int]:
idx = year * 12 + (month - 1) + delta
return idx // 12, idx % 12 + 1
def compute_installment_dates(first: datetime, n: int) -> list[datetime]:
"""Cuota 1 = `first` verbatim; cuotas 2..n land on the 19th of consecutive
billing cycles (cycle = [18th, next 18th), see get_cycle_range)."""
if first.day >= 18:
cycle_y, cycle_m = first.year, first.month
else:
cycle_y, cycle_m = get_previous_cycle(first.year, first.month)
dates = [first]
for i in range(1, n):
y, m = _add_months(cycle_y, cycle_m, i)
dates.append(datetime(y, m, 19))
return dates
def _generate_cuotas(
session: Session, plan: InstallmentPlan, anchor: Transaction
) -> None:
amounts = compute_installment_amounts(anchor.amount, plan.num_installments)
dates = compute_installment_dates(
plan.first_installment_date, plan.num_installments
)
merchant_label = " ".join(anchor.merchant.split()) # collapse "CC X"
ref_base = anchor.reference or f"TC{anchor.id}"
n = plan.num_installments
for i, (amount, when) in enumerate(zip(amounts, dates), start=1):
session.add(
Transaction(
amount=amount,
currency=anchor.currency,
merchant=f"{merchant_label} CUOTA:{i:02d}/{n:02d}",
city=anchor.city,
date=when,
card_type=anchor.card_type,
card_last4=anchor.card_last4,
reference=f"{ref_base}-C{i:02d}",
transaction_type=TransactionType.COMPRA,
source=anchor.source,
bank=anchor.bank,
notes=f"Tasa Cero plan #{plan.id}",
category_id=anchor.category_id,
deferred_to_next_cycle=False,
installment_plan_id=plan.id,
)
)
def _delete_cuotas(session: Session, plan: InstallmentPlan) -> None:
cuotas = session.exec(
select(Transaction).where(Transaction.installment_plan_id == plan.id)
).all()
for cuota in cuotas:
session.delete(cuota)
def create_plan(
session: Session,
anchor: Transaction,
num_installments: int = DEFAULT_NUM_INSTALLMENTS,
first_installment_date: Optional[datetime] = None,
) -> InstallmentPlan:
"""Convert `anchor` (must be committed, id set) into a Tasa Cero plan and
generate its cuota rows. Caller commits."""
amounts = compute_installment_amounts(anchor.amount, num_installments)
plan = InstallmentPlan(
anchor_transaction_id=anchor.id,
num_installments=num_installments,
first_installment_date=first_installment_date or anchor.date,
total_amount=anchor.amount,
installment_amount=amounts[0],
currency=anchor.currency,
)
session.add(plan)
session.flush() # assign plan.id for the cuota FK/notes
anchor.is_installment_anchor = True
session.add(anchor)
_generate_cuotas(session, plan, anchor)
return plan
def regenerate_cuotas(
session: Session,
plan: InstallmentPlan,
anchor: Transaction,
num_installments: Optional[int] = None,
first_installment_date: Optional[datetime] = None,
) -> InstallmentPlan:
"""Apply plan edits by delete-and-recreate of the cuota rows. Per-cuota
edits (e.g. recategorizations) are lost; category is re-inherited from
the anchor. Caller commits."""
if num_installments is not None:
plan.num_installments = num_installments
if first_installment_date is not None:
plan.first_installment_date = first_installment_date
plan.total_amount = anchor.amount
plan.installment_amount = compute_installment_amounts(
anchor.amount, plan.num_installments
)[0]
_delete_cuotas(session, plan)
_generate_cuotas(session, plan, anchor)
session.add(plan)
return plan
def delete_plan(session: Session, plan: InstallmentPlan, anchor: Transaction) -> None:
"""Unconvert: remove cuotas + plan, restore the anchor to a normal
transaction. Caller commits."""
_delete_cuotas(session, plan)
anchor.is_installment_anchor = False
session.add(anchor)
session.delete(plan)
def teardown_plan_for_anchor(session: Session, anchor: Transaction) -> None:
"""The anchor itself is being deleted: remove its plan and cuotas."""
if not anchor.is_installment_anchor:
return
plan = session.exec(
select(InstallmentPlan).where(
InstallmentPlan.anchor_transaction_id == anchor.id
)
).first()
if plan:
_delete_cuotas(session, plan)
session.delete(plan)

View File

@@ -1,3 +1,5 @@
from decimal import Decimal
from sqlmodel import Session, select
from app.models.models import (
@@ -8,8 +10,8 @@ from app.models.models import (
Transaction,
)
MEMP_MONTHLY = 200000.0
MPAT_MONTHLY = 200000.0
MEMP_MONTHLY = Decimal("200000")
MPAT_MONTHLY = Decimal("200000")
def _get_savings_account(session: Session, bank: Bank) -> Account | None:

View File

@@ -0,0 +1,94 @@
"""Per-source ingestion health, derived from existing data (review UX-17).
The n8n flows fail silently from the app's perspective — a parser break just
looks like missing data. This derives "when did we last receive something from
each source" plus a cadence threshold, so a stalled flow becomes visible
in-app without any n8n integration.
"""
from dataclasses import dataclass
from sqlmodel import Session, func, select
from app.models.models import (
Bank,
ExchangeRate,
MunicipalReceipt,
PensionSnapshot,
Transaction,
TransactionSource,
TransactionType,
)
from app.timeutil import utcnow
@dataclass
class SourceSpec:
key: str
label: str
warn_after_days: float
description: str
# Cadences: BAC emails arrive near-daily with normal card use; salary is
# biweekly/monthly; municipal and pension are monthly; rates refresh every 6h.
SOURCES: list[SourceSpec] = [
SourceSpec("bac_credit_card", "BAC Tarjeta de Crédito", 7,
"Correos de compra parseados por n8n"),
SourceSpec("salary", "Depósitos de Salario", 35,
"Correos de depósito parseados por n8n"),
SourceSpec("municipal", "Recibos Municipales", 40,
"PDF mensual vía n8n (cron)"),
SourceSpec("pensions", "Estados de Pensión", 40,
"PDFs diarios/mensuales vía n8n"),
SourceSpec("exchange_rate", "Tipo de Cambio", 1,
"Actualización automática cada 6 horas"),
]
def _last_received(session: Session, key: str):
if key == "bac_credit_card":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.bank == Bank.BAC,
)
).one()
if key == "salary":
return session.exec(
select(func.max(Transaction.created_at)).where(
Transaction.transaction_type == TransactionType.SALARY
)
).one()
if key == "municipal":
return session.exec(select(func.max(MunicipalReceipt.created_at))).one()
if key == "pensions":
return session.exec(select(func.max(PensionSnapshot.created_at))).one()
if key == "exchange_rate":
return session.exec(select(func.max(ExchangeRate.fetched_at))).one()
raise ValueError(f"unknown sync source: {key}")
def compute_sync_status(session: Session) -> list[dict]:
now = utcnow()
out: list[dict] = []
for spec in SOURCES:
last = _last_received(session, spec.key)
if last is None:
status = "never"
age_days = None
else:
age_days = (now - last).total_seconds() / 86400
status = "ok" if age_days <= spec.warn_after_days else "warning"
out.append(
{
"key": spec.key,
"label": spec.label,
"description": spec.description,
"last_received": last.isoformat() if last else None,
"age_days": round(age_days, 1) if age_days is not None else None,
"warn_after_days": spec.warn_after_days,
"status": status,
}
)
return out

65
backend/app/timeutil.py Normal file
View File

@@ -0,0 +1,65 @@
import contextvars
from datetime import date, datetime, timedelta, timezone, tzinfo
from zoneinfo import ZoneInfo
# Costa Rica has no DST; a fixed offset is exact year-round. This is the
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
CR_TZ = timezone(timedelta(hours=-6))
# Per-request client timezone, set by the agent middleware from the
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
# Costa Rica so headless callers keep the owner's local semantics.
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
"client_tz", default=CR_TZ
)
def set_client_timezone(name: str | None) -> contextvars.Token | None:
"""Bind the request's IANA timezone; ignores unknown/absent names.
Proxies that see the header twice merge the values into a comma list
("Zone, Zone") — take the first entry.
"""
if not name:
return None
try:
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
except (KeyError, ValueError):
return None
def reset_client_timezone(token: contextvars.Token | None) -> None:
if token is not None:
_client_tz.reset(token)
def client_tz() -> tzinfo:
return _client_tz.get()
def today_client() -> date:
"""Current date where the USER is (request timezone, CR fallback).
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
in UTC. Anything user-facing that means "today" (agent answers, date
defaults) must come from here, never date.today().
"""
return datetime.now(client_tz()).date()
def today_cr() -> date:
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
return datetime.now(CR_TZ).date()
def utcnow() -> datetime:
"""Naive UTC now, via the non-deprecated API.
Every datetime column in the schema is TIMESTAMP WITHOUT TIME ZONE, so the
whole app speaks naive-UTC. Replacing datetime.utcnow() call sites with
this helper removes the Python 3.12 deprecation without changing any
stored value or comparison. When the columns move to TIMESTAMPTZ
(Phase 2), this becomes `datetime.now(timezone.utc)` and the type change
happens in exactly one place.
"""
return datetime.now(timezone.utc).replace(tzinfo=None)

22
backend/app/uploads.py Normal file
View File

@@ -0,0 +1,22 @@
from fastapi import UploadFile
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB — statements are a few hundred KB
async def read_pdf_upload(
file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES
) -> bytes:
"""Read an uploaded PDF with a size cap and magic-byte check.
Raises ValueError with a user-readable, filename-prefixed message —
upload endpoints surface these in their per-file `errors` list.
"""
filename = file.filename or "unknown.pdf"
data = await file.read(max_bytes + 1)
if len(data) > max_bytes:
raise ValueError(
f"{filename}: file exceeds the {max_bytes // (1024 * 1024)} MB limit"
)
if not data.startswith(b"%PDF"):
raise ValueError(f"{filename}: not a PDF file")
return data

182
backend/constraints.txt Normal file
View File

@@ -0,0 +1,182 @@
a2a-sdk==0.3.23
ag-ui-protocol==0.1.19
agent-framework==1.11.0
agent-framework-a2a==1.0.0b260428
agent-framework-ag-ui==1.0.0rc8
agent-framework-anthropic==1.0.0b260428
agent-framework-azure-ai-search==1.0.0b260428
agent-framework-azure-cosmos==1.0.0b260428
agent-framework-azurefunctions==1.0.0b260428
agent-framework-bedrock==1.0.0b260428
agent-framework-chatkit==1.0.0b260428
agent-framework-claude==1.0.0b260428
agent-framework-copilotstudio==1.0.0b260428
agent-framework-core==1.11.0
agent-framework-declarative==1.0.0b260428
agent-framework-devui==1.0.0b260428
agent-framework-durabletask==1.0.0b260428
agent-framework-foundry==1.2.1
agent-framework-foundry-local==1.0.0b260428
agent-framework-github-copilot==1.0.0b260402
agent-framework-lab==1.0.0b251024
agent-framework-mem0==1.0.0b260428
agent-framework-ollama==1.0.0b260428
agent-framework-openai==1.10.1
agent-framework-orchestrations==1.0.0b260428
agent-framework-purview==1.0.0b260428
agent-framework-redis==1.0.0b260428
aiohappyeyeballs==2.6.2
aiohttp==3.14.1
aiosignal==1.4.0
alembic==1.18.4
annotated-doc==0.0.4
annotated-types==0.7.0
anthropic==0.80.0
anyio==4.13.0
async-timeout==5.0.1
asyncio==4.0.0
attrs==26.1.0
azure-ai-inference==1.0.0b9
azure-ai-projects==2.2.0
azure-common==1.1.28
azure-core==1.41.0
azure-cosmos==4.16.1
azure-functions==1.24.0
azure-functions-durable==1.5.0
azure-identity==1.25.3
azure-search-documents==11.7.0b2
azure-storage-blob==12.30.0
backoff==2.2.1
bcrypt==5.0.0
boto3==1.43.26
botocore==1.43.26
certifi==2026.5.20
cffi==2.0.0
charset-normalizer==3.4.7
claude-agent-sdk==0.1.48
click==8.4.1
clr_loader==0.2.10
cryptography==48.0.1
detect-installer==0.1.0
distro==1.9.0
dnspython==2.8.0
docstring_parser==0.18.0
durabletask==1.5.0
durabletask.azuremanaged==1.5.0
ecdsa==0.19.2
email-validator==2.3.0
fastapi==0.133.0
fastapi-cli==0.0.24
fastapi-cloud-cli==0.19.0
fastar==0.11.0
foundry-local-sdk==0.5.1
frozenlist==1.8.0
furl==2.1.4
github-copilot-sdk==0.1.32
google-api-core==2.31.0
google-auth==2.53.0
googleapis-common-protos==1.75.0
griffelib==2.0.2
grpcio==1.81.0
h11==0.16.0
h2==4.3.0
hpack==4.1.0
http-ece==1.2.1
httpcore==1.0.9
httptools==0.8.0
httpx==0.28.1
httpx-sse==0.4.3
hyperframe==6.1.0
idna==3.18
iniconfig==2.3.0
isodate==0.7.2
Jinja2==3.1.6
jiter==0.15.0
jmespath==1.1.0
jsonpath-ng==1.8.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
Mako==1.3.12
markdown-it-py==4.2.0
MarkupSafe==3.0.3
mcp==1.27.2
mdurl==0.1.2
mem0ai==1.0.11
microsoft-agents-activity==0.3.1
microsoft-agents-copilotstudio-client==0.3.1
microsoft-agents-hosting-core==0.3.1
ml_dtypes==0.5.4
msal==1.37.0
msal-extensions==1.3.1
multidict==6.7.1
numpy==2.4.6
ollama==0.5.3
openai==2.41.0
openai-agents==0.17.4
openai-chatkit==1.6.5
opentelemetry-api==1.42.1
opentelemetry-sdk==1.42.1
opentelemetry-semantic-conventions==0.63b1
orderedmultidict==1.0.2
packaging==26.2
passlib==1.7.4
pluggy==1.6.0
portalocker==3.2.0
posthog==7.18.0
powerfx==0.0.34
propcache==0.5.2
proto-plus==1.28.0
protobuf==6.33.6
psycopg2-binary==2.9.12
py-vapid==1.9.4
pyasn1==0.6.3
pyasn1_modules==0.4.2
pycparser==3.0
pydantic==2.13.4
pydantic-extra-types==2.11.1
pydantic-settings==2.14.1
pydantic_core==2.46.4
Pygments==2.20.0
PyJWT==2.13.0
pytest==9.0.3
python-dateutil==2.9.0.post0
python-dotenv==1.2.2
python-jose==3.5.0
python-multipart==0.0.32
python-ulid==3.1.0
pythonnet==3.0.5
pytz==2026.2
pywebpush==2.3.0
PyYAML==6.0.3
qdrant-client==1.18.0
redis==7.1.1
redisvl==0.15.0
referencing==0.37.0
requests==2.34.2
rich==15.0.0
rich-toolkit==0.20.1
rignore==0.7.6
rpds-py==2026.5.1
rsa==4.9.1
s3transfer==0.18.0
sentry-sdk==2.62.0
shellingham==1.5.4
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.50
sqlmodel==0.0.38
sse-starlette==3.4.5
starlette==1.2.1
tenacity==9.1.4
tqdm==4.68.2
typer==0.26.7
types-requests==2.33.0.20260518
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.7.0
uvicorn==0.41.0
uvloop==0.22.1
watchfiles==1.2.0
websockets==16.0
Werkzeug==3.1.8
yarl==1.24.2

View File

@@ -0,0 +1 @@
pytest

View File

@@ -11,6 +11,6 @@ httpx
pywebpush
py-vapid
python-dateutil
agent-framework==1.2.1
agent-framework-ag-ui==1.0.0b260428
agent-framework-openai==1.2.1
agent-framework==1.11.0
agent-framework-ag-ui==1.0.0rc8
agent-framework-openai==1.10.1

View File

51
backend/tests/conftest.py Normal file
View File

@@ -0,0 +1,51 @@
"""Shared fixtures. Env vars are set before any app import so config.Settings()
fail-fast validation passes with deterministic test values regardless of the
developer's .env."""
import os
os.environ.setdefault(
"SECRET_KEY", "test-secret-key-0123456789abcdef0123456789abcdef"
)
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
os.environ.setdefault("DATABASE_URL", "sqlite://")
# app.main builds the MAF agent at import; the OpenAI client only needs a
# syntactically-present key.
os.environ.setdefault("OPENAI_API_KEY", "test-dummy-key")
import pytest
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine
import app.models.models # noqa: F401 — register all tables on the metadata
@pytest.fixture()
def engine():
eng = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
SQLModel.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture()
def session(engine):
with Session(engine) as s:
yield s
@pytest.fixture(autouse=True)
def fixed_exchange_rates(monkeypatch):
"""Never hit the network: CRC passthrough, USD pinned at 500."""
from app.services import exchange_rate
monkeypatch.setattr(
exchange_rate,
"get_crc_multipliers",
lambda session: {"CRC": 1.0, "USD": 500.0},
)

View File

@@ -0,0 +1,14 @@
"""Assistant transport configuration."""
from agent_framework.openai import OpenAIChatClient
from app.agent import agent as agent_module
def test_agent_uses_responses_client_for_the_configured_model(monkeypatch):
monkeypatch.setattr(agent_module.settings, "AGENT_MODEL", "gpt-5.6-luna")
agent = agent_module.build_agent()
assert isinstance(agent.client, OpenAIChatClient)
assert agent.client.model == "gpt-5.6-luna"

View File

@@ -0,0 +1,286 @@
"""Agent tool coverage: session binding, JSON-safe output (no Decimal leaks),
and the batched queries introduced in Phase 2."""
import json
from datetime import date, datetime
from decimal import Decimal
import pytest
from app.agent import tools
from app.agent.tools import reset_session, set_session
from app.models.models import (
Account,
AccountType,
Bank,
Currency,
MunicipalReceipt,
PensionSnapshot,
Transaction,
TransactionType,
WaterMeterReading,
)
@pytest.fixture()
def bound_session(session):
token = set_session(session)
yield session
reset_session(token)
def make_snapshot(fund: Bank, start: date, end: date, saldo: str) -> PensionSnapshot:
zero = Decimal("0")
return PensionSnapshot(
fund=fund,
contract_number="C-1",
period_start=start,
period_end=end,
saldo_anterior=zero,
aportes=zero,
rendimientos=zero,
retiros=zero,
traslados=zero,
comision=zero,
correccion=zero,
bonificacion=zero,
saldo_final=Decimal(saldo),
source_filename="t.pdf",
)
def make_receipt(period: str) -> MunicipalReceipt:
zero = Decimal("0")
return MunicipalReceipt(
receipt_date=date(2026, 4, 1),
due_date=date(2026, 4, 15),
period=period,
account="A1",
finca="F1",
holder_name="x",
holder_cedula="x",
holder_address="x",
subtotal=Decimal("100"),
interests=zero,
iva=Decimal("13"),
total=Decimal("113"),
source_filename="r.pdf",
)
def test_unbound_session_raises(session):
with pytest.raises(RuntimeError, match="not bound to agent context"):
tools.get_accounts()
def test_accounts_output_is_json_safe(bound_session):
bound_session.add(
Account(
bank=Bank.BAC,
currency=Currency.CRC,
label="Main",
balance=Decimal("1234.56"),
account_type=AccountType.BANK,
)
)
bound_session.commit()
out = tools.get_accounts()
json.dumps(out) # would raise on Decimal leakage
assert out[0]["balance"] == 1234.56
def test_pension_latest_only_per_fund(bound_session):
bound_session.add(
make_snapshot(Bank.FCL, date(2026, 1, 1), date(2026, 1, 31), "100")
)
bound_session.add(
make_snapshot(Bank.FCL, date(2026, 2, 1), date(2026, 2, 28), "200")
)
bound_session.add(
make_snapshot(Bank.ROP, date(2026, 1, 1), date(2026, 1, 31), "900")
)
bound_session.commit()
out = tools.get_pension_snapshots(latest_only=True)
json.dumps(out)
assert len(out) == 2
by_fund = {o["fund"]: o for o in out}
assert by_fund["FCL"]["saldo_final"] == 200.0 # latest period wins
assert by_fund["ROP"]["saldo_final"] == 900.0
full = tools.get_pension_snapshots(latest_only=False)
assert len(full) == 3
def test_municipal_receipts_batched_consumption(bound_session):
r1, r2 = make_receipt("2026-03"), make_receipt("2026-04")
r2.account = "A2"
bound_session.add(r1)
bound_session.add(r2)
bound_session.commit()
bound_session.refresh(r1)
bound_session.refresh(r2)
for rid, m3 in ((r1.id, "10.5"), (r1.id, "4.5"), (r2.id, "7")):
bound_session.add(
WaterMeterReading(
meter_id="7335",
period=f"p{m3}",
consumption_m3=Decimal(m3),
receipt_id=rid,
)
)
bound_session.commit()
out = tools.get_municipal_receipts()
json.dumps(out)
by_account = {o["account"]: o for o in out}
assert by_account["A1"]["water_consumption_m3"] == 15.0
assert by_account["A2"]["water_consumption_m3"] == 7.0
def test_recent_transactions_json_safe(bound_session):
bound_session.add(
Transaction(
amount=Decimal("42.42"),
merchant="M",
date=datetime(2026, 4, 1),
)
)
bound_session.commit()
out = tools.get_recent_transactions()
json.dumps(out)
assert out[0]["amount"] == 42.42
def test_recent_transactions_exclude_future_cuotas(bound_session):
from datetime import timedelta
from app.timeutil import today_cr
today = datetime.combine(today_cr(), datetime.min.time())
bound_session.add(
Transaction(amount=Decimal("10"), merchant="BILLED", date=today)
)
bound_session.add(
Transaction(
amount=Decimal("20"),
merchant="FUTURE CUOTA:03/06",
date=today + timedelta(days=45),
)
)
bound_session.commit()
merchants = [t["merchant"] for t in tools.get_recent_transactions()]
assert "BILLED" in merchants
assert "FUTURE CUOTA:03/06" not in merchants
def test_salary_deposits_returns_individual_filtered_rows(bound_session):
bound_session.add_all(
[
Transaction(
amount=Decimal("1500"),
merchant="SALARY JULY",
date=datetime(2026, 7, 14, 9),
transaction_type=TransactionType.SALARY,
reference="PAY-2026-07",
),
Transaction(
amount=Decimal("1400"),
merchant="SALARY JUNE",
date=datetime(2026, 6, 14, 9),
transaction_type=TransactionType.SALARY,
),
Transaction(
amount=Decimal("999"),
merchant="PURCHASE",
date=datetime(2026, 7, 14, 10),
),
]
)
bound_session.commit()
out = tools.get_salary_deposits(
start_date="2026-07-14", end_date="2026-07-15"
)
json.dumps(out)
assert out == [
{
"id": out[0]["id"],
"date": "2026-07-14T09:00:00",
"amount": 1500.0,
"currency": "CRC",
"merchant": "SALARY JULY",
"source": "CREDIT_CARD",
"bank": "BAC",
"transaction_type": "SALARY",
"reference": "PAY-2026-07",
"notes": None,
}
]
def test_get_current_date_is_costa_rica_and_cycle_consistent():
from app.timeutil import today_cr
out = tools.get_current_date()
today = today_cr()
assert out["date"] == today.isoformat()
# The active cycle must contain today: [start, end)
start, end = out["cycle_range"]
assert start <= out["date"] < end
assert start.endswith("-18") and end.endswith("-18")
def test_daily_spending_day_scoped(bound_session):
from datetime import timedelta
from app.timeutil import today_cr
today = datetime.combine(today_cr(), datetime.min.time())
bound_session.add(
Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9))
)
bound_session.add(
Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15))
)
bound_session.add(
Transaction(
amount=Decimal("999"),
merchant="FUTURE CUOTA",
date=today + timedelta(days=40),
)
)
bound_session.commit()
out = tools.get_daily_spending(
start_date=today.date().isoformat(),
end_date=(today.date() + timedelta(days=60)).isoformat(),
)
assert len(out) == 1
assert out[0]["date"] == today.date().isoformat()
assert out[0]["total_crc"] == 150.0
assert out[0]["count"] == 2
def test_get_current_date_respects_client_timezone():
from datetime import datetime, timezone as tz
from app.timeutil import (
client_tz,
reset_client_timezone,
set_client_timezone,
)
token = set_client_timezone("Asia/Tokyo")
try:
out = tools.get_current_date()
assert out["timezone"] == "Asia/Tokyo"
assert out["date"] == datetime.now(client_tz()).date().isoformat()
finally:
reset_client_timezone(token)
# Unknown names are ignored — CR fallback stays active.
assert set_client_timezone("Not/AZone") is None
assert set_client_timezone(None) is None
assert tools.get_current_date()["timezone"] == "UTC-06:00"

View File

@@ -0,0 +1,62 @@
"""Analytics endpoints: category aggregation (Decimal regression) and the
arbitrary date-range filters added for the Analytics range picker."""
from datetime import datetime
from decimal import Decimal
from app.api.v1.endpoints.analytics import daily_spending, spending_by_category
from app.models.models import Category, Transaction
from tests.test_installments import make_cc_tx
def seed(session):
cat = Category(name="Groceries")
session.add(cat)
session.commit()
make_cc_tx(session, merchant="A", amount="1000.50", reference="a1",
date=datetime(2026, 6, 2), category_id=cat.id)
make_cc_tx(session, merchant="B", amount="2000.00", reference="b1",
date=datetime(2026, 6, 20))
return cat
class TestSpendingByCategory:
def test_decimal_sum_does_not_crash_and_percentages_add_up(self, session):
"""Regression: Decimal grand total vs float row total raised
TypeError, 500ing the endpoint whenever it had data."""
seed(session)
rows = spending_by_category(session=session, _user="t")
assert {r.category_name for r in rows} == {"Groceries", "Uncategorized"}
assert sum(r.percentage for r in rows) == 100.0
def test_date_range_overrides_cycle(self, session):
seed(session)
rows = spending_by_category(
start_date="2026-06-01",
end_date="2026-06-10",
cycle_year=2026,
cycle_month=1, # would match nothing; range must win
session=session,
_user="t",
)
assert len(rows) == 1
assert rows[0].category_name == "Groceries"
assert rows[0].total == 1000.50
class TestDailySpending:
def test_date_range_filter(self, session):
seed(session)
rows = daily_spending(
start_date="2026-06-15", end_date="2026-07-01",
session=session, _user="t",
)
assert [r.date for r in rows] == ["2026-06-20"]
assert rows[0].total == 2000.00
def test_future_cuotas_excluded(self, session):
make_cc_tx(session, merchant="FUTURE", amount="500.00",
reference="f1", date=datetime(2030, 1, 19))
rows = daily_spending(session=session, _user="t")
assert rows == []

154
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,154 @@
"""JWT lifecycle, API-token validation, constant-time creds, login rate limit."""
from datetime import timedelta
import pytest
from fastapi import HTTPException, Request
from jose import jwt
from app import auth as auth_mod
from app.auth import (
ALGORITHM,
LOGIN_RATE_LIMIT,
_login_attempts,
_validate_token,
check_login_rate_limit,
create_access_token,
hash_token,
verify_admin_credentials,
)
from app.config import settings
from app.models.models import APIToken
from app.timeutil import utcnow
def make_request(ip: str) -> Request:
return Request(
{
"type": "http",
"headers": [(b"x-forwarded-for", ip.encode())],
"client": ("127.0.0.1", 1234),
}
)
class TestJWT:
def test_roundtrip_and_seven_day_expiry(self):
token = create_access_token("charlie")
assert _validate_token(token) == "charlie"
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
)
# exp is a true UTC epoch; a naive datetime's .timestamp() would be
# interpreted as local time, so compare against an aware now.
from datetime import datetime, timezone
lifetime = payload["exp"] - datetime.now(timezone.utc).timestamp()
assert lifetime == pytest.approx(7 * 24 * 3600, abs=60)
def test_expired_token_rejected(self, engine, monkeypatch):
# The API-token fallback path opens a DB session; point it at the
# test engine so the lookup runs (and finds nothing).
monkeypatch.setattr("app.db.engine", engine)
expired = jwt.encode(
{"sub": "charlie", "exp": utcnow() - timedelta(minutes=1)},
settings.SECRET_KEY,
algorithm=ALGORITHM,
)
with pytest.raises(HTTPException) as exc:
_validate_token(expired)
assert exc.value.status_code == 401
def test_garbage_token_rejected(self, engine, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
with pytest.raises(HTTPException):
_validate_token("not-a-jwt")
class TestAPIToken:
def test_active_api_token_accepted(self, engine, session, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
session.add(
APIToken(name="n8n", token_hash=hash_token("tok-123"), is_active=True)
)
session.commit()
assert _validate_token("tok-123") == "api:n8n"
def test_expired_api_token_rejected(self, engine, session, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
session.add(
APIToken(
name="old",
token_hash=hash_token("tok-old"),
is_active=True,
expires_at=utcnow() - timedelta(days=1),
)
)
session.commit()
with pytest.raises(HTTPException) as exc:
_validate_token("tok-old")
assert exc.value.status_code == 401
def test_revoked_api_token_rejected(self, engine, session, monkeypatch):
monkeypatch.setattr("app.db.engine", engine)
session.add(
APIToken(
name="revoked",
token_hash=hash_token("tok-rev"),
is_active=False,
)
)
session.commit()
with pytest.raises(HTTPException):
_validate_token("tok-rev")
class TestCredentials:
def test_correct_credentials_pass(self):
verify_admin_credentials(
settings.ADMIN_USERNAME, settings.ADMIN_PASSWORD
) # no exception
@pytest.mark.parametrize(
"user,pw",
[
("wrong", "test-password"),
("testadmin", "wrong"),
("", ""),
],
)
def test_wrong_credentials_rejected(self, user, pw):
with pytest.raises(HTTPException) as exc:
verify_admin_credentials(user, pw)
assert exc.value.status_code == 401
class TestLoginRateLimit:
def setup_method(self):
_login_attempts.clear()
def test_limit_allows_then_blocks(self):
req = make_request("203.0.113.1")
for _ in range(LOGIN_RATE_LIMIT):
check_login_rate_limit(req)
with pytest.raises(HTTPException) as exc:
check_login_rate_limit(req)
assert exc.value.status_code == 429
assert "Retry-After" in exc.value.headers
def test_limit_is_per_ip(self):
for _ in range(LOGIN_RATE_LIMIT):
check_login_rate_limit(make_request("203.0.113.2"))
# A different IP is unaffected.
check_login_rate_limit(make_request("203.0.113.3"))
def test_window_slides(self):
req = make_request("203.0.113.4")
for _ in range(LOGIN_RATE_LIMIT):
check_login_rate_limit(req)
# Age every recorded attempt past the window; the next attempt passes.
attempts = _login_attempts["203.0.113.4"]
for i in range(len(attempts)):
attempts[i] -= auth_mod.LOGIN_RATE_WINDOW_SECONDS + 1
check_login_rate_limit(req) # no exception

View File

@@ -0,0 +1,196 @@
"""Projection engine: recurring-item schedules, no-double-count, cumulative
balances with overrides and the 2026-03 fresh start. Also serves as the
float-behavior baseline ahead of the Phase 2 Decimal migration (plan 1.5)."""
from datetime import datetime
import pytest
from sqlmodel import Session
from app.models.models import (
BalanceOverride,
Category,
RecurringFrequency,
RecurringItem,
RecurringItemType,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.budget_projection import (
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
get_effective_amount,
)
def make_item(**kw) -> RecurringItem:
defaults = dict(
name="item",
amount=1000.0,
item_type=RecurringItemType.EXPENSE,
frequency=RecurringFrequency.MONTHLY,
)
defaults.update(kw)
return RecurringItem(**defaults)
class TestEffectiveAmount:
def test_monthly_base_and_override(self):
item = make_item(amount=100.0, override_amounts={"4": 250.0})
assert get_effective_amount(item, 3, 2026) == 100.0
assert get_effective_amount(item, 4, 2026) == 250.0
def test_weekly_counts_weekday_occurrences(self):
# day_of_month stores weekday, 0=Monday. March 2026 has 5 Mondays
# (2, 9, 16, 23, 30); April 2026 has 4 (6, 13, 20, 27).
item = make_item(
amount=100.0, frequency=RecurringFrequency.WEEKLY, day_of_month=0
)
assert get_effective_amount(item, 3, 2026) == 500.0
assert get_effective_amount(item, 4, 2026) == 400.0
def test_quarterly_active_in_quarter_end_months_only(self):
item = make_item(frequency=RecurringFrequency.QUARTERLY, amount=900.0)
for month in (3, 6, 9, 12):
assert get_effective_amount(item, month, 2026) == 900.0
for month in (1, 2, 4, 5, 7, 8, 10, 11):
assert get_effective_amount(item, month, 2026) is None
def test_biannual_base_and_six_months_later(self):
item = make_item(
frequency=RecurringFrequency.BIANNUAL, month_of_year=2, amount=70.0
)
assert get_effective_amount(item, 2, 2026) == 70.0
assert get_effective_amount(item, 8, 2026) == 70.0
assert get_effective_amount(item, 5, 2026) is None
late = make_item(
frequency=RecurringFrequency.BIANNUAL, month_of_year=9, amount=70.0
)
assert get_effective_amount(late, 9, 2026) == 70.0
assert get_effective_amount(late, 3, 2026) == 70.0
def test_yearly_defaults_to_december(self):
item = make_item(frequency=RecurringFrequency.YEARLY, amount=50.0)
assert get_effective_amount(item, 12, 2026) == 50.0
assert get_effective_amount(item, 6, 2026) is None
july = make_item(
frequency=RecurringFrequency.YEARLY, month_of_year=7, amount=50.0
)
assert get_effective_amount(july, 7, 2026) == 50.0
class TestMonthlyProjection:
def _seed(self, session: Session):
groceries = Category(name="Groceries")
other = Category(name="Other")
session.add(groceries)
session.add(other)
session.commit()
session.refresh(groceries)
session.refresh(other)
session.add(
RecurringItem(
name="Salary",
amount=500_000.0,
item_type=RecurringItemType.INCOME,
frequency=RecurringFrequency.MONTHLY,
)
)
session.add(
RecurringItem(
name="Groceries budget",
amount=100_000.0,
item_type=RecurringItemType.EXPENSE,
frequency=RecurringFrequency.MONTHLY,
category_id=groceries.id,
)
)
session.commit()
return groceries, other
def _cc(self, date, amount, category_id=None):
return Transaction(
amount=amount,
merchant="X",
date=date,
transaction_type=TransactionType.COMPRA,
source=TransactionSource.CREDIT_CARD,
category_id=category_id,
)
def test_actuals_replace_projection_for_covered_category(self, session):
groceries, _ = self._seed(session)
# April budget ⇒ cycle [Mar 18, Apr 18)
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
session.commit()
data = compute_monthly_projection(session, 2026, 4)
grocery_line = next(
i for i in data["expense_items"] if i["name"] == "Groceries budget"
)
assert grocery_line["used_actual"] is True
assert grocery_line["amount"] == pytest.approx(80_000.0)
assert grocery_line["projected_amount"] == pytest.approx(100_000.0)
assert data["projected_fixed_expenses"] == pytest.approx(80_000.0)
def test_projection_used_when_no_actuals(self, session):
self._seed(session)
data = compute_monthly_projection(session, 2026, 4)
assert data["projected_fixed_expenses"] == pytest.approx(100_000.0)
assert data["projected_income"] == pytest.approx(500_000.0)
assert data["net_balance"] == pytest.approx(400_000.0)
def test_uncovered_and_uncategorized_actuals_add_up(self, session):
groceries, other = self._seed(session)
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
session.add(self._cc(datetime(2026, 4, 2), 5_000.0, other.id))
session.add(self._cc(datetime(2026, 4, 3), 2_000.0, None))
session.commit()
data = compute_monthly_projection(session, 2026, 4)
assert data["uncovered_actual"] == pytest.approx(7_000.0)
assert data["gran_total_egresos"] == pytest.approx(87_000.0)
assert data["net_balance"] == pytest.approx(500_000.0 - 87_000.0)
class TestYearlyCumulative:
def _income_only(self, session: Session, amount=100.0):
session.add(
RecurringItem(
name="Income",
amount=amount,
item_type=RecurringItemType.INCOME,
frequency=RecurringFrequency.MONTHLY,
)
)
session.commit()
def test_fresh_start_zeroes_months_before_march_2026(self, session):
self._income_only(session)
months = compute_yearly_projection_with_cumulative(session, 2026)
assert months[0]["cumulative_balance"] == 0.0 # January
assert months[1]["cumulative_balance"] == 0.0 # February
assert months[2]["cumulative_balance"] == pytest.approx(100.0) # March
assert months[11]["cumulative_balance"] == pytest.approx(1000.0) # Dec
def test_override_resets_cumulative_chain(self, session):
self._income_only(session)
session.add(BalanceOverride(year=2026, month=4, override_balance=500.0))
session.commit()
months = compute_yearly_projection_with_cumulative(session, 2026)
april, may = months[3], months[4]
assert april["balance_overridden"] is True
assert april["cumulative_balance"] == pytest.approx(500.0)
assert may["carryover_balance"] == pytest.approx(500.0)
assert may["cumulative_balance"] == pytest.approx(600.0)
def test_next_year_carries_december_forward(self, session):
self._income_only(session)
months_2027 = compute_yearly_projection_with_cumulative(session, 2027)
# 2026 accrues 10 months (MarDec) of 100 → Jan 2027 carries 1000.
assert months_2027[0]["carryover_balance"] == pytest.approx(1000.0)
assert months_2027[0]["cumulative_balance"] == pytest.approx(1100.0)

View File

@@ -0,0 +1,139 @@
"""Chat-run audit persistence and redacted HTTP access logs."""
import asyncio
from datetime import timedelta
from agent_framework_ag_ui import AGUIThreadSnapshot
from starlette.requests import Request
from starlette.responses import Response
from sqlmodel import select
import app.agent.chat_audit as chat_audit
import app.agent.snapshot_store as snapshot_store_module
from app.agent.snapshot_store import PostgresSnapshotStore
from app.models.models import ChatRunLog
from app.timeutil import utcnow
def _audit_store(monkeypatch, engine):
monkeypatch.setattr(chat_audit, "engine", engine)
monkeypatch.setattr(snapshot_store_module, "engine", engine)
return PostgresSnapshotStore()
def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine, session):
store = _audit_store(monkeypatch, engine)
run_id = chat_audit.create_chat_run(
request_id="req-1",
agui_run_id="run-1",
thread_id="main",
messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}],
client_ip="203.0.113.7",
user_agent="Mozilla/5.0 TestBrowser/1.0",
browser="TestBrowser",
)
token = chat_audit.set_current_chat_run(run_id)
try:
asyncio.run(
store.save(
scope="default",
thread_id="resp_123",
snapshot=AGUIThreadSnapshot(
messages=[
{"id": "u1", "role": "user", "content": "¿Cuál es mi saldo?"},
{
"id": "a1",
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "get_net_worth", "arguments": "{}"},
}
],
},
{"id": "t1", "role": "tool", "tool_call_id": "call-1", "content": "{\"net_crc\": 1}"},
{"id": "a2", "role": "assistant", "content": "Tu saldo es ₡1."},
]
),
)
)
finally:
chat_audit.reset_current_chat_run(token)
row = session.get(ChatRunLog, run_id)
assert row is not None
assert row.status == "completed"
assert row.agui_run_id == "run-1"
assert row.response_thread_id == "resp_123"
assert row.user_prompt == "¿Cuál es mi saldo?"
assert row.client_ip == "203.0.113.7"
assert row.user_agent == "Mozilla/5.0 TestBrowser/1.0"
assert row.browser == "TestBrowser"
assert row.assistant_response == "Tu saldo es ₡1."
assert row.tool_calls == [
{
"id": "call-1",
"name": "get_net_worth",
"arguments": "{}",
"result": '{"net_crc": 1}',
}
]
def test_failure_and_retention_cleanup(monkeypatch, engine, session):
_audit_store(monkeypatch, engine)
failed_id = chat_audit.create_chat_run(
request_id="req-failed", agui_run_id="run-failed", thread_id="main", messages=[]
)
chat_audit.mark_chat_run_failed(failed_id, "RuntimeError: provider unavailable")
assert session.get(ChatRunLog, failed_id).status == "failed"
expired = ChatRunLog(
request_id="req-expired",
model="gpt-5.6-luna",
expires_at=utcnow() - timedelta(seconds=1),
)
session.add(expired)
session.commit()
assert chat_audit.purge_expired_chat_runs() == 1
assert session.exec(select(ChatRunLog).where(ChatRunLog.request_id == "req-expired")).first() is None
def test_api_access_log_never_contains_request_body(monkeypatch):
from app.main import log_api_request
logged = []
monkeypatch.setattr("app.main.log_event", lambda event, **fields: logged.append((event, fields)))
scope = {
"type": "http",
"method": "POST",
"path": "/api/auth/login",
"raw_path": b"/api/auth/login",
"query_string": b"",
"headers": [
(b"x-real-ip", b"203.0.113.9"),
(b"user-agent", b"Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"),
],
"scheme": "http",
"server": ("testserver", 80),
"client": ("testclient", 1234),
}
request = Request(scope)
async def call_next(_request):
return Response(status_code=201)
response = asyncio.run(log_api_request(request, call_next))
assert response.headers["X-Request-ID"]
assert logged[0][0] == "api_request"
fields = logged[0][1]
assert fields["path"] == "/api/auth/login"
assert fields["status_code"] == 201
assert fields["client_ip"] == "203.0.113.9"
assert fields["browser"] == "Chrome"
assert fields["user_agent"] == "Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"
assert "password" not in fields
assert "body" not in fields

View File

@@ -0,0 +1,167 @@
"""Chat thread persistence: snapshot store round-trip + clear endpoint."""
import asyncio
from agent_framework_ag_ui import AGUIThreadSnapshot
from sqlmodel import select
import app.agent.snapshot_store as snapshot_store_module
from app.agent.snapshot_store import PostgresSnapshotStore
from app.api.v1.endpoints.chat import clear_thread
from app.models.models import ChatThreadSnapshot
def _store(monkeypatch, engine):
# The store opens its own sessions from app.db.engine; point it at the
# test engine instead.
monkeypatch.setattr(snapshot_store_module, "engine", engine)
return PostgresSnapshotStore()
def test_save_get_roundtrip_normalizes_and_filters(monkeypatch, engine):
store = _store(monkeypatch, engine)
snapshot = AGUIThreadSnapshot(
messages=[
{"id": "u1", "role": "user", "content": "hola"},
{
"id": "a1",
"role": "assistant",
"content": "",
# MAF emits snake_case; the store must return camelCase so
# @ag-ui/client's Zod schema doesn't strip it.
"tool_calls": [{"id": "t1", "type": "function"}],
},
{"id": "t1r", "role": "tool", "content": "ok", "tool_call_id": "t1"},
# MAF writes tool_calls: null on plain-text assistant messages —
# must not survive as toolCalls: null in the replay.
{"id": "a2", "role": "assistant", "content": "done", "tool_calls": None},
{"id": "ctx-123", "role": "system", "content": "run context"},
],
state={"k": "v"},
)
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
assert loaded is not None
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1r", "a2"]
assistant = loaded.messages[1]
assert "tool_calls" not in assistant
assert assistant["toolCalls"] == [{"id": "t1", "type": "function"}]
tool_msg = loaded.messages[2]
assert "tool_call_id" not in tool_msg
assert tool_msg["toolCallId"] == "t1"
text_msg = loaded.messages[3]
assert "toolCalls" not in text_msg and "tool_calls" not in text_msg
assert loaded.state == {"k": "v"}
def test_save_upserts_single_row(monkeypatch, engine, session):
store = _store(monkeypatch, engine)
for n in range(3):
snap = AGUIThreadSnapshot(
messages=[{"id": f"u{i}", "role": "user", "content": str(i)} for i in range(n + 1)]
)
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
rows = session.exec(select(ChatThreadSnapshot)).all()
assert len(rows) == 1
assert len(rows[0].messages) == 3
def test_get_unknown_thread_returns_none(monkeypatch, engine):
store = _store(monkeypatch, engine)
assert asyncio.run(store.get(scope="default", thread_id="nope")) is None
def test_delete_and_scope_isolation(monkeypatch, engine):
store = _store(monkeypatch, engine)
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
assert asyncio.run(store.get(scope="other", thread_id="main")) is None
assert asyncio.run(store.delete(scope="default", thread_id="main")) is True
assert asyncio.run(store.delete(scope="default", thread_id="main")) is False
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
def test_save_dedupes_multi_run_turn(monkeypatch, engine):
"""Mirror of MAF's duplicate merge on frontend-tool roundtrips: repeated
message ids, a re-issued identical render call, and extra tool results."""
store = _store(monkeypatch, engine)
def call(cid, name, args='{"a":1}'):
return {"id": cid, "type": "function", "function": {"name": name, "arguments": args}}
snapshot = AGUIThreadSnapshot(
messages=[
{"id": "u1", "role": "user", "content": "resumen"},
{"id": "a1", "role": "assistant", "content": "",
"tool_calls": [call("c1", "get_cycle_summary"), call("c2", "render_spending_summary")]},
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
# MAF re-records the resent suffix verbatim…
{"id": "u1", "role": "user", "content": "resumen"},
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
{"id": "t2", "role": "tool", "content": "ok", "tool_call_id": "c2"},
# …plus a synthetic second result for an answered call…
{"id": "t3", "role": "tool", "content": "", "tool_call_id": "c1"},
# …and the model re-issues the same render call with a new id.
{"id": "a2", "role": "assistant", "content": "",
"tool_calls": [call("c3", "render_spending_summary")]},
{"id": "t4", "role": "tool", "content": "ok", "tool_call_id": "c3"},
]
)
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1", "t2"]
# The duplicate render call (c3) and its result are gone; each surviving
# call has exactly one result.
assert [tc["id"] for tc in loaded.messages[1]["toolCalls"]] == ["c1", "c2"]
assert [(m["toolCallId"]) for m in loaded.messages if m["role"] == "tool"] == ["c1", "c2"]
def test_clear_thread_endpoint(monkeypatch, engine, session):
store = _store(monkeypatch, engine)
assert clear_thread(session=session, _user="testadmin") == {"cleared": False}
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
assert clear_thread(session=session, _user="testadmin") == {"cleared": True}
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
def test_drop_stale_client_history(session, monkeypatch, engine):
from app.main import _drop_stale_client_history
history = [
{"role": "user", "content": "vieja pregunta"},
{"role": "assistant", "content": "vieja respuesta"},
{"role": "tool", "content": "x", "toolCallId": "t1"},
{"role": "user", "content": "nueva pregunta"},
]
# No stored snapshot for the thread → stale client: keep last user turn.
assert _drop_stale_client_history(session, "main", list(history)) == [
{"role": "user", "content": "nueva pregunta"}
]
# Fresh conversation (no assistant/tool yet) passes through.
fresh = [{"role": "user", "content": "hola"}]
assert _drop_stale_client_history(session, "main", list(fresh)) == fresh
# Empty messages (hydration trigger) pass through.
assert _drop_stale_client_history(session, "main", []) == []
# With a stored snapshot the full history is legitimate.
store = _store(monkeypatch, engine)
asyncio.run(
store.save(
scope="default",
thread_id="main",
snapshot=AGUIThreadSnapshot(
messages=[{"id": "u1", "role": "user", "content": "hola"}]
),
)
)
assert _drop_stale_client_history(session, "main", list(history)) == history

View File

@@ -0,0 +1,44 @@
"""Fail-fast settings validation (SEC-01 / BE-15)."""
import pytest
from pydantic import ValidationError
from app.config import Settings
GOOD = {
"SECRET_KEY": "f" * 64,
"ADMIN_USERNAME": "charlie",
"ADMIN_PASSWORD": "a-real-password",
}
def make(**overrides) -> Settings:
return Settings(_env_file=None, **{**GOOD, **overrides})
def test_valid_settings_boot():
s = make()
assert s.ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 * 7
assert s.COOKIE_SECURE is True
assert s.cors_origins_list[0] == "https://wealth.cescalante.dev"
@pytest.mark.parametrize(
"overrides",
[
{"SECRET_KEY": "change-me-in-production"},
{"SECRET_KEY": "short"},
{"SECRET_KEY": ""},
{"ADMIN_PASSWORD": "admin"},
{"ADMIN_PASSWORD": ""},
{"ADMIN_USERNAME": ""},
],
)
def test_weak_or_missing_secrets_rejected(overrides):
with pytest.raises(ValidationError):
make(**overrides)
def test_cors_origins_parse_and_strip():
s = make(CORS_ORIGINS=" https://a.example , http://b.example ,")
assert s.cors_origins_list == ["https://a.example", "http://b.example"]

View File

@@ -0,0 +1,202 @@
"""Characterization tests for billing-cycle math (review finding BE-06).
VERDICT: BE-06 is a false positive. The system is internally consistent:
- `get_cycle_range(Y, M)` returns the cycle that STARTS on the 18th of M:
[M/18, (M+1)/18).
- Budget month M covers the credit-card cycle that ENDS on the 18th of M,
i.e. `get_previous_cycle(M)` then `get_cycle_range(M-1)` = [(M-1)/18, M/18) —
exactly what the comment in compute_actuals_by_source says.
- The frontend (Budget.tsx) follows the same convention: viewing month M it
queries cycle_month = M-1.
These tests pin that convention so it can never silently shift.
"""
from datetime import datetime
import pytest
from sqlmodel import Session
from app.models.models import (
Currency,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.budget_projection import (
compute_actuals_by_source,
get_cycle_range,
get_month_range,
get_previous_cycle,
)
def cc_tx(
date: datetime,
amount: float = 1000.0,
*,
tx_type: TransactionType = TransactionType.COMPRA,
source: TransactionSource = TransactionSource.CREDIT_CARD,
currency: Currency = Currency.CRC,
deferred: bool = False,
) -> Transaction:
return Transaction(
amount=amount,
currency=currency,
merchant="TEST",
date=date,
transaction_type=tx_type,
source=source,
deferred_to_next_cycle=deferred,
)
def cc_net(session: Session, year: int, month: int) -> float:
return compute_actuals_by_source(session, year, month)["CREDIT_CARD"]["net"]
class TestRangeHelpers:
def test_cycle_starts_on_the_18th_of_its_label_month(self):
assert get_cycle_range(2026, 3) == (
datetime(2026, 3, 18),
datetime(2026, 4, 18),
)
def test_december_cycle_wraps_into_next_year(self):
assert get_cycle_range(2026, 12) == (
datetime(2026, 12, 18),
datetime(2027, 1, 18),
)
def test_month_range_normal_and_december(self):
assert get_month_range(2026, 4) == (
datetime(2026, 4, 1),
datetime(2026, 5, 1),
)
assert get_month_range(2026, 12) == (
datetime(2026, 12, 1),
datetime(2027, 1, 1),
)
def test_previous_cycle_january_wraps_to_prior_december(self):
assert get_previous_cycle(2026, 1) == (2025, 12)
assert get_previous_cycle(2026, 7) == (2026, 6)
@pytest.mark.parametrize("month", [0, 13, -1, 99])
def test_invalid_month_rejected(self, month):
with pytest.raises(ValueError):
get_cycle_range(2026, month)
with pytest.raises(ValueError):
get_month_range(2026, month)
class TestBudgetMonthCycleConvention:
"""Budget month M ← credit-card cycle [(M-1)/18, M/18)."""
def test_april_budget_covers_march18_to_april17(self, session):
included = [
cc_tx(datetime(2026, 3, 18, 0, 0), 100.0), # first instant of cycle
cc_tx(datetime(2026, 4, 1, 12, 0), 200.0), # mid-cycle
cc_tx(datetime(2026, 4, 17, 23, 59), 400.0), # last day of cycle
]
excluded = [
cc_tx(datetime(2026, 3, 17, 23, 59), 7000.0), # previous cycle
cc_tx(datetime(2026, 4, 18, 0, 0), 9000.0), # next cycle
]
for tx in included + excluded:
session.add(tx)
session.commit()
assert cc_net(session, 2026, 4) == pytest.approx(700.0)
# The boundary transactions land in the adjacent budget months instead.
assert cc_net(session, 2026, 3) == pytest.approx(7000.0)
assert cc_net(session, 2026, 5) == pytest.approx(9000.0)
def test_january_budget_reaches_into_prior_year(self, session):
session.add(cc_tx(datetime(2025, 12, 20), 500.0))
session.commit()
assert cc_net(session, 2026, 1) == pytest.approx(500.0)
def test_cash_uses_calendar_month_not_cycle(self, session):
session.add(
cc_tx(
datetime(2026, 4, 17),
300.0,
source=TransactionSource.CASH,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)
assert actuals["CASH"]["net"] == pytest.approx(300.0)
# Not in March's calendar month, despite being in March's... no —
# Apr 17 belongs to April calendar month only.
assert compute_actuals_by_source(session, 2026, 3)["CASH"][
"net"
] == pytest.approx(0.0)
class TestDeferredTransactions:
def test_deferred_moves_to_next_budget_month(self, session):
# 2026-03-10 lies in cycle [Feb 18, Mar 18) → budget month 3.
# Deferred, it must count in budget month 4 instead.
session.add(cc_tx(datetime(2026, 3, 10), 1500.0, deferred=True))
session.commit()
assert cc_net(session, 2026, 3) == pytest.approx(0.0)
assert cc_net(session, 2026, 4) == pytest.approx(1500.0)
# And it does not leak into month 5.
assert cc_net(session, 2026, 5) == pytest.approx(0.0)
def test_deferred_devolucion_also_moves(self, session):
session.add(cc_tx(datetime(2026, 3, 10), 9000.0)) # normal, month 3
session.add(
cc_tx(
datetime(2026, 3, 10),
2000.0,
tx_type=TransactionType.DEVOLUCION,
deferred=True,
)
)
session.commit()
assert cc_net(session, 2026, 3) == pytest.approx(9000.0)
assert cc_net(session, 2026, 4) == pytest.approx(-2000.0)
class TestAmountSemantics:
def test_devolucion_subtracts_from_net(self, session):
session.add(cc_tx(datetime(2026, 4, 1), 5000.0))
session.add(
cc_tx(
datetime(2026, 4, 2),
1200.0,
tx_type=TransactionType.DEVOLUCION,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)["CREDIT_CARD"]
assert actuals["total_compra"] == pytest.approx(5000.0)
assert actuals["total_devolucion"] == pytest.approx(1200.0)
assert actuals["net"] == pytest.approx(3800.0)
assert actuals["count"] == 2
def test_usd_converted_at_multiplier(self, session):
# conftest pins USD→CRC at 500.
session.add(cc_tx(datetime(2026, 4, 1), 10.0, currency=Currency.USD))
session.commit()
assert cc_net(session, 2026, 4) == pytest.approx(5000.0)
def test_income_types_excluded_from_counts(self, session):
session.add(
cc_tx(
datetime(2026, 4, 1),
999999.0,
tx_type=TransactionType.SALARY,
source=TransactionSource.TRANSFER,
)
)
session.commit()
actuals = compute_actuals_by_source(session, 2026, 4)["TRANSFER"]
assert actuals["count"] == 0
# SALARY is not COMPRA/DEVOLUCION so it contributes nothing to net.
assert actuals["net"] == pytest.approx(0.0)

View File

@@ -0,0 +1,60 @@
"""Characterization of the BAC paste-import line parser."""
from datetime import datetime
from app.api.v1.endpoints.import_transactions import (
make_reference_hash,
parse_bac_line,
)
from app.models.models import TransactionType
class TestParseBacLine:
def test_tab_separated_purchase(self):
line = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
parsed = parse_bac_line(line)
assert parsed is not None
assert parsed["date"] == datetime(2026, 4, 15)
assert parsed["merchant"] == "WALMART"
assert parsed["city"] == "SAN JOSE"
assert parsed["amount"] == 25000.50
assert parsed["currency"] == "CRC"
assert parsed["transaction_type"] == TransactionType.COMPRA
def test_multispace_separated_usd(self):
line = "01/05/2026 AMAZON\\SEATTLE\\US 12.99 USD"
parsed = parse_bac_line(line)
assert parsed is not None
assert parsed["amount"] == 12.99
assert parsed["currency"] == "USD"
def test_negative_amount_is_refund(self):
parsed = parse_bac_line("15/04/2026\tSTORE\\X\\CR\t-5,000.00 CRC")
assert parsed is not None
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
assert parsed["amount"] == 5000.0 # stored as absolute value
def test_pago_recibido_is_refund(self):
parsed = parse_bac_line("15/04/2026\tPAGO RECIBIDO\\\\\t100.00 CRC")
assert parsed is not None
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
def test_unparseable_lines_return_none(self):
assert parse_bac_line("") is None
assert parse_bac_line("not a transaction") is None
assert parse_bac_line("99/99/2026\tX\\Y\\Z\t1.00 CRC") is None
assert parse_bac_line("15/04/2026\tX\\Y\\Z\t1.00 GBP") is None
class TestReferenceHash:
def test_deterministic_and_merchant_normalized(self):
a = make_reference_hash(datetime(2026, 4, 15), " walmart ", 100.0, "CRC")
b = make_reference_hash(datetime(2026, 4, 15), "WALMART", 100.0, "CRC")
assert a == b
assert len(a) == 16
def test_different_inputs_differ(self):
base = make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "CRC")
assert make_reference_hash(datetime(2026, 4, 16), "X", 100.0, "CRC") != base
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.1, "CRC") != base
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "USD") != base

View File

@@ -0,0 +1,131 @@
"""Paste-import dedup transparency + override (UX-20) and bulk actions (4.4).
These exercise the endpoint functions directly with a session — no HTTP
stack needed.
"""
from datetime import datetime
from decimal import Decimal
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints.import_transactions import (
PasteImportRequest,
paste_import,
)
from app.api.v1.endpoints.transactions import BulkActionRequest, bulk_action
from app.models.models import Category, Transaction, TransactionSource
LINE = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
def do_import(session, text=LINE, **kw):
return paste_import(
PasteImportRequest(text=text, **kw), session=session, _user="t"
)
class TestImportReview:
def test_duplicate_is_reported_with_matched_row(self, session):
first = do_import(session)
assert first.imported == 1 and first.duplicates == 0
second = do_import(session)
assert second.imported == 0
assert second.duplicates == 1
assert len(second.skipped) == 1
skip = second.skipped[0]
assert skip.merchant == "WALMART"
assert skip.amount == 25000.50
assert skip.existing_merchant == "WALMART"
assert skip.existing_amount == 25000.50
assert skip.existing_id > 0
def test_allow_duplicates_overrides_dedup(self, session):
do_import(session)
forced = do_import(session, allow_duplicates=True)
assert forced.imported == 1
assert forced.duplicates == 0
assert forced.skipped == []
def make_tx(session, merchant="M", category_id=None):
tx = Transaction(
amount=Decimal("10"),
merchant=merchant,
date=datetime(2026, 4, 1),
category_id=category_id,
source=TransactionSource.CREDIT_CARD,
)
session.add(tx)
session.commit()
session.refresh(tx)
return tx
class TestBulkActions:
def test_bulk_set_category(self, session):
cat = Category(name="Bulk")
session.add(cat)
session.commit()
session.refresh(cat)
txs = [make_tx(session, f"m{i}") for i in range(3)]
result = bulk_action(
BulkActionRequest(
ids=[t.id for t in txs], action="set_category", category_id=cat.id
),
session=session,
_user="t",
)
assert result["affected"] == 3
for t in txs:
session.refresh(t)
assert t.category_id == cat.id
def test_bulk_set_deferred_and_clear_category(self, session):
cat = Category(name="X")
session.add(cat)
session.commit()
session.refresh(cat)
tx = make_tx(session, category_id=cat.id)
bulk_action(
BulkActionRequest(ids=[tx.id], action="set_deferred", deferred=True),
session=session,
_user="t",
)
session.refresh(tx)
assert tx.deferred_to_next_cycle is True
bulk_action(
BulkActionRequest(ids=[tx.id], action="set_category", category_id=None),
session=session,
_user="t",
)
session.refresh(tx)
assert tx.category_id is None
def test_bulk_delete(self, session):
txs = [make_tx(session, f"d{i}") for i in range(2)]
result = bulk_action(
BulkActionRequest(ids=[t.id for t in txs], action="delete"),
session=session,
_user="t",
)
assert result["affected"] == 2
assert session.get(Transaction, txs[0].id) is None
@pytest.mark.parametrize(
"req",
[
BulkActionRequest(ids=[], action="delete"),
BulkActionRequest(ids=[1], action="explode"),
BulkActionRequest(ids=[1], action="set_deferred"), # missing flag
BulkActionRequest(ids=[1], action="set_category", category_id=99999),
],
)
def test_invalid_requests_rejected(self, session, req):
with pytest.raises(HTTPException):
bulk_action(req, session=session, _user="t")

View File

@@ -0,0 +1,385 @@
"""Tasa Cero installment plans: rounding, scheduling, budget exclusion,
auto-detection at ingestion, regeneration, unconvert, and cascade behavior.
Rounding and scheduling cases are pinned against real BAC Financiamientos
data (2026-05/07). Endpoint functions are exercised directly with a session,
same style as test_import_review_and_bulk.py.
"""
from datetime import datetime
from decimal import Decimal
import pytest
from fastapi import HTTPException
from app.api.v1.endpoints.installments import (
create_installment_plan,
delete_installment_plan,
get_installment_plan,
list_installment_plans,
update_installment_plan,
)
from app.api.v1.endpoints.transactions import (
BulkActionRequest,
bulk_action,
create_transaction,
delete_transaction,
update_transaction,
)
from app.models.models import (
Category,
InstallmentPlan,
InstallmentPlanCreate,
InstallmentPlanUpdate,
Transaction,
TransactionCreate,
TransactionSource,
TransactionType,
TransactionUpdate,
)
from app.services.budget_projection import (
compute_actuals_by_source,
get_cycle_range,
)
from app.services.installments import (
compute_installment_amounts,
compute_installment_dates,
is_tasa_cero_merchant,
)
from sqlmodel import select
def make_cc_tx(
session,
merchant="CC CONSTRUPLAZA",
amount="111053.60",
date=datetime(2026, 6, 25, 15, 43),
reference="75669363",
source=TransactionSource.CREDIT_CARD,
**kw,
):
tx = Transaction(
amount=Decimal(amount),
merchant=merchant,
date=date,
reference=reference,
source=source,
**kw,
)
session.add(tx)
session.commit()
session.refresh(tx)
return tx
def convert(session, tx, n=3, first=None):
return create_installment_plan(
InstallmentPlanCreate(
transaction_id=tx.id, num_installments=n, first_installment_date=first
),
session=session,
_user="t",
)
def get_cuotas(session, plan_id):
return session.exec(
select(Transaction)
.where(Transaction.installment_plan_id == plan_id)
.order_by(Transaction.date)
).all()
class TestRounding:
"""Pinned against real BAC plans."""
@pytest.mark.parametrize(
"total,n,per,last",
[
("111053.60", 3, "37017.80", "37018.00"),
("425800.00", 6, "70966.60", "70967.00"),
("98632.00", 3, "32877.30", "32877.40"),
("190864.80", 3, "63621.60", "63621.60"), # divides exactly
],
)
def test_real_bac_plans(self, total, n, per, last):
amounts = compute_installment_amounts(Decimal(total), n)
assert amounts[:-1] == [Decimal(per)] * (n - 1)
assert amounts[-1] == Decimal(last)
def test_sum_invariant(self):
for total, n in [("100.00", 3), ("214800.00", 3), ("99999.99", 7)]:
amounts = compute_installment_amounts(Decimal(total), n)
assert sum(amounts) == Decimal(total)
assert len(amounts) == n
class TestScheduling:
def test_first_before_cut_stays_in_purchase_cycle(self):
# 14/06 purchase (cycle 18/05-18/06) -> cuotas 19/06, 19/07
dates = compute_installment_dates(datetime(2026, 6, 14, 19, 22), 3)
assert dates == [
datetime(2026, 6, 14, 19, 22),
datetime(2026, 6, 19),
datetime(2026, 7, 19),
]
def test_first_after_cut(self):
# 27/06 purchase (cycle 18/06-18/07) -> cuotas 19/07, 19/08
dates = compute_installment_dates(datetime(2026, 6, 27, 19, 43), 3)
assert dates == [
datetime(2026, 6, 27, 19, 43),
datetime(2026, 7, 19),
datetime(2026, 8, 19),
]
def test_december_wrap(self):
dates = compute_installment_dates(datetime(2026, 12, 20), 3)
assert dates == [
datetime(2026, 12, 20),
datetime(2027, 1, 19),
datetime(2027, 2, 19),
]
def test_consecutive_cycles(self):
"""Each cuota lands in its own consecutive billing cycle."""
dates = compute_installment_dates(datetime(2026, 5, 5, 11, 19), 6)
cycles = []
for d in dates:
if d.day >= 18:
cy, cm = d.year, d.month
else:
cy, cm = (d.year, d.month - 1) if d.month > 1 else (d.year - 1, 12)
start, end = get_cycle_range(cy, cm)
assert start <= d < end
cycles.append((cy, cm))
assert cycles == [(2026, m) for m in range(4, 10)]
class TestDetection:
@pytest.mark.parametrize(
"merchant,expected",
[
("CC CONSTRUPLAZA", True),
("CC TILO.CO", True),
("CONSTRUPLAZA S", False),
("ALMACENES SIMAN", False),
("MERCCADO", False),
],
)
def test_merchant_regex(self, merchant, expected):
assert is_tasa_cero_merchant(merchant) is expected
def test_ingestion_auto_creates_plan(self, session):
tx = create_transaction(
TransactionCreate(
amount=Decimal("301614.60"),
merchant="CC CONSTRUPLAZA",
date=datetime(2026, 6, 27, 19, 43),
reference="24724909",
),
session=session,
_user="t",
)
assert tx.is_installment_anchor is True
plan = session.exec(
select(InstallmentPlan).where(
InstallmentPlan.anchor_transaction_id == tx.id
)
).one()
assert plan.num_installments == 3
assert plan.first_installment_date == tx.date
cuotas = get_cuotas(session, plan.id)
assert [c.amount for c in cuotas] == [
Decimal("100538.20"),
Decimal("100538.20"),
Decimal("100538.20"),
]
assert cuotas[0].merchant == "CC CONSTRUPLAZA CUOTA:01/03"
assert cuotas[0].reference == "24724909-C01"
assert all(c.installment_plan_id == plan.id for c in cuotas)
@pytest.mark.parametrize(
"kw",
[
{"merchant": "WALMART"},
{"transaction_type": TransactionType.DEVOLUCION},
{"source": TransactionSource.CASH},
],
)
def test_ingestion_ignores_non_tasa_cero(self, session, kw):
data = dict(
amount=Decimal("1000"),
merchant="CC CONSTRUPLAZA",
date=datetime(2026, 6, 1),
)
data.update(kw)
tx = create_transaction(
TransactionCreate(**data), session=session, _user="t"
)
assert tx.is_installment_anchor is False
assert session.exec(select(InstallmentPlan)).all() == []
def test_duplicate_reference_still_409_and_creates_nothing(self, session):
make_cc_tx(session)
with pytest.raises(HTTPException) as exc:
create_transaction(
TransactionCreate(
amount=Decimal("1"),
merchant="CC CONSTRUPLAZA",
date=datetime(2026, 7, 1),
reference="75669363",
),
session=session,
_user="t",
)
assert exc.value.status_code == 409
assert session.exec(select(InstallmentPlan)).all() == []
class TestBudgetExclusion:
def test_anchor_excluded_cuotas_counted_per_month(self, session):
tx = make_cc_tx(session, date=datetime(2026, 6, 25))
convert(session, tx) # cuotas: 25/06, 19/07, 19/08
# Budget month = cycle ENDING on its 18th: 25/06 -> July, 19/07 -> Aug,
# 19/08 -> Sep. The full 111,053.60 must appear nowhere.
by_month = {
m: compute_actuals_by_source(session, 2026, m)["CREDIT_CARD"]["net"]
for m in (6, 7, 8, 9, 10)
}
assert by_month[6] == 0
assert by_month[7] == pytest.approx(37017.80)
assert by_month[8] == pytest.approx(37017.80)
assert by_month[9] == pytest.approx(37018.00)
assert by_month[10] == 0
def test_cuotas_inherit_anchor_category(self, session):
cat = Category(name="Ferretería")
session.add(cat)
session.commit()
tx = make_cc_tx(session, category_id=cat.id)
plan = convert(session, tx)
assert all(
c.category_id == cat.id for c in get_cuotas(session, plan.id)
)
class TestPlanLifecycle:
def test_manual_convert_validations(self, session):
tx = make_cc_tx(session)
convert(session, tx)
# already converted
with pytest.raises(HTTPException) as exc:
convert(session, tx)
assert exc.value.status_code == 409
# a cuota cannot itself be converted
cuota = session.exec(
select(Transaction).where(Transaction.installment_plan_id.is_not(None))
).first()
with pytest.raises(HTTPException) as exc:
convert(session, cuota)
assert exc.value.status_code == 400
# non-CC rejected
cash = make_cc_tx(
session, reference="r-cash", source=TransactionSource.CASH
)
with pytest.raises(HTTPException) as exc:
convert(session, cash)
assert exc.value.status_code == 400
def test_regenerate_3_to_6(self, session):
tx = make_cc_tx(session, amount="425800.00", reference="58710871")
plan = convert(session, tx)
updated = update_installment_plan(
plan.id,
InstallmentPlanUpdate(
num_installments=6,
first_installment_date=datetime(2026, 5, 19),
),
session=session,
_user="t",
)
assert updated.num_installments == 6
assert updated.installment_amount == pytest.approx(70966.60)
assert updated.last_installment_amount == pytest.approx(70967.00)
cuotas = get_cuotas(session, plan.id)
assert len(cuotas) == 6
assert cuotas[0].date == datetime(2026, 5, 19)
assert cuotas[-1].date == datetime(2026, 10, 19)
assert cuotas[0].reference == "58710871-C01"
assert cuotas[-1].merchant == "CC CONSTRUPLAZA CUOTA:06/06"
def test_unconvert_restores_anchor(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
delete_installment_plan(plan.id, session=session, _user="t")
session.refresh(tx)
assert tx.is_installment_anchor is False
assert get_cuotas(session, plan.id) == []
assert session.exec(select(InstallmentPlan)).all() == []
def test_delete_anchor_tears_down_plan(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
delete_transaction(tx.id, session=session, _user="t")
assert session.exec(select(InstallmentPlan)).all() == []
assert get_cuotas(session, plan.id) == []
def test_bulk_delete_anchor_tears_down_plan(self, session):
tx = make_cc_tx(session)
convert(session, tx)
bulk_action(
BulkActionRequest(ids=[tx.id], action="delete"),
session=session,
_user="t",
)
assert session.exec(select(InstallmentPlan)).all() == []
assert session.exec(select(Transaction)).all() == []
def test_list_and_detail(self, session):
tx = make_cc_tx(session, date=datetime(2026, 5, 12, 15, 41))
convert(session, tx, first=datetime(2026, 5, 19))
resp = list_installment_plans(session=session, _user="t")
assert len(resp.plans) == 1
plan = resp.plans[0]
assert plan.merchant == "CC CONSTRUPLAZA"
assert plan.total_amount == pytest.approx(111053.60)
assert plan.remaining_amount + plan.paid_amount == pytest.approx(
111053.60
)
assert resp.total_remaining == pytest.approx(plan.remaining_amount)
detail = get_installment_plan(plan.id, session=session, _user="t")
assert len(detail.cuotas) == 3
class TestDeferredGuards:
def test_patch_cuota_deferred_rejected(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
cuota = get_cuotas(session, plan.id)[0]
with pytest.raises(HTTPException) as exc:
update_transaction(
cuota.id,
TransactionUpdate(deferred_to_next_cycle=True),
session=session,
_user="t",
)
assert exc.value.status_code == 400
def test_bulk_set_deferred_skips_cuotas(self, session):
tx = make_cc_tx(session)
plan = convert(session, tx)
cuota = get_cuotas(session, plan.id)[0]
normal = make_cc_tx(session, merchant="WALMART", reference="w1")
resp = bulk_action(
BulkActionRequest(
ids=[cuota.id, normal.id], action="set_deferred", deferred=True
),
session=session,
_user="t",
)
assert resp["affected"] == 1
session.refresh(cuota)
session.refresh(normal)
assert cuota.deferred_to_next_cycle is False
assert normal.deferred_to_next_cycle is True

View File

@@ -0,0 +1,67 @@
"""Pin the Money serialization contract (Phase 2 Decimal migration, BE-02):
NUMERIC storage, but JSON emits plain numbers so the frontend's `number`
types keep working."""
import json
from datetime import datetime
from decimal import Decimal
from sqlmodel import select
from app.models.models import (
Account,
AccountRead,
Bank,
Currency,
Transaction,
TransactionRead,
)
def test_money_columns_are_numeric():
assert str(Transaction.__table__.c.amount.type) == "NUMERIC(15, 2)"
assert str(Account.__table__.c.balance.type) == "NUMERIC(15, 2)"
def test_money_serializes_as_json_number():
read = TransactionRead(
id=1,
amount=Decimal("25000.50"),
merchant="X",
date=datetime(2026, 4, 1),
created_at=datetime(2026, 4, 1),
)
dumped = read.model_dump(mode="json")
assert dumped["amount"] == 25000.5
assert isinstance(dumped["amount"], float)
# and the whole payload is json.dumps-able (no Decimal leaks)
json.dumps(dumped)
def test_money_accepts_float_input():
# n8n and the SPA POST plain JSON numbers; they must coerce to Decimal.
read = AccountRead(
id=1,
bank=Bank.BAC,
currency=Currency.CRC,
label="x",
balance=1234.56,
updated_at=datetime(2026, 4, 1),
)
assert read.balance == Decimal("1234.56")
def test_decimal_roundtrip_through_db(session):
session.add(
Transaction(
amount=Decimal("9999.99"),
merchant="ROUNDTRIP",
date=datetime(2026, 4, 1),
)
)
session.commit()
row = session.exec(
select(Transaction).where(Transaction.merchant == "ROUNDTRIP")
).one()
assert isinstance(row.amount, Decimal)
assert row.amount == Decimal("9999.99")

View File

@@ -0,0 +1,112 @@
"""Sync-status derivation (UX-17) and CSV export (ARCH-18)."""
import csv
import io
from datetime import datetime, timedelta
from decimal import Decimal
from app.models.models import (
Bank,
Category,
Currency,
ExchangeRate,
Transaction,
TransactionSource,
TransactionType,
)
from app.services.csv_export import build_transactions_csv
from app.services.sync_status import compute_sync_status
from app.timeutil import utcnow
def by_key(statuses: list[dict]) -> dict[str, dict]:
return {s["key"]: s for s in statuses}
class TestSyncStatus:
def test_empty_db_reports_never(self, session):
statuses = by_key(compute_sync_status(session))
assert len(statuses) == 5
assert all(s["status"] == "never" for s in statuses.values())
assert all(s["last_received"] is None for s in statuses.values())
def test_fresh_data_is_ok_and_stale_warns(self, session):
fresh = Transaction(
amount=Decimal("1"),
merchant="X",
date=utcnow(),
source=TransactionSource.CREDIT_CARD,
bank=Bank.BAC,
)
fresh.created_at = utcnow() - timedelta(days=1)
stale_salary = Transaction(
amount=Decimal("1"),
merchant="EMPLOYER",
date=utcnow(),
transaction_type=TransactionType.SALARY,
source=TransactionSource.TRANSFER,
)
stale_salary.created_at = utcnow() - timedelta(days=60)
session.add(fresh)
session.add(stale_salary)
session.add(
ExchangeRate(
date=utcnow(),
buy_rate=Decimal("500"),
sell_rate=Decimal("510"),
)
)
session.commit()
statuses = by_key(compute_sync_status(session))
assert statuses["bac_credit_card"]["status"] == "ok"
assert statuses["bac_credit_card"]["age_days"] == 1.0
assert statuses["salary"]["status"] == "warning"
assert statuses["exchange_rate"]["status"] == "ok"
assert statuses["municipal"]["status"] == "never"
class TestCsvExport:
def _seed(self, session):
cat = Category(name="Food")
session.add(cat)
session.commit()
session.refresh(cat)
session.add(
Transaction(
amount=Decimal("1500.50"),
merchant="SODA, LA \"BUENA\"", # exercises CSV quoting
date=datetime(2026, 4, 2),
category_id=cat.id,
currency=Currency.CRC,
)
)
session.add(
Transaction(
amount=Decimal("10"),
merchant="CASHTHING",
date=datetime(2026, 4, 3),
source=TransactionSource.CASH,
)
)
session.commit()
def test_full_export_with_quoting_and_category(self, session):
self._seed(session)
text = build_transactions_csv(session)
rows = list(csv.reader(io.StringIO(text)))
assert rows[0][:3] == ["id", "date", "merchant"]
assert len(rows) == 3 # header + 2
newest_first = rows[1]
assert newest_first[2] == "CASHTHING"
soda = rows[2]
assert soda[2] == 'SODA, LA "BUENA"' # round-trips through quoting
assert soda[4] == "1500.50"
assert soda[9] == "Food"
def test_source_filter(self, session):
self._seed(session)
text = build_transactions_csv(session, source=TransactionSource.CASH)
rows = list(csv.reader(io.StringIO(text)))
assert len(rows) == 2
assert rows[1][2] == "CASHTHING"

View File

@@ -0,0 +1,41 @@
"""Upload validation: size cap and PDF magic-byte check (SEC-06)."""
import asyncio
from io import BytesIO
import pytest
from fastapi import UploadFile
from app.uploads import read_pdf_upload
def make_upload(data: bytes, filename: str = "test.pdf") -> UploadFile:
return UploadFile(file=BytesIO(data), filename=filename)
def test_valid_pdf_accepted():
data = b"%PDF-1.7 fake content"
out = asyncio.run(read_pdf_upload(make_upload(data)))
assert out == data
def test_non_pdf_rejected_with_filename():
with pytest.raises(ValueError, match=r"evil\.pdf: not a PDF"):
asyncio.run(read_pdf_upload(make_upload(b"MZ\x90\x00binary", "evil.pdf")))
def test_oversized_rejected():
big = b"%PDF" + b"x" * 100
with pytest.raises(ValueError, match="exceeds the 0 MB limit"):
asyncio.run(read_pdf_upload(make_upload(big), max_bytes=50))
def test_exact_limit_accepted():
data = b"%PDF" + b"x" * 46 # exactly 50 bytes
out = asyncio.run(read_pdf_upload(make_upload(data), max_bytes=50))
assert len(out) == 50
def test_empty_file_rejected():
with pytest.raises(ValueError, match="not a PDF"):
asyncio.run(read_pdf_upload(make_upload(b"")))

205
codebase-review/PROGRESS.md Normal file
View File

@@ -0,0 +1,205 @@
# Implementation Progress
Tracking execution of [07-improvement-plan.md](07-improvement-plan.md). Newest entries first within each phase.
---
## Phase 0 — Security Hardening Sprint ✅ DONE
**Completed and deployed to production 2026-06-09** (deploy task 81, verified live).
| Plan item | Status | Commit |
|---|---|---|
| 0.1 Fail-fast configuration (SEC-01, BE-15) | ✅ | `15ea1ef` |
| 0.2 Login hardening: compare_digest + 5/min/IP rate limit (SEC-02, SEC-03) | ✅ | `b412370` |
| 0.3 CORS pinned to prod domain + localhost dev (SEC-04) | ✅ | `b412370` |
| 0.4 7-day tokens (was 30) + COOKIE_SECURE setting (SEC-05, SEC-08) | ✅ | `15ea1ef`, `b412370` |
| 0.5 Security headers via Hono middleware (SEC-07, FE-08) | ✅ | `bebd7b5` |
| 0.6 Paste-import 1 MB cap / proxy 502 / 401 loop guard (ARCH-05, FE-23, FE-22) | ✅ | `3a357e4`, `bebd7b5`, `ad29cce` |
| 0.7 Housekeeping: dead AgentHomeClient removed, dev compose creds → .env, .env.example | ✅ | `4a758b2`, `15ea1ef` |
**Production verification (2026-06-09):**
- Health 200 after deploy; frontend+backend containers recreated, healthy
- `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy` live on prod responses
- Login rate limit live: 6th rapid attempt → **429 with `Retry-After: 58`**
- CORS live: `evil.example` origin gets no allow header; app origin allowed
- Fail-fast config: behaviorally tested (rejects missing/default/weak secrets in 4/4 cases); prod booted with real Gitea-injected secrets
**Incidents & discoveries during rollout:**
- Deploy task 80 **failed** on a latent CI bug unrelated to Phase 0: unpinned corepack resolved pnpm 11, which hard-errors on unreviewed dependency build scripts (`ERR_PNPM_IGNORED_BUILDS`). Fixed in `d874cf5` by pinning `packageManager: pnpm@10.33.1` and completing the build-script review in `pnpm-workspace.yaml` (approve `@swc/core`, `esbuild`; ignore `@scarf/scarf`). A failed build never touches running containers — prod was unaffected.
- The Gitea repo is a **pull-mirror** (no direct pushes). Deploys are triggered by pushing to GitHub and then running a mirror-sync via the Gitea API from the VPS (token via `gitea admin user generate-access-token`, POST `/repos/admin/wealthysmart/mirror-sync` from inside the container — port 3000 is not published on the host).
- **Dev credential change:** dev no longer accepts `admin/admin`. The generated dev password lives in the gitignored `.env` (old file backed up as `.env.bak-phase0`). Prod credentials unchanged.
- Two temporary `phase0-sync-*` Gitea tokens should be revoked in Gitea UI → Settings → Applications.
**Corrections to the review docs found during implementation:** none beyond those already noted in [README.md](README.md).
---
## Phase 1 — Foundations: Tests, Migrations, Time ✅ DONE
Completed 2026-06-09.
| Plan item | Status | Commit |
|---|---|---|
| 1.1 pytest harness, 55-test suite (no network, SQLite fixtures) | ✅ | `c725c14` |
| 1.1 CI gate: test job (pytest + typecheck) blocks deploy | ✅ | (workflow) |
| 1.2 Cycle-math characterization + month validation (BE-07) | ✅ | `0a4c3e9`, `c725c14` |
| 1.3 Alembic adoption: autogen baseline + stamp-or-upgrade startup | ✅ | `9a25c4b` |
| 1.4 Datetime sweep: 29 utcnow sites → `app.timeutil.utcnow()` | ✅ | `996bd43` |
| 1.5 Decimal-equivalence baseline (float behavior pinned by tests) | ✅ | `c725c14` |
| Pre-req: all 20 frontend type errors fixed, typecheck green | ✅ | `49b96ed` |
**Key findings & decisions:**
- **BE-06 is a FALSE POSITIVE.** `get_cycle_range(Y,M)` returns the cycle *starting* M/18; budget month M composes `get_previous_cycle``[(M-1)/18, M/18)`, exactly as the code comment and the frontend convention state. Empirically validated against prod: May 2026 budget's credit-card figure reproduced by independent SQL over the cycle window (95 transactions exactly, refunds to the colón, 0.13% residual from a single EUR transaction the simplified SQL can't convert). Convention now pinned by `tests/test_cycle_math.py` and the docstring.
- **Datetime strategy:** all DB columns are naive `TIMESTAMP`, so the sweep deliberately keeps naive-UTC semantics via a shared `app/timeutil.py::utcnow()` (non-deprecated API, zero behavior change). Going tz-aware would have poisoned naive/aware comparisons. TIMESTAMPTZ migration deferred to Phase 2 — now a one-line change.
- **Alembic baseline** (`c3da001a0eb3`) autogenerated against empty Postgres, then schema-diffed against the live schema until column-identical. Two model fixes fell out: `deferred_to_next_cycle` now declares its `server_default` (live DBs have it from the legacy ALTER), `usersettings.data` is JSONB on Postgres (matching live) via a dialect variant. Startup stamps pre-Alembic DBs, upgrades fresh ones, under a pg advisory lock (prod uvicorn runs 2 workers → lifespan runs twice).
- Verified locally: adoption + idempotent re-run on dev DB, fresh 13-table build on empty DB, full dev-container boot through the Alembic path, 55/55 tests, typecheck 0 errors, production vite build.
**Dev environment note:** backend dev tooling lives in `backend/.venv` (gitignored); `pytest` and `alembic` run from there. See CLAUDE.md for the migration workflow.
---
## Phase 2 — Backend Correctness & Robustness ✅ DONE
Completed 2026-06-10.
| Plan item | Status | Commit |
|---|---|---|
| 2.1 Money → Decimal/NUMERIC (29 columns, BE-02) | ✅ | `74886fb` |
| 2.2 Upload hardening: 10 MB cap, %PDF magic, to_thread (SEC-06, BE-14) | ✅ | (uploads) |
| 2.3 FK delete rules, grouped queries (10→3), N+1 fixes, tiebreakers | ✅ | `3cdd7d9` |
| 2.4 Error visibility: per-source rate logging, freshness, clear agent errors | ✅ | `82f10a5` |
| 2.5 Agent cleanup: real multipliers, year bounds, offsets, read-only policy | ✅ | `82f10a5` |
| 2.6 Consistency: enums, keyword status codes, SimpleCookie, import hygiene | ✅ | `440da3e` |
**Key decisions:**
- **Decimal strategy:** `Money = Annotated[Decimal, PlainSerializer(float, when_used="json")]`
— storage is exact `NUMERIC(15,2)` (rates 15,6) while JSON keeps emitting plain
numbers, so the frontend contract is unchanged (pinned by
`test_models_serialization.py`). Services coerce to float at their computation
boundaries. Migration `7f567c8bafdb` verified on dev: per-row values and table
sums byte-identical across the type change.
- **FK rules** (`7884505b16b3`): transactions/recurring items `SET NULL` on category
delete — this also fixes the 500 that `DELETE /categories` raised for in-use
categories; water readings `CASCADE`. Verified with a rolled-back probe on dev.
- **Agent net worth** no longer guesses (hardcoded 600 CRC / 1.08 EUR removed): it
converts via `get_crc_multipliers` and reports unconvertible accounts explicitly.
- **Read-only tool policy** documented in `agent/tools.py` (SEC-09): ingested email
text is a prompt-injection surface; any future write tool needs UI confirmation.
**Review findings closed as overstated/false during implementation:**
- BE-09 (temp-file leak): parsers already use context-managed temp files.
- BE-10 (per-file commits): the pension batch already commits atomically.
- ARCH-08 (swallowed cancellation): `CancelledError` is a `BaseException` since
Python 3.8 — `except Exception` never caught it.
- ARCH-03 (filter precedence): `if cycle / elif dates` is already explicit.
- BE-22 (upsert race): accepted as-is — single writer (daily n8n cron), batch-
scoped commit; ON CONFLICT complexity not warranted.
Tests: 55 → **69** (new: model serialization, agent tools, upload validation).
**Production verification (2026-06-10, deploy after CI test gate):** boot logs show
both migrations applied in order; `alembic_version = 7884505b16b3`;
`transaction.amount` is `numeric(15,2)`; FK rules live (`SET NULL`×2, `CASCADE`);
data intact through the type change (238 transactions, sum unchanged); live API
emits amounts as JSON numbers.
---
## Phase 3 — Frontend Data Layer & Feedback ✅ DONE (3.4 deferred)
Completed 2026-06-10.
| Plan item | Status | Commit |
|---|---|---|
| 3.1 TanStack Query: provider, api timeout/signal, useBudget, Budget | ✅ | `65dffda` |
| 3.1 All remaining pages on queries with ErrorState+retry | ✅ | `7a1733b` |
| 3.2 Sonner toasts on every mutation; confirm on override clear | ✅ | (toasts) |
| 3.3 Auth probe: network failure ≠ logged out (retry once) | ✅ | (auth) |
| 3.5 Lazy context init, CK repair logging, upload-result cap | ✅ | (auth/pages) |
| 3.4 OpenAPI type generation | ⏸ **deferred** | — |
**What changed for the user:** every page now shows an explicit error panel with
a Reintentar button when the API fails (previously: silent empty charts/tables);
every mutation (transaction save/delete, deferred toggle, recurring items,
balance overrides) confirms success or failure with a toast; month navigation
keeps the previous month visible while loading; rapid filter/month changes can
no longer paint stale results (request cancellation); all requests time out at
30s instead of hanging; clearing a balance override asks for confirmation; the
privacy mask and theme no longer flash wrong on reload; a transient network
blip at app start no longer kicks an authenticated user to the login page.
**Architecture:** TanStack Query with `staleTime` 30s and one retry;
`['budget']`, `['transactions']`, `['analytics']`, `['salarios']`,
`['pensions']`, `['municipal']` key families; mutations invalidate their family.
`useBudget` keeps its original return shape so consumers didn't churn.
**3.4 (OpenAPI type generation) deferred to its own pass:** it needs the backend
`response_model` sweep (ARCH-16) first and touches every endpoint plus the
api.ts type exports — a separate, mechanical change with its own verification.
The hand-written types are currently accurate (typecheck is green against real
responses). Revisit after Phase 4's backend endpoints land.
**Closed as already-handled during implementation:** FE-04 (the Pensions ROI
fallback already guarded `len >= 2`).
## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED
## Phase 4 — UX, Trust & Features ✅ DONE (iterations 1 + 2)
### Iteration 2 (2026-06-11)
| Plan item | Status |
|---|---|
| 4.2 Import review: skipped duplicates listed with matched row + import-anyway override (re-imports only skipped lines) | ✅ |
| 4.4 Bulk operations: POST /transactions/bulk + selection UI (assign category, defer/restore, delete) | ✅ |
| 4.7 Editable pension assumptions (UserSettings-backed) | ✅ |
| 4.7 Dirty-form guard (UX-06), empty-state CTA (UX-14) | ✅ |
| BE-08 hash tightening | superseded — the override solves the practical harm; rehashing would orphan every existing reference |
| UX-11 mobile layout | **false positive** — a card layout already existed (two real bugs in it fixed: undo-pending rows showed, en-US dates) |
| UX-13 cycle filter | **mostly false positive** — daily-spending already accepts cycle params; the trend is multi-cycle by design |
| UX-24 meter labels, UX-22 breadcrumb, shared period navigator (UX-03) | not done — needs dedicated UI; lowest-value remainder |
**The improvement plan is complete** — and as of 2026-06-12, so is everything
that had been deferred:
### Final batch (2026-06-12)
| Item | Status |
|---|---|
| 3.4 OpenAPI codegen: response_model sweep + generated api-types.gen.ts + CI drift gate (caught 2 real drifts: missing SAVINGS enum value, untyped raw_charges) | ✅ |
| UX-03 shared PeriodNavigator · UX-22 breadcrumb · UX-24 meter labels | ✅ |
| Wishlist: rate-history chart (Analytics), contribution calculator (Pensiones), what-if Planificador page, PWA installability | ✅ |
| Bonus fix: sw.js was a self-unregistering stub — `serviceWorker.ready` never resolved, so web push had been silently dead; replaced with a working worker | ✅ |
Tests: **82**. Final false-positive tally across the whole plan: BE-06, BE-09,
BE-10, ARCH-03, ARCH-08, BE-22 (accepted), FE-04, UX-04, UX-11, UX-13 — the
characterization-tests-first approach caught all of them before any "fix"
landed. **Nothing from the review or wishlist remains.**
### Iteration 1 (2026-06-10)
Iteration 1 completed 2026-06-10. The plan marks 4.x items independent; this
iteration delivered the highest-impact slice.
| Plan item | Status | Commit |
|---|---|---|
| 4.1 Sync Status page + nav badge (UX-17, the #1 trust feature) | ✅ | `4ceb675` |
| 4.3 Localization: shared es-CR dates module, stray labels | ✅ | (l10n) |
| 4.5 Undo grace (5s + Deshacer) for transaction deletes | ✅ | `629d118` |
| 4.8 CSV export endpoint + Budget/Salarios buttons | ✅ | `571428f` |
| 4.7 picks: aria-pressed, deferred row styling, override hint | ✅ | `b3a7f51` |
**Sync Status design:** per-source health derived entirely from existing data
(`max(created_at)` per source + cadence thresholds: BAC 7d, salary 35d,
municipal/pensions 40d, exchange rate 1d) — no n8n integration required for v1.
Endpoint tested; verified live (it immediately flagged the dev DB's stale data).
**Deferred to iteration 2** (each independent): 4.2 import review & dedup
transparency (L — includes the reference-hash tightening, which must migrate
existing hashes to avoid breaking dedup), 4.4 transaction search-everywhere is
**not needed** (search already applies to all sources — UX-04 was a false
positive) but bulk operations remain (L), 4.6 mobile card layout (M), 4.7
remainder: shared period navigator, editable pension assumptions, cycle filter
on all Analytics charts, empty-state actions, dirty-form guard, breadcrumb,
meter labels. 4.9 wishlist unscheduled.

View File

@@ -16,6 +16,11 @@ services:
interval: 10s
timeout: 5s
retries: 5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
backend:
build:
@@ -31,11 +36,17 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false"
expose:
- "8000"
networks:
- wealthysmart-network
wealthysmart-network:
# Avoid the generic `backend` name, which is also used on the shared
# nginx network by another application.
aliases:
- wealthysmart-api
depends_on:
db:
condition: service_healthy
@@ -45,6 +56,11 @@ services:
timeout: 5s
retries: 3
start_period: 10s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
frontend:
build:
@@ -57,8 +73,8 @@ services:
restart: unless-stopped
environment:
NODE_ENV: production
BACKEND_URL: http://wealthysmart-backend-prod:8000
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
BACKEND_URL: http://wealthysmart-api:8000
AGENT_URL: http://wealthysmart-api:8000/api/v1/agent/agui
JWT_SECRET: ${SECRET_KEY}
COOKIE_DOMAIN: wealth.cescalante.dev
COOKIE_SECURE: "true"
@@ -78,6 +94,11 @@ services:
interval: 30s
timeout: 10s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks:
wealthysmart-network:

View File

@@ -15,6 +15,11 @@ services:
interval: 5s
timeout: 3s
retries: 5
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
backend:
build:
@@ -30,7 +35,9 @@ services:
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
ENABLE_INSTRUMENTATION: "false"
ports:
- "8001:8000"
volumes:
@@ -38,6 +45,11 @@ services:
depends_on:
db:
condition: service_healthy
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
develop:
watch:
- path: ./backend/app
@@ -58,9 +70,17 @@ services:
- "5175:3000"
environment:
NODE_ENV: development
# Vite runs inside this container, so localhost would refer to the
# frontend rather than the Python service.
BACKEND_URL: http://backend:8000
AGENT_URL: http://backend:8000/api/v1/agent/agui
depends_on:
- backend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
develop:
watch:
- path: ./frontend/src

View File

@@ -22,7 +22,9 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
ENV NODE_OPTIONS=--max-old-space-size=1536
# 1536 stopped being enough at the 2026-07 UI revamp (cmdk, day-picker,
# chart wrappers); rollup now peaks between 1.5G and 2G.
ENV NODE_OPTIONS=--max-old-space-size=2048
RUN corepack enable && pnpm build
# Production: Hono serves dist/ + /api/copilotkit on port 3000

View File

@@ -20,6 +20,5 @@
"hooks": "@/hooks"
},
"menuColor": "default-translucent",
"menuAccent": "subtle",
"registries": {}
"menuAccent": "subtle"
}

View File

@@ -0,0 +1,113 @@
import { expect, test, type Page } from "@playwright/test";
test.setTimeout(150_000);
async function startFreshAssistant(page: Page) {
await page.goto("/login");
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/");
const initialConnect = page.waitForResponse((response) => {
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
return false;
}
try {
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
} catch {
return false;
}
});
await page.goto("/asistente");
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
await (await initialConnect).finished();
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
if (await newConversation.isEnabled()) {
await newConversation.click();
const reconnect = page.waitForResponse((response) => {
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
return false;
}
try {
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
} catch {
return false;
}
});
await page.getByRole("button", { name: "Borrar historial" }).click();
await (await reconnect).finished();
await expect(
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
).toBeEnabled({ timeout: 30_000 });
}
}
async function ask(page: Page, question: string, expectedTool: string) {
// The BFF response is the exact CopilotKit stream consumed by the browser.
// Checking it catches server-side RUN_ERROR events even if the UI changes.
const stream = page.waitForResponse(
(response) => {
if (
!response.url().includes("/api/copilotkit") ||
response.request().method() !== "POST"
) {
return false;
}
try {
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
} catch {
return false;
}
},
);
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
await expect(composer).toBeEnabled({ timeout: 30_000 });
await composer.fill(question);
const sendButton = page.getByTestId("copilot-send-button");
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
await sendButton.click();
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
const response = await stream;
await response.finished();
const events = await response.text();
expect(events).not.toContain("RUN_ERROR");
expect(events).toContain(expectedTool);
// The HTTP stream may finish before CopilotKit has committed the tool run
// and released its client-side running state. Waiting for the page action
// avoids losing the next Enter keypress during that short interval.
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
timeout: 30_000,
});
}
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
const consoleErrors: string[] = [];
page.on("console", (message) => {
if (message.type() === "error") consoleErrors.push(message.text());
});
await startFreshAssistant(page);
await ask(page, "What date is it today?", "get_current_date");
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
await ask(page, "What is my net worth?", "get_net_worth");
await ask(page, "How much did I spend today?", "get_daily_spending");
await ask(page, "Show my spending by category this month.", "render_spending_summary");
const spendingCard = page.getByTestId("spending-summary-card");
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
timeout: 30_000,
});
await page.reload();
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
});

127
frontend/e2e/feature-doc.ts Normal file
View File

@@ -0,0 +1,127 @@
import fs from "node:fs";
import path from "node:path";
import { test, type Page, type TestInfo } from "@playwright/test";
import * as narration from "./narration";
import { renderFinalVideo } from "./video-render";
import type { Clip } from "./subtitles";
const DEMO = !!process.env.PW_DEMO;
/**
* Wraps test.step() so every step of a demo spec produces a captioned
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
* recording with cursor annotations, chapter-title cards, burned-in
* subtitles and (when `say` is available) spoken Spanish narration.
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
* attached to the Playwright HTML report; save() stitches everything into
* a Markdown walkthrough that can be published as-is.
*/
export class FeatureDoc {
private steps: { title: string; caption: string; file: string }[] = [];
private clips: Clip[] = [];
private readonly dir: string;
private recordingStartedAt = 0;
constructor(
private page: Page,
private testInfo: TestInfo,
private slug: string,
private title: string,
private intro: string,
) {
this.dir = path.resolve("e2e-docs", slug);
fs.rmSync(this.dir, { recursive: true, force: true });
fs.mkdirSync(this.dir, { recursive: true });
}
/** Starts the native screencast recording. Call before the first step(). */
async start() {
if (!DEMO) return;
this.recordingStartedAt = Date.now();
await this.page.screencast.start({
path: path.join(this.dir, "raw.webm"),
size: { width: 1920, height: 1080 },
});
await this.page.screencast.showActions({ cursor: "pointer" });
}
async step(title: string, caption: string, fn: () => Promise<void>) {
const narrationPromise = DEMO
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
: Promise.resolve(null);
const stepStart = Date.now();
if (DEMO) {
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
}
await test.step(title, fn);
const narrationResult = await narrationPromise;
if (DEMO) {
const elapsed = Date.now() - stepStart;
const target = (narrationResult?.durationMs ?? 0) + 300;
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
}
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]+/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}.png`;
const body = await this.page.screenshot({
path: path.join(this.dir, file),
animations: "disabled",
});
await this.testInfo.attach(title, { body, contentType: "image/png" });
this.steps.push({ title, caption, file });
if (DEMO) {
this.clips.push({
title,
caption,
file,
startMs: stepStart - this.recordingStartedAt,
endMs: Date.now() - this.recordingStartedAt,
narration: narrationResult,
});
}
}
async save() {
let videoName = "video.mp4";
if (DEMO) {
await this.page.screencast.stop();
const { videoFile, degraded } = await renderFinalVideo({
rawWebmPath: path.join(this.dir, "raw.webm"),
clips: this.clips,
outDir: this.dir,
});
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
videoName = path.basename(videoFile);
await this.testInfo.attach("video", {
path: videoFile,
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
});
}
const md = [
`# ${this.title}`,
"",
this.intro,
"",
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\`${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
"",
...this.steps.flatMap((s, i) => [
`## ${i + 1}. ${s.title}`,
"",
s.caption,
"",
`![${s.title}](${s.file})`,
"",
]),
].join("\n");
fs.writeFileSync(path.join(this.dir, "index.md"), md);
}
}

View File

@@ -0,0 +1,102 @@
import { test, expect } from "@playwright/test";
import { FeatureDoc } from "./feature-doc";
/**
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
* captioned, screenshot-per-step Markdown doc plus the full video (native
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
* HTML report.
*/
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
// video breaks page.screencast's internal sizing (verified: it then records
// a shrunken frame letterboxed inside the requested canvas). The fixture's
// own video ends up redundant/unused but harmless.
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
const doc = new FeatureDoc(
page,
testInfo,
"financiamientos",
"Financiamientos — Tasa Cero",
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
);
await doc.start();
await doc.step(
"Iniciar sesión",
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
async () => {
await page.goto("/login");
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
// the recording — it isn't part of the product.
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
},
);
await doc.step(
"Panel de inicio",
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
async () => {
await page.getByRole("button", { name: /sign in/i }).click();
await page.waitForURL("/");
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
await page.waitForLoadState("networkidle");
},
);
await doc.step(
"Modo privacidad",
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
async () => {
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
await expect(page.locator("html")).toHaveClass(/privacy/);
},
);
await doc.step(
"Página de Financiamientos",
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
async () => {
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
await page.waitForURL("/financiamientos");
await expect(page.getByText("Saldo faltante total")).toBeVisible();
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
await page.waitForLoadState("networkidle");
},
);
await doc.step(
"Tabla de planes",
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
async () => {
const rows = page.getByRole("button", { name: /editar plan de/i });
await expect(rows.first()).toBeVisible();
expect(await rows.count()).toBeGreaterThan(0);
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
},
);
await doc.step(
"Detalle de un plan",
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
async () => {
await page.getByRole("button", { name: /editar plan de/i }).first().click();
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
},
);
await doc.step(
"Cerrar el detalle",
"El plan queda igual que estaba — el recorrido es de solo lectura.",
async () => {
await page.keyboard.press("Escape");
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
},
);
await doc.save();
});

59
frontend/e2e/narration.ts Normal file
View File

@@ -0,0 +1,59 @@
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
let sayChecked = false;
let sayAvailable = false;
function hasSay() {
if (!sayChecked) {
sayChecked = true;
try {
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
sayAvailable = true;
} catch {
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
}
}
return sayAvailable;
}
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
export function synthesize(
text: string,
outDir: string,
index: number,
voice = "Paulina",
): Promise<{ path: string; durationMs: number } | null> {
const key = `${voice}:${text}`;
const cached = cache.get(key);
if (cached) return cached;
const promise = (async () => {
if (!hasSay()) return null;
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
try {
await execFileAsync("say", ["-v", voice, "-o", file, text]);
const { stdout } = await execFileAsync("ffprobe", [
"-v", "error",
"-show_entries", "format=duration",
"-of", "csv=p=0",
file,
]);
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
return { path: file, durationMs };
} catch (err) {
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
fs.rmSync(file, { force: true });
return null;
}
})();
cache.set(key, promise);
return promise;
}

33
frontend/e2e/subtitles.ts Normal file
View File

@@ -0,0 +1,33 @@
export interface Clip {
title: string;
caption: string;
file: string;
startMs: number;
endMs: number;
narration: { path: string; durationMs: number } | null;
}
export function msToSrtTimestamp(ms: number): string {
const clamped = Math.max(0, Math.round(ms));
const hours = Math.floor(clamped / 3_600_000);
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
const seconds = Math.floor((clamped % 60_000) / 1000);
const millis = clamped % 1000;
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
}
/** One subtitle cue per step, spanning its full on-screen duration. */
export function buildSrt(clips: Clip[]): string {
return clips
.map((clip, i) => {
const index = i + 1;
return [
String(index),
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
clip.caption,
"",
].join("\n");
})
.join("\n");
}

View File

@@ -0,0 +1,88 @@
import { execFile, execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { buildSrt, type Clip } from "./subtitles";
const execFileAsync = promisify(execFile);
function hasFfmpeg() {
try {
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
const SUBTITLE_STYLE =
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
function escapeForFilter(p: string) {
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
}
export async function renderFinalVideo({
rawWebmPath,
clips,
outDir,
}: {
rawWebmPath: string;
clips: Clip[];
outDir: string;
}): Promise<{ videoFile: string; degraded: string[] }> {
const degraded: string[] = [];
const srtPath = path.join(outDir, "captions.srt");
fs.writeFileSync(srtPath, buildSrt(clips));
if (!hasFfmpeg()) {
const dest = path.join(outDir, "video.webm");
fs.renameSync(rawWebmPath, dest);
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
return { videoFile: dest, degraded };
}
const narrated = clips.filter((c) => c.narration);
const mp4Path = path.join(outDir, "video.mp4");
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
if (narrated.length === 0) {
degraded.push("No narration available — burned-in subtitles only.");
await execFileAsync("ffmpeg", [
"-y", "-i", rawWebmPath,
"-vf", srtArg,
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-an",
mp4Path,
]);
} else {
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
const adelayParts = narrated.map(
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
);
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
const filterComplex = [
`[0:v]${srtArg}[vout]`,
...adelayParts,
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
].join(";");
await execFileAsync("ffmpeg", [
"-y", "-i", rawWebmPath, ...audioInputs,
"-filter_complex", filterComplex,
"-map", "[vout]", "-map", "[aout]",
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
"-c:a", "aac",
"-shortest",
"-movflags", "+faststart",
mp4Path,
]);
}
fs.rmSync(rawWebmPath, { force: true });
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
return { videoFile: mp4Path, degraded };
}

View File

@@ -12,6 +12,8 @@
<meta name="description" content="WealthySmart — Smart personal finance management" />
<meta name="theme-color" content="#0f172a" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>WealthySmart</title>
</head>
<body>

View File

@@ -8,40 +8,50 @@
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
"build": "vite build",
"preview": "tsx server.ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"e2e": "playwright test",
"demo": "PW_DEMO=1 playwright test --grep @demo",
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
},
"dependencies": {
"@ag-ui/client": "0.0.52",
"@ag-ui/client": "0.0.57",
"@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.56.4",
"@copilotkit/react-ui": "1.56.4",
"@copilotkit/runtime": "1.56.4",
"@copilotkit/react-core": "1.62.3",
"@copilotkit/react-ui": "1.62.3",
"@copilotkit/runtime": "1.62.3",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"concurrently": "^9.1.2",
"date-fns": "^4.4.0",
"hono": "^4.12.15",
"lucide-react": "^1.12.0",
"react": "19.2.5",
"react-day-picker": "^10.0.1",
"react-dom": "19.2.5",
"react-router-dom": "^7.6.0",
"recharts": "^3.8.1",
"recharts": "^3.8.0",
"rxjs": "^7.8.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tsx": "^4.19.4",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react-swc": "^3.9.0",
"@vitejs/plugin-react-oxc": "^0.4.3",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6.3.5"
"vite": "npm:rolldown-vite@latest"
}
}

View File

@@ -0,0 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "@playwright/test";
// Demo specs log in with the real dev credentials; pull them from the repo
// .env so they never live in the test source.
const repoEnv = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env");
if (fs.existsSync(repoEnv)) {
for (const line of fs.readFileSync(repoEnv, "utf8").split("\n")) {
const m = line.match(/^([A-Z_]+)=["']?(.*?)["']?$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
export default defineConfig({
testDir: "./e2e",
outputDir: "./test-results",
fullyParallel: false,
// Demo runs synthesize narration and run several ffmpeg passes in save();
// that can exceed the 30s default, especially with many steps.
timeout: process.env.PW_DEMO ? 120_000 : 30_000,
reporter: [
["list"],
["html", { outputFolder: "playwright-report", open: "never" }],
],
use: {
baseURL: "http://localhost:3000",
// Demo runs (pnpm demo) pace every action so the recorded video is
// watchable by a human; functional e2e runs stay at full speed.
launchOptions: {
slowMo: process.env.PW_DEMO ? 500 : 0,
},
locale: "es-CR",
timezoneId: "America/Costa_Rica",
viewport: { width: 1920, height: 1080 },
// Retina-density rendering so doc screenshots stay crisp when zoomed.
deviceScaleFactor: 2,
// Full recordings are only useful for the paced demo. Keeping them off
// for regular e2e avoids making test success depend on artifact uploads.
video: process.env.PW_DEMO ? { mode: "on", size: { width: 1920, height: 1080 } } : "off",
screenshot: "only-on-failure",
trace: process.env.PW_DEMO ? "on" : "on-first-retry",
},
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: true,
stdout: "ignore",
timeout: 60_000,
},
});

3319
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "WealthySmart",
"short_name": "WealthySmart",
"description": "Smart personal finance management",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"lang": "es-CR",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

View File

@@ -1,4 +1,42 @@
/* WealthySmart service worker: web push + PWA installability.
* (Replaces the old self-unregistering cleanup stub, which also meant
* `navigator.serviceWorker.ready` never resolved and push silently no-oped.) */
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
await self.registration.unregister();
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});
self.addEventListener("push", (event) => {
let data = { title: "WealthySmart", body: "", url: "/" };
try {
data = { ...data, ...event.data.json() };
} catch {
if (event.data) data.body = event.data.text();
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: { url: data.url },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) {
client.navigate(url);
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});

View File

@@ -1,18 +1,20 @@
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
AgentRunner,
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
import { Observable } from "rxjs";
import { Observable, ReplaySubject } from "rxjs";
import { Hono } from "hono";
const AGENT_URL =
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
const BACKEND_URL =
process.env.BACKEND_URL ?? "http://localhost:8001";
// Prod sets AGENT_URL explicitly (compose network); the default derives from
// BACKEND_URL so local dev reaches the docker backend published on :8001.
const AGENT_URL =
process.env.AGENT_URL ?? `${BACKEND_URL}/api/v1/agent/agui`;
const isProd = process.env.NODE_ENV === "production";
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
@@ -118,6 +120,27 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
const open = new Set<string>(); // tool call IDs currently in progress
const closed = new Set<string>(); // tool call IDs that already received END
// Render tools are display-once: after the client returns their result,
// the model sometimes re-issues the same call (fresh id) on the
// follow-up run, painting a second identical card. Track which render
// tools already ran in the current user turn (from the request's own
// message history) and suppress repeats — both their streamed events
// and their entries in this run's MESSAGES_SNAPSHOT.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
let lastUserIdx = -1;
inputMessages.forEach((m, i) => { if (m?.role === "user") lastUserIdx = i; });
const renderedThisTurn = new Set<string>();
for (const m of inputMessages.slice(lastUserIdx + 1)) {
const calls = m?.toolCalls ?? m?.tool_calls;
if (m?.role !== "assistant" || !Array.isArray(calls)) continue;
for (const tc of calls) {
const name = tc?.function?.name;
if (name && RENDER_TOOLS.has(name)) renderedThisTurn.add(name);
}
}
const suppressed = new Set<string>(); // toolCallIds of dropped repeats
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -130,13 +153,51 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
if (type === EventType.TOOL_CALL_START) {
if (!id || closed.has(id) || open.has(id)) return; // duplicate
const name: string | undefined = event?.toolCallName;
if (name && RENDER_TOOLS.has(name)) {
if (renderedThisTurn.has(name)) {
suppressed.add(id);
return; // repeat render call — drop the whole call
}
renderedThisTurn.add(name);
}
open.add(id);
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
if (id && suppressed.has(id)) return;
if (id && closed.has(id)) return; // already completed, drop
if (type === EventType.TOOL_CALL_END && id) {
open.delete(id);
closed.add(id);
}
} else if (type === EventType.MESSAGES_SNAPSHOT && suppressed.size > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const cleaned = msgs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((m: any) => {
const calls = m?.toolCalls ?? m?.tool_calls;
if (m?.role !== "assistant" || !Array.isArray(calls)) return m;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const kept = calls.filter((tc: any) => !suppressed.has(String(tc?.id ?? "")));
if (kept.length === calls.length) return m;
const { toolCalls: _a, tool_calls: _b, ...rest } = m;
return kept.length > 0 ? { ...rest, toolCalls: kept } : rest;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((m: any) => {
if (m?.role === "tool" && suppressed.has(String(m?.toolCallId ?? m?.tool_call_id ?? ""))) {
return false;
}
if (m?.role === "assistant") {
const calls = m?.toolCalls ?? m?.tool_calls;
const hasCalls = Array.isArray(calls) && calls.length > 0;
const hasText = typeof m?.content === "string" && m.content.length > 0;
if (!hasCalls && !hasText) return false;
}
return true;
});
observer.next({ ...event, messages: cleaned });
return;
}
observer.next(event); // emit raw BaseEvent
@@ -189,24 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
}
}
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
// by id then APPENDS unknown ids, so the browser ends up with two assistant
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
// the assistant-with-toolCalls message in state past the run). The right fix
// is to drop just the orphan text-only assistant message that has no streamed
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
// ── DropRunMessagesSnapshotMiddleware ────────────────────────────────────────
// MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their
// #3619) and, with thread persistence on, rebuilds history under STORE ids.
// ag-ui's apply treats the snapshot as authoritative: client messages absent
// from it are dropped, matching ids are replaced. Because streamed ids and
// snapshot ids never agree for tool+text turns, every mid-session snapshot
// either duplicates the current answer (fresh-id append) or wipes earlier
// ones (store-id replace with empty content) — both observed live. During a
// normal run the client already built the exact turn from streamed events,
// so the snapshot adds nothing: swallow it. Hydration replays (empty
// input.messages) pass through untouched — there the snapshot IS the
// conversation, and the client converges to store ids on every reload.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class ReconcileSnapshotMiddleware extends (Middleware as any) {
class DropRunMessagesSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
const streamedTextIds = new Set<string>();
const isHydration =
!Array.isArray(input?.messages) || input.messages.length === 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
@@ -215,33 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
const type: string = event?.type ?? "";
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
streamedTextIds.add(String(event.messageId));
if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
return; // client state from streaming is already correct
}
if (type === EventType.MESSAGES_SNAPSHOT) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
const filtered = msgs.filter((m) => {
if (m?.role !== "assistant") return true;
const id = String(m?.id ?? "");
const hasText = typeof m?.content === "string" && m.content.length > 0;
const hasToolCalls =
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
return false; // drop orphan text-only assistant duplicate
}
return true;
});
if (filtered.length !== msgs.length) {
observer.next({ ...event, messages: filtered });
return;
}
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -259,7 +297,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
// them if any render tool call is detected in the same turn.
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
const RENDER_TOOLS = new Set(["render_spending_summary"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
@@ -345,57 +383,193 @@ app.use("*", async (c, next) => {
});
});
app.all("/api/copilotkit/*", async (c) => {
const cookieHeader = c.req.header("cookie") ?? "";
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
const token = match?.[1];
const agentHeaders: Record<string, string> = token
? { Authorization: `Bearer ${token}` }
: {};
// ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
// Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
// wrapper (which was already the v2 single-route handler underneath) onto
// createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
// run() is a pure proxy to the MAF backend (which persists every turn in
// chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
// so the ONLY conversation store is the DB. This deletes the in-memory
// runner (whose process-local replay resurrected cleared conversations) and
// the client-side hydration hack that compensated for it.
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// The v2 handler nags about anonymous telemetry unless disabled.
process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
const agent = new HttpAgent({ url: AGENT_URL, headers });
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
// workarounds are still needed after CopilotKit/MAF upgrades.
/* eslint-disable @typescript-eslint/no-explicit-any */
if (process.env.CK_MIDDLEWARES !== "off") {
agent.use(new SuppressRenderToolTextMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new StripModelArtifactsMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new ReconcileSnapshotMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DropRunMessagesSnapshotMiddleware() as any);
agent.use(new DeduplicateToolCallMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
agent.use(new DebugLogMiddleware() as any);
}
/* eslint-enable @typescript-eslint/no-explicit-any */
return agent;
}
// ── PostgresAgentRunner ───────────────────────────────────────────────────────
// The conversation store is Postgres (chatthreadsnapshot), written by MAF at
// every run end. So the runner records NOTHING:
// - run() proxies the agent's event stream (the per-request clone the v2
// handler prepared, auth headers already forwarded onto it).
// - connect() asks MAF for its snapshot hydration — an empty-messages run
// returns RUN_STARTED → [STATE_SNAPSHOT] → MESSAGES_SNAPSHOT → RUN_FINISHED
// with no LLM call (unknown thread: RUN_STARTED → RUN_FINISHED). The same
// stream shape the in-memory runner's replay approximated, but sourced
// from the DB, so it is correct across clears, restarts and instances.
// - Live runs are tracked in-process only for stop(); the official
// sqlite-runner does the same (single-instance limitation acknowledged).
// isRunning() has no caller in the v2 SSE path (verified at 1.62.2).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const liveRuns = new Map<string, any>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function proxyAgentRun(agent: any, input: any, threadId: string): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subject = new ReplaySubject<any>(Infinity);
liveRuns.set(threadId, agent);
agent
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.runAgent(input, { onEvent: ({ event }: any) => subject.next(event) })
.then(() => subject.complete())
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch((err: any) => subject.error(err))
.finally(() => {
if (liveRuns.get(threadId) === agent) liveRuns.delete(threadId);
});
return subject.asObservable();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class PostgresAgentRunner extends (AgentRunner as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(request: any): Observable<any> {
return proxyAgentRun(request.agent, request.input, request.threadId);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
connect(request: any): Observable<any> {
// request.headers = authorization + x-* from the incoming browser
// request (extractForwardableHeaders) — exactly what MAF needs.
const agent = makeWealthysmartAgent(request.headers ?? {});
agent.threadId = request.threadId;
// No messages + known threadId = MAF's hydration trigger.
return proxyAgentRun(agent, {}, `connect:${request.threadId}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
isRunning(request: any): Promise<boolean> {
return Promise.resolve(liveRuns.has(request.threadId));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async stop(request: any): Promise<boolean | undefined> {
const agent = liveRuns.get(request.threadId);
if (!agent) return false;
try {
agent.abortRun();
liveRuns.delete(request.threadId);
return true;
} catch {
return false;
}
}
}
const runtime = new CopilotRuntime({
agents: { wealthysmart: agent },
a2ui: { injectA2UITool: true },
agents: { wealthysmart: makeWealthysmartAgent() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runner: new PostgresAgentRunner() as any,
// a2ui disabled 2026-07-04: CK 1.60+ validates generated ops against a
// catalog and our agent's ops fail validation (surface never paints).
// Markdown tables cover the use case until the pipeline matures.
a2ui: { enabled: false },
beforeRequestMiddleware: async ({ request: outbound }) => {
if (outbound.method !== "POST") return;
const ct = outbound.headers.get("content-type") ?? "";
if (!ct.includes("application/json")) return;
try {
const body = (await outbound.clone().json()) as { messages?: AGUIMessage[] };
const body = (await outbound.clone().json()) as {
messages?: AGUIMessage[];
context?: { description?: string; value?: string }[];
};
if (!Array.isArray(body.messages)) return;
const paired = pairOrphanToolCalls(body.messages);
if (paired.length === body.messages.length) return;
// An empty messages array is MAF's hydration trigger (known threadId
// + no messages → replay the stored snapshot without invoking the
// LLM). Injecting anything would turn it into a normal run.
if (body.messages.length === 0) return;
let messages = pairOrphanToolCalls(body.messages);
// MAF's AG-UI endpoint declares `context` on its request model but
// never feeds it to the model (agent_framework_ag_ui, checked at
// python-1.10.0). CopilotKit 1.60+ ships the A2UI catalog schema and
// guidelines in that context — without them the model emits ops the
// A2UI validator rejects and the surface never paints. Inject the
// context as a system message ourselves. Remove once MAF consumes
// RunAgentInput.context.
if (Array.isArray(body.context) && body.context.length > 0) {
const contextText = body.context
.map((c) => `## ${c.description ?? "Context"}\n${c.value ?? ""}`)
.join("\n\n");
messages = [
{
id: `ctx-${Date.now()}`,
role: "system",
content: `Additional run context:\n\n${contextText}`,
} as AGUIMessage,
...messages,
];
}
if (messages === body.messages) return;
return new Request(outbound.url, {
method: outbound.method,
headers: outbound.headers,
body: JSON.stringify({ ...body, messages: paired }),
body: JSON.stringify({ ...body, messages }),
});
} catch {
} catch (err) {
// Skipping the repair is survivable; doing it silently is not —
// orphan tool calls then fail downstream with no trace of why.
console.error("[copilotkit] outbound request repair skipped:", err);
return;
}
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
const ckHandler = createCopilotRuntimeHandler({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
basePath: "/api/copilotkit",
// Single-route mode: same envelope protocol the client already resolved
// via auto-detect (its GET /info probe 405s here, so it stays "single").
// Multi-route would only add /threads* REST endpoints we don't use and is
// exposed to CopilotKit#4953 (frontend tools under multi-route).
mode: "single-route",
cors: false,
});
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
});
// The v2 handler forwards only `authorization` and `x-*` headers to agents
// and to runner.connect — cookies never pass. Translate the auth cookie into
// a Bearer header before the handler sees the request.
function withBearerFromCookie(req: Request): Request {
if (req.headers.get("authorization")) return req;
const cookie = req.headers.get("cookie") ?? "";
const match = cookie.match(/(?:^|;\s*)ws_token=([^;]+)/);
if (!match) return req;
const headers = new Headers(req.headers);
headers.set("Authorization", `Bearer ${match[1]}`);
return new Request(req, { headers });
}
app.all("/api/copilotkit/*", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
app.all("/api/copilotkit", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
app.get("/api/health", (c) => c.json({ ok: true }));

View File

@@ -1,17 +1,32 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { CopilotKit } from "@copilotkit/react-core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import { AuthProvider, useAuth } from "./AuthContext";
import { ThemeProvider } from "./contexts/theme-context";
import { PrivacyProvider } from "./contexts/privacy-context";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
import Layout from "./components/Layout";
import LoginPage from "./pages/Login";
import Inicio from "./pages/Inicio";
import Asistente from "./pages/Asistente";
import Analytics from "./pages/Analytics";
import Budget from "./pages/Budget";
import Salarios from "./pages/Salarios";
import Financiamientos from "./pages/Financiamientos";
import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
import SyncStatus from "./pages/SyncStatus";
import Planificador from "./pages/Planificador";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
@@ -28,7 +43,7 @@ function AppRoutes() {
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
/>
<Route
element={
@@ -37,14 +52,17 @@ function AppRoutes() {
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/asistente" replace />} />
<Route index element={<Inicio />} />
<Route path="/asistente" element={<Asistente />} />
<Route path="/budget" element={<Budget />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/proyecciones" element={<Proyecciones />} />
<Route path="/salarios" element={<Salarios />} />
<Route path="/financiamientos" element={<Financiamientos />} />
<Route path="/pensions" element={<Pensions />} />
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/sync" element={<SyncStatus />} />
<Route path="/planificador" element={<Planificador />} />
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route>
@@ -58,9 +76,22 @@ export default function App() {
<ThemeProvider>
<PrivacyProvider>
<AuthProvider>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
<QueryClientProvider client={queryClient}>
<CopilotKit
runtimeUrl="/api/copilotkit"
agent="wealthysmart"
headers={{
// Lets the agent resolve "hoy"/"este mes" in the user's
// local time wherever they are; backend falls back to
// Costa Rica when absent.
"X-Client-Timezone":
Intl.DateTimeFormat().resolvedOptions().timeZone,
}}
>
<AppRoutes />
<Toaster richColors position="top-right" closeButton />
</CopilotKit>
</QueryClientProvider>
</AuthProvider>
</PrivacyProvider>
</ThemeProvider>

View File

@@ -20,12 +20,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Probe auth state by hitting a protected endpoint.
// If the ws_token cookie is valid, the server returns 200; else 401.
fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => setAuthenticated(r.ok))
.catch(() => setAuthenticated(false))
.finally(() => setIsLoading(false));
// Probe auth state by hitting a protected endpoint. An HTTP status is an
// authoritative answer (200 = in, 401 = out); a thrown fetch is a NETWORK
// failure and must not log the user out — retry once before giving up.
let cancelled = false;
const probe = () => fetch("/api/v1/auth/me", { credentials: "include" });
probe()
.catch(() => new Promise((r) => setTimeout(r, 1500)).then(probe))
.then((r) => {
if (!cancelled) setAuthenticated((r as Response).ok);
})
.catch(() => {
// Network still down: leave unauthenticated but log the real cause.
console.warn("Auth probe failed twice (network) — backend unreachable");
if (!cancelled) setAuthenticated(false);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const logout = async () => {

View File

@@ -26,7 +26,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
const [cycles, setCycles] = useState<CycleOption[]>([]);
useEffect(() => {
api.get('/transactions/cycles').then((r) => setCycles(r.data));
api.get<CycleOption[]>('/transactions/cycles').then((r) => setCycles(r.data));
}, []);
const selectedKey = value ? `${value.year}-${value.month}` : 'all';
@@ -37,7 +37,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
<Select
value={selectedKey}
onValueChange={(val) => {
if (val === 'all') {
if (!val || val === 'all') {
onChange(null);
} else {
const [y, m] = val.split('-').map(Number);
@@ -49,7 +49,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All time</SelectItem>
<SelectItem value="all">Todo el período</SelectItem>
{cycles.map((c) => (
<SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
{c.label} ({c.count})

View File

@@ -0,0 +1,28 @@
import { AlertTriangle, RotateCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
message?: string;
onRetry?: () => void;
}
export default function ErrorState({
message = 'No se pudieron cargar los datos',
onRetry,
}: Props) {
return (
<div
role="alert"
className="flex flex-col items-center justify-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-8 text-center"
>
<AlertTriangle className="h-6 w-6 text-destructive" aria-hidden="true" />
<p className="text-sm text-muted-foreground">{message}</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
Reintentar
</Button>
)}
</div>
);
}

View File

@@ -1,213 +1,105 @@
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
Sparkles,
Calculator,
BarChart3,
Landmark,
PiggyBank,
Droplets,
LogOut,
TrendingUp,
Wallet,
Menu,
Sun,
Moon,
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { Link, Outlet, useLocation } from "react-router-dom";
import { Eye, EyeOff, Moon, Sun } from "lucide-react";
import { titleFor } from "@/lib/navigation";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { AppSidebar } from "@/components/app-sidebar";
import { CommandMenu } from "@/components/command-menu";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from "@/components/ui/sheet";
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
interface NavSection {
label: string;
items: { to: string; icon: LucideIcon; label: string }[];
}
const navSections: NavSection[] = [
{
label: "General",
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
},
{
label: "Finanzas",
items: [
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
{ to: "/salarios", icon: Landmark, label: "Salarios" },
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
],
},
{
label: "Servicios",
items: [
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
],
},
];
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { pathname } = useLocation();
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
return (
<nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => (
<div key={section.label}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
{section.label}
</p>
{section.items.map(({ to, icon: Icon, label }) => (
<Link
key={to}
to={to}
onClick={onNavigate}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
isActive(to)
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
<Icon className="w-4 h-4" />
{label}
</Link>
))}
</div>
))}
</nav>
);
}
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
export default function Layout() {
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
const { pathname } = useLocation();
const title = titleFor(pathname);
// Asistente hosts its own scroll container (the chat), so its route caps
// the shell at the viewport; every other page keeps normal body scroll.
const fullHeight = pathname === "/asistente";
return (
<div className="min-h-screen bg-background text-foreground">
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<SidebarProvider>
<AppSidebar />
<SidebarInset className={fullHeight ? "h-svh overflow-hidden" : undefined}>
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
{pathname === "/" ? (
<BreadcrumbPage>Inicio</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link to="/" />}>
Inicio
</BreadcrumbLink>
)}
</BreadcrumbItem>
{pathname !== "/" && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{title}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-1">
<CommandMenu />
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
onClick={togglePrivacy}
title="Toggle privacy mode"
aria-label="Toggle privacy mode"
aria-pressed={privacyMode}
>
<Menu className="w-5 h-5" />
</Button>
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{privacyMode ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleLogout}
title="Sign out"
aria-label="Sign out"
className="hidden md:inline-flex"
onClick={toggleTheme}
title="Toggle theme"
aria-label="Toggle theme"
aria-pressed={theme === "dark"}
>
<LogOut className="w-4 h-4" />
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</Button>
</div>
</div>
</header>
<div className="flex">
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
<div className="flex-1">
<SidebarNav />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<button
onClick={handleLogout}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</div>
</aside>
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="p-0 w-64">
<SheetHeader className="p-4">
<SheetTitle className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div>
<span style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<div className="flex flex-col h-[calc(100%-65px)]">
<div className="flex-1 overflow-y-auto">
<SidebarNav onNavigate={() => setMobileOpen(false)} />
</div>
<div className="px-3 pb-4">
<Separator className="mb-2" />
<SheetClose render={<span />}>
<button
onClick={() => {
setMobileOpen(false);
void handleLogout();
}}
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
>
<LogOut className="w-4 h-4" />
Cerrar sesión
</button>
</SheetClose>
</div>
</div>
</SheetContent>
</Sheet>
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-6xl mx-auto">
<main className="flex min-h-0 flex-1 flex-col px-4 py-6 sm:px-6 lg:px-8">
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
<Outlet />
</div>
</main>
</div>
</div>
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { type UserSettingsData } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
interface Props {
meterIds: string[];
current: Record<string, string>;
settingsData: UserSettingsData;
onClose: () => void;
}
/** Human names for water meters, persisted in UserSettings (review UX-24). */
export default function MeterLabelsDialog({
meterIds,
current,
settingsData,
onClose,
}: Props) {
const queryClient = useQueryClient();
const [labels, setLabels] = useState<Record<string, string>>(() => ({
...current,
}));
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
const cleaned = Object.fromEntries(
Object.entries(labels).filter(([, v]) => v.trim() !== ''),
);
await api.patch('/settings/', {
data: { ...settingsData, meter_labels: cleaned },
});
toast.success('Etiquetas guardadas');
queryClient.invalidateQueries({ queryKey: ['settings'] });
onClose();
} catch {
toast.error('No se pudieron guardar las etiquetas');
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Etiquetas de medidores</DialogTitle>
</DialogHeader>
<div className="space-y-3">
{meterIds.map((id) => (
<div key={id} className="space-y-1">
<Label className="text-xs font-mono">Medidor {id}</Label>
<Input
value={labels[id] ?? ''}
placeholder="p. ej. Casa, Cochera…"
onChange={(e) =>
setLabels((prev) => ({ ...prev, [id]: e.target.value }))
}
/>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,6 +1,10 @@
import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import { toast } from 'sonner';
import api, { type ImportResult } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { formatShortDate } from '@/lib/dates';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@@ -39,8 +43,40 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
setResult(data);
if (data.imported > 0) onImported();
} catch (err) {
console.error(err);
} catch {
toast.error('No se pudo importar - revisa el formato');
} finally {
setImporting(false);
}
};
// Re-import ONLY the skipped lines with the dedup check disabled, so rows
// that imported on the first pass are never duplicated.
const handleImportDuplicates = async () => {
if (!result || result.skipped.length === 0) return;
const lines = text.trim().split('\n');
const dupText = result.skipped
.map((s) => lines[s.line - 1])
.filter(Boolean)
.join('\n');
setImporting(true);
try {
const { data } = await api.post<ImportResult>('/import/paste', {
text: dupText,
bank,
source,
allow_duplicates: true,
});
toast.success(`${data.imported} duplicado(s) importados`);
setResult({
...result,
imported: result.imported + data.imported,
skipped: [],
duplicates: 0,
});
onImported();
} catch {
toast.error('No se pudieron importar los duplicados');
} finally {
setImporting(false);
}
@@ -61,7 +97,7 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bank</Label>
<Select value={bank} onValueChange={setBank}>
<Select value={bank} onValueChange={(v) => v && setBank(v)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
@@ -74,7 +110,7 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
</div>
<div className="space-y-2">
<Label>Source</Label>
<Select value={source} onValueChange={setSource}>
<Select value={source} onValueChange={(v) => v && setSource(v)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
@@ -134,6 +170,39 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
</Alert>
)}
{result.skipped.length > 0 && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 space-y-2">
<p className="text-sm font-medium">
{result.skipped.length} duplicado(s) omitido(s)
</p>
<ul className="text-xs max-h-40 overflow-y-auto space-y-1.5">
{result.skipped.map((s) => (
<li key={`${s.line}-${s.existing_id}`} className="space-y-0.5">
<span className="font-mono">
{s.merchant} · {formatShortDate(s.date)} ·{' '}
{formatAmount(s.amount, s.currency)}
</span>
<span className="block text-muted-foreground">
coincide con #{s.existing_id}: {s.existing_merchant} del{' '}
{formatShortDate(s.existing_date)} ({s.existing_source})
</span>
</li>
))}
</ul>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={handleImportDuplicates}
disabled={importing}
>
{importing
? 'Importando...'
: 'Importar duplicados de todos modos'}
</Button>
</div>
)}
<Button onClick={onClose} className="w-full">
Done
</Button>

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import api, { type UserSettingsData } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export interface FundAssumption {
monthlyContribution: number;
annualRate: number;
}
export type PensionAssumptions = Partial<
Record<'FCL' | 'ROP' | 'VOL', FundAssumption>
>;
interface Props {
current: Record<'FCL' | 'ROP' | 'VOL', FundAssumption>;
settingsData: UserSettingsData;
onClose: () => void;
}
const FUND_LABELS: Record<'FCL' | 'ROP' | 'VOL', string> = {
FCL: 'FCL — Capitalización Laboral',
ROP: 'ROP — Régimen Obligatorio',
VOL: 'VOL — Fondo Voluntario',
};
/** Per-fund projection assumptions, persisted server-side in UserSettings so
* they survive devices and reloads (review UX-12). */
export default function PensionAssumptionsDialog({
current,
settingsData,
onClose,
}: Props) {
const queryClient = useQueryClient();
const [form, setForm] = useState(() => ({
FCL: { ...current.FCL },
ROP: { ...current.ROP },
VOL: { ...current.VOL },
}));
const [saving, setSaving] = useState(false);
const setField = (
fund: 'FCL' | 'ROP' | 'VOL',
field: keyof FundAssumption,
value: string,
) => {
const num = parseFloat(value);
setForm((prev) => ({
...prev,
[fund]: { ...prev[fund], [field]: isNaN(num) ? 0 : num },
}));
};
const handleSave = async () => {
setSaving(true);
try {
// The settings PATCH replaces the whole blob — merge over what exists.
await api.patch('/settings/', {
data: { ...settingsData, pension_assumptions: form },
});
toast.success('Supuestos de pensión guardados');
queryClient.invalidateQueries({ queryKey: ['settings'] });
onClose();
} catch {
toast.error('No se pudieron guardar los supuestos');
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Supuestos de proyección</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground -mt-2">
Usados para proyectar el crecimiento de cada fondo. El saldo inicial
siempre viene del último estado de cuenta.
</p>
<div className="space-y-4">
{(Object.keys(FUND_LABELS) as Array<'FCL' | 'ROP' | 'VOL'>).map(
(fund) => (
<div key={fund} className="space-y-2">
<p className="text-sm font-medium">{FUND_LABELS[fund]}</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Aporte mensual ()</Label>
<Input
type="number"
min="0"
step="1000"
value={form[fund].monthlyContribution}
onChange={(e) =>
setField(fund, 'monthlyContribution', e.target.value)
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Rendimiento anual (%)</Label>
<Input
type="number"
min="0"
max="30"
step="0.1"
value={form[fund].annualRate}
onChange={(e) =>
setField(fund, 'annualRate', e.target.value)
}
/>
</div>
</div>
</div>
),
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Guardando...' : 'Guardar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
label: string;
onPrev: () => void;
onNext: () => void;
prevDisabled?: boolean;
nextDisabled?: boolean;
/** 'outline' for page-level (year), 'ghost' for inline (month) */
variant?: 'outline' | 'ghost';
labelClassName?: string;
}
/** The one way to step through periods (UX-03) — same chevron pattern for
* years and months across Budget and Proyecciones. */
export default function PeriodNavigator({
label,
onPrev,
onNext,
prevDisabled,
nextDisabled,
variant = 'outline',
labelClassName = 'w-16 text-center font-semibold tabular-nums',
}: Props) {
return (
<div className="flex items-center gap-1">
<Button
variant={variant}
size="icon"
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
disabled={prevDisabled}
onClick={onPrev}
aria-label={`Período anterior (${label})`}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className={labelClassName}>{label}</span>
<Button
variant={variant}
size="icon"
className={variant === 'ghost' ? 'h-7 w-7' : undefined}
disabled={nextDisabled}
onClick={onNext}
aria-label={`Período siguiente (${label})`}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
);
}

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