Compare commits

...

43 Commits

Author SHA1 Message Date
Carlos Escalante
20b4ad102d Wrap transaction_type in col() for notin_ filter
All checks were successful
Deploy to VPS / deploy (push) Successful in 12s
SQLModel enum columns need col() to expose SQLAlchemy operators like
notin_. Without it the agent tool raised at query build time and the
chat card flashed away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:58:28 -06:00
Carlos Escalante
ec716e698f Exclude SALARY and DEPOSITO from agent recent-transactions tool
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
The 'last N transactions' answer was including salary deposits, which the
user reads as expense activity. Filter income types out at the query level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:55:53 -06:00
Carlos Escalante
f556c392fb Pass OPENAI_API_KEY and AGENT_MODEL to prod from Gitea secrets
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Backend was hitting OpenAI with no key (401) because the deploy workflow
never wrote OPENAI_API_KEY into .env.prod. Add it plus AGENT_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:49:55 -06:00
Carlos Escalante
aa4bb6512f Proxy /api/v1 and /api/auth from Hono to FastAPI in prod
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s
In production the browser talks to the Hono server, which only proxied
/api/copilotkit/*. All other /api/* requests hit the SPA static fallback
and got index.html back. Forward /api/v1/* and /api/auth/* to BACKEND_URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:43:34 -06:00
Carlos Escalante
6b3069eef4 Fix prod backend hostname collision on nginx-prod-network
All checks were successful
Deploy to VPS / deploy (push) Successful in 5s
Frontend joins both wealthysmart-network-prod and nginx-prod-network
(needed for nginx-proxy reverse proxy + TLS). Another container on
nginx-prod-network is named "backend" too (receipts-backend-prod),
so DNS resolved "backend" to a sibling app and the agent endpoint
returned 404. Pin the agent/backend URLs to the unique container
name wealthysmart-backend-prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:31:16 -06:00
Carlos Escalante
ead8fb8684 Fix prod frontend container: tsx on PATH and cap build heap
All checks were successful
Deploy to VPS / deploy (push) Successful in 5s
Runner stage was invoking `tsx server.ts` via sh, which doesn't
have node_modules/.bin on PATH, so the container crash-looped with
"tsx: not found" (502 at the edge). Use the absolute binary path
instead.

Also caps the Vite build's V8 heap to 1.5 GB so a future build on
the 4-core / 8 GB VPS can't OOM-kill neighbouring services.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:21:45 -06:00
Carlos Escalante
097fe9c4cf Point sync-db at old-vps and add A2UI theming notes
All checks were successful
Deploy to VPS / deploy (push) Successful in 1m17s
The production SSH alias is old-vps; the placeholder "production"
alias does not exist. Also captures research findings on theming
the A2UI basic catalog without overriding component internals,
for later reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:58 -06:00
Carlos Escalante
c92bfc66fe Update pages and components for new module paths
Repoints imports at the relocated lib/api and src/contexts modules,
and refreshes Layout + Login alongside the rest of the migration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:46 -06:00
Carlos Escalante
cf8b7be778 Fix Tabs orientation selectors
Tailwind variants like data-horizontal and group-data-horizontal
never match the data-orientation=horizontal attribute Base UI
emits, so the flex layout collapsed and TabsList stretched
vertically. Switch to data-[orientation=...] selectors that
actually fire.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:33 -06:00
Carlos Escalante
8b3a19b552 Add Skeleton primitive and budget detail loading state
Replaces the blank flash on the budget detail tab with skeleton
placeholders that mirror the final card layout, so the page no
longer shifts when the API returns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:22 -06:00
Carlos Escalante
5d5727ec4e Add Asistente chat page with A2UI render tools
Wires CopilotKit v2 chat into the SPA as the Asistente page,
declares a render_spending_summary action backed by a custom
SpendingSummaryCard, and configures static suggestions shown
before the first message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:12 -06:00
Carlos Escalante
140a75f706 Add cookie-based SPA auth and update container plumbing
Backend now exposes /api/auth/login + /api/auth/logout setting an
httpOnly ws_token cookie, and get_current_user accepts either the
cookie (SPA) or a Bearer token (n8n/CLI). AuthContext probes the
cookie via /api/v1/auth/me. Dockerfiles and compose files updated
for the new agent service deps and CopilotKit dev sidecar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:02:02 -06:00
Carlos Escalante
7f602a67af Add Microsoft Agent Framework assistant with read-only tools
Wires up an OpenAI-backed MAF agent that exposes WealthySmart
data through tool calls (recent transactions, cycle summary,
analytics, pensions). Pulls in agent-framework + AG-UI adapter
+ OpenAI client deps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:50 -06:00
Carlos Escalante
5f2a4105f3 Migrate frontend to Vite + Hono CopilotKit runtime
Replaces the Next.js scaffold with a Vite SPA paired with a Hono
sidecar that hosts the CopilotKit runtime and proxies AG-UI traffic
to the MAF backend. Adds dev/prod Dockerfile, .dockerignore,
.gitignore, pnpm workspace config, and updates entrypoints
(main.tsx / App.tsx / index.css / index.html) plus the service
worker accordingly.

Server middleware reconciles MAF MESSAGES_SNAPSHOT id mismatches
so post-tool-call assistant text doesn't render twice, suppresses
duplicate text emitted alongside render tools, and strips OpenAI
training-token leaks from streamed deltas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:40 -06:00
Carlos Escalante
c4768e6912 Drop legacy pages, contexts, and dashboard widgets
Removes Dashboard / Transactions / Transfers pages, the section
configuration UI, the legacy useSettings hook, and the standalone
PrivacyContext/ThemeContext modules. Privacy/theme contexts now live
under src/contexts/ and the API helper / push-notifications module
move under src/lib/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:26 -06:00
Carlos Escalante
9fe17c0607 Drop legacy Next.js + CRA scaffold assets
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 22:01:06 -06:00
Carlos Escalante
98d32df763 Ignore tech_docs and local Claude state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:57:58 -06:00
Carlos Escalante
d4d0f65759 Exclude income transactions from budget transactions list
All checks were successful
Deploy to VPS / deploy (push) Successful in 16s
Salary/deposit transactions were showing in the "Efectivo y
Transferencias" tab on the Budget page with a negative sign,
which is confusing since that view is for expenses only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:27:50 -06:00
Carlos Escalante
d929ed6573 Remove Ahorro from budget UI, add SALARY type and savings auto-accrual
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
Ahorro was already deducted from gross salary so displaying it in
budget projections was misleading. This removes the Ahorro card,
summary line, Proyecciones column, and Ahorro Anual card from the UI,
and strips all savings fields from budget API responses.

Adds SALARY TransactionType so salary deposits can be distinguished
from generic DEPOSITO transfers. When a SALARY transaction arrives,
the system auto-increments MEMP and MPAT savings account balances
(+200K CRC each) once per month via an idempotent accrual log.

New CRUD endpoints at /api/v1/savings-accrual/ allow manual correction
of the accrual history. Feb+Mar 2026 are seeded as historical baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:13:29 -06:00
Carlos Escalante
94a8a894a6 Convert all currencies to CRC and poll rates every 6h
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Budget/transactions/salarios totals summed Transaction.amount directly,
so USD/EUR entries were treated as CRC and effectively disappeared from
the dashboard (the analytics fix in 9a80f2a only covered analytics).
Adds a shared get_converted_amount_expr() helper driven by the full
Currency enum — USD/EUR via ExchangeRate-API, BTC/XMR via CoinGecko —
and wires it into every func.sum(Transaction.amount) site.

Also starts a background task in the FastAPI lifespan that force-refreshes
every currency 4x/day, persisting USD to the DB and updating in-memory
caches for the rest. Failures are swallowed per-currency so a CoinGecko
outage cannot take out USD/EUR, and the last-known rate is always retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:16:20 -06:00
Carlos Escalante
9a80f2a997 Convert USD and EUR to CRC in analytics endpoints
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
All three analytics endpoints (by-category, monthly-trend, daily-spending)
now convert foreign currency amounts to CRC using current exchange rates.
EUR/CRC rate derived from ExchangeRate-API (USD-based cross rate).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:41:38 -06:00
Carlos Escalante
efe6d88286 Add EUR currency support for international transactions
All checks were successful
Deploy to VPS / deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 20:29:37 -06:00
Carlos Escalante
4da00750a8 Fix migration to use IF NOT EXISTS and Postgres-compatible DEFAULT
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:14:20 -06:00
Carlos Escalante
792cef5006 Fix analytics case() bug, add privacy mode, add prod DB sync script
All checks were successful
Deploy to VPS / deploy (push) Successful in 28s
Fix SQLAlchemy case() import in monthly-trend endpoint. Add
data-sensitive attributes to Analytics charts and tables for privacy
blur. Add scripts/sync-db.sh for one-click prod-to-local PostgreSQL
sync. Remove SQLite artifacts from gitignore.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:58 -06:00
Carlos Escalante
78e20f30cb Replace axios with native fetch API wrapper
Drop axios dependency in favor of a lightweight fetch-based client
that preserves the same { data: T } interface, keeping all 25
consumer files unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:48 -06:00
Carlos Escalante
51c106dc6c Add Proyecciones page with yearly financial projections view
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:37 -06:00
Carlos Escalante
0fdb5447b7 Add deferred transactions, revamp budget projections and UI
Adds deferred_to_next_cycle flag to transactions for billing cycle
bleed-over handling. Overhauls budget projection engine and refreshes
Budget page with improved monthly detail and transaction columns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:10:23 -06:00
Carlos Escalante
37e04273b9 Add clickable legend toggles and charge trend chart
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
- Both water consumption and charge trend charts now have clickable legends
  to show/hide individual series
- Hidden series appear dimmed in the legend
- Added line chart showing charge evolution over time (one line per charge type)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:38:30 -06:00
Carlos Escalante
c005956458 Support multi-file upload for municipal receipts
All checks were successful
Deploy to VPS / deploy (push) Successful in 15s
Upload panel now accepts multiple PDFs at once (drag-drop or file picker),
shows a file queue with individual remove buttons, and displays per-file
results after processing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:23:14 -06:00
Carlos Escalante
8f775e5531 Add python-dateutil to requirements for production
All checks were successful
Deploy to VPS / deploy (push) Successful in 33s
The municipal receipts endpoint uses dateutil.relativedelta to derive
the billing period (previous month from receipt date).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:16:33 -06:00
Carlos Escalante
739a32efd4 Add municipal receipt module and convert navbar to sidebar
All checks were successful
Deploy to VPS / deploy (push) Successful in 58s
- New module: Municipalidad de Belén receipt extraction via pdftotext+regex
  - Backend: MunicipalReceipt + WaterMeterReading models, upload/list/detail/water-consumption endpoints
  - Auto-creates budget Transaction on upload (duplicate-safe via reference)
  - Frontend: ServiciosMunicipales page with summary cards, water consumption bar chart, receipt history, PDF upload
- Convert top navbar to left sidebar with section headers (General, Finanzas, Servicios)
  - Desktop: fixed 220px sidebar, mobile: sheet overlay
  - Grouped nav: Dashboard | Presupuesto, Salarios, Pensiones, Analytics | Municipalidad

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:11:51 -06:00
Carlos Escalante
45166f9d20 Add privacy mode toggle to blur sensitive financial amounts
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Eye/EyeOff icon next to theme toggle. Persists in localStorage.
Applies CSS blur to all elements marked with data-sensitive attribute
across Dashboard, Budget, Pensions, Salarios, and Transactions pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:43:50 -06:00
Carlos Escalante
aedf3aa3b0 Fix plus sign showing before negative rendimientos values
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:24:58 -06:00
Carlos Escalante
cab4d86b5c Restore MEMP/MPAT in Bank enum for DB compatibility
All checks were successful
Deploy to VPS / deploy (push) Successful in 13s
Existing account rows reference these values. Enum must stay compatible
with data in production. Only the frontend and seed data were cleaned up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:21:28 -06:00
Carlos Escalante
22334c2129 Remove MPAT and MEMP fund references from pension module
All checks were successful
Deploy to VPS / deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:18:36 -06:00
Carlos Escalante
0923337fff Fix pension paste parser for split-line format from bank website
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
The bank website puts field labels and amounts on separate lines.
Parser now handles both inline and split formats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:09:51 -06:00
Carlos Escalante
898b540b3f Fix roiEarned crash when chart data has fewer than 12 entries
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:06:08 -06:00
Carlos Escalante
3c9656f416 Add manual pension data entry and fix chart to use real historical data
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
- Add paste-and-preview modal for entering pension fund balances from bank website
- Backend upsert logic so n8n PDF uploads overwrite manual entries
- Chart now shows actual snapshot data with dynamic month labels
- New POST /pensions/manual endpoint for JSON-based fund entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:03:29 -06:00
Carlos Escalante
e011a3adcc Fix date picker defaulting to UTC instead of browser local time
All checks were successful
Deploy to VPS / deploy (push) Successful in 17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:02:08 -06:00
Carlos Escalante
b68129a171 Add cumulative balance tracking with editable overrides
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
- New BalanceOverride table for manual balance adjustments per month
- Cumulative balance computation with cross-year carryover
- Three new columns: Acum. Anterior, Neto Mes, Balance Acum.
- Inline editing on Balance Acum. cell (pencil icon for overrides)
- Year navigation clamped to 2026–2030, fresh start at March 2026
- PUT/DELETE /budget/balance-override/{year}/{month} endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:03:43 -06:00
Carlos Escalante
99d0c4ebd7 Split budget Resumen into Detalle, Transacciones, and Proyecciones sub-tabs
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Reduces scrolling by organizing the budget overview into three inner tabs.
Clicking a month in the yearly table auto-switches to the Detalle tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:14:43 -06:00
Carlos Escalante
26a26b8ca2 Fix category dropdown showing ID and sort alphabetically
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
SelectValue now renders the category name instead of the raw ID.
Categories are sorted alphabetically when fetched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:56:03 -06:00
Carlos Escalante
fe8d0144eb Add Electronics category and fix DEPOSITO shown as expense
All checks were successful
Deploy to VPS / deploy (push) Successful in 24s
DEPOSITO transactions (salaries) were displaying with negative sign and
red styling. Flipped logic so only COMPRA is negative; DEPOSITO and
DEVOLUCION both show as positive income.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:49:22 -06:00
90 changed files with 13594 additions and 4051 deletions

View File

@@ -23,6 +23,8 @@ jobs:
LETSENCRYPT_EMAIL=${{ secrets.LETSENCRYPT_EMAIL }}
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}
AGENT_MODEL=${{ secrets.AGENT_MODEL }}
ENVEOF
sed -i 's/^[[:space:]]*//' .env.prod

8
.gitignore vendored
View File

@@ -2,7 +2,15 @@ node_modules/
dist/
__pycache__/
*.pyc
*.db
*.db.bak
.env
.env.*
!.env.example
docs/legacy_budget_analysis.md
# Reference clones of framework repos (read-only, not tracked)
tech_docs/
# Claude Code local state
.claude/

View File

@@ -13,9 +13,33 @@ Personal finance management web app.
cd frontend && pnpm install && pnpm run dev
```
## Local Docker
```bash
# Backend + DB containers
docker exec wealthysmart-db-dev psql -U wealthy_user -d wealthysmart -c 'SQL;'
```
## Deployment
- Deployed via Gitea Actions (self-hosted runner on VPS)
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
- Domain: wealth.cescalante.dev
- Reverse proxy: nginx-proxy + acme-companion (auto TLS)
## Infrastructure
- **Single server**: `ssh old-vps` — runs everything (WealthySmart, n8n, Forgejo, Vaultwarden, nginx-proxy)
- `ssh production` is **NOT valid** — do not use
- n8n UI: https://n8n.cescalante.dev — n8n DB queryable via `docker exec portfolio-db-prod psql -U portfolio_user -d n8n`
## n8n Flows
Four automated flows on old-vps feed data into WealthySmart:
1. **BAC Credit Card** — Gmail trigger → POST /transactions/
2. **Salary Deposits** — Gmail trigger → POST /transactions/
3. **Municipal Receipts** — Cron trigger → POST /municipal-receipts/upload
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`

View File

@@ -2,6 +2,6 @@ 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
RUN pip install --no-cache-dir --pre -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

View File

@@ -0,0 +1,74 @@
"""Microsoft Agent Framework agent wired with OpenAI + WealthySmart tools."""
from __future__ import annotations
from datetime import date
from agent_framework import Agent
from agent_framework.openai import OpenAIChatCompletionClient
from app.config import settings
from app.agent.tools import TOOLS
SYSTEM_PROMPT = """You are the WealthySmart assistant, an AI analyst for a
personal-finance app owned by a single user (Carlos).
Context you can rely on:
- The user's primary currency is Costa Rican colones (CRC, ₡). USD and EUR
balances and transactions are always converted to CRC using the latest
exchange rate before being summed.
- Credit-card billing cycles run from the 18th of a month to the 18th of the
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.
- 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.
- 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.
- 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
summarize or export the change instead.
Language: match the user. The app is bilingual (Spanish/English); respond in
whichever language they used.
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.
"""
def build_agent() -> Agent:
client = OpenAIChatCompletionClient(
api_key=settings.OPENAI_API_KEY,
model=settings.AGENT_MODEL,
)
return Agent(
name="wealthysmart",
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
client=client,
tools=TOOLS,
)

467
backend/app/agent/tools.py Normal file
View File

@@ -0,0 +1,467 @@
"""
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.
"""
from __future__ import annotations
import contextvars
from datetime import datetime
from typing import Annotated, Optional
from pydantic import Field
from sqlalchemy import case
from sqlmodel import Session, col, func, select
from app.models.models import (
Account,
BalanceOverride,
Category,
MunicipalReceipt,
PensionSnapshot,
RecurringItem,
Transaction,
TransactionSource,
TransactionType,
WaterMeterReading,
)
from app.services.budget_projection import (
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
get_cycle_range,
)
from app.services.exchange_rate import (
get_converted_amount_expr,
get_current_rate,
)
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
def set_session(session: Session) -> contextvars.Token:
return _session_ctx.set(session)
def reset_session(token: contextvars.Token) -> None:
_session_ctx.reset(token)
def _s() -> Session:
return _session_ctx.get()
# ─── Tools ──────────────────────────────────────────────────────────────────
def get_accounts() -> list[dict]:
"""List every account with current balance, currency, bank and type
(BANK, PENSION, CRYPTO, SAVINGS, LIABILITY). Use this for net-worth and
balance questions."""
rows = _s().exec(select(Account).order_by(Account.account_type, Account.label)).all()
return [
{
"id": a.id,
"bank": a.bank.value,
"label": a.label,
"currency": a.currency.value,
"balance": a.balance,
"account_type": a.account_type.value,
"next_payment": a.next_payment,
}
for a in rows
]
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
assets_crc = 0.0
liabilities_crc = 0.0
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
if a.account_type.value == "LIABILITY":
liabilities_crc += amt
else:
assets_crc += amt
return {
"assets_crc": round(assets_crc, 2),
"liabilities_crc": round(liabilities_crc, 2),
"net_crc": round(assets_crc - liabilities_crc, 2),
}
def get_recent_transactions(
limit: Annotated[int, Field(ge=1, le=100, description="How many rows to return")] = 20,
source: Annotated[
Optional[str],
Field(description="Filter by source: CREDIT_CARD, CASH, or TRANSFER"),
] = None,
category_id: Annotated[Optional[int], Field(description="Filter by category id")] = None,
search: Annotated[
Optional[str], Field(description="Substring match against merchant name")
] = None,
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]:
"""Recent transactions, newest first. Use filters to narrow down. For
billing-cycle scoped totals prefer get_cycle_summary."""
q = select(Transaction).where(
col(Transaction.transaction_type).notin_(
[TransactionType.SALARY, TransactionType.DEPOSITO]
)
)
if source:
q = q.where(Transaction.source == TransactionSource(source))
if category_id is not None:
q = q.where(Transaction.category_id == category_id)
if search:
q = q.where(col(Transaction.merchant).ilike(f"%{search}%"))
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()).limit(limit)
return [
{
"id": t.id,
"date": t.date.isoformat(),
"merchant": t.merchant,
"amount": t.amount,
"currency": t.currency.value,
"source": t.source.value,
"transaction_type": t.transaction_type.value,
"bank": t.bank.value,
"category_id": t.category_id,
}
for t in _s().exec(q).all()
]
def get_cycle_summary(
cycle_year: Annotated[int, Field(description="Billing cycle year, e.g. 2026")],
cycle_month: Annotated[
int,
Field(ge=1, le=12, description="Billing cycle month (cycle runs 18th→18th)"),
],
) -> dict:
"""Totals for a credit-card billing cycle (18th of month → 18th of next).
Returns spend by source, count, and spend by category."""
session = _s()
amount_crc = get_converted_amount_expr(session)
start, end = get_cycle_range(cycle_year, cycle_month)
totals = session.exec(
select(
Transaction.source,
func.count(),
func.coalesce(func.sum(amount_crc), 0),
)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
)
.group_by(Transaction.source)
).all()
by_category = session.exec(
select(
Category.name,
func.coalesce(func.sum(amount_crc), 0),
func.count(),
)
.join(Category, Category.id == Transaction.category_id, isouter=True)
.where(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
)
.group_by(Category.name)
.order_by(func.sum(amount_crc).desc())
).all()
return {
"cycle_year": cycle_year,
"cycle_month": cycle_month,
"range": [start.isoformat(), end.isoformat()],
"by_source": [
{"source": s.value, "count": c, "total_crc": float(t)}
for s, c, t in totals
],
"by_category": [
{"category": n or "Uncategorized", "total_crc": float(t), "count": c}
for n, t, c in by_category
],
}
def get_budget_projection(
year: Annotated[int, Field(description="Year to project")],
month: Annotated[
Optional[int],
Field(ge=1, le=12, description="If given, return only that month's detail"),
] = None,
) -> dict:
"""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."""
session = _s()
if month is None:
months_data = compute_yearly_projection_with_cumulative(session, year)
return {
"year": year,
"months": months_data,
"annual_income": sum(m["projected_income"] for m in months_data),
"annual_expenses": sum(m["gran_total_egresos"] for m in months_data),
"annual_net": sum(m["net_balance"] for m in months_data),
}
return compute_monthly_projection(session, year, month)
def list_recurring_items() -> list[dict]:
"""All recurring items (income and expense, SAVINGS excluded) used by the
budget projection. Useful to explain what's driving a month's projection."""
rows = _s().exec(
select(RecurringItem)
.where(RecurringItem.is_active == True) # noqa: E712
.order_by(RecurringItem.item_type, RecurringItem.name)
).all()
return [
{
"id": r.id,
"name": r.name,
"amount": r.amount,
"currency": r.currency.value,
"item_type": r.item_type.value,
"frequency": r.frequency.value,
"day_of_month": r.day_of_month,
"category_id": r.category_id,
}
for r in rows
]
def get_pension_snapshots(
fund: Annotated[
Optional[str],
Field(description="Filter by fund bank code (FCL, ROP, VOL, etc.)"),
] = None,
latest_only: Annotated[
bool,
Field(description="If true, return only the latest snapshot per fund"),
] = True,
) -> 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 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,
}
for r in rows
]
def get_salary_summary() -> dict:
"""Summary of salary deposits (count, total in CRC, latest date)."""
session = _s()
amount_crc = get_converted_amount_expr(session)
row = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.SALARY)
).first()
count = row[0] if row else 0
total = float(row[1]) if row else 0.0
latest = row[2].isoformat() if row and row[2] else None
return {"count": count, "total_crc": total, "latest_date": latest}
def get_municipal_receipts(
limit: Annotated[int, Field(ge=1, le=50)] = 12,
account: Annotated[
Optional[str], Field(description="Municipal account/contract id")
] = None,
) -> list[dict]:
"""Recent municipal receipts (water + related services) with totals and
water consumption in m³."""
q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc())
if account:
q = q.where(MunicipalReceipt.account == account)
q = q.limit(limit)
rows = _s().exec(q).all()
out: list[dict] = []
for r in rows:
readings = _s().exec(
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
).all()
out.append(
{
"id": r.id,
"receipt_date": r.receipt_date.isoformat(),
"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),
}
)
return out
def get_analytics_by_category(
cycle_year: Annotated[Optional[int], Field(description="Scope to a billing cycle")] = None,
cycle_month: Annotated[Optional[int], Field(ge=1, le=12)] = None,
) -> list[dict]:
"""Spending breakdown by category in CRC (optionally scoped to a billing
cycle). Percentages sum to 100."""
session = _s()
amount_crc = get_converted_amount_expr(session)
q = (
select(
Transaction.category_id,
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == TransactionType.COMPRA)
.group_by(Transaction.category_id)
)
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
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
out = []
for cat_id, total, count in rows:
name = "Uncategorized"
if cat_id:
cat = session.get(Category, cat_id)
if cat:
name = cat.name
out.append(
{
"category_id": cat_id,
"category": name,
"total_crc": float(total),
"count": count,
"percentage": round(float(total) / grand * 100, 1),
}
)
out.sort(key=lambda x: x["total_crc"], reverse=True)
return out
def get_monthly_trend(
months: Annotated[int, Field(ge=1, le=24, description="How many months back")] = 6,
) -> list[dict]:
"""Spending trend by billing cycle for the last N months."""
session = _s()
amount_crc = get_converted_amount_expr(session)
now = datetime.now()
results: list[dict] = []
y, m = now.year, now.month
for _ in range(months):
start, end = get_cycle_range(y, m)
row = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.coalesce(
func.sum(
case((Transaction.currency == "USD", Transaction.amount), else_=0)
),
0,
),
).where(
Transaction.transaction_type == TransactionType.COMPRA,
Transaction.date >= start,
Transaction.date < end,
)
).first()
results.append(
{
"year": y,
"month": m,
"total_crc": float(row[1]) if row else 0.0,
"total_usd_raw": float(row[2]) if row else 0.0,
"count": row[0] if row else 0,
}
)
if m == 1:
y, m = y - 1, 12
else:
m -= 1
return list(reversed(results))
def get_exchange_rate() -> dict:
"""Latest USD/CRC exchange rate (buy and sell). All multi-currency data
in the app is normalized to CRC using these rates."""
rate = get_current_rate(_s())
if not rate:
return {"buy_rate": None, "sell_rate": None, "date": None}
return {
"buy_rate": rate.buy_rate,
"sell_rate": rate.sell_rate,
"date": rate.date.isoformat(),
}
def list_categories() -> list[dict]:
"""All transaction categories (id, name, icon). Use when the user asks
about a category and you need the id to filter by."""
rows = _s().exec(select(Category).order_by(Category.name)).all()
return [{"id": c.id, "name": c.name, "icon": c.icon} for c in rows]
# Registered with the agent in agent.py
TOOLS = [
get_accounts,
get_net_worth,
get_recent_transactions,
get_cycle_summary,
get_budget_projection,
list_recurring_items,
get_pension_snapshots,
get_salary_summary,
get_municipal_receipts,
get_analytics_by_category,
get_monthly_trend,
get_exchange_rate,
list_categories,
]

View File

@@ -3,12 +3,14 @@ from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import case
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.api.v1.endpoints.transactions import get_cycle_range
from app.services.budget_projection import get_cycle_range
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -43,10 +45,12 @@ def spending_by_category(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
amount_crc = get_converted_amount_expr(session)
query = (
select(
Transaction.category_id,
func.sum(Transaction.amount).label("total"),
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")
@@ -87,7 +91,12 @@ def monthly_trend(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
"""Monthly spending totals using billing cycle boundaries (18th-18th)."""
"""Monthly spending totals using billing cycle boundaries (18th-18th).
total_crc includes all currencies converted to CRC at current rates.
total_usd is the raw USD amount (unconverted) for display purposes.
"""
amount_crc = get_converted_amount_expr(session)
now = datetime.now()
results = []
month_names = [
@@ -102,18 +111,10 @@ def monthly_trend(
row = session.exec(
select(
func.count(),
func.coalesce(func.sum(amount_crc), 0),
func.coalesce(
func.sum(
func.case(
(Transaction.currency == "CRC", Transaction.amount),
else_=0,
)
),
0,
),
func.coalesce(
func.sum(
func.case(
case(
(Transaction.currency == "USD", Transaction.amount),
else_=0,
)
@@ -162,10 +163,12 @@ def daily_spending(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
amount_crc = get_converted_amount_expr(session)
query = (
select(
func.date(Transaction.date).label("day"),
func.sum(Transaction.amount).label("total"),
func.sum(amount_crc).label("total"),
func.count().label("count"),
)
.where(Transaction.transaction_type == "COMPRA")

View File

@@ -1,8 +1,7 @@
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from fastapi import Depends
from app.auth import create_access_token
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
from app.config import settings
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
)
token = create_access_token(form_data.username)
return {"access_token": token, "token_type": "bearer"}
@router.get("/me")
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
return {"username": username}

View File

@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Path, Query
@@ -7,13 +8,23 @@ from sqlmodel import Session, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
BalanceOverride,
BalanceOverrideCreate,
BalanceOverrideRead,
RecurringItem,
RecurringItemCreate,
RecurringItemRead,
RecurringItemType,
RecurringItemUpdate,
)
from app.services.budget_projection import compute_monthly_projection
from app.services.budget_projection import (
FRESH_START_MONTH,
FRESH_START_YEAR,
MAX_YEAR,
MIN_YEAR,
compute_monthly_projection,
compute_yearly_projection_with_cumulative,
)
router = APIRouter(prefix="/budget", tags=["budget"])
@@ -28,7 +39,9 @@ def list_recurring_items(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = select(RecurringItem)
query = select(RecurringItem).where(
RecurringItem.item_type != RecurringItemType.SAVINGS
)
if item_type:
query = query.where(RecurringItem.item_type == item_type)
if is_active is not None:
@@ -90,13 +103,15 @@ class MonthlyProjectionResponse(BaseModel):
year: int
projected_income: float
projected_fixed_expenses: float
projected_savings: float
actual_credit_card: float
actual_cash: float
actual_transfers: float
uncovered_actual: float
gran_total_egresos: float
net_balance: float
carryover_balance: float = 0.0
cumulative_balance: float = 0.0
balance_overridden: bool = False
class YearlyProjectionResponse(BaseModel):
@@ -104,7 +119,6 @@ class YearlyProjectionResponse(BaseModel):
months: list[MonthlyProjectionResponse]
annual_income: float
annual_expenses: float
annual_savings: float
annual_net: float
@@ -114,31 +128,37 @@ def get_yearly_projection(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
if year < MIN_YEAR or year > MAX_YEAR:
raise HTTPException(
status_code=400,
detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}",
)
months_data = compute_yearly_projection_with_cumulative(session, year)
months = []
annual_income = 0.0
annual_expenses = 0.0
annual_savings = 0.0
annual_net = 0.0
for m in range(1, 13):
data = compute_monthly_projection(session, year, m)
for data in months_data:
monthly = MonthlyProjectionResponse(
month=data["month"],
year=data["year"],
projected_income=data["projected_income"],
projected_fixed_expenses=data["projected_fixed_expenses"],
projected_savings=data["projected_savings"],
actual_credit_card=data["actual_credit_card"],
actual_cash=data["actual_cash"],
actual_transfers=data["actual_transfers"],
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"],
carryover_balance=data["carryover_balance"],
cumulative_balance=data["cumulative_balance"],
balance_overridden=data["balance_overridden"],
)
months.append(monthly)
annual_income += data["projected_income"]
annual_expenses += data["gran_total_egresos"]
annual_savings += data["projected_savings"]
annual_net += data["net_balance"]
return YearlyProjectionResponse(
@@ -146,7 +166,6 @@ def get_yearly_projection(
months=months,
annual_income=annual_income,
annual_expenses=annual_expenses,
annual_savings=annual_savings,
annual_net=annual_net,
)
@@ -171,19 +190,23 @@ class ActualsBySource(BaseModel):
count: int
class CCCategorySpending(BaseModel):
category_name: str
amount: float
class MonthlyDetailResponse(BaseModel):
year: int
month: int
income_items: list[RecurringItemDetail]
expense_items: list[RecurringItemDetail]
savings_items: list[RecurringItemDetail]
actuals_by_source: list[ActualsBySource]
total_projected_income: float
total_projected_expenses: float
total_projected_savings: float
uncovered_actual: float
gran_total_egresos: float
net_balance: float
cc_by_category: list[CCCategorySpending]
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
@@ -199,12 +222,71 @@ def get_monthly_detail(
month=data["month"],
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]],
savings_items=[RecurringItemDetail(**i) for i in data["savings_items"]],
actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
total_projected_income=data["projected_income"],
total_projected_expenses=data["projected_fixed_expenses"],
total_projected_savings=data["projected_savings"],
uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"],
cc_by_category=[CCCategorySpending(**c) for c in data["cc_by_category"]],
)
# --- Balance Override CRUD ---
@router.put(
"/balance-override/{year}/{month}",
response_model=BalanceOverrideRead,
)
def upsert_balance_override(
year: int,
month: int = Path(ge=1, le=12),
data: BalanceOverrideCreate = ...,
session: Session = Depends(get_session),
_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}")
if year == FRESH_START_YEAR and month < FRESH_START_MONTH:
raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
existing = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == month
)
).first()
if existing:
existing.override_balance = data.override_balance
existing.updated_at = datetime.utcnow()
session.add(existing)
session.commit()
session.refresh(existing)
return existing
override = BalanceOverride(
year=year, month=month, override_balance=data.override_balance
)
session.add(override)
session.commit()
session.refresh(override)
return override
@router.delete("/balance-override/{year}/{month}", status_code=204)
def delete_balance_override(
year: int,
month: int = Path(ge=1, le=12),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == month
)
).first()
if not existing:
raise HTTPException(404, "No override found for this month")
session.delete(existing)
session.commit()

View File

@@ -0,0 +1,285 @@
from datetime import datetime
from dateutil.relativedelta import relativedelta
from typing import Optional
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.db import get_session
from app.models.models import (
Category,
Currency,
MunicipalReceipt,
MunicipalReceiptRead,
Transaction,
TransactionSource,
TransactionType,
WaterMeterReading,
WaterMeterReadingRead,
)
from app.services.municipal_receipt_pdf import extract_municipal_receipt
router = APIRouter(prefix="/municipal-receipts", tags=["municipal-receipts"])
# --- Response models ---
class MunicipalReceiptDetailRead(MunicipalReceiptRead):
water_readings: list[WaterMeterReadingRead] = []
class MunicipalReceiptUploadResult(BaseModel):
imported: int
updated: int
errors: list[str]
receipt: Optional[MunicipalReceiptRead] = None
# --- Helpers ---
def _auto_categorize(merchant: str, session: Session) -> Optional[int]:
categories = session.exec(select(Category)).all()
merchant_lower = merchant.lower()
for cat in categories:
if cat.auto_match_patterns:
patterns = [p.strip().lower() for p in cat.auto_match_patterns.split(",")]
if any(p in merchant_lower for p in patterns if p):
return cat.id
return None
def _upsert_receipt(
session: Session, data: dict, filename: str
) -> tuple[MunicipalReceipt, bool]:
"""Insert or update a municipal receipt. Returns (row, is_new)."""
r = data["receipt"]
totals = data["totals"]
receipt_date_str = r["date"]
# The receipt is issued in month N but covers month N-1
receipt_dt = datetime.strptime(receipt_date_str, "%Y-%m-%d").date()
billing_month = receipt_dt - relativedelta(months=1)
period = billing_month.strftime("%Y-%m")
existing = session.exec(
select(MunicipalReceipt).where(
MunicipalReceipt.account == r["account"],
MunicipalReceipt.period == period,
)
).first()
charges = [
{"detail": c["detail"], "amount": c.get("amount", 0)}
for c in data.get("charges", [])
]
fields = dict(
receipt_date=datetime.strptime(receipt_date_str, "%Y-%m-%d").date(),
due_date=datetime.strptime(r["due_date"], "%Y-%m-%d").date(),
period=period,
account=r["account"],
finca=r.get("finca", ""),
holder_name=r.get("account_holder", {}).get("name", ""),
holder_cedula=r.get("account_holder", {}).get("cedula", ""),
holder_address=r.get("account_holder", {}).get("address", ""),
subtotal=totals.get("subtotal", 0),
interests=totals.get("interests", 0),
iva=totals.get("iva", 0),
total=totals.get("total", 0),
raw_charges=charges,
source_filename=filename,
)
if existing:
for k, v in fields.items():
setattr(existing, k, v)
session.add(existing)
# Delete old water readings for this receipt
old_readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == existing.id
)
).all()
for rd in old_readings:
session.delete(rd)
session.flush()
return existing, False
row = MunicipalReceipt(**fields)
session.add(row)
session.flush()
return row, True
def _insert_water_readings(
session: Session, receipt: MunicipalReceipt, data: dict
) -> None:
"""Insert water meter readings (current + historical) for a receipt."""
# Current period readings
for wm in data.get("water_meters", []):
reading = WaterMeterReading(
receipt_id=receipt.id,
meter_id=str(wm["meter_id"]),
period=wm["period"],
reading_previous=wm.get("reading_previous", 0),
reading_current=wm.get("reading_current", 0),
consumption_m3=wm.get("consumption_m3", 0),
agua_potable=wm.get("agua_potable", 0),
serv_ambientales=wm.get("serv_ambientales", 0),
alcant_sanitario=wm.get("alcant_sanitario", 0),
iva=wm.get("iva", 0),
is_historical=False,
)
session.add(reading)
# Historical consumption entries
for hc in data.get("historical_consumption", []):
period = hc["period"]
meter_id = str(hc["meter_id"])
# Upsert: check if this historical entry already exists
existing = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.meter_id == meter_id,
WaterMeterReading.period == period,
WaterMeterReading.is_historical == True, # noqa: E712
)
).first()
if existing:
existing.consumption_m3 = hc.get("consumption_m3", 0)
session.add(existing)
else:
session.add(
WaterMeterReading(
receipt_id=receipt.id,
meter_id=meter_id,
period=period,
consumption_m3=hc.get("consumption_m3", 0),
is_historical=True,
)
)
def _ensure_transaction(
session: Session, receipt: MunicipalReceipt
) -> None:
"""Create a budget Transaction for this receipt if one doesn't exist."""
reference = f"municipal-{receipt.account}-{receipt.period}"
existing = session.exec(
select(Transaction).where(Transaction.reference == reference)
).first()
if existing:
# Update amount in case receipt was re-uploaded with corrections
existing.amount = receipt.total
session.add(existing)
return
category_id = _auto_categorize("municipalidad", session)
tx = Transaction(
amount=receipt.total,
currency=Currency.CRC,
merchant="Municipalidad de Belén",
date=datetime.combine(receipt.receipt_date, datetime.min.time()),
transaction_type=TransactionType.COMPRA,
source=TransactionSource.TRANSFER,
reference=reference,
category_id=category_id,
notes=f"Recibo municipal {receipt.period}",
)
session.add(tx)
# --- Endpoints ---
@router.post("/upload", response_model=MunicipalReceiptUploadResult)
async def upload_municipal_receipt(
file: UploadFile,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
filename = file.filename or "unknown.pdf"
errors: list[str] = []
try:
pdf_bytes = await file.read()
data = extract_municipal_receipt(pdf_bytes, filename)
except ValueError as e:
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
except Exception as e:
return MunicipalReceiptUploadResult(
imported=0, updated=0, errors=[f"{filename}: {e}"]
)
receipt, is_new = _upsert_receipt(session, data, filename)
_insert_water_readings(session, receipt, data)
_ensure_transaction(session, receipt)
session.commit()
session.refresh(receipt)
return MunicipalReceiptUploadResult(
imported=1 if is_new else 0,
updated=0 if is_new else 1,
errors=errors,
receipt=MunicipalReceiptRead.model_validate(receipt),
)
@router.get("/", response_model=list[MunicipalReceiptRead])
def list_receipts(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(MunicipalReceipt).order_by(
MunicipalReceipt.receipt_date.desc() # type: ignore[union-attr]
)
).all()
return rows
@router.get("/water-consumption", response_model=list[WaterMeterReadingRead])
def get_water_consumption(
months: int = Query(default=24, ge=1, le=120),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
rows = session.exec(
select(WaterMeterReading)
.where(WaterMeterReading.is_historical == False) # noqa: E712
.order_by(
WaterMeterReading.period.asc(), # type: ignore[union-attr]
WaterMeterReading.meter_id.asc(), # type: ignore[union-attr]
)
.limit(months * 3) # up to 3 meters per month
).all()
return rows
@router.get("/{receipt_id}", response_model=MunicipalReceiptDetailRead)
def get_receipt_detail(
receipt_id: int,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
receipt = session.get(MunicipalReceipt, receipt_id)
if not receipt:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Receipt not found")
readings = session.exec(
select(WaterMeterReading).where(
WaterMeterReading.receipt_id == receipt_id
)
).all()
return MunicipalReceiptDetailRead(
**MunicipalReceiptRead.model_validate(receipt).model_dump(),
water_readings=[
WaterMeterReadingRead.model_validate(r) for r in readings
],
)

View File

@@ -1,3 +1,5 @@
from datetime import date
from fastapi import APIRouter, Depends, UploadFile
from pydantic import BaseModel
from sqlmodel import Session, select
@@ -12,11 +14,93 @@ router = APIRouter(prefix="/pensions", tags=["pensions"])
class PensionUploadResult(BaseModel):
imported: int
updated: int
duplicates: int
errors: list[str]
snapshots: list[PensionSnapshotRead]
class PensionManualEntry(BaseModel):
fund: str
period_start: date
period_end: date
saldo_anterior: float
aportes: float
rendimientos: float
retiros: float
traslados: float
comision: float
correccion: float = 0.0
bonificacion: float = 0.0
saldo_final: float
class PensionManualRequest(BaseModel):
entries: list[PensionManualEntry]
def _upsert_snapshot(
session: Session,
fund: 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,
source_filename: str,
contract_number: str = "",
) -> tuple[PensionSnapshot, bool]:
"""Insert or update a pension snapshot. Returns (row, is_new)."""
existing = session.exec(
select(PensionSnapshot).where(
PensionSnapshot.fund == Bank(fund),
PensionSnapshot.period_start == period_start,
PensionSnapshot.period_end == period_end,
)
).first()
if existing:
existing.saldo_anterior = saldo_anterior
existing.aportes = aportes
existing.rendimientos = rendimientos
existing.retiros = retiros
existing.traslados = traslados
existing.comision = comision
existing.correccion = correccion
existing.bonificacion = bonificacion
existing.saldo_final = saldo_final
existing.source_filename = source_filename
if contract_number:
existing.contract_number = contract_number
session.add(existing)
return existing, False
row = PensionSnapshot(
fund=Bank(fund),
contract_number=contract_number,
period_start=period_start,
period_end=period_end,
saldo_anterior=saldo_anterior,
aportes=aportes,
rendimientos=rendimientos,
retiros=retiros,
traslados=traslados,
comision=comision,
correccion=correccion,
bonificacion=bonificacion,
saldo_final=saldo_final,
source_filename=source_filename,
)
session.add(row)
return row, True
@router.post("/upload", response_model=PensionUploadResult)
async def upload_pension_pdfs(
files: list[UploadFile],
@@ -24,9 +108,9 @@ async def upload_pension_pdfs(
_user: str = Depends(get_current_user),
):
imported = 0
duplicates = 0
updated = 0
errors: list[str] = []
created: list[PensionSnapshot] = []
results: list[PensionSnapshot] = []
for file in files:
filename = file.filename or "unknown.pdf"
@@ -41,20 +125,9 @@ async def upload_pension_pdfs(
continue
for snap in fund_snapshots:
existing = session.exec(
select(PensionSnapshot).where(
PensionSnapshot.fund == Bank(snap.fund),
PensionSnapshot.period_start == snap.period_start,
PensionSnapshot.period_end == snap.period_end,
)
).first()
if existing:
duplicates += 1
continue
row = PensionSnapshot(
fund=Bank(snap.fund),
contract_number=snap.contract_number,
row, is_new = _upsert_snapshot(
session,
fund=snap.fund,
period_start=snap.period_start,
period_end=snap.period_end,
saldo_anterior=snap.saldo_anterior,
@@ -67,21 +140,72 @@ async def upload_pension_pdfs(
bonificacion=snap.bonificacion,
saldo_final=snap.saldo_final,
source_filename=filename,
contract_number=snap.contract_number,
)
session.add(row)
created.append(row)
results.append(row)
if is_new:
imported += 1
else:
updated += 1
if imported > 0:
if imported > 0 or updated > 0:
session.commit()
for row in created:
for row in results:
session.refresh(row)
return PensionUploadResult(
imported=imported,
duplicates=duplicates,
updated=updated,
duplicates=0,
errors=errors,
snapshots=[PensionSnapshotRead.model_validate(r) for r in created],
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
)
@router.post("/manual", response_model=PensionUploadResult)
def submit_manual_entries(
body: PensionManualRequest,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
imported = 0
updated = 0
results: list[PensionSnapshot] = []
for entry in body.entries:
row, is_new = _upsert_snapshot(
session,
fund=entry.fund,
period_start=entry.period_start,
period_end=entry.period_end,
saldo_anterior=entry.saldo_anterior,
aportes=entry.aportes,
rendimientos=entry.rendimientos,
retiros=entry.retiros,
traslados=entry.traslados,
comision=entry.comision,
correccion=entry.correccion,
bonificacion=entry.bonificacion,
saldo_final=entry.saldo_final,
source_filename="manual-entry",
)
results.append(row)
if is_new:
imported += 1
else:
updated += 1
if imported > 0 or updated > 0:
session.commit()
for row in results:
session.refresh(row)
return PensionUploadResult(
imported=imported,
updated=updated,
duplicates=0,
errors=[],
snapshots=[PensionSnapshotRead.model_validate(r) for r in results],
)

View File

@@ -8,9 +8,12 @@ from sqlmodel import Session, col, func, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import Transaction, TransactionRead, TransactionType
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/salarios", tags=["salarios"])
SALARIO_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
class SalariosSummary(BaseModel):
count: int
@@ -27,7 +30,7 @@ def list_salarios(
):
query = (
select(Transaction)
.where(Transaction.transaction_type == TransactionType.DEPOSITO)
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
.order_by(col(Transaction.date).desc())
.offset(offset)
.limit(limit)
@@ -40,12 +43,13 @@ def salarios_summary(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
amount_crc = get_converted_amount_expr(session)
result = session.exec(
select(
func.count(),
func.coalesce(func.sum(Transaction.amount), 0),
func.coalesce(func.sum(amount_crc), 0),
func.max(Transaction.date),
).where(Transaction.transaction_type == TransactionType.DEPOSITO)
).where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
).first()
return SalariosSummary(
count=result[0] if result else 0,

View File

@@ -0,0 +1,83 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, col, select
from app.auth import get_current_user
from app.db import get_session
from app.models.models import (
SavingsAccrual,
SavingsAccrualCreate,
SavingsAccrualRead,
SavingsAccrualUpdate,
)
router = APIRouter(prefix="/savings-accrual", tags=["savings-accrual"])
@router.get("/", response_model=list[SavingsAccrualRead])
def list_accruals(
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
query = select(SavingsAccrual).order_by(
col(SavingsAccrual.year).desc(), col(SavingsAccrual.month).desc()
)
return session.exec(query).all()
@router.post("/", response_model=SavingsAccrualRead, status_code=201)
def create_accrual(
data: SavingsAccrualCreate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
existing = session.exec(
select(SavingsAccrual).where(
SavingsAccrual.year == data.year,
SavingsAccrual.month == data.month,
)
).first()
if existing:
raise HTTPException(
status_code=409,
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
)
accrual = SavingsAccrual.model_validate(data)
accrual.applied_at = datetime.utcnow()
session.add(accrual)
session.commit()
session.refresh(accrual)
return accrual
@router.patch("/{accrual_id}", response_model=SavingsAccrualRead)
def update_accrual(
accrual_id: int,
data: SavingsAccrualUpdate,
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
accrual = session.get(SavingsAccrual, accrual_id)
if not accrual:
raise HTTPException(status_code=404, detail="Accrual not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(accrual, key, value)
session.add(accrual)
session.commit()
session.refresh(accrual)
return accrual
@router.delete("/{accrual_id}", status_code=204)
def delete_accrual(
accrual_id: int = Path(...),
session: Session = Depends(get_session),
_user: str = Depends(get_current_user),
):
accrual = session.get(SavingsAccrual, accrual_id)
if not accrual:
raise HTTPException(status_code=404, detail="Accrual not found")
session.delete(accrual)
session.commit()

View File

@@ -19,19 +19,12 @@ from app.models.models import (
TransactionUpdate,
)
from app.services.budget_projection import get_cycle_range, get_previous_cycle
from app.services.exchange_rate import get_converted_amount_expr
router = APIRouter(prefix="/transactions", tags=["transactions"])
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
class BillingCycle(BaseModel):
year: int
month: int
@@ -54,6 +47,7 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
@router.get("/", response_model=list[TransactionRead])
def list_transactions(
source: Optional[TransactionSource] = None,
exclude_source: Optional[TransactionSource] = None,
search: Optional[str] = None,
category_id: Optional[int] = None,
cycle_year: Optional[int] = None,
@@ -68,13 +62,32 @@ def list_transactions(
query = select(Transaction)
if source:
query = query.where(Transaction.source == source)
if exclude_source:
query = query.where(Transaction.source != exclude_source)
if category_id:
query = query.where(Transaction.category_id == category_id)
if search:
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
if cycle_year and cycle_month:
start, end = get_cycle_range(cycle_year, cycle_month)
query = query.where(Transaction.date >= start, Transaction.date < end)
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_(
Transaction.date >= start,
Transaction.date < end,
Transaction.deferred_to_next_cycle == False, # noqa: E712
),
and_(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.deferred_to_next_cycle == True, # noqa: E712
),
)
)
elif start_date and end_date:
query = query.where(
Transaction.date >= datetime.fromisoformat(start_date),
@@ -98,6 +111,7 @@ def list_billing_cycles(
return []
min_date, max_date = result
amount_crc = get_converted_amount_expr(session)
cycles = []
# Determine which cycle the min_date falls into
@@ -117,7 +131,7 @@ def list_billing_cycles(
# Count transactions in this cycle
count_result = session.exec(
select(func.count(), func.coalesce(func.sum(Transaction.amount), 0)).where(
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date < end
)
).first()
@@ -182,16 +196,23 @@ def create_transaction(
session.refresh(tx)
# Send push notification
symbol = "" if tx.currency == Currency.CRC else tx.currency.value
symbols = {Currency.CRC: "", Currency.USD: "$", Currency.EUR: ""}
symbol = symbols.get(tx.currency, tx.currency.value)
amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}"
is_deposit = tx.transaction_type == TransactionType.DEPOSITO
is_income = tx.transaction_type in (TransactionType.DEPOSITO, TransactionType.SALARY)
is_salary = tx.transaction_type == TransactionType.SALARY
label = "salario" if is_salary else ("depósito" if is_income else tx.transaction_type.value.lower())
send_push_to_all(
session,
title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}",
url="/salarios" if is_deposit else "/budget",
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {label}",
url="/salarios" if is_income else "/budget",
)
if is_salary:
from app.services.savings_accrual import maybe_apply_monthly_savings
maybe_apply_monthly_savings(session, tx)
return tx

View File

@@ -8,9 +8,11 @@ from app.api.v1.endpoints import (
categories,
exchange_rate,
import_transactions,
municipal_receipts,
notifications,
pensions,
salarios,
savings_accrual,
settings,
tokens,
transactions,
@@ -30,3 +32,5 @@ api_router.include_router(budget.router)
api_router.include_router(notifications.router)
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)

View File

@@ -1,7 +1,9 @@
import hashlib
import re
from datetime import datetime, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi import Cookie, Depends, Header, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlmodel import Session, select
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
# Try JWT first
def _validate_token(token: str) -> str:
"""Validate JWT and return subject, or raise 401."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
@@ -51,3 +53,26 @@ def get_current_user(token: str = Depends(oauth2_scheme)) -> str:
return f"api:{api_token.name}"
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
def get_current_user_cookie_or_bearer(
authorization: Optional[str] = Header(default=None),
ws_token: Optional[str] = Cookie(default=None),
) -> str:
"""Accepts httpOnly cookie (SPA) or Bearer token (API clients / n8n)."""
token: Optional[str] = None
if authorization and authorization.lower().startswith("bearer "):
token = authorization.split(" ", 1)[1].strip()
elif ws_token:
token = ws_token
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
return _validate_token(token)
def get_current_user(
authorization: Optional[str] = Header(default=None),
ws_token: Optional[str] = Cookie(default=None),
) -> str:
"""SPA cookie or Bearer token. Single dependency for all v1 endpoints."""
return get_current_user_cookie_or_bearer(authorization, ws_token)

View File

@@ -12,6 +12,8 @@ class Settings(BaseSettings):
VAPID_PRIVATE_KEY: str = ""
VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
OPENAI_API_KEY: str = ""
AGENT_MODEL: str = "gpt-5.4-mini"
class Config:
env_file = ".env"

View File

@@ -1,3 +1,4 @@
from sqlalchemy import text
from sqlmodel import SQLModel, Session, create_engine
from app.config import settings
@@ -9,6 +10,72 @@ def init_db():
SQLModel.metadata.create_all(engine)
def run_migrations():
"""Run idempotent schema migrations for columns added after initial create."""
with engine.connect() as conn:
try:
conn.execute(
text(
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
)
)
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():
with Session(engine) as session:
yield session

View File

@@ -1,19 +1,77 @@
import asyncio
import json
import re
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
from fastapi import FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from jose import JWTError, jwt
from pydantic import BaseModel
from app.agent.agent import build_agent
from app.agent.tools import reset_session, set_session
from app.api.v1.router import api_router
from app.auth import ALGORITHM, create_access_token
from app.config import settings
from app.db import init_db
from app.db import get_session, init_db, run_migrations
from app.seed import seed_db
from app.services.exchange_rate import refresh_rates_periodically
AGENT_PATH = "/api/v1/agent/agui"
def _pair_orphan_tool_calls(messages: list) -> list:
"""Inject synthetic tool responses for any assistant tool_calls that have
no matching tool message. OpenAI rejects histories where a tool_calls
entry is not immediately followed by the corresponding tool response."""
out: list = []
pending: list[str] = []
def flush():
for call_id in pending:
out.append({"role": "tool", "tool_call_id": call_id, "content": ""})
pending.clear()
for msg in messages:
role = msg.get("role", "")
if role == "tool":
call_id = msg.get("tool_call_id") or msg.get("toolCallId")
if call_id and call_id in pending:
pending.remove(call_id)
out.append(msg)
continue
if role == "assistant":
flush()
out.append(msg)
for tc in msg.get("tool_calls") or msg.get("toolCalls") or []:
tc_id = tc.get("id") if isinstance(tc, dict) else None
if tc_id:
pending.append(tc_id)
continue
flush()
out.append(msg)
flush()
return out
@asynccontextmanager
async def lifespan(app: FastAPI):
init_db()
run_migrations()
seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
try:
yield
finally:
rate_refresh_task.cancel()
try:
await rate_refresh_task
except asyncio.CancelledError:
pass
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
@@ -26,9 +84,106 @@ app.add_middleware(
allow_headers=["*"],
)
@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
bind a DB session to a ContextVar so agent tools can query without going
through Depends."""
if not request.url.path.startswith(AGENT_PATH):
return await call_next(request)
if request.method == "OPTIONS":
return await call_next(request)
auth_header = request.headers.get("authorization", "")
token: str | None = None
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)
if not token:
return Response(status_code=401, content="Missing auth")
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
if not payload.get("sub"):
return Response(status_code=401, content="Invalid token")
except JWTError:
return Response(status_code=401, content="Invalid token")
# 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"] = _pair_orphan_tool_calls(body["messages"])
raw = json.dumps(body).encode()
except Exception:
pass
# 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)
try:
return await call_next(request)
finally:
reset_session(token_var)
try:
next(session_gen)
except StopIteration:
pass
# Register app routes
app.include_router(api_router)
# Mount the AG-UI agent endpoint.
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
@app.get("/")
def root():
return {"app": "WealthySmart", "version": "0.1.0"}
# ── Cookie-based auth endpoints (used by the Vite SPA) ──────────────────────
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/api/auth/login")
def cookie_login(body: LoginRequest, response: Response):
if (
body.username != settings.ADMIN_USERNAME
or body.password != settings.ADMIN_PASSWORD
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(body.username)
response.set_cookie(
key="ws_token",
value=token,
httponly=True,
samesite="lax",
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
secure=False, # set True behind TLS in production via nginx
)
return {"ok": True}
@app.post("/api/auth/logout", status_code=204)
def cookie_logout(response: Response):
response.delete_cookie("ws_token")
@app.get("/api/health")
def health():
return {"ok": True}

View File

@@ -24,6 +24,7 @@ class TransactionType(str, enum.Enum):
COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION"
DEPOSITO = "DEPOSITO"
SALARY = "SALARY"
class TransactionSource(str, enum.Enum):
@@ -35,6 +36,7 @@ class TransactionSource(str, enum.Enum):
class Currency(str, enum.Enum):
CRC = "CRC"
USD = "USD"
EUR = "EUR"
BTC = "BTC"
XMR = "XMR"
@@ -140,6 +142,7 @@ class TransactionBase(SQLModel):
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)
class Transaction(TransactionBase, table=True):
@@ -168,6 +171,7 @@ class TransactionUpdate(SQLModel):
source: Optional[TransactionSource] = None
notes: Optional[str] = None
category_id: Optional[int] = None
deferred_to_next_cycle: Optional[bool] = None
# --- Exchange Rate ---
@@ -333,3 +337,133 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
class PensionSnapshotRead(PensionSnapshotBase):
id: int
created_at: datetime
# --- Balance Override ---
class BalanceOverride(SQLModel, table=True):
__table_args__ = (UniqueConstraint("year", "month"),)
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)
class BalanceOverrideCreate(SQLModel):
override_balance: float
class BalanceOverrideRead(SQLModel):
id: int
year: int
month: int
override_balance: float
updated_at: datetime
# --- Savings Accrual ---
class SavingsAccrualBase(SQLModel):
year: int
month: int
memp_amount: float = 200000.0
mpat_amount: float = 200000.0
trigger_transaction_id: Optional[int] = None
notes: Optional[str] = None
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)
class SavingsAccrualCreate(SavingsAccrualBase):
pass
class SavingsAccrualRead(SavingsAccrualBase):
id: int
applied_at: datetime
class SavingsAccrualUpdate(SQLModel):
memp_amount: Optional[float] = None
mpat_amount: Optional[float] = None
notes: Optional[str] = None
# --- Municipal Receipt ---
class MunicipalReceiptBase(SQLModel):
receipt_date: date
due_date: date
period: str # "YYYY-MM"
account: str
finca: str
holder_name: str
holder_cedula: str
holder_address: str
subtotal: float
interests: float
iva: float
total: float
raw_charges: list[dict] = Field(
default_factory=list,
sa_column=Column(JSON, nullable=False, server_default="[]"),
)
source_filename: str
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)
water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt",
)
class MunicipalReceiptCreate(MunicipalReceiptBase):
pass
class MunicipalReceiptRead(MunicipalReceiptBase):
id: int
created_at: datetime
# --- Water Meter Reading ---
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
is_historical: bool = False
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
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)
receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings",
)
class WaterMeterReadingRead(WaterMeterReadingBase):
id: int
created_at: datetime

View File

@@ -17,7 +17,7 @@ DEFAULT_CATEGORIES = [
("Food & Delivery", "utensils", "uber eats,rappi,mcdonalds,subway,pizza,restaurant,soda,cafe,coyote ugly,el rodeo,steak house"),
("Utilities", "zap", "c.n.f.l,cnfl,ice,aya,claro cr telecomunicaciones"),
("Transportation", "car", "gasolina,gasolinera,uber rides,didi,parqueo,parking,peaje,estacion de servicio,estac.de serv"),
("Shopping", "shopping-bag", "amazon,ebay,ticotek,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"),
("Shopping", "shopping-bag", "amazon,ebay,construplaza,epa,novex,novedades chayfer,total imports,tiendalaliga,gnc live well"),
("Entertainment", "film", "netflix,disney,cine,steam,playstation,blizzard,diablo"),
("Health", "heart-pulse", "farmacia,hospital,clinica,laboratorio,optica,medicina regenerativa,neumi,doer fitness,kettlebell,lacrosse"),
("Education", "graduation-cap", "universidad,udemy,coursera,libro"),
@@ -27,6 +27,7 @@ DEFAULT_CATEGORIES = [
("Telecom", "phone", "liberty,tigo,kolbi"),
("Parking & Fees", "circle-parking", "centro comercial curridabat,debito compass,cobro administr,compass"),
("Auto", "car-front", "auto lavado,lavado"),
("Electronics", "cpu", "extremetechcr,extreme tech,ticotek,ishop,gollo,radioshack"),
("Lab & Medical", "microscope", "laboratorio echandi"),
("Other", "tag", ""),
]
@@ -43,9 +44,6 @@ DEFAULT_ACCOUNTS = [
(Bank.FCL, Currency.CRC, "FCL", AccountType.PENSION),
(Bank.ROP, Currency.CRC, "ROP", AccountType.PENSION),
(Bank.VOL, Currency.CRC, "VOL", AccountType.PENSION),
# Savings (CRC)
(Bank.MEMP, Currency.CRC, "MEMP", AccountType.SAVINGS),
(Bank.MPAT, Currency.CRC, "MPAT", AccountType.SAVINGS),
# Liabilities
(Bank.MORTGAGE, Currency.USD, "Mortgage", AccountType.LIABILITY),
# Crypto
@@ -173,21 +171,6 @@ DEFAULT_RECURRING_ITEMS = [
"month_of_year": 1,
"notes": "Car insurance every 6 months (Jan, Jul)",
},
# Savings
{
"name": "Ahorro MEMP",
"amount": 200000,
"item_type": RecurringItemType.SAVINGS,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Monthly savings to MEMP account",
},
{
"name": "Ahorro MPAT",
"amount": 200000,
"item_type": RecurringItemType.SAVINGS,
"frequency": RecurringFrequency.MONTHLY,
"notes": "Monthly savings to MPAT account",
},
]

View File

@@ -1,9 +1,10 @@
import calendar
from datetime import datetime
from sqlmodel import Session, func, select
from sqlmodel import Session, col, func, select
from app.models.models import (
BalanceOverride,
RecurringFrequency,
RecurringItem,
RecurringItemType,
@@ -11,6 +12,16 @@ from app.models.models import (
TransactionSource,
TransactionType,
)
from app.services.exchange_rate import get_converted_amount_expr
MIN_YEAR = 2026
MAX_YEAR = 2030
# Fresh start: months before this are zeroed out
FRESH_START_YEAR = 2026
FRESH_START_MONTH = 3
# Income-like transaction types that should never be counted as expenses
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
@@ -64,36 +75,138 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
return start, end
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
start = datetime(year, month, 18)
if month == 12:
end = datetime(year + 1, 1, 18)
else:
end = datetime(year, month + 1, 18)
return start, end
def get_previous_cycle(year: int, month: int) -> tuple[int, int]:
"""Return (year, month) for the billing cycle preceding the given one."""
if month == 1:
return year - 1, 12
return year, month - 1
def compute_actuals_by_source(
session: Session, year: int, month: int
) -> dict[str, dict]:
"""Query actual transaction totals for a calendar month, grouped by source."""
start, end = get_month_range(year, month)
"""Query actual transaction totals grouped by source.
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
# CC billing cycle for budget month M is the cycle that *ends* around the 18th of M
# i.e. cycle (M-1): from (M-1)/18 to M/18, paid with month M salary
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
results = {}
for source in TransactionSource:
compra = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
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
)
).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
)
).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
)
).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,
"total_devolucion": devolucion,
"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(Transaction.amount), 0)).where(
Transaction.date >= start,
Transaction.date < end,
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 >= start,
Transaction.date < end,
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
).one()
@@ -112,39 +225,173 @@ def compute_actuals_by_source(
def compute_actuals_by_category(
session: Session, year: int, month: int
) -> dict[int, float]:
"""Return {category_id: net_amount} for actual transactions in a calendar month."""
start, end = get_month_range(year, month)
"""Return {category_id: net_amount} for actual transactions.
rows = session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= start,
Transaction.date < end,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
Credit card uses billing cycle (18th-18th) with deferred logic.
Cash/Transfer use calendar month (1st-1st).
"""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
totals: dict[int, float] = {}
def _merge_rows(rows: list) -> None:
for cat_id, tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# 1) CC normal in this cycle (not deferred)
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 2) CC deferred from previous cycle
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# 3) Non-CC: calendar month
_merge_rows(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
return totals
def compute_cc_by_category(
session: Session, year: int, month: int
) -> list[dict]:
"""Return credit card spending by category for the billing cycle."""
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
amount_crc = get_converted_amount_expr(session)
totals: dict[int | None, float] = {}
def _merge(rows: list) -> None:
for cat_id, tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
totals[cat_id] = totals.get(cat_id, 0) + val
# CC normal in this cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# CC deferred from previous cycle
_merge(
session.exec(
select(
Transaction.category_id,
Transaction.transaction_type,
func.sum(amount_crc),
)
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.category_id, Transaction.transaction_type)
).all()
)
# Resolve category names
from app.models.models import Category
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"
result.append({"category_name": name, "amount": round(amount, 2)})
return sorted(result, key=lambda x: x["amount"], reverse=True)
def compute_monthly_projection(
session: Session, year: int, month: int
) -> dict:
"""Compute full monthly projection with no-double-count logic."""
items = session.exec(
select(RecurringItem).where(RecurringItem.is_active == True) # noqa: E712
select(RecurringItem).where(
RecurringItem.is_active == True, # noqa: E712
RecurringItem.item_type != RecurringItemType.SAVINGS,
)
).all()
actuals_by_source = compute_actuals_by_source(session, year, month)
@@ -152,11 +399,9 @@ def compute_monthly_projection(
income_items = []
expense_items = []
savings_items = []
total_income = 0.0
total_fixed_expenses = 0.0
total_savings = 0.0
for item in items:
effective = get_effective_amount(item, month, year)
@@ -190,10 +435,6 @@ def compute_monthly_projection(
total_fixed_expenses += effective
expense_items.append(detail)
elif item.item_type == RecurringItemType.SAVINGS:
savings_items.append(detail)
total_savings += effective
# Sum actuals from sources for categories NOT covered by recurring items
covered_category_ids = {
item.category_id
@@ -208,34 +449,75 @@ def compute_monthly_projection(
if cat_id not in covered_category_ids:
uncovered_actual += amount
# Also add transactions with no category
start, end = get_month_range(year, month)
uncategorized = session.exec(
select(
Transaction.transaction_type,
func.sum(Transaction.amount),
)
.where(
Transaction.date >= start,
Transaction.date < end,
Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO,
)
.group_by(Transaction.transaction_type)
).all()
for tx_type, amount in uncategorized:
# Also add transactions with no category (hybrid ranges + deferred)
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
def _sum_uncategorized(rows: list) -> float:
total = 0.0
for tx_type, amount in rows:
val = float(amount)
if tx_type == TransactionType.DEVOLUCION:
val = -val
uncovered_actual += val
total += val
return total
# CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cc_start,
Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= prev_start,
Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712
)
.group_by(Transaction.transaction_type)
).all()
)
# Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized(
session.exec(
select(Transaction.transaction_type, func.sum(amount_crc))
.where(
Transaction.date >= cal_start,
Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr]
col(Transaction.transaction_type).notin_(INCOME_TYPES),
)
.group_by(Transaction.transaction_type)
).all()
)
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
cc_by_category = compute_cc_by_category(session, year, month)
gran_total = total_fixed_expenses + uncovered_actual
# Savings are NOT deducted — they are already deducted from gross salary
# (the income amounts are net, post-savings)
net_balance = total_income - gran_total
return {
@@ -243,7 +525,6 @@ def compute_monthly_projection(
"month": month,
"projected_income": total_income,
"projected_fixed_expenses": total_fixed_expenses,
"projected_savings": total_savings,
"actual_credit_card": actual_credit_card,
"actual_cash": actual_cash,
"actual_transfers": actual_transfers,
@@ -252,6 +533,83 @@ def compute_monthly_projection(
"net_balance": net_balance,
"income_items": income_items,
"expense_items": expense_items,
"savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category,
}
def _get_december_cumulative(session: Session, year: int) -> float:
"""Get the cumulative balance for December of a given year."""
# Check for an override first
override = session.exec(
select(BalanceOverride).where(
BalanceOverride.year == year, BalanceOverride.month == 12
)
).first()
if override:
return 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}
cumulative = 0.0
if year > FRESH_START_YEAR:
cumulative = _get_december_cumulative(session, year - 1)
for m in range(1, 13):
if year == FRESH_START_YEAR and m < FRESH_START_MONTH:
continue
data = compute_monthly_projection(session, year, m)
cumulative += data["net_balance"]
if m in override_map:
cumulative = override_map[m]
return cumulative
def compute_yearly_projection_with_cumulative(
session: Session, year: int
) -> list[dict]:
"""Compute all 12 months with cumulative balance tracking."""
overrides = session.exec(
select(BalanceOverride).where(BalanceOverride.year == year)
).all()
override_map = {o.month: o.override_balance for o in overrides}
# Determine January carryover
if year <= FRESH_START_YEAR:
carryover = 0.0
else:
carryover = _get_december_cumulative(session, year - 1)
months = []
for m in range(1, 13):
data = compute_monthly_projection(session, year, m)
is_before_fresh_start = (
year == FRESH_START_YEAR and m < FRESH_START_MONTH
)
if is_before_fresh_start:
data["carryover_balance"] = 0.0
data["cumulative_balance"] = 0.0
data["balance_overridden"] = False
else:
data["carryover_balance"] = carryover
cumulative = carryover + data["net_balance"]
if m in override_map:
cumulative = override_map[m]
data["balance_overridden"] = True
else:
data["balance_overridden"] = False
data["cumulative_balance"] = cumulative
carryover = cumulative
months.append(data)
return months

View File

@@ -1,12 +1,21 @@
import asyncio
import logging
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
import httpx
from sqlalchemy import case
from sqlmodel import Session, col, select
from app.config import settings
from app.db import engine
from app.models.models import ExchangeRate
logger = logging.getLogger(__name__)
# Scheduled refresh interval — 4x/day
REFRESH_INTERVAL_SECONDS = 6 * 3600
# BCCR indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos"
@@ -23,6 +32,14 @@ _cache: dict[str, tuple[ExchangeRate, datetime]] = {}
_last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
CACHE_TTL = timedelta(hours=1)
# Generic X/CRC mid-market rate cache (by currency code)
_xcrc_cache: dict[str, tuple[float, datetime]] = {}
_last_known_xcrc: dict[str, float] = {}
# CoinGecko ids for supported crypto codes
_COINGECKO_IDS = {"BTC": "bitcoin", "XMR": "monero"}
COINGECKO_URL = "https://api.coingecko.com/api/v3/simple/price"
def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
"""Fetch a single indicator from BCCR API."""
@@ -167,6 +184,188 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
return None
def _fetch_fiat_crc_mid(code: str) -> float | None:
"""Derive {code}/CRC mid-market rate from ExchangeRate-API (USD-based).
X/CRC = CRC_per_USD / X_per_USD
"""
try:
resp = httpx.get(EXCHANGERATE_API_URL, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("result") == "success":
crc = data["rates"].get("CRC")
x = data["rates"].get(code)
if crc and x:
return float(crc) / float(x)
except Exception:
pass
return None
def _fetch_crypto_crc(code: str) -> float | None:
"""Fetch {code}/CRC spot from CoinGecko."""
coin_id = _COINGECKO_IDS.get(code)
if not coin_id:
return None
try:
resp = httpx.get(
COINGECKO_URL,
params={"ids": coin_id, "vs_currencies": "crc"},
timeout=10,
)
resp.raise_for_status()
data = resp.json()
price = data.get(coin_id, {}).get("crc")
if price:
return float(price)
except Exception:
pass
return None
def get_crc_rate(code: str) -> float | None:
"""Get current {code}→CRC rate (cached 1 hour). Fiat via ExchangeRate-API, crypto via CoinGecko."""
if code == "CRC":
return 1.0
cached = _xcrc_cache.get(code)
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
return cached[0]
if code in _COINGECKO_IDS:
rate = _fetch_crypto_crc(code)
else:
rate = _fetch_fiat_crc_mid(code)
if rate is not None:
_xcrc_cache[code] = (rate, datetime.utcnow())
_last_known_xcrc[code] = rate
return rate
return _last_known_xcrc.get(code)
def get_crc_multipliers(session: Session) -> dict[str, float]:
"""Return {currency_code: CRC_multiplier} for every supported currency."""
from app.models.models import Currency
multipliers: dict[str, float] = {"CRC": 1.0}
usd_rate = get_current_rate(session)
if usd_rate:
multipliers["USD"] = usd_rate.sell_rate
for code in (c.value for c in Currency):
if code in multipliers:
continue
rate = get_crc_rate(code)
if rate is not None:
multipliers[code] = rate
return multipliers
def get_converted_amount_expr(session: Session):
"""Return a SQLAlchemy expression converting Transaction.amount to CRC.
Builds a CASE that multiplies by the per-currency CRC rate; CRC passes through.
Missing rates fall back to 1.0 (treat as CRC) rather than 0.0 so a transient
API outage does not silently zero out foreign-currency totals.
"""
from app.models.models import Transaction
multipliers = get_crc_multipliers(session)
whens = [
(Transaction.currency == code, Transaction.amount * mult)
for code, mult in multipliers.items()
if code != "CRC"
]
if not whens:
return Transaction.amount
return case(*whens, else_=Transaction.amount)
def _refresh_usd_rate() -> bool:
"""Force-fetch USD/CRC from APIs and persist to DB. Returns True on success."""
fetched = _fetch_rate_from_apis()
if fetched is None:
return False
buy, sell = fetched
with Session(engine) as session:
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
session.add(rate)
session.commit()
session.refresh(rate)
_remember(rate)
return True
def _refresh_other_rate(code: str) -> bool:
"""Force-fetch {code}/CRC and update in-memory cache. Returns True on success."""
if code in _COINGECKO_IDS:
rate = _fetch_crypto_crc(code)
else:
rate = _fetch_fiat_crc_mid(code)
if rate is None:
return False
_xcrc_cache[code] = (rate, datetime.utcnow())
_last_known_xcrc[code] = rate
return True
def refresh_all_rates() -> dict[str, bool]:
"""Force-refresh every supported currency.
Each currency is refreshed independently — one failure does not affect others.
On success the DB (for USD) and in-memory caches are updated. On failure the
previous value is retained via `_last_known_*` / stale-DB fallback, so callers
always see the most recent working rate.
"""
from app.models.models import Currency
results: dict[str, bool] = {}
try:
results["USD"] = _refresh_usd_rate()
except Exception:
logger.exception("USD rate refresh failed")
results["USD"] = False
for currency in Currency:
code = currency.value
if code in ("CRC", "USD"):
continue
try:
results[code] = _refresh_other_rate(code)
except Exception:
logger.exception("%s rate refresh failed", code)
results[code] = False
return results
async def refresh_rates_periodically(
interval_seconds: int = REFRESH_INTERVAL_SECONDS,
) -> None:
"""Background loop that refreshes all currency rates every `interval_seconds`.
Never raises — failures are logged and the last-known rates are retained.
Runs one refresh immediately on startup, then sleeps on the fixed interval.
"""
while True:
try:
report = await asyncio.to_thread(refresh_all_rates)
ok = sorted(k for k, v in report.items() if v)
failed = sorted(k for k, v in report.items() if not v)
logger.info(
"Exchange rate refresh complete: ok=%s failed=%s", ok, failed
)
except Exception:
logger.exception("Exchange rate refresh loop crashed")
await asyncio.sleep(interval_seconds)
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
"""Get historical exchange rates."""
cutoff = datetime.utcnow() - timedelta(days=days)

View File

@@ -0,0 +1,291 @@
"""
Extract structured data from Municipalidad de Belén receipts using pdftotext + regex.
"""
import re
import subprocess
import tempfile
from dataclasses import dataclass, field
def _parse_amount(s: str) -> float:
"""Parse a Costa Rican formatted number: '1,875.00' → 1875.00"""
return float(s.replace(",", ""))
def _parse_date(s: str) -> str:
"""Convert dd/mm/yyyy → YYYY-MM-DD"""
d, m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}-{d.zfill(2)}"
def _parse_period(s: str) -> str:
"""Convert mm/yyyy → YYYY-MM"""
m, y = s.strip().split("/")
return f"{y}-{m.zfill(2)}"
@dataclass
class Charge:
detail: str
interests: float
iva: float
amount: float
@dataclass
class WaterMeter:
period: str
meter_id: str
reading_previous: int
reading_current: int
consumption_m3: int
agua_potable: float
serv_ambientales: float
alcant_sanitario: float
iva: float
@dataclass
class HistoricalConsumption:
meter_id: str
period: str
consumption_m3: int
@dataclass
class MunicipalReceiptData:
receipt_date: str # YYYY-MM-DD
due_date: str # YYYY-MM-DD
holder_name: str
holder_cedula: str
holder_address: str
account: str
finca: str
charges: list[Charge] = field(default_factory=list)
subtotal: float = 0.0
interests: float = 0.0
iva: float = 0.0
total: float = 0.0
water_meters: list[WaterMeter] = field(default_factory=list)
historical_consumption: list[HistoricalConsumption] = field(default_factory=list)
def _pdf_to_text(pdf_bytes: bytes) -> str:
"""Convert PDF bytes to text using pdftotext -layout."""
with tempfile.NamedTemporaryFile(suffix=".pdf") as tmp:
tmp.write(pdf_bytes)
tmp.flush()
result = subprocess.run(
["pdftotext", "-layout", tmp.name, "-"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
raise ValueError(f"pdftotext failed: {result.stderr}")
return result.stdout
# Regex patterns
RE_FECHA = re.compile(r"Fecha:\s*(\d{2}/\d{2}/\d{4})")
RE_VENCIMIENTO = re.compile(r"Fecha de vencimiento:\s*(\d{2}/\d{2}/\d{4})")
RE_NOMBRE = re.compile(r"Nombre:\s*(.+)")
RE_CEDULA = re.compile(r"Cédula:\s*(\d+)")
RE_DIRECCION = re.compile(r"Dirección:\s*(.+)")
# Charge line: DETAIL_TEXT account finca interests iva periodo_actual periodo_anterior
RE_CHARGE = re.compile(
r"^([A-ZÁÉÍÓÚÑ][A-ZÁÉÍÓÚÑ\s.]+?)\s+"
r"(\d{4})\s+"
r"(\d{6}---\d{3})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s*$"
)
RE_SUBTOTAL = re.compile(r"Sub-Total:\s+([\d,]+\.\d{2})")
RE_INTERESES = re.compile(r"Intereses:\s+([\d,]+\.\d{2})")
RE_IVA = re.compile(r"IVA\s+([\d,]+\.\d{2})")
RE_TOTAL = re.compile(r"Total:\s+([\d,]+\.\d{2})")
# Water meter line: period meter_id lec_ant lec_act consumo agua_potable serv_amb alcant iva
RE_WATER_METER = re.compile(
r"(\d{2}/\d{4})\s+"
r"(\d{4})\s+"
r"(\d{5})\s+"
r"(\d{5})\s+"
r"(\d+)\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})\s+"
r"([\d,]+\.\d{2})"
)
# Historical consumption: meter_id period consumption
RE_HISTORICAL = re.compile(
r"(\d{4})\s+(\d{2}/\d{4})\s+(\d{5})"
)
def extract_municipal_receipt(
pdf_bytes: bytes, filename: str
) -> dict:
"""Extract structured data from a municipal receipt PDF.
Returns a dict matching the target JSON schema.
"""
text = _pdf_to_text(pdf_bytes)
if "RECIBO MUNICIPAL" not in text:
raise ValueError(f"{filename}: Not a municipal receipt")
data = MunicipalReceiptData(
receipt_date="",
due_date="",
holder_name="",
holder_cedula="",
holder_address="",
account="",
finca="",
)
# --- Header fields ---
m = RE_FECHA.search(text)
if m:
data.receipt_date = _parse_date(m.group(1))
m = RE_VENCIMIENTO.search(text)
if m:
data.due_date = _parse_date(m.group(1))
m = RE_NOMBRE.search(text)
if m:
data.holder_name = m.group(1).strip()
m = RE_CEDULA.search(text)
if m:
data.holder_cedula = m.group(1).strip()
m = RE_DIRECCION.search(text)
if m:
data.holder_address = m.group(1).strip().rstrip(".")
# --- Charges ---
for line in text.splitlines():
m = RE_CHARGE.match(line.strip())
if m:
detail = m.group(1).strip()
data.account = m.group(2)
data.finca = m.group(3)
interests = _parse_amount(m.group(4))
iva = _parse_amount(m.group(5))
amount = _parse_amount(m.group(6))
data.charges.append(Charge(detail=detail, interests=interests, iva=iva, amount=amount))
# --- Totals ---
m = RE_SUBTOTAL.search(text)
if m:
data.subtotal = _parse_amount(m.group(1))
m = RE_INTERESES.search(text)
if m:
data.interests = _parse_amount(m.group(1))
m = RE_IVA.search(text)
if m:
data.iva = _parse_amount(m.group(1))
m = RE_TOTAL.search(text)
if m:
data.total = _parse_amount(m.group(1))
# --- Water meters ---
for m in RE_WATER_METER.finditer(text):
data.water_meters.append(
WaterMeter(
period=_parse_period(m.group(1)),
meter_id=m.group(2),
reading_previous=int(m.group(3)),
reading_current=int(m.group(4)),
consumption_m3=int(m.group(5)),
agua_potable=_parse_amount(m.group(6)),
serv_ambientales=_parse_amount(m.group(7)),
alcant_sanitario=_parse_amount(m.group(8)),
iva=_parse_amount(m.group(9)),
)
)
# --- Historical consumption ---
# Only parse lines AFTER "DETALLE DE CONSUMO MESES ANTERIORES"
hist_section = text.split("DETALLE DE CONSUMO MESES ANTERIORES")
if len(hist_section) > 1:
for m in RE_HISTORICAL.finditer(hist_section[1]):
data.historical_consumption.append(
HistoricalConsumption(
meter_id=m.group(1),
period=_parse_period(m.group(2)),
consumption_m3=int(m.group(3)),
)
)
# --- Validation ---
if not data.receipt_date:
raise ValueError(f"{filename}: Could not parse receipt date")
if not data.charges:
raise ValueError(f"{filename}: No charges found")
# --- Build output dict ---
return {
"receipt": {
"type": "RECIBO MUNICIPAL",
"issuer": {
"name": "MUNICIPALIDAD DE BELÉN",
"phone": "(506) 2587-0000",
"fax": "(506) 2293-3667",
"website": "www.belen.go.cr",
},
"date": data.receipt_date,
"due_date": data.due_date,
"account_holder": {
"name": data.holder_name,
"cedula": data.holder_cedula,
"address": data.holder_address,
},
"account": data.account,
"finca": data.finca,
},
"charges": [
{"detail": c.detail, "interests": c.interests, "iva": c.iva, "amount": c.amount}
for c in data.charges
],
"totals": {
"subtotal": data.subtotal,
"interests": data.interests,
"iva": data.iva,
"total": data.total,
},
"water_meters": [
{
"period": wm.period,
"meter_id": wm.meter_id,
"reading_previous": wm.reading_previous,
"reading_current": wm.reading_current,
"consumption_m3": wm.consumption_m3,
"agua_potable": wm.agua_potable,
"serv_ambientales": wm.serv_ambientales,
"alcant_sanitario": wm.alcant_sanitario,
"iva": wm.iva,
}
for wm in data.water_meters
],
"historical_consumption": [
{
"meter_id": hc.meter_id,
"period": hc.period,
"consumption_m3": hc.consumption_m3,
}
for hc in data.historical_consumption
],
}

View File

@@ -0,0 +1,62 @@
from sqlmodel import Session, select
from app.models.models import (
Account,
AccountType,
Bank,
SavingsAccrual,
Transaction,
)
MEMP_MONTHLY = 200000.0
MPAT_MONTHLY = 200000.0
def _get_savings_account(session: Session, bank: Bank) -> Account | None:
return session.exec(
select(Account).where(
Account.account_type == AccountType.SAVINGS,
Account.bank == bank,
)
).first()
def maybe_apply_monthly_savings(session: Session, tx: Transaction) -> SavingsAccrual | None:
"""Apply monthly savings contribution if this is the first salary of the month.
Idempotent: if a SavingsAccrual row already exists for (year, month), do nothing.
Bumps MEMP and MPAT savings account balances and records the accrual.
"""
year = tx.date.year
month = tx.date.month
existing = session.exec(
select(SavingsAccrual).where(
SavingsAccrual.year == year,
SavingsAccrual.month == month,
)
).first()
if existing:
return None
memp = _get_savings_account(session, Bank.MEMP)
mpat = _get_savings_account(session, Bank.MPAT)
if memp is None or mpat is None:
return None
memp.balance += MEMP_MONTHLY
mpat.balance += MPAT_MONTHLY
session.add(memp)
session.add(mpat)
accrual = SavingsAccrual(
year=year,
month=month,
memp_amount=MEMP_MONTHLY,
mpat_amount=MPAT_MONTHLY,
trigger_transaction_id=tx.id,
)
session.add(accrual)
session.commit()
session.refresh(accrual)
return accrual

View File

@@ -10,3 +10,7 @@ alembic
httpx
pywebpush
py-vapid
python-dateutil
agent-framework==1.2.1
agent-framework-ag-ui==1.0.0b260428
agent-framework-openai==1.2.1

View File

@@ -30,6 +30,8 @@ services:
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
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}
expose:
- "8000"
networks:
@@ -47,22 +49,32 @@ services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.prod
dockerfile: Dockerfile
target: runner
args:
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
container_name: wealthysmart-frontend-prod
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
JWT_SECRET: ${SECRET_KEY}
COOKIE_DOMAIN: wealth.cescalante.dev
COOKIE_SECURE: "true"
VIRTUAL_HOST: wealth.cescalante.dev
VIRTUAL_PORT: "3000"
LETSENCRYPT_HOST: wealth.cescalante.dev
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
expose:
- "80"
- "3000"
networks:
- wealthysmart-network
- nginx-prod-network
depends_on:
- backend
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/"]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3

View File

@@ -25,6 +25,8 @@ services:
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
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}
ports:
- "8001:8000"
volumes:
@@ -32,17 +34,52 @@ services:
depends_on:
db:
condition: service_healthy
develop:
watch:
- path: ./backend/app
action: sync
target: /app/app
- path: ./backend/requirements.txt
action: rebuild
- path: ./backend/Dockerfile
action: rebuild
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: dev
container_name: wealthysmart-frontend-dev
ports:
- "5175:5173"
volumes:
- ./frontend:/app
- /app/node_modules
- "5175:3000"
environment:
NODE_ENV: development
AGENT_URL: http://backend:8000/api/v1/agent/agui
depends_on:
- backend
develop:
watch:
- path: ./frontend/src
action: sync
target: /app/src
- path: ./frontend/public
action: sync
target: /app/public
- path: ./frontend/server.ts
action: sync
target: /app/server.ts
- path: ./frontend/vite.config.ts
action: sync+restart
target: /app/vite.config.ts
- path: ./frontend/tsconfig.json
action: sync+restart
target: /app/tsconfig.json
- path: ./frontend/package.json
action: rebuild
- path: ./frontend/pnpm-lock.yaml
action: rebuild
- path: ./frontend/Dockerfile
action: rebuild
volumes:
postgres_data:

View File

@@ -0,0 +1,97 @@
{
"name": "WealthySmart: BAC Pensions Statements parser",
"nodes": [
{
"parameters": {
"pollTimes": {
"item": [
{
"hour": 0
}
]
},
"simple": false,
"filters": {
"q": "Estado de cuenta Pensiones BAC CREDOMATIC -{tarjeta} "
},
"options": {
"downloadAttachments": true
}
},
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1.3,
"position": [
0,
0
],
"id": "bc94143f-9980-4b94-9cd7-8e206dc1232e",
"name": "Gmail Trigger",
"credentials": {
"gmailOAuth2": {
"id": "LkAGMVgAqsiCkMFX",
"name": "Gmail account"
}
}
},
{
"parameters": {
"method": "POST",
"url": "https://wealth.cescalante.dev/api/v1/pensions/upload",
"authentication": "genericCredentialType",
"genericAuthType": "httpBearerAuth",
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "files",
"inputDataFieldName": "attachment_0"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.4,
"position": [
208,
0
],
"id": "b73c0182-8d56-48b3-b37e-c4dd9361a062",
"name": "HTTP Request",
"credentials": {
"httpBearerAuth": {
"id": "d4Le4M5bCuhEb2NN",
"name": "BAC Parser - wealth.cescalante.dev"
}
}
}
],
"pinData": {},
"connections": {
"Gmail Trigger": {
"main": [
[
{
"node": "HTTP Request",
"type": "main",
"index": 0
}
]
]
}
},
"active": true,
"settings": {
"executionOrder": "v1",
"binaryMode": "separate"
},
"versionId": "f0bbb681-1cf3-49f4-b06e-de0218b7ff3e",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "f446bf52a953467f756f2c188ff6b09473528d6316ecb3b9568cdbfbaa3258f3"
},
"id": "e88c3UhBeo9WCbcy",
"tags": []
}

View File

@@ -0,0 +1,53 @@
# A2UI Theming Findings
Captured 2026-04-28 while researching how to beautify the table the agent renders via `render_a2ui` without overriding component internals.
## TL;DR
- A2UI v0.9 docs prescribe theming through a small set of **documented top-level CSS variables** declared on `:where(:root)` (zero specificity, so host CSS wins without `!important`).
- Default theme uses `light-dark()` so tokens self-toggle. Recommended dark-mode coupling: add the documented `a2ui-dark` class on `<html>` whenever Tailwind's `.dark` is on; that flips `color-scheme: dark` and resolves every `light-dark()` to its dark branch.
- **In our app, this approach has near-zero visible effect.** We render through `@copilotkit/a2ui-renderer` (React variant), not `@a2ui/lit`. Inspecting the compiled React renderer (`node_modules/.pnpm/@copilotkit+a2ui-renderer@1.56.4/.../catalog/basic/components/*.mjs`), every component uses **hardcoded inline styles**; the only CSS variable consumed across the whole basic catalog is `--a2ui-primary-color`.
- This matches open issues `google/A2UI#977` and `#1285` ("React renderer parity is incomplete").
## Documented token surface (Lit / Angular)
From `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`:
- Color palette: `--a2ui-color-background`, `--a2ui-color-on-background`, `--a2ui-color-surface`, `--a2ui-color-on-surface`, `--a2ui-color-primary`, `--a2ui-color-primary-light/-dark/-hover`, `--a2ui-color-on-primary`, `--a2ui-color-secondary`, `--a2ui-color-secondary-light/-dark/-hover`, `--a2ui-color-on-secondary`, `--a2ui-color-input`, `--a2ui-color-on-input`, `--a2ui-color-border`.
- Borders: `--a2ui-border`, `--a2ui-border-width`, `--a2ui-border-radius`.
- Typography: `--a2ui-font-family-title`, `--a2ui-font-family-monospace`, `--a2ui-font-size`, `--a2ui-font-scale`, `--a2ui-font-size-xs..2xl`, `--a2ui-line-height-headings`, `--a2ui-line-height-body`.
- Spacing: `--a2ui-grid-base`, `--a2ui-spacing-xs..xl`.
Per-component tokens like `--a2ui-card-background` exist but the renderer README and the official theming guide explicitly frame them as catalog-internal, not stable spec. Avoid relying on them.
## Dark-mode coupling
A2UI default uses `light-dark(...)` and supports forced modes via `a2ui-light` / `a2ui-dark` classes (see the `:where(.a2ui-dark)` block in `default.ts`). Best practice for a host that uses Tailwind `.dark` on `<html>`: toggle the matching `a2ui-dark` class in lockstep — one effect-driven `useEffect`, or add it directly to the root if the app is dark-only. No need to redeclare every token under `.dark`; the default rules already pick up the right branch through `light-dark()`.
## Footguns in v0.9
- The agent-supplied `theme` payload (e.g. `createSurface.theme.primaryColor`) is **not yet wired** in the basic catalog. Tracked in `google/A2UI#979` (Lit) and `#977` (React). Don't expect agent-side theming to recolor anything in v0.9; only the host CSS does.
- `hintedStyles` (h1h6 mappings) require all keys when overridden — see `google/A2UI#602`.
- Tracking issues for the broader theming surface: `google/A2UI#1083`, `#1118`.
## Why this won't move the table in our app
`@copilotkit/a2ui-renderer@1.56.4` (React) is what `<CopilotKit a2ui={{}}>` plugs in. Its components apply colors and borders as inline `style` props (`backgroundColor: "#fff"`, `border: "1px solid #ccc"`, etc.). Inline styles can only be overridden with `!important` in author CSS, which contradicts the "don't take its freedom" goal. So the documented `--a2ui-*` token contract is currently a **Lit-only surface** for this catalog.
## Practical options for this codebase
1. **Quick win**: set `--a2ui-primary-color: var(--primary)` on `:root`. Real but small effect (primary buttons, links).
2. **Prompt-side polish**: instruct the agent to compose richer A2UI structures — wrap the table in a `Card`, use `usageHint: "h3"` for the section title, insert a `Divider`, group columns with `Row` + `Column` weights — so the renderer's existing inline styles produce a cleaner result. No code change.
3. **Switch renderer**: replace `@copilotkit/a2ui-renderer` with `@a2ui/lit` (or wait for `@a2ui/react` parity). Then every documented `--a2ui-*` token actually applies. Non-trivial migration.
4. **Custom catalog overrides**: register replacement React components for `Row`/`List`/`Card` via `createA2UICatalog`; full control, most invasive, accepts ownership of those components forever.
## Source links
- `tech_docs/A2UI/docs/guides/theming.md`
- `tech_docs/A2UI/renderers/web_core/src/v0_9/basic_catalog/styles/default.ts`
- `tech_docs/A2UI/renderers/lit/README.md` §"CSS-based Basic Catalog Theming"
- `https://github.com/google/A2UI/issues/977` (React renderer theming gap)
- `https://github.com/google/A2UI/issues/1285` (React renderer parity)
- `https://github.com/google/A2UI/issues/1118` (theming surface roadmap)
- `https://github.com/google/A2UI/issues/979` (Lit `theme` payload not wired)
- `https://github.com/google/A2UI/pull/1079` (v0.9 CSS-variable rollout)

15
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.next
out
build
coverage
.DS_Store
*.pem
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
.env*
.vercel
*.tsbuildinfo
next-env.d.ts

41
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -1,7 +1,39 @@
FROM node:20-slim
RUN corepack enable && corepack prepare pnpm@latest --activate
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
# Dev: Vite HMR on port 3000 + Hono CK server on port 3001
FROM node:22-alpine AS dev
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["pnpm", "run", "dev", "--", "--host"]
ENV NODE_ENV=development
EXPOSE 3000
CMD ["sh", "-c", "corepack enable && pnpm dev"]
# Build Vite SPA
FROM node:22-alpine AS builder
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
RUN corepack enable && pnpm build
# Production: Hono serves dist/ + /api/copilotkit on port 3000
FROM node:22-alpine AS runner
RUN apk add --no-cache libc6-compat
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY server.ts package.json ./
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["./node_modules/.bin/tsx", "server.ts"]

View File

@@ -1,14 +0,0 @@
# Stage 1: Build
FROM node:20-slim AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -12,8 +12,6 @@
<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.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>WealthySmart</title>
</head>
<body>

View File

@@ -1,31 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API to backend (same docker network)
location /api/ {
proxy_pass http://wealthysmart-backend-prod:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# No cache for service worker
location /sw.js {
add_header Cache-Control "no-cache";
}
# Cache immutable assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1,37 +1,46 @@
{
"name": "wealthysmart-frontend",
"private": true,
"name": "frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"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": "vite preview"
"preview": "tsx server.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@ag-ui/client": "0.0.52",
"@base-ui/react": "^1.4.1",
"@copilotkit/react-core": "1.56.4",
"@copilotkit/react-ui": "1.56.4",
"@copilotkit/runtime": "1.56.4",
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.12.0",
"recharts": "^2.15.4",
"shadcn": "^4.1.0",
"concurrently": "^9.1.2",
"hono": "^4.12.15",
"lucide-react": "^1.12.0",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-router-dom": "^7.6.0",
"recharts": "^3.8.1",
"rxjs": "^7.8.1",
"tailwind-merge": "^3.5.0",
"tsx": "^4.19.4",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.4"
"@tailwindcss/vite": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react-swc": "^3.9.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6.3.5"
}
}

8442
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,3 @@
onlyBuiltDependencies: '["@swc/core", "esbuild"]'
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#10b981"/>
<text x="16" y="23" text-anchor="middle" font-size="20" font-weight="bold" fill="#0f172a" font-family="system-ui">W</text>
</svg>

Before

Width:  |  Height:  |  Size: 248 B

View File

@@ -1,27 +0,0 @@
{
"name": "WealthySmart",
"short_name": "WealthySmart",
"description": "Smart personal finance management",
"start_url": "/",
"display": "standalone",
"background_color": "#0f172a",
"theme_color": "#0f172a",
"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,98 +1,4 @@
const CACHE_NAME = 'wealthysmart-v1';
const STATIC_ASSETS = ['/', '/index.html'];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS))
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Network-first for API calls
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(request).catch(() => caches.match(request)));
return;
}
// Only handle http(s) requests — skip chrome-extension:// etc.
if (!url.protocol.startsWith('http')) return;
// Cache-first for static assets
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.match(request).then((cached) => cached || fetch(request).then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return res;
}))
);
return;
}
// Network-first for navigation, fallback to cached index.html
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match('/index.html'))
);
return;
}
// Default: network with cache fallback
event.respondWith(fetch(request).catch(() => caches.match(request)));
});
// --- Push Notifications ---
self.addEventListener('push', (event) => {
if (!event.data) return;
let data;
try {
data = event.data.json();
} catch {
// Fallback for plain-text pushes (e.g. browser test pushes)
data = { title: 'WealthySmart', body: event.data.text() };
}
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
data: { url: data.url || '/' },
vibrate: [200, 100, 200],
tag: 'transaction',
renotify: true,
};
event.waitUntil(self.registration.showNotification(data.title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
for (const client of windowClients) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.navigate(url);
return client.focus();
}
}
return clients.openWindow(url);
})
);
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", async () => {
await self.registration.unregister();
});

416
frontend/server.ts Normal file
View File

@@ -0,0 +1,416 @@
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import {
CopilotRuntime,
ExperimentalEmptyAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
import { Observable } 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";
const isProd = process.env.NODE_ENV === "production";
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
// ── pairOrphanToolCalls ──────────────────────────────────────────────────────
// CopilotKit's browser-side store sometimes drops the tool-role message
// between turns, producing assistant(toolCalls=[X]) → assistant(text) which
// OpenAI rejects. Inject a synthetic empty tool response for each orphan id.
interface AGUIMessage {
id?: string;
role?: string;
content?: unknown;
toolCalls?: Array<{ id?: string }>;
tool_calls?: Array<{ id?: string }>;
toolCallId?: string;
tool_call_id?: string;
}
function pairOrphanToolCalls(messages: AGUIMessage[]): AGUIMessage[] {
const out: AGUIMessage[] = [];
let pending: string[] = [];
const flush = () => {
for (const callId of pending) {
out.push({ id: `synth-${Math.random().toString(36).slice(2)}`, role: "tool", toolCallId: callId, content: "" });
}
pending = [];
};
for (const msg of messages) {
const role = String(msg?.role ?? "");
if (role === "tool") {
const callId = msg.toolCallId ?? msg.tool_call_id;
if (callId) pending = pending.filter((p) => p !== callId);
out.push(msg);
continue;
}
if (role === "assistant") {
flush();
const toolCalls = msg.toolCalls ?? msg.tool_calls ?? [];
out.push(msg);
for (const tc of toolCalls) {
if (tc?.id) pending.push(String(tc.id));
}
continue;
}
flush();
out.push(msg);
}
flush();
return out;
}
// ── DeduplicateToolCallMiddleware ─────────────────────────────────────────────
// MAF re-emits TOOL_CALL_START for declaration-only calls when they are invoked
// in parallel with other tools, causing verifyEvents to throw an AGUIError.
// This middleware tracks completed tool calls and silently drops any duplicate
// START/ARGS/END events for a call ID that has already been closed.
//
// NOTE: runNextWithState emits { event, messages, state } wrappers; always
// extract `.event` and forward the raw BaseEvent — never the wrapper.
// ── DebugLogMiddleware ──────────────────────────────────────────────────────
// Temporary: logs every event type flowing out to diagnose double-response bug.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class DebugLogMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
const type: string = event?.type ?? "";
const extra = event?.toolCallName ? ` [${event.toolCallName}]`
: event?.activityType ? ` [${event.activityType}]`
: event?.messageId ? ` [msg:${event.messageId.slice(0, 8)}]`
: "";
console.log(`[DBG] ${type}${extra}`);
if (type === EventType.MESSAGES_SNAPSHOT) {
const msgs = (event as any)?.messages ?? [];
console.log(`[DBG] SNAPSHOT msgs: ${msgs.map((m: any) => `${m.role}:${(m.id ?? "").slice(0,8)}:${typeof m.content === "string" ? m.content.length : "?"}c`).join(" | ")}`);
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class DeduplicateToolCallMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
const open = new Set<string>(); // tool call IDs currently in progress
const closed = new Set<string>(); // tool call IDs that already received END
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event; // raw BaseEvent
const type: string = event?.type ?? "";
const id: string | undefined = event?.toolCallId;
if (type === EventType.TOOL_CALL_START) {
if (!id || closed.has(id) || open.has(id)) return; // duplicate
open.add(id);
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
if (id && closed.has(id)) return; // already completed, drop
if (type === EventType.TOOL_CALL_END && id) {
open.delete(id);
closed.add(id);
}
}
observer.next(event); // emit raw BaseEvent
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── StripModelArtifactsMiddleware ────────────────────────────────────────────
// Some OpenAI models occasionally leak training-data special tokens such as
// `<|ipynb_marker|>` into completion text. Filter them out of streamed deltas
// before they reach the chat UI.
const ARTIFACT_RE = /<\|[^|>]+\|>/g;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class StripModelArtifactsMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event;
if (
event?.type === EventType.TEXT_MESSAGE_CONTENT &&
typeof event?.delta === "string" &&
ARTIFACT_RE.test(event.delta)
) {
const cleaned = event.delta.replace(ARTIFACT_RE, "");
if (cleaned.length === 0) return;
observer.next({ ...event, delta: cleaned });
return;
}
observer.next(event);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── 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`.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class ReconcileSnapshotMiddleware 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>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// 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 (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
error: (err: any) => observer.error(err),
complete: () => observer.complete(),
});
return () => sub.unsubscribe();
});
}
}
// ── SuppressRenderToolTextMiddleware ──────────────────────────────────────────
// When the LLM emits text content in the same response as a render tool call
// (render_a2ui or render_spending_summary), the text appears as a duplicate
// 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"]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
run(input: any, next: any): Observable<any> {
let renderToolSeen = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const textBuffer: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return new Observable<any>((observer) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sub = (this as any).runNextWithState(input, next).subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (eventWithState: any) => {
const event = eventWithState.event; // raw BaseEvent
const type: string = event?.type ?? "";
if (type === EventType.TOOL_CALL_START) {
const toolName: string = event?.toolCallName ?? "";
if (RENDER_TOOLS.has(toolName)) {
renderToolSeen = true;
textBuffer.length = 0; // discard text buffered before we saw the tool call
}
}
if (
type === EventType.TEXT_MESSAGE_START ||
type === EventType.TEXT_MESSAGE_CONTENT ||
type === EventType.TEXT_MESSAGE_END
) {
if (!renderToolSeen) textBuffer.push(event); // buffer raw event
return; // always hold — flush at turn end
}
if (type === EventType.RUN_FINISHED || type === EventType.RUN_ERROR) {
if (!renderToolSeen) {
for (const e of textBuffer) observer.next(e); // flush raw events
}
textBuffer.length = 0;
observer.next(event); // emit raw event
return;
}
observer.next(event); // emit raw event
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: (err: any) => observer.error(err),
complete: () => {
if (!renderToolSeen) {
for (const e of textBuffer) observer.next(e);
}
observer.complete();
},
});
return () => sub.unsubscribe();
});
}
}
// ── Hono app ─────────────────────────────────────────────────────────────────
const app = new Hono();
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}` }
: {};
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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 DeduplicateToolCallMiddleware() as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent.use(new DebugLogMiddleware() as any);
const runtime = new CopilotRuntime({
agents: { wealthysmart: agent },
a2ui: { injectA2UITool: true },
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[] };
if (!Array.isArray(body.messages)) return;
const paired = pairOrphanToolCalls(body.messages);
if (paired.length === body.messages.length) return;
return new Request(outbound.url, {
method: outbound.method,
headers: outbound.headers,
body: JSON.stringify({ ...body, messages: paired }),
});
} catch {
return;
}
},
});
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter: new ExperimentalEmptyAdapter(),
endpoint: "/api/copilotkit",
});
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
});
app.get("/api/health", (c) => c.json({ ok: true }));
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
// but in prod the browser talks to this Hono server, which must forward
// `/api/v1/*` and `/api/auth/*` to the FastAPI container — otherwise the SPA
// fallback below swallows them and returns index.html.
const proxyToBackend = async (c: import("hono").Context) => {
const url = new URL(c.req.url);
const target = `${BACKEND_URL}${url.pathname}${url.search}`;
const method = c.req.method;
const headers = new Headers(c.req.raw.headers);
headers.delete("host");
const init: RequestInit = {
method,
headers,
redirect: "manual",
};
if (method !== "GET" && method !== "HEAD") {
init.body = c.req.raw.body;
// @ts-expect-error undici requires duplex for streamed bodies
init.duplex = "half";
}
return fetch(target, init);
};
app.all("/api/v1/*", proxyToBackend);
app.all("/api/auth/*", proxyToBackend);
// In production, serve the Vite build output.
if (isProd) {
app.use("/*", serveStatic({ root: "./dist" }));
// SPA fallback: any non-asset path returns index.html
app.get("/*", serveStatic({ path: "./dist/index.html" }));
}
serve({ fetch: app.fetch, port: PORT }, (info) => {
console.log(`CopilotKit server on port ${info.port} [${isProd ? "prod" : "dev"}]`);
});

View File

@@ -1,27 +1,34 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './AuthContext';
import { ThemeProvider } from './ThemeContext';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Budget from './pages/Budget';
import Analytics from './pages/Analytics';
import Salarios from './pages/Salarios';
import Pensions from './pages/Pensions';
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { CopilotKit } from "@copilotkit/react-core";
import { AuthProvider, useAuth } from "./AuthContext";
import { ThemeProvider } from "./contexts/theme-context";
import { PrivacyProvider } from "./contexts/privacy-context";
import Layout from "./components/Layout";
import LoginPage from "./pages/Login";
import Asistente from "./pages/Asistente";
import Analytics from "./pages/Analytics";
import Budget from "./pages/Budget";
import Salarios from "./pages/Salarios";
import Pensions from "./pages/Pensions";
import Proyecciones from "./pages/Proyecciones";
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
function AppRoutes() {
const { isAuthenticated } = useAuth();
const { isAuthenticated, isLoading } = useAuth();
if (isLoading) return null;
return (
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
/>
<Route
element={
@@ -30,12 +37,14 @@ function AppRoutes() {
</ProtectedRoute>
}
>
<Route path="/" element={<Dashboard />} />
<Route index element={<Navigate to="/asistente" replace />} />
<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="/pensions" element={<Pensions />} />
{/* Redirect old routes */}
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
</Route>
@@ -47,9 +56,13 @@ export default function App() {
return (
<BrowserRouter>
<ThemeProvider>
<PrivacyProvider>
<AuthProvider>
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
<AppRoutes />
</CopilotKit>
</AuthProvider>
</PrivacyProvider>
</ThemeProvider>
</BrowserRouter>
);

View File

@@ -1,33 +1,40 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
import { logout as apiLogout } from "@/lib/api";
interface AuthCtx {
isAuthenticated: boolean;
logout: () => void;
isLoading: boolean;
logout: () => Promise<void>;
setAuthenticated: (v: boolean) => void;
}
const AuthContext = createContext<AuthCtx>({
isAuthenticated: false,
logout: () => {},
isLoading: true,
logout: async () => {},
setAuthenticated: () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token'));
const [isAuthenticated, setAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const check = () => setAuthenticated(!!localStorage.getItem('token'));
window.addEventListener('storage', check);
return () => window.removeEventListener('storage', check);
// 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));
}, []);
const logout = () => {
localStorage.removeItem('token');
const logout = async () => {
await apiLogout();
setAuthenticated(false);
};
return (
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}>
<AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
{children}
</AuthContext.Provider>
);

View File

@@ -1,31 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({ theme: 'dark', toggleTheme: () => {} });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme') as Theme;
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -1,280 +0,0 @@
import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api/v1',
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(err);
},
);
export default api;
export async function login(username: string, password: string) {
const form = new URLSearchParams();
form.append('username', username);
form.append('password', password);
const { data } = await api.post('/auth/login', form);
localStorage.setItem('token', data.access_token);
return data;
}
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
}
// --- User Settings ---
export interface SectionSettings {
label: string;
color: string;
cardColor: string;
visible: boolean;
order: number;
expanded: boolean;
}
export interface DashboardSettings {
sections: Record<string, SectionSettings>;
}
export interface UserSettingsData {
dashboard: DashboardSettings;
}
export interface UserSettingsResponse {
key: string;
data: UserSettingsData;
updated_at: string;
}
export const getSettings = () => api.get<UserSettingsResponse>('/settings/');
export const updateSettings = (data: UserSettingsData) => api.patch<UserSettingsResponse>('/settings/', { data });
export interface Transaction {
id: number;
amount: number;
currency: string;
merchant: string;
city: string | null;
date: string;
card_type: string | null;
card_last4: string | null;
authorization_code: string | null;
reference: string | null;
transaction_type: string;
source: string;
bank: string;
notes: string | null;
category_id: number | null;
category: Category | null;
created_at: string;
}
// --- Budget / Recurring Items ---
export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS';
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY';
export interface RecurringItem {
id: number;
name: string;
amount: number;
currency: string;
item_type: RecurringItemType;
frequency: RecurringFrequency;
day_of_month: number | null;
month_of_year: number | null;
override_amounts: Record<string, number> | null;
category_id: number | null;
is_active: boolean;
notes: string | null;
created_at: string;
category: Category | null;
}
export interface RecurringItemCreate {
name: string;
amount: number;
currency?: string;
item_type: RecurringItemType;
frequency?: RecurringFrequency;
day_of_month?: number | null;
month_of_year?: number | null;
override_amounts?: Record<string, number> | null;
category_id?: number | null;
is_active?: boolean;
notes?: string | null;
}
export interface RecurringItemUpdate {
name?: string;
amount?: number;
currency?: string;
item_type?: RecurringItemType;
frequency?: RecurringFrequency;
day_of_month?: number | null;
month_of_year?: number | null;
override_amounts?: Record<string, number> | null;
category_id?: number | null;
is_active?: boolean;
notes?: string | null;
}
export interface RecurringItemDetail {
id: number;
name: string;
amount: number;
projected_amount: number | null;
used_actual: boolean;
item_type: string;
frequency: string;
category_name: string | null;
category_id: number | null;
}
export interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export interface MonthlyProjection {
month: number;
year: number;
projected_income: number;
projected_fixed_expenses: number;
projected_savings: number;
actual_credit_card: number;
actual_cash: number;
actual_transfers: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
}
export interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_savings: number;
annual_net: number;
}
export interface MonthlyDetail {
year: number;
month: number;
income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[];
savings_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[];
total_projected_income: number;
total_projected_expenses: number;
total_projected_savings: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
}
// Budget API functions
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
api.get<RecurringItem[]>('/budget/recurring', { params });
export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>('/budget/recurring', data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) =>
api.delete(`/budget/recurring/${id}`);
export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
// --- Salarios ---
export interface SalariosSummary {
count: number;
total_amount: number;
latest_date: string | null;
}
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>('/salarios/', { params });
export const getSalariosSummary = () =>
api.get<SalariosSummary>('/salarios/summary');
// --- Pensions ---
export interface PensionSnapshot {
id: number;
fund: string;
contract_number: string;
period_start: string;
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
source_filename: string;
created_at: string;
}
export interface PensionUploadResult {
imported: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
export const uploadPensionPDFs = (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append('files', f));
return api.post<PensionUploadResult>('/pensions/upload', form);
};
export const getPensionSnapshots = () =>
api.get<PensionSnapshot[]>('/pensions/snapshots');
export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>('/pensions/fund-summary');

View File

@@ -0,0 +1,39 @@
"use client";
import { CopilotChat } from "@copilotkit/react-ui";
import { Sparkles } from "lucide-react";
const SUGGESTIONS = [
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
];
export default function AgentHomeClient() {
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight font-heading flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat
className="h-full"
labels={{
title: "WealthySmart",
initial: "¿Qué quieres saber sobre tus finanzas?",
placeholder: "Escribe tu pregunta…",
}}
suggestions={SUGGESTIONS}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Calendar } from 'lucide-react';
import api from '../api';
import api from '@/lib/api';
import {
Select,
SelectContent,

View File

@@ -1,76 +0,0 @@
import { Settings } from 'lucide-react';
import type { SectionSettings } from '../api';
import { formatAmount } from '@/lib/format';
import { Card } from '@/components/ui/card';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from '@/components/ui/accordion';
import { getColorClasses } from '@/lib/colors';
import { cn } from '@/lib/utils';
interface Props {
sectionId: string;
settings: SectionSettings;
total?: number;
totalCurrency?: string;
onToggleExpanded: (expanded: boolean) => void;
onOpenConfig: () => void;
children: React.ReactNode;
}
export default function DashboardSection({
sectionId,
settings,
total,
totalCurrency,
onToggleExpanded,
onOpenConfig,
children,
}: Props) {
const colors = getColorClasses(settings.color);
return (
<Card className={cn('relative overflow-hidden border-l-4', colors.borderLeft)}>
{/* Settings icon — outside accordion trigger to avoid button-in-button */}
<button
onClick={onOpenConfig}
className="absolute top-2.5 right-3 z-10 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors cursor-pointer"
title="Section settings"
aria-label="Section settings"
>
<Settings className="w-3.5 h-3.5" />
</button>
<Accordion
value={settings.expanded ? [sectionId] : []}
onValueChange={(value: string[]) => onToggleExpanded(value.includes(sectionId))}
>
<AccordionItem value={sectionId} className="border-none">
<AccordionTrigger
className="px-4 py-3 hover:no-underline cursor-pointer"
aria-label={`Expand ${settings.label}`}
>
<div className="flex items-center justify-between w-full pr-8">
<span className="text-sm font-semibold text-foreground">
{settings.label}
</span>
{total != null && totalCurrency && (
<span className="text-sm font-bold font-mono text-foreground">
{formatAmount(total, totalCurrency)}
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
<div className="divide-y divide-border mx-4 mb-4 rounded-lg overflow-hidden">
{children}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</Card>
);
}

View File

@@ -1,93 +1,139 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
Sparkles,
Calculator,
BarChart3,
Landmark,
PiggyBank,
Droplets,
LogOut,
TrendingUp,
Wallet,
Menu,
Sun,
Moon,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useAuth } from '../AuthContext';
import { useTheme } from '../ThemeContext';
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button';
Eye,
EyeOff,
type LucideIcon,
} from "lucide-react";
import { useTheme } from "@/contexts/theme-context";
import { usePrivacy } from "@/contexts/privacy-context";
import { useAuth } from "@/AuthContext";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetClose,
} from '@/components/ui/sheet';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
} from "@/components/ui/sheet";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/budget', icon: Calculator, label: 'Presupuesto' },
{ to: '/salarios', icon: Landmark, label: 'Salarios' },
{ to: '/pensions', icon: PiggyBank, label: 'Pensiones' },
{ to: '/analytics', icon: BarChart3, label: 'Analytics' },
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>
);
}
export default function Layout() {
const { logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => {
subscribeToPush();
}, []);
const handleLogout = () => {
logout();
navigate('/login');
const handleLogout = async () => {
await logout();
navigate("/login", { replace: true });
};
return (
<div className="min-h-screen bg-background text-foreground">
{/* Top bar */}
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
>
<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 font-heading">
<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>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
end={to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
))}
</nav>
<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" />}
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
@@ -99,70 +145,69 @@ export default function Layout() {
>
<LogOut className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setMobileOpen(true)}
title="Open menu"
aria-label="Open menu"
className="md:hidden"
>
<Menu className="w-5 h-5" />
</Button>
</div>
</div>
</header>
{/* Mobile nav sheet */}
<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">
<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 className="font-heading">
<span style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</SheetTitle>
</SheetHeader>
<Separator />
<nav className="flex flex-col gap-1 p-4">
{navItems.map(({ to, icon: Icon, label }) => (
<SheetClose key={to} render={<span />}>
<NavLink
to={to}
end={to === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)
}
>
<Icon className="w-4 h-4" />
{label}
</NavLink>
</SheetClose>
))}
<Separator className="my-2" />
<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={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full"
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" />
Sign out
Cerrar sesión
</button>
</nav>
</SheetClose>
</div>
</div>
</SheetContent>
</Sheet>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-6xl mx-auto">
<Outlet />
</div>
</main>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import api, { type ImportResult } from '../api';
import api, { type ImportResult } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
import { type PensionUploadResult, submitPensionManualEntries } from '@/lib/api';
import { parsePensionPaste, type PensionParsedEntry } from '@/lib/parsePensionPaste';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface Props {
onClose: () => void;
onImported: () => void;
}
const formatCRC = (n: number) =>
new Intl.NumberFormat('es-CR', {
style: 'currency',
currency: 'CRC',
maximumFractionDigits: 0,
}).format(n);
const FUND_LABELS: Record<string, string> = {
ROP: 'ROP',
FCL: 'FCL',
VOL: 'Voluntario',
};
export default function PensionManualEntryModal({ onClose, onImported }: Props) {
const [text, setText] = useState('');
const [parsed, setParsed] = useState<PensionParsedEntry[] | null>(null);
const [parseError, setParseError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<PensionUploadResult | null>(null);
const handlePreview = () => {
setParseError('');
const entries = parsePensionPaste(text);
if (entries.length === 0) {
setParseError('No se encontraron datos de fondos. Verifica que el texto pegado tenga el formato correcto.');
setParsed(null);
return;
}
setParsed(entries);
};
const handleSubmit = async () => {
if (!parsed) return;
setSubmitting(true);
try {
const { data } = await submitPensionManualEntries(parsed);
setResult(data);
if (data.imported > 0 || data.updated > 0) onImported();
} catch (err) {
console.error(err);
} finally {
setSubmitting(false);
}
};
return (
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ClipboardPaste className="w-4 h-4 text-primary" />
Ingresar Datos de Pensión
</DialogTitle>
</DialogHeader>
{result ? (
<div className="space-y-4">
<Alert>
<CheckCircle className="h-4 w-4 text-primary" />
<AlertTitle className="text-primary">Datos Guardados</AlertTitle>
<AlertDescription>
{result.imported > 0 && `${result.imported} nuevo(s)`}
{result.imported > 0 && result.updated > 0 && ' · '}
{result.updated > 0 && `${result.updated} actualizado(s)`}
</AlertDescription>
</Alert>
<Button onClick={onClose} className="w-full">Listo</Button>
</div>
) : !parsed ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Pegar resumen del período</Label>
<Textarea
className="h-56 font-mono text-xs resize-y"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Pega aquí el texto del resumen de BAC Pensiones.\n\nEjemplo:\nResumen del Período\tROP\tFCL\nSaldo Anterior\t¢ 18,684,764.98\t¢ 650,467.87\nAportes\t¢ 120,012.00\t¢ 60,006.00\n...\n\nSepara ROP+FCL y Voluntario con una línea "---"`}
/>
<p className="text-xs text-muted-foreground">
Pega los bloques de ROP+FCL y Fondo Voluntario. Sepáralos con "---".
</p>
</div>
{parseError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{parseError}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancelar</Button>
<Button onClick={handlePreview} disabled={!text.trim()}>
Vista Previa
</Button>
</DialogFooter>
</div>
) : (
<div className="space-y-4">
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-3 py-2 font-medium">Fondo</th>
<th className="text-left px-3 py-2 font-medium">Período</th>
<th className="text-right px-3 py-2 font-medium">Saldo Ant.</th>
<th className="text-right px-3 py-2 font-medium">Aportes</th>
<th className="text-right px-3 py-2 font-medium">Rendim.</th>
<th className="text-right px-3 py-2 font-medium">Saldo Final</th>
</tr>
</thead>
<tbody>
{parsed.map((e, i) => (
<tr key={i} className="border-b last:border-0">
<td className="px-3 py-2 font-medium">{FUND_LABELS[e.fund] ?? e.fund}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{e.period_start} {e.period_end}
</td>
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.saldo_anterior)}</td>
<td className="px-3 py-2 text-right font-mono text-xs">{formatCRC(e.aportes)}</td>
<td className={`px-3 py-2 text-right font-mono text-xs ${e.rendimientos < 0 ? 'text-red-500' : 'text-green-600'}`}>
{formatCRC(e.rendimientos)}
</td>
<td className="px-3 py-2 text-right font-mono text-xs font-semibold">{formatCRC(e.saldo_final)}</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setParsed(null)}>
Editar
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Guardando...' : 'Confirmar'}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,137 +0,0 @@
import { useState } from 'react';
import type { SectionSettings } from '../api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { COLOR_OPTIONS, getColorClasses } from '@/lib/colors';
import { cn } from '@/lib/utils';
interface Props {
sectionId: string;
settings: SectionSettings;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (sectionId: string, updated: Partial<SectionSettings>) => void;
}
function ColorSwatch({ color }: { color: string }) {
const classes = getColorClasses(color);
return (
<span className="flex items-center gap-2">
<span className={cn('w-3 h-3 rounded-full', classes.bg, classes.ring, 'ring-1')} />
{color}
</span>
);
}
export default function SectionConfigDialog({ sectionId, settings, open, onOpenChange, onSave }: Props) {
const [label, setLabel] = useState(settings.label);
const [color, setColor] = useState(settings.color);
const [cardColor, setCardColor] = useState(settings.cardColor);
const [visible, setVisible] = useState(settings.visible);
const [order, setOrder] = useState(String(settings.order));
const handleSave = () => {
onSave(sectionId, {
label,
color,
cardColor,
visible,
order: parseInt(order) || 0,
});
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Configure Section</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Label</Label>
<Input value={label} onChange={(e) => setLabel(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Section Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c}>
<ColorSwatch color={c} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Card Color</Label>
<Select value={cardColor} onValueChange={setCardColor}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COLOR_OPTIONS.map((c) => (
<SelectItem key={c} value={c}>
<ColorSwatch color={c} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor={`visible-${sectionId}`}>Visible</Label>
<input
id={`visible-${sectionId}`}
type="checkbox"
checked={visible}
onChange={(e) => setVisible(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary cursor-pointer"
/>
</div>
<div className="space-y-2">
<Label>Order</Label>
<Input
type="number"
min="0"
max="10"
value={order}
onChange={(e) => setOrder(e.target.value)}
className="w-20"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,9 +7,11 @@ import {
TrendingUp,
TrendingDown,
ArrowLeftRight,
ArrowRightFromLine,
Banknote,
} from 'lucide-react';
import api, { type Transaction } from '../api';
import api, { type Transaction } from '@/lib/api';
import TransactionModal from './TransactionModal';
import ConfirmDialog from './ConfirmDialog';
import { Button } from '@/components/ui/button';
@@ -30,7 +32,9 @@ export interface TransactionListProps {
emptyIcon?: React.ReactNode;
emptyMessage?: string;
showCategory?: boolean;
showSourceIcon?: boolean;
addLabel?: string;
onToggleDeferred?: (tx: Transaction) => void;
}
export default function TransactionList({
@@ -43,7 +47,9 @@ export default function TransactionList({
emptyIcon,
emptyMessage = 'No transactions found',
showCategory = true,
showSourceIcon = false,
addLabel = 'Add Transaction',
onToggleDeferred,
}: TransactionListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Transaction | null>(null);
@@ -68,8 +74,8 @@ export default function TransactionList({
};
const columns = useMemo(
() => getTransactionColumns({ showCategory, onEdit: handleEdit, onDelete: (id) => setDeleteId(id) }),
[showCategory],
() => getTransactionColumns({ showCategory, showSourceIcon, onEdit: handleEdit, onDelete: (id) => setDeleteId(id), onToggleDeferred }),
[showCategory, showSourceIcon, onToggleDeferred],
);
const empty = transactions.length === 0 && !loading;
@@ -119,7 +125,16 @@ export default function TransactionList({
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
{showSourceIcon && (
tx.source === 'CASH'
? <Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: tx.source === 'TRANSFER'
? <ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: null
)}
</div>
<p className="text-xs text-muted-foreground">
{new Date(tx.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
{showCategory && tx.category && (
@@ -128,6 +143,7 @@ export default function TransactionList({
</p>
</div>
<span
data-sensitive
className={cn(
'font-mono text-sm font-medium shrink-0',
tx.transaction_type === 'DEVOLUCION' && 'text-primary'
@@ -137,6 +153,18 @@ export default function TransactionList({
{formatAmount(tx.amount, tx.currency)}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{onToggleDeferred && (
<Button
variant="ghost"
size="icon"
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
onClick={() => onToggleDeferred(tx)}
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
>
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" title="Edit transaction" aria-label="Edit transaction" onClick={() => handleEdit(tx)}>
<Pencil className="w-4 h-4" />
</Button>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import api, { type Category, type Transaction } from '../api';
import api, { type Category, type Transaction } from '@/lib/api';
import { formatLocalDatetime } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -33,7 +34,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
merchant: '',
amount: '',
currency: 'CRC',
date: new Date().toISOString().slice(0, 16),
date: formatLocalDatetime(new Date()),
transaction_type: 'COMPRA',
source,
bank: 'BAC',
@@ -47,7 +48,10 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
const [error, setError] = useState('');
useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data));
api.get('/categories/').then((r) => {
const sorted = [...r.data].sort((a: Category, b: Category) => a.name.localeCompare(b.name));
setCategories(sorted);
});
}, []);
useEffect(() => {
@@ -147,6 +151,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
<SelectContent>
<SelectItem value="CRC">CRC ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
</SelectContent>
</Select>
</div>
@@ -178,7 +183,11 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })}
>
<SelectTrigger className="w-full">
<SelectValue />
<SelectValue>
{form.category_id
? categories.find((c) => c.id === Number(form.category_id))?.name ?? form.category_id
: 'Auto-detect'}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>

View File

@@ -1,150 +1,491 @@
import { type MonthlyDetail as MonthlyDetailType } from '@/api';
import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import {
TrendingUp,
TrendingDown,
PiggyBank,
CreditCard,
Banknote,
ArrowLeftRight,
Info,
} from 'lucide-react';
const MONTH_NAMES = [
'', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
type PaletteMode = 'chatgpt' | 'gemini';
const SOURCE_LABELS: Record<string, { label: string; icon: typeof CreditCard }> = {
CREDIT_CARD: { label: 'Tarjeta de Crédito', icon: CreditCard },
const PALETTES: Record<PaletteMode, { income: string[]; expense: string[]; cc: string[] }> = {
chatgpt: {
// Pure green scale, darkest → lightest (assigned by rank)
income: ['#14532D', '#16A34A', '#4ADE80', '#BBF7D0'],
// Pure amber scale, darkest → lightest (assigned by rank)
expense: ['#92400E', '#B45309', '#D97706', '#F59E0B', '#FCD34D'],
// Warm-to-cool alternating for CC categories
cc: ['#B45309', '#2563EB', '#DC2626', '#16A34A', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5'],
},
gemini: {
// Qualitative greens: dark green, mint, pale green, forest
income: ['#2D6A4F', '#52B788', '#B7E4C7', '#1B4332'],
// Terracotta, slate blue, sage, sand — diverse hues
expense: ['#E07A5F', '#3D405B', '#81B29A', '#F2CC8F', '#D56B4E', '#2E344A', '#6A9E85', '#E5B87A'],
// Pastel/muted diverse for CC categories
cc: ['#6366F1', '#EC4899', '#14B8A6', '#F97316', '#8B5CF6',
'#06B6D4', '#EF4444', '#10B981', '#F59E0B', '#3B82F6'],
},
};
const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> = {
CASH: { label: 'Efectivo', icon: Banknote },
TRANSFER: { label: 'Transferencias', icon: ArrowLeftRight },
};
interface MonthlyDetailProps {
detail: MonthlyDetailType;
detail: MonthlyDetailType | null;
loading?: boolean;
onNavigateToTransactions?: () => void;
}
export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
if (loading) {
function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof TrendingUp; title: string }) {
return (
<div className="grid gap-4 md:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="h-48" />
</Card>
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<TitleIcon className="w-4 h-4" />
{title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center">
<div className="h-[200px] w-full flex items-center justify-center">
<Skeleton className="h-[160px] w-[160px] rounded-full" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-1.5">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</CardContent>
</Card>
);
}
export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading || !detail) {
return (
<div className="space-y-4">
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<PieCardSkeleton titleIcon={TrendingUp} title="Ingresos" />
<PieCardSkeleton titleIcon={TrendingDown} title="Egresos Fijos" />
</div>
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<div className="h-[200px] w-full md:w-1/2 flex items-center justify-center">
<Skeleton className="h-[160px] w-[160px] rounded-full" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-1.5">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 flex-1" />
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-28" />
</div>
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2 text-muted-foreground">
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
))}
</CardContent>
</Card>
<Card className="border-2 border-muted/40">
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
</div>
<Separator />
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-6 w-32" />
</div>
</CardContent>
</Card>
</div>
</div>
);
}
const { income: incomeColors, expense: expenseColors, cc: ccColors } = PALETTES[paletteMode];
const incomeData = detail.income_items.map((item) => ({ name: item.name, value: item.amount }));
const expenseData = detail.expense_items.map((item) => ({ name: item.name, value: item.amount }));
// For ChatGPT mode: assign colors by rank (largest = darkest)
// For Gemini mode: assign colors by position (qualitative)
function buildColorMap(data: { name: string; value: number }[], colors: string[]): Map<string, string> {
if (paletteMode === 'chatgpt') {
const sorted = [...data].sort((a, b) => b.value - a.value);
const map = new Map<string, string>();
sorted.forEach((item, i) => {
map.set(item.name, colors[Math.min(i, colors.length - 1)]);
});
return map;
}
// Gemini: positional
const map = new Map<string, string>();
data.forEach((item, i) => {
map.set(item.name, colors[i % colors.length]);
});
return map;
}
const incomeColorMap = buildColorMap(incomeData, incomeColors);
const expenseColorMap = buildColorMap(expenseData, expenseColors);
const incomeConfig = incomeData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: incomeColorMap.get(item.name)! };
return acc;
}, {});
const expenseConfig = expenseData.reduce<ChartConfig>((acc, item) => {
acc[item.name] = { label: item.name, color: expenseColorMap.get(item.name)! };
return acc;
}, {});
// CC spending by category
const ccData = (detail.cc_by_category ?? []).map((item) => ({
name: item.category_name,
value: item.amount,
}));
const ccConfig = ccData.reduce<ChartConfig>((acc, item, i) => {
acc[item.name] = { label: item.name, color: ccColors[i % ccColors.length] };
return acc;
}, {});
const ccTotal = ccData.reduce((sum, item) => sum + item.value, 0);
// Filter actuals to only cash and transfer (no credit card)
const cashTransferActuals = detail.actuals_by_source.filter(
(src) => src.source !== 'CREDIT_CARD' && src.count > 0
);
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">
Detalle: {MONTH_NAMES[detail.month]} {detail.year}
</h3>
{/* Palette Toggle */}
<div className="flex items-center justify-end gap-1">
<span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<Button
variant={paletteMode === 'chatgpt' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('chatgpt')}
>
ChatGPT
</Button>
<Button
variant={paletteMode === 'gemini' ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
onClick={() => setPaletteMode('gemini')}
>
Gemini
</Button>
</div>
<div className="grid gap-4 md:grid-cols-3">
{/* Income Card */}
{/* Pie Charts */}
<div className="grid gap-4 md:grid-cols-2">
{/* Income Pie */}
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
Ingresos
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.income_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span className="truncate mr-2">{item.name}</span>
<span className="font-mono text-primary whitespace-nowrap">
{formatAmount(item.amount, 'CRC')}
</span>
<CardContent>
{incomeData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={incomeConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={incomeData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{incomeData.map((item, i) => (
<Cell key={i} fill={incomeColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{incomeData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: incomeColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total</span>
<span className="font-mono text-primary">
<span data-sensitive className="font-mono text-primary">
{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin ingresos</p>
)}
</CardContent>
</Card>
{/* Expenses Card */}
{/* Expenses Pie */}
<Card>
<CardHeader className="pb-2">
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-destructive" />
Egresos Fijos
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.expense_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1 truncate mr-2">
<span className="truncate">{item.name}</span>
{item.used_actual && (
<Badge variant="secondary" className="text-[10px] px-1 py-0 shrink-0">
real
</Badge>
<CardContent>
{expenseData.length > 0 ? (
<div className="flex flex-col items-center">
<ChartContainer config={expenseConfig} className="h-[200px] w-full">
<PieChart>
<Pie
data={expenseData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{expenseData.map((item, i) => (
<Cell key={i} fill={expenseColorMap.get(item.name)!} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
</div>
<div className="text-right whitespace-nowrap">
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
{item.used_actual && item.projected_amount != null && (
<span className="block text-[10px] text-muted-foreground font-mono line-through">
{formatAmount(item.projected_amount, 'CRC')}
</span>
)}
</div>
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 mt-1 w-full">
{expenseData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: expenseColorMap.get(item.name) }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
{detail.expense_items.length === 0 && (
<p className="text-sm text-muted-foreground">Sin egresos fijos</p>
)}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Fijos</span>
<span className="font-mono">
<span data-sensitive className="font-mono">
{formatAmount(detail.total_projected_expenses, 'CRC')}
</span>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">Sin egresos fijos</p>
)}
</CardContent>
</Card>
</div>
{/* Credit Card by Category */}
{ccData.length > 0 && (
<Card>
<CardHeader className="pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Tarjeta de Crédito
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row items-center gap-4">
<ChartContainer config={ccConfig} className="h-[200px] w-full md:w-1/2">
<PieChart>
<Pie
data={ccData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={2}
strokeWidth={2}
stroke="var(--card)"
>
{ccData.map((_, i) => (
<Cell key={i} fill={ccColors[i % ccColors.length]} />
))}
</Pie>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => (
<span>{name}: {formatAmount(Number(value), 'CRC')}</span>
)}
/>
}
/>
</PieChart>
</ChartContainer>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 w-full md:w-1/2">
{ccData.map((item, i) => (
<div key={item.name} className="flex items-center gap-1.5 text-xs">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: ccColors[i % ccColors.length] }}
/>
<span className="truncate text-muted-foreground">{item.name}</span>
</div>
))}
</div>
</div>
<Separator className="mt-3" />
<div className="flex items-center justify-between w-full mt-2 font-semibold text-sm">
<span>Total Tarjeta</span>
<span data-sensitive className="font-mono">
{formatAmount(ccTotal, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Actuals Card */}
{/* Actuals + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Cash & Transfer Actuals Card */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CreditCard className="w-4 h-4" />
Transacciones Reales
<Banknote className="w-4 h-4" />
Efectivo o Transferencias
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.actuals_by_source.map((src) => {
{cashTransferActuals.map((src) => {
const meta = SOURCE_LABELS[src.source];
if (!meta || src.count === 0) return null;
if (!meta) return null;
const Icon = meta.icon;
const isClickable = onNavigateToTransactions != null;
return (
<div key={src.source} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1.5">
<button
type="button"
className={cn(
'flex items-center gap-1.5',
isClickable && 'cursor-pointer hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
)}
onClick={isClickable ? onNavigateToTransactions : undefined}
disabled={!isClickable}
>
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
<span>{meta.label}</span>
<span className="text-xs text-muted-foreground">({src.count})</span>
</div>
<span className="font-mono whitespace-nowrap">
</button>
<span data-sensitive className="font-mono whitespace-nowrap">
{formatAmount(src.net, 'CRC')}
</span>
</div>
);
})}
{cashTransferActuals.length === 0 && (
<p className="text-sm text-muted-foreground">Sin transacciones</p>
)}
{detail.uncovered_actual > 0 && (
<>
<Separator />
@@ -153,42 +494,12 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
<Info className="w-3 h-3 text-muted-foreground" />
<span className="text-muted-foreground">No cubierto por fijos</span>
</div>
<span className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
<span data-sensitive className="font-mono">{formatAmount(detail.uncovered_actual, 'CRC')}</span>
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* Savings + Summary */}
<div className="grid gap-4 md:grid-cols-2">
{/* Savings */}
{detail.savings_items.length > 0 && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<PiggyBank className="w-4 h-4" />
Ahorro
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{detail.savings_items.map((item) => (
<div key={item.id} className="flex items-center justify-between text-sm">
<span>{item.name}</span>
<span className="font-mono">{formatAmount(item.amount, 'CRC')}</span>
</div>
))}
<Separator />
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Ahorro</span>
<span className="font-mono">
{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Summary */}
<Card className={cn(
@@ -198,26 +509,21 @@ export default function MonthlyDetail({ detail, loading }: MonthlyDetailProps) {
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>Total Ingresos</span>
<span className="font-mono font-medium text-primary">
<span data-sensitive className="font-mono font-medium text-primary">
+{formatAmount(detail.total_projected_income, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Gran Total Egresos</span>
<span className="font-mono font-medium">
<span data-sensitive className="font-mono font-medium">
-{formatAmount(detail.gran_total_egresos, 'CRC')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Ahorro</span>
<span className="font-mono font-medium">
-{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
<Separator />
<div className="flex items-center justify-between">
<span className="font-semibold">Balance Neto</span>
<span
data-sensitive
className={cn(
'font-mono font-bold text-lg',
detail.net_balance >= 0 ? 'text-primary' : 'text-destructive',

View File

@@ -5,7 +5,7 @@ import {
type RecurringItemUpdate,
type RecurringItemType,
type RecurringFrequency,
} from '@/api';
} from '@/lib/api';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -29,7 +29,6 @@ import { Plus, Trash2 } from 'lucide-react';
const TYPE_OPTIONS: { value: RecurringItemType; label: string }[] = [
{ value: 'INCOME', label: 'Ingreso' },
{ value: 'EXPENSE', label: 'Egreso' },
{ value: 'SAVINGS', label: 'Ahorro' },
];
const FREQ_OPTIONS: { value: RecurringFrequency; label: string }[] = [

View File

@@ -4,7 +4,7 @@ import {
type RecurringItem,
type RecurringItemCreate,
type RecurringItemUpdate,
} from '@/api';
} from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -17,7 +17,6 @@ import ConfirmDialog from '@/components/ConfirmDialog';
const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
INCOME: { label: 'Ingreso', variant: 'default' },
EXPENSE: { label: 'Egreso', variant: 'secondary' },
SAVINGS: { label: 'Ahorro', variant: 'outline' },
};
const FREQ_LABELS: Record<string, string> = {
@@ -106,7 +105,7 @@ export default function RecurringItemsManager({
<DataTableColumnHeader column={column} title="Monto" className="justify-end" />
),
cell: ({ row }) => (
<span className="font-mono text-sm">
<span data-sensitive className="font-mono text-sm">
{formatAmount(row.original.amount, row.original.currency)}
</span>
),

View File

@@ -1,6 +1,10 @@
import { type MonthlyProjection } from '@/api';
import { useState, useRef, useEffect } from 'react';
import { Pencil } from 'lucide-react';
import { type MonthlyProjection } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
@@ -15,19 +19,65 @@ const MONTH_NAMES = [
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const FRESH_START_YEAR = 2026;
const FRESH_START_MONTH = 3;
interface YearlyOverviewProps {
months: MonthlyProjection[];
selectedMonth: number;
year: number;
onSelectMonth: (month: number) => void;
onSaveOverride: (month: number, value: number) => Promise<void>;
onClearOverride: (month: number) => Promise<void>;
}
export default function YearlyOverview({
months,
selectedMonth,
year,
onSelectMonth,
onSaveOverride,
onClearOverride,
}: YearlyOverviewProps) {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
const [editingMonth, setEditingMonth] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingMonth !== null && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingMonth]);
const handleStartEdit = (m: MonthlyProjection) => {
setEditingMonth(m.month);
setEditValue(String(Math.round(m.cumulative_balance)));
};
const handleSave = async () => {
if (editingMonth === null) return;
const trimmed = editValue.trim();
if (trimmed === '') {
await onClearOverride(editingMonth);
} else {
const num = parseFloat(trimmed);
if (!isNaN(num)) {
await onSaveOverride(editingMonth, num);
}
}
setEditingMonth(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
setEditingMonth(null);
}
};
return (
<div className="overflow-x-auto">
@@ -39,14 +89,19 @@ export default function YearlyOverview({
<TableHead className="text-right">Egresos Fijos</TableHead>
<TableHead className="text-right">Otros Gastos</TableHead>
<TableHead className="text-right">Gran Total</TableHead>
<TableHead className="text-right">Ahorro</TableHead>
<TableHead className="text-right">Balance</TableHead>
<TableHead className="text-right">Acum. Anterior</TableHead>
<TableHead className="text-right">Neto Mes</TableHead>
<TableHead className="text-right">Balance Acum.</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{months.map((m) => {
const isSelected = m.month === selectedMonth;
const isCurrent = m.month === currentMonth && m.year === currentYear;
const isBeforeFreshStart =
year === FRESH_START_YEAR && m.month < FRESH_START_MONTH;
const isEditing = editingMonth === m.month;
return (
<TableRow
key={m.month}
@@ -54,6 +109,7 @@ export default function YearlyOverview({
'cursor-pointer transition-colors',
isSelected && 'bg-accent',
isCurrent && !isSelected && 'bg-accent/40',
isBeforeFreshStart && 'opacity-40',
)}
onClick={() => onSelectMonth(m.month)}
>
@@ -63,22 +119,36 @@ export default function YearlyOverview({
<span className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-primary" />
)}
</TableCell>
<TableCell className="text-right font-mono text-sm text-primary">
<TableCell data-sensitive className="text-right font-mono text-sm text-primary">
{formatAmount(m.projected_income, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm">
<TableCell data-sensitive className="text-right font-mono text-sm">
{formatAmount(m.projected_fixed_expenses, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm text-muted-foreground">
<TableCell data-sensitive className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.uncovered_actual, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm font-medium">
<TableCell data-sensitive className="text-right font-mono text-sm font-medium">
{formatAmount(m.gran_total_egresos, 'CRC')}
</TableCell>
<TableCell className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.projected_savings, 'CRC')}
<TableCell
className={cn(
'text-right font-mono text-sm',
m.carryover_balance >= 0
? 'text-muted-foreground'
: 'text-destructive',
)}
>
{isBeforeFreshStart
? '—'
: <span data-sensitive>
{m.carryover_balance >= 0 ? '+' : ''}
{formatAmount(m.carryover_balance, 'CRC')}
</span>
}
</TableCell>
<TableCell
data-sensitive
className={cn(
'text-right font-mono text-sm font-semibold',
m.net_balance >= 0 ? 'text-primary' : 'text-destructive',
@@ -87,6 +157,46 @@ export default function YearlyOverview({
{m.net_balance >= 0 ? '+' : ''}
{formatAmount(m.net_balance, 'CRC')}
</TableCell>
<TableCell
className="text-right font-mono text-sm font-semibold p-0 pr-2"
onClick={(e) => {
if (isBeforeFreshStart) return;
e.stopPropagation();
if (!isEditing) handleStartEdit(m);
}}
>
{isBeforeFreshStart ? (
<span className="px-2"></span>
) : isEditing ? (
<Input
ref={inputRef}
type="number"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="h-7 w-36 text-right font-mono text-sm ml-auto"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-0.5 rounded cursor-pointer hover:bg-muted/50',
m.cumulative_balance >= 0
? 'text-primary'
: 'text-destructive',
)}
>
{m.balance_overridden && (
<Pencil className="w-3 h-3 text-amber-500 shrink-0" />
)}
<span data-sensitive>
{m.cumulative_balance >= 0 ? '+' : ''}
{formatAmount(m.cumulative_balance, 'CRC')}
</span>
</span>
)}
</TableCell>
</TableRow>
);
})}

View File

@@ -0,0 +1,456 @@
import { TrendingDown, TrendingUp, Wallet, ArrowRightLeft } from "lucide-react";
// ── helpers ──────────────────────────────────────────────────────────────────
function fmtCRC(n: number | undefined | null) {
if (n == null) return "₡0";
return `${Math.round(n).toLocaleString("es-CR")}`;
}
function fmtCurrency(amount: number | undefined | null, currency: string | undefined | null) {
if (amount == null) return "₡0";
if (currency === "USD") return `$${amount.toFixed(2)}`;
if (currency === "EUR") return `${amount.toFixed(2)}`;
return fmtCRC(amount);
}
function sourceLabel(source: string | undefined | null) {
if (!source) return "Otro";
const map: Record<string, string> = {
CREDIT_CARD: "Tarjeta",
CASH: "Efectivo",
TRANSFER: "Transferencia",
SINPE: "SINPE",
OTHER: "Otro",
};
return map[source] ?? source.replace(/_/g, " ");
}
// ── Spinner ───────────────────────────────────────────────────────────────────
function Spinner() {
return (
<div className="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block" />
);
}
// ── SpendingSummaryCard ───────────────────────────────────────────────────────
export interface SpendingBySource {
source: string;
total_crc: number;
count: number;
}
export interface SpendingByCategory {
category: string;
amount_crc: number;
count: number;
}
export interface SpendingSummaryArgs {
title?: string;
period?: string;
total_crc?: number;
by_source?: SpendingBySource[];
by_category?: SpendingByCategory[];
}
export function SpendingSummaryCard({
args,
status,
}: {
args: SpendingSummaryArgs;
status: string;
}) {
const { title, period, total_crc, by_source = [], by_category = [] } = args;
const max = Math.max(...by_category.map((c) => c.amount_crc), 1);
const PALETTE = [
"bg-primary",
"bg-chart-1",
"bg-chart-2",
"bg-chart-3",
"bg-chart-4",
"bg-chart-5",
];
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
{title ?? "Resumen de gastos"}
</p>
{period && (
<p className="text-xs text-muted-foreground mt-0.5">{period}</p>
)}
</div>
<div className="text-right shrink-0">
<p className="text-2xl font-bold tabular-nums">{fmtCRC(total_crc)}</p>
<p className="text-[11px] text-muted-foreground">total gastado</p>
</div>
</div>
{/* By source */}
{by_source.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{by_source.filter((s) => s?.source != null).map((s) => (
<div
key={s.source}
className="rounded-lg bg-secondary/40 border border-border/50 px-3 py-2"
>
<p className="text-[11px] text-muted-foreground">
{sourceLabel(s.source)}
</p>
<p className="font-semibold text-sm tabular-nums">
{fmtCRC(s.total_crc)}
</p>
<p className="text-[11px] text-muted-foreground">
{s.count} mov.
</p>
</div>
))}
</div>
)}
{/* By category */}
{by_category.length > 0 && (
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-2">
Por categoría
</p>
<div className="space-y-2">
{by_category.filter((c) => c?.category != null).slice(0, 7).map((c, i) => (
<div key={c.category}>
<div className="flex justify-between text-xs mb-1">
<span className="text-card-foreground">{c.category}</span>
<span className="text-muted-foreground tabular-nums">
{fmtCRC(c.amount_crc)}
<span className="text-[10px] ml-1">({c.count})</span>
</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${PALETTE[i % PALETTE.length]}`}
style={{
width: `${Math.round(((c.amount_crc ?? 0) / max) * 100)}%`,
}}
/>
</div>
</div>
))}
</div>
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Obteniendo datos
</div>
)}
</div>
);
}
// ── TransactionListCard ───────────────────────────────────────────────────────
export interface TransactionRow {
date: string;
merchant: string;
amount: number;
currency: string;
category: string | null;
source: string;
}
export interface TransactionListArgs {
title?: string;
transactions?: TransactionRow[];
}
const SOURCE_ICON: Record<string, React.ReactNode> = {
CREDIT_CARD: <Wallet className="w-3 h-3" />,
TRANSFER: <ArrowRightLeft className="w-3 h-3" />,
};
export function TransactionListCard({
args,
status,
}: {
args: TransactionListArgs;
status: string;
}) {
const { title, transactions = [] } = args;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 my-2 shadow-sm">
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-3">
{title ?? "Transacciones"}
</p>
{transactions.length === 0 && status !== "inProgress" && (
<p className="text-sm text-muted-foreground">Sin transacciones.</p>
)}
<div className="divide-y divide-border/50">
{transactions.map((t, i) => (
<div key={i} className="flex items-center justify-between py-2 gap-3">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0 w-6 h-6 rounded-full bg-secondary flex items-center justify-center text-muted-foreground">
{SOURCE_ICON[t.source] ?? <Wallet className="w-3 h-3" />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{t.merchant}</p>
<p className="text-[11px] text-muted-foreground">
{t.date}
{t.category && ` · ${t.category}`}
</p>
</div>
</div>
<p className="text-sm font-semibold tabular-nums shrink-0 text-destructive">
{fmtCurrency(t.amount, t.currency)}
</p>
</div>
))}
</div>
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-2">
<Spinner /> Cargando
</div>
)}
</div>
);
}
// ── NetWorthCard ──────────────────────────────────────────────────────────────
export interface AccountRow {
bank: string;
label: string;
balance_crc: number;
account_type: string;
currency: string;
}
export interface NetWorthArgs {
total_assets_crc?: number;
total_liabilities_crc?: number;
net_worth_crc?: number;
accounts?: AccountRow[];
}
export function NetWorthCard({
args,
status,
}: {
args: NetWorthArgs;
status: string;
}) {
const {
total_assets_crc = 0,
total_liabilities_crc = 0,
net_worth_crc = 0,
accounts = [],
} = args;
const isPositive = net_worth_crc >= 0;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
{/* Net worth headline */}
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
Patrimonio neto
</p>
<div className="flex items-center gap-1 mt-0.5">
{isPositive ? (
<TrendingUp className="w-4 h-4 text-green-500" />
) : (
<TrendingDown className="w-4 h-4 text-destructive" />
)}
<span
className={`text-2xl font-bold tabular-nums ${isPositive ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(net_worth_crc)}
</span>
</div>
</div>
<div className="text-right text-xs space-y-1">
<p>
<span className="text-muted-foreground">Activos </span>
<span className="font-semibold text-green-500">
{fmtCRC(total_assets_crc)}
</span>
</p>
<p>
<span className="text-muted-foreground">Pasivos </span>
<span className="font-semibold text-destructive">
{fmtCRC(total_liabilities_crc)}
</span>
</p>
</div>
</div>
{/* Asset bar */}
{total_assets_crc + total_liabilities_crc > 0 && (
<div className="h-2 bg-secondary rounded-full overflow-hidden flex">
<div
className="h-full bg-green-500 transition-all"
style={{
width: `${Math.round(
(total_assets_crc /
(total_assets_crc + total_liabilities_crc)) *
100,
)}%`,
}}
/>
<div className="h-full bg-destructive flex-1" />
</div>
)}
{/* Accounts */}
{accounts.length > 0 && (
<div className="divide-y divide-border/50">
{accounts.map((a, i) => (
<div key={i} className="flex items-center justify-between py-2">
<div>
<p className="text-sm font-medium">{a.label || a.bank}</p>
<p className="text-[11px] text-muted-foreground">
{a.bank} · {a.account_type} · {a.currency}
</p>
</div>
<p
className={`text-sm font-semibold tabular-nums ${a.balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(a.balance_crc)}
</p>
</div>
))}
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Calculando
</div>
)}
</div>
);
}
// ── BudgetMonthCard ───────────────────────────────────────────────────────────
const MONTH_NAMES = [
"", "Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio",
"Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre",
];
export interface BudgetMonthArgs {
year?: number;
month?: number;
projected_income_crc?: number;
projected_expenses_crc?: number;
actual_total_crc?: number;
net_balance_crc?: number;
}
export function BudgetMonthCard({
args,
status,
}: {
args: BudgetMonthArgs;
status: string;
}) {
const {
year,
month,
projected_income_crc = 0,
projected_expenses_crc = 0,
actual_total_crc = 0,
net_balance_crc = 0,
} = args;
const usedPct =
projected_expenses_crc > 0
? Math.min(Math.round((actual_total_crc / projected_expenses_crc) * 100), 100)
: 0;
const isOver = actual_total_crc > projected_expenses_crc;
return (
<div className="rounded-xl border border-border bg-card text-card-foreground p-4 space-y-4 my-2 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground">
Presupuesto
</p>
{month != null && year != null && (
<p className="text-sm font-semibold mt-0.5">
{MONTH_NAMES[month]} {year}
</p>
)}
</div>
<div
className={`text-right text-2xl font-bold tabular-nums ${net_balance_crc >= 0 ? "text-green-500" : "text-destructive"}`}
>
{fmtCRC(net_balance_crc)}
<p className="text-[11px] font-normal text-muted-foreground">
balance neto
</p>
</div>
</div>
{/* Rows */}
<div className="space-y-1.5 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Ingresos proyectados</span>
<span className="font-medium tabular-nums text-green-500">
{fmtCRC(projected_income_crc)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Gastos proyectados</span>
<span className="font-medium tabular-nums">
{fmtCRC(projected_expenses_crc)}
</span>
</div>
<div className="flex justify-between border-t border-border/50 pt-1.5">
<span className="text-muted-foreground">Gastado real</span>
<span
className={`font-semibold tabular-nums ${isOver ? "text-destructive" : ""}`}
>
{fmtCRC(actual_total_crc)}
</span>
</div>
</div>
{/* Progress bar */}
{projected_expenses_crc > 0 && (
<div>
<div className="flex justify-between text-[11px] text-muted-foreground mb-1">
<span>Ejecución presupuestaria</span>
<span className={isOver ? "text-destructive font-semibold" : ""}>
{usedPct}%
</span>
</div>
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${isOver ? "bg-destructive" : "bg-primary"}`}
style={{ width: `${usedPct}%` }}
/>
</div>
</div>
)}
{status === "inProgress" && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Spinner /> Cargando
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { ArrowLeftRight, ArrowRightFromLine, Banknote, Pencil, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
import { type Transaction } from '@/api';
import { type Transaction } from '@/lib/api';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -10,14 +10,18 @@ import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'
interface TransactionColumnOptions {
showCategory: boolean;
showSourceIcon?: boolean;
onEdit: (tx: Transaction) => void;
onDelete: (txId: number) => void;
onToggleDeferred?: (tx: Transaction) => void;
}
export function getTransactionColumns({
showCategory,
showSourceIcon,
onEdit,
onDelete,
onToggleDeferred,
}: TransactionColumnOptions): ColumnDef<Transaction, unknown>[] {
const columns: ColumnDef<Transaction, unknown>[] = [
{
@@ -43,18 +47,29 @@ export function getTransactionColumns({
<div
className={cn(
'w-6 h-6 rounded flex items-center justify-center shrink-0',
tx.transaction_type === 'DEVOLUCION'
? 'bg-primary/10 text-primary'
: 'bg-destructive/10 text-destructive',
tx.transaction_type === 'COMPRA'
? 'bg-destructive/10 text-destructive'
: 'bg-primary/10 text-primary',
)}
>
{tx.transaction_type === 'DEVOLUCION' ? (
<TrendingUp className="w-3 h-3" />
) : (
{tx.transaction_type === 'COMPRA' ? (
<TrendingDown className="w-3 h-3" />
) : (
<TrendingUp className="w-3 h-3" />
)}
</div>
<span className="truncate">{tx.merchant}</span>
{showSourceIcon && tx.source === 'CASH' && (
<Banknote className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
)}
{showSourceIcon && tx.source === 'TRANSFER' && (
<ArrowLeftRight className="w-3.5 h-3.5 text-muted-foreground shrink-0 ml-1" />
)}
{tx.deferred_to_next_cycle && (
<Badge variant="outline" className="ml-1.5 text-[10px] px-1 py-0 shrink-0 text-amber-600 border-amber-300">
Diferida
</Badge>
)}
</div>
);
},
@@ -88,12 +103,13 @@ export function getTransactionColumns({
const tx = row.original;
return (
<span
data-sensitive
className={cn(
'font-mono font-medium',
tx.transaction_type === 'DEVOLUCION' && 'text-primary',
tx.transaction_type !== 'COMPRA' && 'text-primary',
)}
>
{tx.transaction_type === 'DEVOLUCION' ? '+' : '-'}
{tx.transaction_type === 'COMPRA' ? '-' : '+'}
{formatAmount(tx.amount, tx.currency)}
</span>
);
@@ -108,6 +124,18 @@ export function getTransactionColumns({
const tx = row.original;
return (
<div className="flex items-center justify-end gap-1">
{onToggleDeferred && (
<Button
variant="ghost"
size="icon"
title={tx.deferred_to_next_cycle ? 'Quitar diferida' : 'Diferir al siguiente ciclo'}
aria-label={tx.deferred_to_next_cycle ? 'Remove deferred' : 'Defer to next cycle'}
onClick={() => onToggleDeferred(tx)}
className={cn(tx.deferred_to_next_cycle && 'text-amber-600')}
>
<ArrowRightFromLine className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"

View File

@@ -0,0 +1,14 @@
import { cn } from "@/lib/utils";
export function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted/60", className)}
{...props}
/>
);
}

View File

@@ -13,7 +13,7 @@ function Tabs({
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col data-[orientation=vertical]:flex-row data-[orientation=vertical]:items-start",
className
)}
{...props}
@@ -22,7 +22,7 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
@@ -56,10 +56,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}

View File

@@ -0,0 +1,31 @@
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
const PrivacyContext = createContext<{
privacyMode: boolean;
togglePrivacy: () => void;
}>({ privacyMode: false, togglePrivacy: () => {} });
export function PrivacyProvider({ children }: { children: ReactNode }) {
const [privacyMode, setPrivacyMode] = useState<boolean>(false);
useEffect(() => {
setPrivacyMode(localStorage.getItem("privacyMode") === "true");
}, []);
useEffect(() => {
document.documentElement.classList.toggle("privacy", privacyMode);
localStorage.setItem("privacyMode", String(privacyMode));
}, [privacyMode]);
const togglePrivacy = () => setPrivacyMode((p) => !p);
return (
<PrivacyContext.Provider value={{ privacyMode, togglePrivacy }}>
{children}
</PrivacyContext.Provider>
);
}
export const usePrivacy = () => useContext(PrivacyContext);

View File

@@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{
theme: Theme;
toggleTheme: () => void;
}>({ theme: "dark", toggleTheme: () => {} });
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
// Initialize once on mount (localStorage + prefers-color-scheme).
useEffect(() => {
const saved = localStorage.getItem("theme") as Theme | null;
const initial: Theme = saved
? saved
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
setTheme(initial);
}, []);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark"));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);

View File

@@ -11,7 +11,9 @@ import {
createRecurringItem,
updateRecurringItem as apiUpdateItem,
deleteRecurringItem as apiDeleteItem,
} from '@/api';
upsertBalanceOverride,
deleteBalanceOverride,
} from '@/lib/api';
export function useBudget(initialYear: number) {
const [year, setYear] = useState(initialYear);
@@ -71,6 +73,16 @@ export function useBudget(initialYear: number) {
await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]);
};
const saveBalanceOverride = async (overrideYear: number, month: number, value: number) => {
await upsertBalanceOverride(overrideYear, month, value);
await fetchProjection();
};
const clearBalanceOverride = async (overrideYear: number, month: number) => {
await deleteBalanceOverride(overrideYear, month);
await fetchProjection();
};
return {
year,
setYear,
@@ -84,6 +96,8 @@ export function useBudget(initialYear: number) {
addItem,
updateItem,
deleteItem,
saveBalanceOverride,
clearBalanceOverride,
refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]),
};
}

View File

@@ -1,55 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import {
getSettings,
updateSettings,
type UserSettingsData,
type SectionSettings,
} from '../api';
const DEFAULT_SETTINGS: UserSettingsData = {
dashboard: {
sections: {
crc_accounts: { label: 'CRC Accounts', color: 'primary', cardColor: 'primary', visible: true, order: 0, expanded: false },
usd_accounts: { label: 'USD Accounts', color: 'chart-1', cardColor: 'chart-1', visible: true, order: 1, expanded: false },
pension: { label: 'Pension', color: 'chart-2', cardColor: 'chart-2', visible: true, order: 2, expanded: false },
savings: { label: 'Savings', color: 'chart-3', cardColor: 'chart-3', visible: true, order: 3, expanded: false },
liabilities: { label: 'Liabilities', color: 'destructive', cardColor: 'destructive', visible: true, order: 4, expanded: false },
crypto: { label: 'Crypto', color: 'chart-4', cardColor: 'chart-4', visible: true, order: 5, expanded: false },
},
},
};
export function useSettings() {
const [settings, setSettings] = useState<UserSettingsData>(DEFAULT_SETTINGS);
const [loading, setLoading] = useState(true);
useEffect(() => {
getSettings()
.then((r) => setSettings(r.data.data))
.catch(() => {}) // use defaults on error
.finally(() => setLoading(false));
}, []);
const patchSection = useCallback(
async (sectionId: string, partial: Partial<SectionSettings>) => {
setSettings((prev) => {
const updated = {
...prev,
dashboard: {
...prev.dashboard,
sections: {
...prev.dashboard.sections,
[sectionId]: { ...prev.dashboard.sections[sectionId], ...partial },
},
},
};
// Fire-and-forget save
updateSettings(updated).catch(console.error);
return updated;
});
},
[]
);
return { settings, loading, patchSection };
}

View File

@@ -1,12 +1,15 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/noto-sans";
@import "@fontsource-variable/ibm-plex-sans";
@import "tailwindcss";
@import "tw-animate-css";
@import "@copilotkit/react-core/v2/styles.css";
@custom-variant dark (&:is(.dark *));
:root {
--font-sans: "Noto Sans Variable", sans-serif;
--font-heading: "IBM Plex Sans Variable", sans-serif;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
@@ -25,11 +28,11 @@
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
--chart-1: oklch(0.55 0.16 145);
--chart-2: oklch(0.62 0.19 25);
--chart-3: oklch(0.58 0.14 250);
--chart-4: oklch(0.68 0.15 80);
--chart-5: oklch(0.52 0.13 320);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
@@ -39,6 +42,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--copilot-kit-primary-color: var(--primary);
}
.dark {
@@ -60,11 +65,11 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
--chart-1: oklch(0.60 0.16 145);
--chart-2: oklch(0.67 0.19 25);
--chart-3: oklch(0.63 0.14 250);
--chart-4: oklch(0.73 0.15 80);
--chart-5: oklch(0.57 0.13 320);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.704 0.14 182.503);
@@ -73,11 +78,39 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--copilot-kit-primary-color: var(--primary);
}
/* Wire CopilotKit v2 CSS variables to WealthySmart's dark palette.
The v2 CSS sets --background/--muted/etc directly on [data-copilotkit]
elements (unlayered), overriding inherited values from .dark on <html>.
Using html.dark [data-copilotkit] (specificity 0,2,1) beats the v2's
own .dark [data-copilotkit] (specificity 0,2,0) and restores dark mode. */
html.dark [data-copilotkit] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.437 0.078 188.216);
--primary-foreground: oklch(0.984 0.014 180.72);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
}
@theme inline {
--font-sans: 'Noto Sans Variable', sans-serif;
--font-heading: 'IBM Plex Sans Variable', sans-serif;
--font-sans: var(--font-sans);
--font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -124,8 +157,13 @@
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
font-family: var(--font-sans);
}
}
/* Privacy mode: blur sensitive financial data */
.privacy [data-sensitive] {
filter: blur(8px);
user-select: none;
transition: filter 0.2s ease;
}

470
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,470 @@
const BASE_URL = "/api/v1";
class ApiError extends Error {
response: { status: number; data: unknown };
constructor(status: number, data: unknown) {
super(`Request failed with status ${status}`);
this.response = { status, data };
}
}
interface RequestConfig {
params?: Record<string, string | number | boolean | undefined>;
}
async function request<T>(
method: string,
url: string,
body?: unknown,
config?: RequestConfig,
): Promise<{ data: T }> {
let fullUrl = `${BASE_URL}${url}`;
if (config?.params) {
const search = new URLSearchParams();
for (const [k, v] of Object.entries(config.params)) {
if (v !== undefined) search.set(k, String(v));
}
const qs = search.toString();
if (qs) fullUrl += `?${qs}`;
}
const headers: Record<string, string> = {};
let fetchBody: BodyInit | undefined;
if (body instanceof FormData || body instanceof URLSearchParams) {
fetchBody = body;
} else if (body !== undefined) {
headers["Content-Type"] = "application/json";
fetchBody = JSON.stringify(body);
}
const res = await fetch(fullUrl, {
method,
headers,
body: fetchBody,
credentials: "same-origin",
});
if (res.status === 401) {
await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
if (typeof window !== "undefined") window.location.replace("/login");
throw new ApiError(401, null);
}
if (!res.ok) {
let data: unknown = null;
try {
data = await res.json();
} catch {}
throw new ApiError(res.status, data);
}
if (res.status === 204) return { data: null as T };
const data = await res.json();
return { data };
}
const api = {
get<T = unknown>(url: string, config?: RequestConfig) {
return request<T>("GET", url, undefined, config);
},
post<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("POST", url, body, config);
},
patch<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("PATCH", url, body, config);
},
put<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>("PUT", url, body, config);
},
delete<T = unknown>(url: string, config?: RequestConfig) {
return request<T>("DELETE", url, undefined, config);
},
};
export default api;
export async function login(username: string, password: string) {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
credentials: "same-origin",
});
if (!res.ok) {
let data: unknown = null;
try {
data = await res.json();
} catch {}
throw new ApiError(res.status, data);
}
return res.json();
}
export async function logout() {
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
}
// ─── Types ──────────────────────────────────────────────────────────────────
export interface Account {
id: number;
bank: string;
currency: string;
label: string;
balance: number;
account_type: string;
next_payment: number | null;
updated_at: string;
}
export interface Category {
id: number;
name: string;
icon: string;
auto_match_patterns: string | null;
}
export interface ImportResult {
imported: number;
duplicates: number;
errors: string[];
}
export interface Transaction {
id: number;
amount: number;
currency: string;
merchant: string;
city: string | null;
date: string;
card_type: string | null;
card_last4: string | null;
authorization_code: string | null;
reference: string | null;
transaction_type: string;
source: string;
bank: string;
notes: string | null;
category_id: number | null;
category: Category | null;
deferred_to_next_cycle: boolean;
created_at: string;
}
// --- Budget / Recurring Items ---
export type RecurringItemType = "INCOME" | "EXPENSE";
export type RecurringFrequency =
| "WEEKLY"
| "MONTHLY"
| "QUARTERLY"
| "BIANNUAL"
| "YEARLY";
export interface RecurringItem {
id: number;
name: string;
amount: number;
currency: string;
item_type: RecurringItemType;
frequency: RecurringFrequency;
day_of_month: number | null;
month_of_year: number | null;
override_amounts: Record<string, number> | null;
category_id: number | null;
is_active: boolean;
notes: string | null;
created_at: string;
category: Category | null;
}
export interface RecurringItemCreate {
name: string;
amount: number;
currency?: string;
item_type: RecurringItemType;
frequency?: RecurringFrequency;
day_of_month?: number | null;
month_of_year?: number | null;
override_amounts?: Record<string, number> | null;
category_id?: number | null;
is_active?: boolean;
notes?: string | null;
}
export interface RecurringItemUpdate {
name?: string;
amount?: number;
currency?: string;
item_type?: RecurringItemType;
frequency?: RecurringFrequency;
day_of_month?: number | null;
month_of_year?: number | null;
override_amounts?: Record<string, number> | null;
category_id?: number | null;
is_active?: boolean;
notes?: string | null;
}
export interface RecurringItemDetail {
id: number;
name: string;
amount: number;
projected_amount: number | null;
used_actual: boolean;
item_type: string;
frequency: string;
category_name: string | null;
category_id: number | null;
}
export interface ActualsBySource {
source: string;
total_compra: number;
total_devolucion: number;
net: number;
count: number;
}
export interface MonthlyProjection {
month: number;
year: number;
projected_income: number;
projected_fixed_expenses: number;
actual_credit_card: number;
actual_cash: number;
actual_transfers: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
carryover_balance: number;
cumulative_balance: number;
balance_overridden: boolean;
}
export interface YearlyProjection {
year: number;
months: MonthlyProjection[];
annual_income: number;
annual_expenses: number;
annual_net: number;
}
export interface MonthlyDetail {
year: number;
month: number;
income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[];
total_projected_income: number;
total_projected_expenses: number;
uncovered_actual: number;
gran_total_egresos: number;
net_balance: number;
cc_by_category: { category_name: string; amount: number }[];
}
// --- Savings Accrual ---
export interface SavingsAccrual {
id: number;
year: number;
month: number;
memp_amount: number;
mpat_amount: number;
trigger_transaction_id: number | null;
applied_at: string;
notes: string | null;
}
export interface SavingsAccrualCreate {
year: number;
month: number;
memp_amount?: number;
mpat_amount?: number;
trigger_transaction_id?: number | null;
notes?: string | null;
}
export interface SavingsAccrualUpdate {
memp_amount?: number;
mpat_amount?: number;
notes?: string | null;
}
export const getSavingsAccruals = () =>
api.get<SavingsAccrual[]>("/savings-accrual/");
export const createSavingsAccrual = (data: SavingsAccrualCreate) =>
api.post<SavingsAccrual>("/savings-accrual/", data);
export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) =>
api.patch<SavingsAccrual>(`/savings-accrual/${id}`, data);
export const deleteSavingsAccrual = (id: number) =>
api.delete(`/savings-accrual/${id}`);
// --- Budget ---
export const getRecurringItems = (params?: {
item_type?: string;
is_active?: boolean;
}) => api.get<RecurringItem[]>("/budget/recurring", { params });
export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>("/budget/recurring", data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) =>
api.delete(`/budget/recurring/${id}`);
export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
export const upsertBalanceOverride = (
year: number,
month: number,
override_balance: number,
) =>
api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
export const deleteBalanceOverride = (year: number, month: number) =>
api.delete(`/budget/balance-override/${year}/${month}`);
// --- Salarios ---
export interface SalariosSummary {
count: number;
total_amount: number;
latest_date: string | null;
}
export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>("/salarios/", { params });
export const getSalariosSummary = () =>
api.get<SalariosSummary>("/salarios/summary");
// --- Pensions ---
export interface PensionSnapshot {
id: number;
fund: string;
contract_number: string;
period_start: string;
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
source_filename: string;
created_at: string;
}
export interface PensionUploadResult {
imported: number;
updated: number;
duplicates: number;
errors: string[];
snapshots: PensionSnapshot[];
}
export interface PensionManualEntry {
fund: string;
period_start: string;
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
}
export const uploadPensionPDFs = (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append("files", f));
return api.post<PensionUploadResult>("/pensions/upload", form);
};
export const getPensionSnapshots = () =>
api.get<PensionSnapshot[]>("/pensions/snapshots");
export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>("/pensions/fund-summary");
export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
api.post<PensionUploadResult>("/pensions/manual", { entries });
// --- Municipal Receipts ---
export interface MunicipalCharge {
detail: string;
amount: number;
}
export interface WaterMeterReading {
id: number;
meter_id: string;
period: string;
reading_previous: number;
reading_current: number;
consumption_m3: number;
agua_potable: number;
serv_ambientales: number;
alcant_sanitario: number;
iva: number;
is_historical: boolean;
receipt_id: number | null;
created_at: string;
}
export interface MunicipalReceipt {
id: number;
receipt_date: string;
due_date: string;
period: string;
account: string;
finca: string;
holder_name: string;
holder_cedula: string;
holder_address: string;
subtotal: number;
interests: number;
iva: number;
total: number;
raw_charges: MunicipalCharge[];
source_filename: string;
created_at: string;
}
export interface MunicipalReceiptDetail extends MunicipalReceipt {
water_readings: WaterMeterReading[];
}
export interface MunicipalReceiptUploadResult {
imported: number;
updated: number;
errors: string[];
receipt: MunicipalReceipt | null;
}
export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData();
form.append("file", file);
return api.post<MunicipalReceiptUploadResult>(
"/municipal-receipts/upload",
form,
);
};
export const getMunicipalReceipts = () =>
api.get<MunicipalReceipt[]>("/municipal-receipts/");
export const getMunicipalReceiptDetail = (id: number) =>
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
params: months ? { months } : undefined,
});

View File

@@ -5,9 +5,17 @@ export function formatAmount(amount: number, currency: string) {
if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
if (currency === 'EUR') {
return `${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function formatLocalDatetime(d: Date): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}

View File

@@ -0,0 +1,179 @@
export interface PensionParsedEntry {
fund: string;
period_start: string; // YYYY-MM-DD
period_end: string;
saldo_anterior: number;
aportes: number;
rendimientos: number;
retiros: number;
traslados: number;
comision: number;
correccion: number;
bonificacion: number;
saldo_final: number;
}
function parseAmount(raw: string): number {
const cleaned = raw.replace(/[¢\s]/g, '').replace(/,/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? 0 : num;
}
function parseDateDMY(raw: string): string {
const m = raw.match(/(\d{2})\/(\d{2})\/(\d{4})/);
if (!m) return '';
return `${m[3]}-${m[2]}-${m[1]}`;
}
function extractAmounts(line: string): number[] {
const matches = line.match(/¢\s*-?[\d,.]+/g);
if (!matches) return [];
return matches.map(parseAmount);
}
// Field labels in the order they appear in the bank statement
const FIELD_LABELS: [RegExp, string][] = [
[/saldo\s*anterior/i, 'saldo_anterior'],
[/aportes/i, 'aportes'],
[/rendimientos/i, 'rendimientos'],
[/retiros/i, 'retiros'],
[/traslados/i, 'traslados'],
[/comisi[oó]n/i, 'comision'],
[/bonificaci[oó]n/i, 'bonificacion'],
];
interface BlockResult {
funds: string[];
fields: Record<string, number[]>;
period_start: string;
period_end: string;
}
function parseBlock(lines: string[]): BlockResult | null {
const result: BlockResult = {
funds: [],
fields: {},
period_start: '',
period_end: '',
};
// Detect fund columns from header
const headerLine = lines.find((l) => /resumen del per[ií]odo/i.test(l));
if (!headerLine) return null;
if (/\bROP\b/i.test(headerLine) && /\bFCL\b/i.test(headerLine)) {
result.funds = ['ROP', 'FCL'];
} else if (/voluntario/i.test(headerLine) || /\bVOL\b/i.test(headerLine)) {
result.funds = ['VOL'];
} else {
return null;
}
// Strategy 1: Try same-line parsing (label + amounts on same line)
// Strategy 2: Collect standalone amount lines for split-format parsing
const detectedFieldOrder: string[] = [];
const standaloneAmounts: number[] = [];
for (const line of lines) {
// Check for period
const periodMatch = line.match(/del\s+(\d{2}\/\d{2}\/\d{4})\s+al\s+(\d{2}\/\d{2}\/\d{4})/i);
if (periodMatch) {
result.period_start = parseDateDMY(periodMatch[1]);
result.period_end = parseDateDMY(periodMatch[2]);
continue;
}
// Check for "Saldo Actual" line (always has amounts inline)
if (/saldo\s*actual/i.test(line)) {
const amounts = extractAmounts(line);
if (amounts.length > 0) {
result.fields['saldo_final'] = amounts;
}
continue;
}
// Check if this line matches a field label
let matchedLabel = false;
for (const [regex, key] of FIELD_LABELS) {
if (regex.test(line)) {
matchedLabel = true;
const amounts = extractAmounts(line);
if (amounts.length > 0) {
// Strategy 1: amounts on same line as label
result.fields[key] = amounts;
} else {
// Strategy 2: label-only line, record the order
detectedFieldOrder.push(key);
}
break;
}
}
// If not a label line, check if it's a standalone amount line
if (!matchedLabel) {
const amounts = extractAmounts(line);
if (amounts.length === 1) {
standaloneAmounts.push(amounts[0]);
}
}
}
// If we have standalone amounts and field labels, map them
// Format: N labels, then N amounts for fund1, then N amounts for fund2, ...
if (detectedFieldOrder.length > 0 && standaloneAmounts.length > 0) {
const numFields = detectedFieldOrder.length;
const numFunds = result.funds.length;
if (standaloneAmounts.length >= numFields * numFunds) {
for (let f = 0; f < numFunds; f++) {
for (let i = 0; i < numFields; i++) {
const key = detectedFieldOrder[i];
if (!result.fields[key]) result.fields[key] = [];
result.fields[key].push(standaloneAmounts[f * numFields + i]);
}
}
}
}
return result;
}
export function parsePensionPaste(text: string): PensionParsedEntry[] {
// Split into blocks by "---" or multiple blank lines
const blocks = text.split(/(?:^|\n)-{3,}(?:\n|$)|\n{3,}/);
const entries: PensionParsedEntry[] = [];
for (const block of blocks) {
const lines = block.split('\n').filter((l) => l.trim());
if (lines.length < 3) continue;
const parsed = parseBlock(lines);
if (!parsed || !parsed.period_start || !parsed.period_end) continue;
for (let i = 0; i < parsed.funds.length; i++) {
const fund = parsed.funds[i];
const get = (key: string): number => {
const vals = parsed.fields[key];
if (!vals) return 0;
return vals[i] ?? vals[0] ?? 0;
};
entries.push({
fund,
period_start: parsed.period_start,
period_end: parsed.period_end,
saldo_anterior: get('saldo_anterior'),
aportes: get('aportes'),
rendimientos: get('rendimientos'),
retiros: get('retiros'),
traslados: get('traslados'),
comision: get('comision'),
correccion: 0,
bonificacion: get('bonificacion'),
saldo_final: get('saldo_final'),
});
}
}
return entries;
}

View File

@@ -1,14 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
createRoot(document.getElementById('root')!).render(
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}

View File

@@ -12,8 +12,8 @@ import {
} from 'recharts';
import { BarChart3 } from 'lucide-react';
import api from '../api';
import BillingCycleSelector from '../components/BillingCycleSelector';
import api from '@/lib/api';
import BillingCycleSelector from '@/components/BillingCycleSelector';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
ChartContainer,
@@ -46,9 +46,8 @@ interface DailySpending {
}
const COLORS = [
'var(--chart-1)', 'var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)', 'var(--chart-5)',
'oklch(0.7 0.15 30)', 'oklch(0.65 0.2 300)', 'oklch(0.6 0.15 150)',
'oklch(0.75 0.12 60)', 'oklch(0.55 0.18 250)',
'#B45309', '#16A34A', '#2563EB', '#DC2626', '#7C3AED',
'#D97706', '#0F766E', '#DB2777', '#EA580C', '#4F46E5',
];
function formatCRC(value: number) {
@@ -132,7 +131,7 @@ export default function Analytics() {
</div>
) : (
<div className="flex flex-col items-center">
<ChartContainer config={pieChartConfig} className="h-[260px] w-full">
<ChartContainer data-sensitive config={pieChartConfig} className="h-[260px] w-full">
<PieChart>
<Pie
data={byCategory}
@@ -168,7 +167,7 @@ export default function Analytics() {
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-muted-foreground truncate">{cat.category_name}</span>
<span className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
<span data-sensitive className="text-muted-foreground/60 ml-auto">{cat.percentage}%</span>
</div>
))}
</div>
@@ -190,7 +189,7 @@ export default function Analytics() {
No data
</div>
) : (
<ChartContainer config={trendChartConfig} className="h-[300px] w-full">
<ChartContainer data-sensitive config={trendChartConfig} className="h-[300px] w-full">
<BarChart data={trend}>
<XAxis
dataKey="label"
@@ -229,7 +228,7 @@ export default function Analytics() {
No data for this period
</div>
) : (
<ChartContainer config={dailyChartConfig} className="h-[240px] w-full">
<ChartContainer data-sensitive config={dailyChartConfig} className="h-[240px] w-full">
<LineChart data={daily}>
<XAxis
dataKey="date"
@@ -287,11 +286,11 @@ export default function Analytics() {
style={{ background: COLORS[i % COLORS.length] }}
/>
<span className="text-sm flex-1">{cat.category_name}</span>
<span className="text-xs text-muted-foreground">{cat.count} txns</span>
<span className="text-sm font-mono font-medium w-32 text-right">
<span data-sensitive className="text-xs text-muted-foreground">{cat.count} txns</span>
<span data-sensitive className="text-sm font-mono font-medium w-32 text-right">
{formatCRC(cat.total)}
</span>
<div className="w-24 bg-muted rounded-full h-1.5">
<div data-sensitive className="w-24 bg-muted rounded-full h-1.5">
<div
className="h-1.5 rounded-full"
style={{

View File

@@ -0,0 +1,80 @@
import { CopilotChat, useConfigureSuggestions } from "@copilotkit/react-core/v2";
import { useCopilotAction } from "@copilotkit/react-core";
import { Sparkles } from "lucide-react";
import { SpendingSummaryCard, type SpendingSummaryArgs } from "@/components/chat/ChatCards";
const STATIC_SUGGESTIONS = {
available: "before-first-message" as const,
suggestions: [
{ title: "¿Cuál es mi saldo neto hoy?", message: "¿Cuál es mi saldo neto hoy?" },
{ title: "¿Cuánto gasté en el ciclo anterior?", message: "¿Cuánto gasté en el ciclo anterior?" },
{ title: "Últimas 10 transacciones", message: "Muéstrame mis últimas 10 transacciones" },
{ title: "¿Cómo va mi pensión?", message: "¿Cómo va mi pensión?" },
],
};
export default function Asistente() {
useConfigureSuggestions(STATIC_SUGGESTIONS);
useCopilotAction({
name: "render_spending_summary",
description:
"Render a visual spending summary card with source breakdown and category progress bars. " +
"Call this for any cycle summary, spending totals, or category breakdown.",
parameters: [
{ name: "title", type: "string", description: "Card title (e.g. 'Ciclo actual')" },
{ name: "period", type: "string", description: "Human-readable period (e.g. '18 mar → 18 abr 2026')" },
{ name: "total_crc", type: "number", description: "Total spend in CRC" },
{
name: "by_source",
type: "object[]",
description: "Breakdown by payment source",
attributes: [
{ name: "source", type: "string" },
{ name: "total_crc", type: "number" },
{ name: "count", type: "number" },
],
},
{
name: "by_category",
type: "object[]",
required: false,
description: "Top spending categories with CRC amounts",
attributes: [
{ name: "category", type: "string" },
{ name: "amount_crc", type: "number" },
{ name: "count", type: "number" },
],
},
],
handler: async () => "ok",
render: (props) => (
<SpendingSummaryCard args={props.args as SpendingSummaryArgs} status={props.status} />
),
});
return (
<div className="flex flex-col h-[calc(100vh-105px)]">
<div className="mb-4">
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2" style={{ fontFamily: "var(--font-heading)" }}>
<Sparkles className="w-5 h-5 text-primary" />
Asistente
</h1>
<p className="text-sm text-muted-foreground">
Pregúntale a WealthySmart sobre tus finanzas.
</p>
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border overflow-hidden bg-card">
<CopilotChat
className="h-full"
labels={{
modalHeaderTitle: "WealthySmart",
welcomeMessageText: "¿Qué quieres saber sobre tus finanzas?",
chatInputPlaceholder: "Escribe tu pregunta…",
}}
/>
</div>
</div>
);
}

View File

@@ -1,14 +1,10 @@
import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator, Loader2 } from 'lucide-react';
import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react';
import api, { type Transaction } from '@/api';
import api, { type Transaction } from '@/lib/api';
import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import YearlyOverview from '@/components/budget/YearlyOverview';
import MonthlyDetail from '@/components/budget/MonthlyDetail';
import RecurringItemsManager from '@/components/budget/RecurringItemsManager';
import TransactionList from '@/components/TransactionList';
@@ -18,17 +14,18 @@ const MONTH_NAMES = [
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre',
];
const MIN_YEAR = 2026;
const MAX_YEAR = 2030;
export default function Budget() {
const currentYear = new Date().getFullYear();
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const {
year,
setYear,
selectedMonth,
setSelectedMonth,
projection,
monthDetail,
recurringItems,
loading,
monthLoading,
addItem,
updateItem,
@@ -36,36 +33,62 @@ export default function Budget() {
refresh,
} = useBudget(currentYear);
const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail');
// Transaction list state for the selected month
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [txLoading, setTxLoading] = useState(false);
const [txSearch, setTxSearch] = useState('');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH' | 'TRANSFER'>('CREDIT_CARD');
const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD');
const fetchTransactions = useCallback(async () => {
setTxLoading(true);
try {
// Use calendar month date range
const params: Record<string, unknown> = {
search: txSearch || undefined,
limit: 200,
};
if (txSource === 'CREDIT_CARD') {
params.source = 'CREDIT_CARD';
// Credit card: billing cycle that ends around the 18th of selectedMonth
const prevMonth = selectedMonth === 1 ? 12 : selectedMonth - 1;
const prevYear = selectedMonth === 1 ? year - 1 : year;
params.cycle_year = prevYear;
params.cycle_month = prevMonth;
} else {
// Cash + Transfer merged: calendar month, exclude credit card
params.exclude_source = 'CREDIT_CARD';
const startDate = `${year}-${String(selectedMonth).padStart(2, '0')}-01`;
const endMonth = selectedMonth === 12 ? 1 : selectedMonth + 1;
const endYear = selectedMonth === 12 ? year + 1 : year;
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-01`;
params.start_date = startDate;
params.end_date = endDate;
}
const { data } = await api.get<Transaction[]>('/transactions/', {
params: {
source: txSource,
search: txSearch || undefined,
limit: 200,
start_date: startDate,
end_date: endDate,
},
});
setTransactions(data);
const { data } = await api.get<Transaction[]>('/transactions/', { params });
const INCOME_TYPES = ['DEPOSITO', 'SALARY'];
const filtered = data.filter((tx) => !INCOME_TYPES.includes(tx.transaction_type));
setTransactions(filtered);
} finally {
setTxLoading(false);
}
}, [year, selectedMonth, txSource, txSearch]);
const handleToggleDeferred = useCallback(async (tx: Transaction) => {
await api.patch(`/transactions/${tx.id}`, {
deferred_to_next_cycle: !tx.deferred_to_next_cycle,
});
fetchTransactions();
refresh();
}, [fetchTransactions, refresh]);
const handleNavigateToTransactions = useCallback(() => {
setTxSource('CASH_AND_TRANSFER');
setSubTab('transactions');
}, []);
useEffect(() => {
fetchTransactions();
}, [fetchTransactions]);
@@ -79,11 +102,11 @@ export default function Budget() {
<h1 className="text-2xl font-bold tracking-tight">Presupuesto</h1>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" onClick={() => setYear(year - 1)}>
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
<Button variant="outline" size="icon" onClick={() => setYear(year + 1)}>
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
@@ -95,90 +118,63 @@ export default function Budget() {
<TabsTrigger value="items">Items Recurrentes</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6 mt-4">
{/* Annual Summary */}
{projection && (
<div className="grid gap-3 grid-cols-2 md:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
<p className="text-lg font-bold font-mono text-primary">
{formatAmount(projection.annual_income, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
<p className="text-lg font-bold font-mono">
{formatAmount(projection.annual_expenses, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
<p className="text-lg font-bold font-mono">
{formatAmount(projection.annual_savings, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
<p
className={cn(
'text-lg font-bold font-mono',
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
)}
<TabsContent value="overview" className="mt-4">
<Tabs
value={subTab}
onValueChange={(v) => setSubTab(v as typeof subTab)}
>
{projection.annual_net >= 0 ? '+' : ''}
{formatAmount(projection.annual_net, 'CRC')}
</p>
</CardContent>
</Card>
<div className="flex items-center justify-between">
<TabsList variant="line">
<TabsTrigger value="detail">Detalle</TabsTrigger>
<TabsTrigger value="transactions">Transacciones</TabsTrigger>
</TabsList>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth <= 1}
onClick={() => setSelectedMonth(selectedMonth - 1)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="text-sm font-medium w-28 text-center">
{MONTH_NAMES[selectedMonth]} {year}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={selectedMonth >= 12}
onClick={() => setSelectedMonth(selectedMonth + 1)}
>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* Yearly Overview Table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : projection ? (
<Card>
<CardContent className="p-0">
<YearlyOverview
months={projection.months}
selectedMonth={selectedMonth}
onSelectMonth={setSelectedMonth}
<TabsContent value="detail" className="space-y-6 mt-4">
<MonthlyDetail
detail={monthDetail}
loading={monthLoading || !monthDetail}
onNavigateToTransactions={handleNavigateToTransactions}
/>
</CardContent>
</Card>
) : null}
</TabsContent>
{/* Monthly Detail */}
{monthDetail && <MonthlyDetail detail={monthDetail} loading={monthLoading} />}
{/* Transactions for selected month */}
<div className="space-y-3">
<h3 className="text-lg font-semibold">
Transacciones {MONTH_NAMES[selectedMonth]} {year}
</h3>
<TabsContent value="transactions" className="space-y-3 mt-4">
<Tabs
value={txSource}
onValueChange={(v) => setTxSource(v as typeof txSource)}
>
<TabsList>
<TabsTrigger value="CREDIT_CARD">Tarjeta</TabsTrigger>
<TabsTrigger value="CASH">Efectivo</TabsTrigger>
<TabsTrigger value="TRANSFER">Transferencias</TabsTrigger>
<TabsTrigger value="CASH_AND_TRANSFER">Efectivo y Transferencias</TabsTrigger>
</TabsList>
</Tabs>
<TransactionList
transactions={transactions}
loading={txLoading}
source={txSource}
source={txSource === 'CREDIT_CARD' ? 'CREDIT_CARD' : 'CASH'}
search={txSearch}
onSearchChange={setTxSearch}
onRefresh={() => {
@@ -186,9 +182,12 @@ export default function Budget() {
refresh();
}}
showCategory={txSource === 'CREDIT_CARD'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : txSource === 'CASH' ? 'efectivo' : 'transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
showSourceIcon={txSource === 'CASH_AND_TRANSFER'}
emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`}
onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined}
/>
</div>
</TabsContent>
</Tabs>
</TabsContent>
<TabsContent value="items" className="mt-4">

View File

@@ -1,376 +0,0 @@
import { useEffect, useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
TrendingUp,
TrendingDown,
RefreshCw,
CreditCard,
Pencil,
Check,
X,
BellRing,
Landmark,
} from 'lucide-react';
import api, { type Account, type Transaction } from '../api';
import { useSettings } from '@/hooks/useSettings';
import { formatAmount, formatDate } from '@/lib/format';
import DashboardSection from '@/components/DashboardSection';
import SectionConfigDialog from '@/components/SectionConfigDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
// --- Section definitions ---
interface SectionDef {
filterFn: (a: Account) => boolean;
totalCurrency: string; // empty string = no total
}
const SECTION_DEFS: Record<string, SectionDef> = {
crc_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'CRC', totalCurrency: 'CRC' },
usd_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'USD', totalCurrency: 'USD' },
pension: { filterFn: (a) => a.account_type === 'PENSION', totalCurrency: 'CRC' },
savings: { filterFn: (a) => a.account_type === 'SAVINGS', totalCurrency: 'CRC' },
liabilities: { filterFn: (a) => a.account_type === 'LIABILITY', totalCurrency: '' },
crypto: { filterFn: (a) => a.account_type === 'CRYPTO', totalCurrency: '' },
};
const BANK_ORDER = ['BAC', 'BCR', 'DAVIVIENDA'];
// --- AccountRow ---
interface AccountRowProps {
account: Account;
editingId: number | null;
editValue: string;
setEditValue: (v: string) => void;
startEditing: (a: Account) => void;
saveBalance: (id: number) => void;
cancelEditing: () => void;
}
function AccountRow({
account,
editingId,
editValue,
setEditValue,
startEditing,
saveBalance,
cancelEditing,
}: AccountRowProps) {
const isLiability = account.account_type === 'LIABILITY';
const isCrypto = account.account_type === 'CRYPTO';
const label = isCrypto ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
const isEditing = editingId === account.id;
return (
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors group">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
{isEditing ? (
<div className="flex items-center gap-2">
<Input
type="number"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveBalance(account.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
className="text-sm font-bold font-mono tracking-tight h-auto py-1 w-40"
/>
<Button variant="ghost" size="icon-xs" onClick={() => saveBalance(account.id)} title="Save" aria-label="Save balance">
<Check className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={cancelEditing} title="Cancel" aria-label="Cancel editing">
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<div className="flex items-center gap-1.5">
<span className={cn('text-lg font-bold font-mono tracking-tight', isLiability && 'text-destructive')}>
{formatAmount(account.balance, account.currency)}
</span>
<button
onClick={() => startEditing(account)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title="Edit balance"
aria-label="Edit balance"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{isLiability && account.next_payment != null && (
<span className="text-xs font-mono text-destructive/60 ml-2">
Next: {formatAmount(account.next_payment, account.currency)}
</span>
)}
</div>
)}
</div>
);
}
// --- Dashboard ---
export default function Dashboard() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [recent, setRecent] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
const [configSection, setConfigSection] = useState<string | null>(null);
const [testingPush, setTestingPush] = useState(false);
const { settings, patchSection } = useSettings();
const fetchData = async () => {
setLoading(true);
try {
const [accRes, txRes] = await Promise.all([
api.get('/accounts/'),
api.get('/transactions/recent?limit=5'),
]);
setAccounts(accRes.data);
setRecent(txRes.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
};
useEffect(() => { fetchData(); }, []);
const startEditing = (account: Account) => {
setEditingId(account.id);
setEditValue(String(account.balance));
};
const cancelEditing = () => { setEditingId(null); setEditValue(''); };
const saveBalance = async (accountId: number) => {
const parsed = parseFloat(editValue);
if (isNaN(parsed)) return cancelEditing();
try {
await api.patch(`/accounts/${accountId}`, { balance: parsed });
setEditingId(null);
setEditValue('');
fetchData();
} catch (e) { console.error(e); }
};
const rowProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
// Sort sections by order, filter by visible
const sortedSections = useMemo(() => {
const sections = settings.dashboard.sections;
return Object.entries(sections)
.filter(([, s]) => s.visible)
.sort(([, a], [, b]) => a.order - b.order);
}, [settings]);
// Net worth calculation
const netWorthBreakdown = useMemo(() => {
if (accounts.length === 0) return null;
let assets = 0;
let liabilities = 0;
for (const a of accounts) {
const isLiability = a.account_type === 'LIABILITY';
let crcValue = 0;
if (a.currency === 'USD') {
crcValue = Math.abs(a.balance) * (exchangeRate?.sell_rate ?? 0);
} else if (a.currency === 'CRC') {
crcValue = Math.abs(a.balance);
}
if (isLiability) {
liabilities += crcValue;
} else {
assets += crcValue;
}
}
return { assets, liabilities, net: assets - liabilities };
}, [accounts, exchangeRate]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold font-heading">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Financial overview</p>
</div>
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
</Button>
</div>
{/* Net Worth */}
{netWorthBreakdown != null && (
<Card>
<CardContent className="px-4 py-3">
<div className="flex items-center justify-between text-sm font-mono text-muted-foreground">
<span>Net <span className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
<div className="flex gap-4">
<span>Assets <span className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
<span>Liabilities <span className="text-foreground">{formatAmount(netWorthBreakdown.liabilities, 'CRC')}</span></span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Account sections */}
{sortedSections.map(([sectionId, sectionSettings]) => {
const def = SECTION_DEFS[sectionId];
if (!def) return null;
let accts = accounts.filter(def.filterFn);
if (accts.length === 0) return null;
// Sort bank accounts by bank order
if (sectionId === 'crc_accounts' || sectionId === 'usd_accounts') {
accts = accts.sort((a, b) => BANK_ORDER.indexOf(a.bank) - BANK_ORDER.indexOf(b.bank));
}
const total = accts.reduce((s, a) => s + a.balance, 0);
return (
<DashboardSection
key={sectionId}
sectionId={sectionId}
settings={sectionSettings}
total={def.totalCurrency ? total : undefined}
totalCurrency={def.totalCurrency || undefined}
onToggleExpanded={(expanded) => patchSection(sectionId, { expanded })}
onOpenConfig={() => setConfigSection(sectionId)}
>
{accts.map((a) => (
<AccountRow key={a.id} account={a} {...rowProps} />
))}
</DashboardSection>
);
})}
{/* Exchange rate */}
{exchangeRate && (
<Card>
<CardContent className="p-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">USD/CRC Exchange Rate</span>
<div className="flex items-baseline gap-3 mt-1">
<span className="text-lg font-bold font-mono">Buy: {exchangeRate.buy_rate.toFixed(2)}</span>
<span className="text-lg font-bold font-mono text-muted-foreground">Sell: {exchangeRate.sell_rate.toFixed(2)}</span>
</div>
</CardContent>
</Card>
)}
{/* Recent transactions */}
<Card>
<CardHeader className="border-b flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-muted-foreground" />
<CardTitle className="text-sm">Recent Charges</CardTitle>
</div>
<Link
to="/transactions"
className="flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
>
View all
<ArrowRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardContent className="p-0">
{recent.length === 0 && !loading ? (
<div className="px-5 py-12 text-center text-muted-foreground text-sm">No transactions yet. Add your first one!</div>
) : (
<div className="divide-y divide-border">
{recent.map((tx) => (
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
tx.transaction_type === 'COMPRA' ? 'bg-destructive/10 text-destructive' : 'bg-primary/10 text-primary'
)}>
{tx.transaction_type === 'DEPOSITO' ? <Landmark className="w-4 h-4" /> : tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-muted-foreground">
{formatDate(tx.date)}
{tx.category && <span className="ml-2 text-muted-foreground/60">{tx.category.name}</span>}
</p>
</div>
</div>
<span className={cn(
'font-mono text-sm font-medium shrink-0 ml-4',
tx.transaction_type !== 'COMPRA' && 'text-primary'
)}>
{tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Test push notification */}
<Card className="border-dashed border-yellow-500/50">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">Test Push Notification</p>
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
</div>
<Button
variant="outline"
size="sm"
disabled={testingPush}
onClick={async () => {
setTestingPush(true);
try {
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
const amounts = [4500, 12350, 8900, 25000, 67800];
const i = Math.floor(Math.random() * merchants.length);
await api.post('/transactions/', {
merchant: merchants[i],
amount: amounts[i],
currency: 'CRC',
date: new Date().toISOString(),
bank: 'BAC',
source: 'CREDIT_CARD',
transaction_type: 'COMPRA',
reference: `test-push-${Date.now()}`,
notes: '[TEST] Push notification test — safe to delete',
});
fetchData();
} catch (e) {
console.error('Test push failed:', e);
} finally {
setTestingPush(false);
}
}}
>
<BellRing className="w-4 h-4 mr-2" />
{testingPush ? 'Sending...' : 'Send test'}
</Button>
</CardContent>
</Card>
{/* Section config dialog */}
{configSection && settings.dashboard.sections[configSection] && (
<SectionConfigDialog
sectionId={configSection}
settings={settings.dashboard.sections[configSection]}
open={!!configSection}
onOpenChange={(open) => { if (!open) setConfigSection(null); }}
onSave={(id, partial) => patchSection(id, partial)}
/>
)}
</div>
);
}

View File

@@ -1,34 +1,31 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wallet, ArrowRight, AlertCircle } from 'lucide-react';
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Wallet, ArrowRight, AlertCircle } from "lucide-react";
import { login } from "@/lib/api";
import { useAuth } from "@/AuthContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { login } from '../api';
import { useAuth } from '../AuthContext';
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const { setAuthenticated } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
setError("");
try {
await login(username, password);
setAuthenticated(true);
subscribeToPush();
navigate('/');
navigate("/asistente", { replace: true });
} catch {
setError('Invalid credentials');
setError("Invalid credentials");
} finally {
setLoading(false);
}
@@ -41,7 +38,7 @@ export default function Login() {
<div className="w-10 h-10 rounded-lg bg-primary flex items-center justify-center">
<Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
</div>
<span className="text-2xl font-bold tracking-tight font-heading">
<span className="text-2xl font-bold tracking-tight" style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span>
</span>
</div>
@@ -84,7 +81,7 @@ export default function Login() {
)}
<Button type="submit" disabled={loading} className="w-full h-10">
{loading ? 'Signing in...' : 'Sign in'}
{loading ? "Signing in..." : "Sign in"}
{!loading && <ArrowRight className="w-4 h-4" />}
</Button>
</form>

View File

@@ -31,13 +31,16 @@ import { Separator } from '@/components/ui/separator';
import {
uploadPensionPDFs,
getPensionFundSummary,
getPensionSnapshots,
type PensionSnapshot,
type PensionUploadResult,
} from '@/api';
} from '@/lib/api';
import PensionManualEntryModal from '@/components/PensionManualEntryModal';
import { ClipboardPaste } from 'lucide-react';
// ─── Types ────────────────────────────────────────────────────────────────────
type FundKey = 'FCL' | 'ROP' | 'MPAT' | 'MEMP' | 'VOL';
type FundKey = 'FCL' | 'ROP' | 'VOL';
interface FundDef {
key: FundKey;
@@ -72,7 +75,7 @@ interface TooltipEntry {
const CURRENT_AGE = 30;
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'MPAT', 'MEMP', 'VOL'];
const FUND_KEYS: FundKey[] = ['FCL', 'ROP', 'VOL'];
const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
FCL: {
@@ -99,30 +102,6 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
withdrawalRule: 'Retirable a los 65 años',
defaultTargetAge: 65,
},
MPAT: {
key: 'MPAT',
name: 'MPAT',
fullName: 'Ministerio Patronal',
color: '#f59e0b',
startBalance: 300_000,
monthlyContribution: 200_000,
annualRate: 3.0,
isDividend: true,
withdrawalRule: 'Dividendos anuales en marzo',
defaultTargetAge: 65,
},
MEMP: {
key: 'MEMP',
name: 'MEMP',
fullName: 'Fondo del Empleado',
color: '#8b5cf6',
startBalance: 300_000,
monthlyContribution: 200_000,
annualRate: 3.0,
isDividend: true,
withdrawalRule: '₡100K deducción dos veces al mes · Dividendos en marzo',
defaultTargetAge: 65,
},
VOL: {
key: 'VOL',
name: 'VOL',
@@ -137,10 +116,9 @@ const FUNDS_DEFAULT: Record<FundKey, FundDef> = {
},
};
const MONTHS = [
'Abr 25', 'May 25', 'Jun 25', 'Jul 25',
'Ago 25', 'Sep 25', 'Oct 25', 'Nov 25',
'Dic 25', 'Ene 26', 'Feb 26', 'Mar 26',
const MONTH_NAMES_ES = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
// ─── Utilities ────────────────────────────────────────────────────────────────
@@ -152,46 +130,37 @@ const formatCRC = (amount: number): string =>
maximumFractionDigits: 0,
}).format(amount);
function generateChartData(funds: Record<FundKey, FundDef>): ChartDataPoint[] {
const history: ChartDataPoint[] = new Array(12);
function buildChartFromSnapshots(snapshots: PensionSnapshot[]): ChartDataPoint[] {
// Group by period_end month key (YYYY-MM)
const byMonth = new Map<string, Record<string, number>>();
let bal = {
FCL: funds.FCL.startBalance,
ROP: funds.ROP.startBalance,
MPAT: funds.MPAT.startBalance,
MEMP: funds.MEMP.startBalance,
VOL: funds.VOL.startBalance,
};
history[11] = {
month: MONTHS[11],
FCL: Math.round(bal.FCL),
ROP: Math.round(bal.ROP),
MPAT: Math.round(bal.MPAT),
MEMP: Math.round(bal.MEMP),
VOL: Math.round(bal.VOL),
};
for (let i = 10; i >= 0; i--) {
const undoDividend = i === 10;
bal = {
FCL: Math.max(0, (bal.FCL - 150_000) / (1 + 0.075 / 12)),
ROP: Math.max(0, (bal.ROP - 120_000) / (1 + 0.060 / 12)),
MPAT: Math.max(0, undoDividend ? bal.MPAT / 1.03 - 200_000 : bal.MPAT - 200_000),
MEMP: Math.max(0, undoDividend ? bal.MEMP / 1.03 - 200_000 : bal.MEMP - 200_000),
VOL: Math.max(0, (bal.VOL - 400_000) / (1 + 0.08 / 12)),
};
history[i] = {
month: MONTHS[i],
FCL: Math.round(bal.FCL),
ROP: Math.round(bal.ROP),
MPAT: Math.round(bal.MPAT),
MEMP: Math.round(bal.MEMP),
VOL: Math.round(bal.VOL),
};
for (const snap of snapshots) {
const d = new Date(snap.period_end);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (!byMonth.has(key)) byMonth.set(key, {});
const entry = byMonth.get(key)!;
const fund = snap.fund as FundKey;
// Keep the latest saldo_final per fund per month
entry[fund] = Math.round(snap.saldo_final);
}
return history;
// Sort chronologically and take last 12
const sortedKeys = Array.from(byMonth.keys()).sort();
const last12 = sortedKeys.slice(-12);
return last12.map((key) => {
const [yearStr, monthStr] = key.split('-');
const monthIdx = parseInt(monthStr, 10) - 1;
const yearShort = yearStr.slice(2);
const label = `${MONTH_NAMES_ES[monthIdx]} ${yearShort}`;
const values = byMonth.get(key)!;
return {
month: label,
FCL: values.FCL ?? 0,
ROP: values.ROP ?? 0,
VOL: values.VOL ?? 0,
} as ChartDataPoint;
});
}
function calcProjection(
@@ -260,7 +229,7 @@ function ChartTooltipContent({
/>
<span className="text-muted-foreground">{entry.name}</span>
</span>
<span className="font-mono font-medium text-foreground">{formatCRC(entry.value)}</span>
<span data-sensitive className="font-mono font-medium text-foreground">{formatCRC(entry.value)}</span>
</div>
))}
</div>
@@ -271,32 +240,36 @@ function ChartTooltipContent({
export default function Pensions() {
const [fundSummary, setFundSummary] = useState<PensionSnapshot[]>([]);
const [allSnapshots, setAllSnapshots] = useState<PensionSnapshot[]>([]);
const [visibleFunds, setVisibleFunds] = useState<Set<FundKey>>(new Set(FUND_KEYS));
const [projections, setProjections] = useState<Record<FundKey, ProjectionState>>({
FCL: { contribution: 150_000, rate: 7.5, targetAge: 35 },
ROP: { contribution: 120_000, rate: 6.0, targetAge: 65 },
MPAT: { contribution: 200_000, rate: 3.0, targetAge: 65 },
MEMP: { contribution: 200_000, rate: 3.0, targetAge: 65 },
VOL: { contribution: 400_000, rate: 8.0, targetAge: 57 },
});
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadResult, setUploadResult] = useState<PensionUploadResult | null>(null);
const [showManualEntry, setShowManualEntry] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadFundSummary = useCallback(async () => {
const loadData = useCallback(async () => {
try {
const { data } = await getPensionFundSummary();
setFundSummary(data);
const [summaryRes, snapshotsRes] = await Promise.all([
getPensionFundSummary(),
getPensionSnapshots(),
]);
setFundSummary(summaryRes.data);
setAllSnapshots(snapshotsRes.data);
} catch {
// API not available or no data yet — use defaults
}
}, []);
useEffect(() => {
loadFundSummary();
}, [loadFundSummary]);
loadData();
}, [loadData]);
const FUNDS = useMemo(() => applySnapshots(fundSummary), [fundSummary]);
@@ -309,7 +282,12 @@ export default function Pensions() {
return map;
}, [fundSummary]);
const chartData = useMemo(() => generateChartData(FUNDS), [FUNDS]);
const chartData = useMemo(() => buildChartFromSnapshots(allSnapshots), [allSnapshots]);
const chartDateRange = useMemo(() => {
if (chartData.length < 2) return '';
return `${chartData[0].month}${chartData[chartData.length - 1].month}`;
}, [chartData]);
const roiEarned = useMemo(() => {
return FUND_KEYS.reduce<Record<FundKey, number>>((acc, key) => {
@@ -318,15 +296,18 @@ export default function Pensions() {
// Use real rendimientos from the API
acc[key] = Math.round(snap.rendimientos);
} else {
// Fallback: approximate from hardcoded data
// Fallback: approximate from chart data
const fund = FUNDS[key];
if (fund.isDividend) {
const len = chartData.length;
if (len >= 2 && fund.isDividend) {
acc[key] = Math.max(0, Math.round(
chartData[11][key] - chartData[10][key] - fund.monthlyContribution,
chartData[len - 1][key] - chartData[len - 2][key] - fund.monthlyContribution,
));
} else {
} else if (len > 0) {
const activeMonths = chartData.filter((d) => d[key] > 0).length;
acc[key] = Math.round(fund.startBalance * (fund.annualRate / 100) * (activeMonths / 12));
} else {
acc[key] = 0;
}
}
return acc;
@@ -369,10 +350,11 @@ export default function Pensions() {
setUploadResult(data);
setUploadedFiles([]);
// Refresh fund summary with new data
await loadFundSummary();
await loadData();
} catch (err) {
setUploadResult({
imported: 0,
updated: 0,
duplicates: 0,
errors: [err instanceof Error ? err.message : 'Error al subir archivos'],
snapshots: [],
@@ -458,7 +440,7 @@ export default function Pensions() {
<p className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
Balance actual
</p>
<p className="text-xl font-bold font-mono mt-0.5 leading-tight">
<p data-sensitive className="text-xl font-bold font-mono mt-0.5 leading-tight">
{formatCRC(fund.startBalance)}
</p>
{snap && (
@@ -474,19 +456,19 @@ export default function Pensions() {
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Aportes</span>
<span className="font-mono font-medium">
<span data-sensitive className="font-mono font-medium">
{formatCRC(snap.aportes)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Rendimientos</span>
<span className="font-mono font-medium text-emerald-500">
<span data-sensitive className="font-mono font-medium text-emerald-500">
{formatCRC(snap.rendimientos)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Comisión</span>
<span className="font-mono font-medium text-destructive">
<span data-sensitive className="font-mono font-medium text-destructive">
{formatCRC(snap.comision)}
</span>
</div>
@@ -495,7 +477,7 @@ export default function Pensions() {
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Aporte mensual</span>
<span className="font-mono font-medium">
<span data-sensitive className="font-mono font-medium">
{formatCRC(fund.monthlyContribution)}
</span>
</div>
@@ -521,7 +503,7 @@ export default function Pensions() {
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<TrendingUp className="w-4 h-4" />
Evolución del Balance (Abr 2025 Mar 2026)
Evolución del Balance{chartDateRange && ` (${chartDateRange})`}
</h2>
<Card>
<CardContent className="p-4 space-y-4">
@@ -624,8 +606,8 @@ export default function Pensions() {
{fund.isDividend ? `Dividendos ${fund.annualRate}%` : `${fund.annualRate}% anual`}
</p>
)}
<p className="text-lg font-bold font-mono" style={{ color: fund.color }}>
+{formatCRC(earned)}
<p data-sensitive className="text-lg font-bold font-mono" style={{ color: fund.color }}>
{earned >= 0 ? '+' : ''}{formatCRC(earned)}
</p>
<p className="text-xs text-muted-foreground">en rendimientos</p>
</CardContent>
@@ -704,6 +686,7 @@ export default function Pensions() {
Valor en {years} {years === 1 ? 'año' : 'años'}
</p>
<p
data-sensitive
className="text-lg font-bold font-mono leading-tight"
style={{ color: fund.color }}
>
@@ -717,12 +700,31 @@ export default function Pensions() {
</div>
</section>
{/* ── Manual Entry Modal ──────────────────────────────────────────── */}
{showManualEntry && (
<PensionManualEntryModal
onClose={() => setShowManualEntry(false)}
onImported={loadData}
/>
)}
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<FileText className="w-4 h-4" />
Estados de Cuenta
</h2>
<Button
variant="outline"
size="sm"
onClick={() => setShowManualEntry(true)}
className="gap-1.5"
>
<ClipboardPaste className="w-3.5 h-3.5" />
Ingresar manualmente
</Button>
</div>
<Card>
<CardContent className="p-6 space-y-4">
{/* Drop zone */}
@@ -825,15 +827,16 @@ export default function Pensions() {
: 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}>
<div className="flex items-center gap-2">
{uploadResult.imported > 0 ? (
{(uploadResult.imported > 0 || uploadResult.updated > 0) ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500" />
) : (
<AlertTriangle className="w-4 h-4 text-amber-500" />
)}
<span className="text-sm font-medium">
{uploadResult.imported > 0
? `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`
: 'Ningún extracto nuevo importado'}
{uploadResult.imported > 0 && `${uploadResult.imported} ${uploadResult.imported === 1 ? 'extracto importado' : 'extractos importados'}`}
{uploadResult.imported > 0 && uploadResult.updated > 0 && ' · '}
{uploadResult.updated > 0 && `${uploadResult.updated} actualizado(s)`}
{uploadResult.imported === 0 && uploadResult.updated === 0 && 'Ningún extracto nuevo importado'}
</span>
</div>
{uploadResult.duplicates > 0 && (
@@ -853,7 +856,7 @@ export default function Pensions() {
{' — '}
{new Date(snap.period_end).toLocaleDateString('es-CR', { month: 'short', year: '2-digit' })}
</span>
<span className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
<span data-sensitive className="font-mono font-medium">{formatCRC(snap.saldo_final)}</span>
</div>
))}
</div>

View File

@@ -0,0 +1,112 @@
import { ChevronLeft, ChevronRight, Loader2, TrendingUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useBudget } from '@/hooks/useBudget';
import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import YearlyOverview from '@/components/budget/YearlyOverview';
const MIN_YEAR = 2026;
const MAX_YEAR = 2030;
export default function Proyecciones() {
const currentYear = Math.max(MIN_YEAR, new Date().getFullYear());
const {
year,
setYear,
setSelectedMonth,
projection,
loading,
saveBalanceOverride,
clearBalanceOverride,
} = useBudget(currentYear);
const navigate = useNavigate();
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<TrendingUp className="w-6 h-6 text-primary" />
<h1 className="text-2xl font-bold tracking-tight">Proyecciones</h1>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" disabled={year <= MIN_YEAR} onClick={() => setYear(year - 1)}>
<ChevronLeft className="w-4 h-4" />
</Button>
<span className="w-16 text-center font-semibold tabular-nums">{year}</span>
<Button variant="outline" size="icon" disabled={year >= MAX_YEAR} onClick={() => setYear(year + 1)}>
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
{/* Annual summary cards */}
{projection && (
<div className="grid gap-3 grid-cols-1 md:grid-cols-3">
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ingresos Anuales</p>
<p data-sensitive className="text-lg font-bold font-mono text-primary">
{formatAmount(projection.annual_income, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Egresos Anuales</p>
<p data-sensitive className="text-lg font-bold font-mono">
{formatAmount(projection.annual_expenses, 'CRC')}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Balance Neto Anual</p>
<p
data-sensitive
className={cn(
'text-lg font-bold font-mono',
projection.annual_net >= 0 ? 'text-primary' : 'text-destructive',
)}
>
{projection.annual_net >= 0 ? '+' : ''}
{formatAmount(projection.annual_net, 'CRC')}
</p>
</CardContent>
</Card>
</div>
)}
{/* Yearly table */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : projection ? (
<Card>
<CardContent className="p-0">
<YearlyOverview
months={projection.months}
selectedMonth={0}
year={year}
onSelectMonth={(m) => {
setSelectedMonth(m);
navigate('/budget');
}}
onSaveOverride={async (month, value) => {
await saveBalanceOverride(year, month, value);
}}
onClearOverride={async (month) => {
await clearBalanceOverride(year, month);
}}
/>
</CardContent>
</Card>
) : null}
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '../api';
import { type Transaction, type SalariosSummary, getSalarios, getSalariosSummary } from '@/lib/api';
import { formatAmount, formatDate } from '@/lib/format';
import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';
@@ -64,7 +64,7 @@ export default function Salarios() {
accessorKey: 'amount',
header: ({ column }) => <DataTableColumnHeader column={column} title="Monto" />,
cell: ({ row }) => (
<span className="font-mono font-bold text-primary">
<span data-sensitive className="font-mono font-bold text-primary">
+{formatAmount(row.original.amount, row.original.currency)}
</span>
),
@@ -126,7 +126,7 @@ export default function Salarios() {
<Banknote className="w-4 h-4" />
<span className="text-xs font-medium uppercase tracking-wider">Total acumulado</span>
</div>
<span className="text-2xl font-bold font-mono text-primary">
<span data-sensitive className="text-2xl font-bold font-mono text-primary">
{formatAmount(summary.total_amount, 'CRC')}
</span>
</CardContent>

View File

@@ -0,0 +1,807 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
BarChart,
Bar,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import {
Droplets,
Upload,
X,
FileText,
Loader2,
CheckCircle2,
AlertTriangle,
Receipt,
TrendingDown,
TrendingUp,
CalendarDays,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
uploadMunicipalReceipt,
getMunicipalReceipts,
getWaterConsumption,
type MunicipalReceipt,
type MunicipalReceiptUploadResult,
type WaterMeterReading,
} from '@/lib/api';
// ─── Constants ───────────────────────────────────────────────────────────────
const METER_COLORS: Record<string, string> = {
'7335': '#3b82f6',
'7345': '#10b981',
'9345': '#f59e0b',
};
const MONTH_NAMES_ES = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic',
];
const DEFAULT_METER_COLOR = '#8b5cf6';
// ─── Utilities ───────────────────────────────────────────────────────────────
const formatCRC = (amount: number): string =>
`${amount.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
function periodLabel(period: string): string {
const [yearStr, monthStr] = period.split('-');
const monthIdx = parseInt(monthStr, 10) - 1;
return `${MONTH_NAMES_ES[monthIdx]} ${yearStr.slice(2)}`;
}
function meterColor(meterId: string): string {
return METER_COLORS[meterId] ?? DEFAULT_METER_COLOR;
}
// ─── Chart Data ──────────────────────────────────────────────────────────────
interface ChartPoint {
period: string;
label: string;
[meterId: string]: number | string;
}
function buildChartData(readings: WaterMeterReading[]): ChartPoint[] {
const byPeriod = new Map<string, Record<string, number>>();
for (const r of readings) {
if (!byPeriod.has(r.period)) byPeriod.set(r.period, {});
byPeriod.get(r.period)![r.meter_id] = r.consumption_m3;
}
const sorted = Array.from(byPeriod.keys()).sort();
return sorted.map((period) => ({
period,
label: periodLabel(period),
...byPeriod.get(period)!,
}));
}
function getMeterIds(readings: WaterMeterReading[]): string[] {
return [...new Set(readings.map((r) => r.meter_id))].sort();
}
// ─── Charge Trend Data ───────────────────────────────────────────────────────
const CHARGE_COLORS = [
'#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16',
];
interface ChargeTrendPoint {
period: string;
label: string;
[chargeDetail: string]: number | string;
}
function buildChargeTrendData(receipts: MunicipalReceipt[]): ChargeTrendPoint[] {
const sorted = [...receipts].sort((a, b) => a.period.localeCompare(b.period));
return sorted.map((r) => {
const point: ChargeTrendPoint = { period: r.period, label: periodLabel(r.period) };
for (const charge of r.raw_charges) {
point[charge.detail] = charge.amount;
}
return point;
});
}
function getChargeNames(receipts: MunicipalReceipt[]): string[] {
const names = new Set<string>();
for (const r of receipts) {
for (const c of r.raw_charges) {
names.add(c.detail);
}
}
return [...names];
}
// ─── Chart Tooltip ───────────────────────────────────────────────────────────
interface TooltipEntry {
name: string;
value: number;
color: string;
}
function ChartTooltipContent({
active,
payload,
label,
}: {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
}) {
if (!active || !payload?.length) return null;
const total = payload.reduce((sum, e) => sum + e.value, 0);
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[180px]">
<p className="font-semibold mb-2 text-foreground">{label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
<span className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: entry.color }}
/>
<span className="text-muted-foreground">Medidor {entry.name}</span>
</span>
<span className="font-mono font-medium text-foreground">{entry.value} m³</span>
</div>
))}
<Separator className="my-1.5" />
<div className="flex justify-between text-xs font-medium">
<span className="text-muted-foreground">Total</span>
<span className="font-mono">{total} m³</span>
</div>
</div>
);
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function ServiciosMunicipales() {
const [receipts, setReceipts] = useState<MunicipalReceipt[]>([]);
const [waterReadings, setWaterReadings] = useState<WaterMeterReading[]>([]);
const [loading, setLoading] = useState(true);
// Chart visibility state
const [hiddenMeters, setHiddenMeters] = useState<Set<string>>(new Set());
const [hiddenCharges, setHiddenCharges] = useState<Set<string>>(new Set());
// Upload state
const [uploadedFiles, setUploadedFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadResults, setUploadResults] = useState<MunicipalReceiptUploadResult[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [receiptsRes, waterRes] = await Promise.all([
getMunicipalReceipts(),
getWaterConsumption(24),
]);
setReceipts(receiptsRes.data);
setWaterReadings(waterRes.data);
} catch {
// API not available yet
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// Derived data
const chartData = useMemo(() => buildChartData(waterReadings), [waterReadings]);
const meterIds = useMemo(() => getMeterIds(waterReadings), [waterReadings]);
const chargeTrendData = useMemo(() => buildChargeTrendData(receipts), [receipts]);
const chargeNames = useMemo(() => getChargeNames(receipts), [receipts]);
const latestReceipt = receipts[0] ?? null;
const avgMonthly = useMemo(() => {
if (receipts.length === 0) return 0;
const sum = receipts.reduce((s, r) => s + r.total, 0);
return sum / receipts.length;
}, [receipts]);
const currentConsumption = useMemo(() => {
if (chartData.length === 0) return { total: 0, prev: 0 };
const latest = chartData[chartData.length - 1];
const prev = chartData.length >= 2 ? chartData[chartData.length - 2] : null;
const sumValues = (point: ChartPoint) =>
meterIds.reduce((s, id) => s + ((point[id] as number) || 0), 0);
return {
total: sumValues(latest),
prev: prev ? sumValues(prev) : 0,
};
}, [chartData, meterIds]);
const consumptionDelta = currentConsumption.prev > 0
? currentConsumption.total - currentConsumption.prev
: 0;
// Upload handlers
const handleFiles = useCallback((files: FileList | null) => {
if (!files) return;
const pdfs = Array.from(files).filter((f) => f.type === 'application/pdf');
if (pdfs.length > 0) {
setUploadedFiles((prev) => [...prev, ...pdfs]);
setUploadResults([]);
}
}, []);
const removeFile = (index: number) => {
setUploadedFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (uploadedFiles.length === 0) return;
setIsUploading(true);
setUploadResults([]);
const results: MunicipalReceiptUploadResult[] = [];
for (const file of uploadedFiles) {
try {
const { data } = await uploadMunicipalReceipt(file);
results.push(data);
} catch (err) {
results.push({
imported: 0,
updated: 0,
errors: [`${file.name}: ${err instanceof Error ? err.message : 'Error al subir'}`],
receipt: null,
});
}
}
setUploadResults(results);
setUploadedFiles([]);
await loadData();
setIsUploading(false);
};
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-8">
{/* ── Page Header ─────────────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Droplets className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-2xl font-bold font-heading">Servicios Municipales</h1>
<p className="text-sm text-muted-foreground">
Municipalidad de Belén recibos y consumo de agua
</p>
</div>
</div>
{/* ── Summary Cards ───────────────────────────────────────────────── */}
<section className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Receipt className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Último recibo
</span>
</div>
<p data-sensitive className="text-lg font-bold font-mono">
{latestReceipt ? formatCRC(latestReceipt.total) : '—'}
</p>
{latestReceipt && (
<p className="text-xs text-muted-foreground mt-0.5">
{periodLabel(latestReceipt.period)}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<CalendarDays className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Promedio mensual
</span>
</div>
<p data-sensitive className="text-lg font-bold font-mono">
{receipts.length > 0 ? formatCRC(avgMonthly) : '—'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{receipts.length} {receipts.length === 1 ? 'recibo' : 'recibos'}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
<Droplets className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Consumo actual
</span>
</div>
<p className="text-lg font-bold font-mono">
{currentConsumption.total > 0 ? `${currentConsumption.total}` : '—'}
</p>
{chartData.length > 0 && (
<p className="text-xs text-muted-foreground mt-0.5">
{periodLabel(chartData[chartData.length - 1].period)}
</p>
)}
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-1.5 mb-2">
{consumptionDelta <= 0 ? (
<TrendingDown className="w-3.5 h-3.5 text-emerald-500" />
) : (
<TrendingUp className="w-3.5 h-3.5 text-amber-500" />
)}
<span className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
Variación
</span>
</div>
<p
className={`text-lg font-bold font-mono ${
consumptionDelta <= 0 ? 'text-emerald-500' : 'text-amber-500'
}`}
>
{currentConsumption.prev > 0
? `${consumptionDelta > 0 ? '+' : ''}${consumptionDelta}`
: '—'}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
vs mes anterior
</p>
</CardContent>
</Card>
</section>
{/* ── Water Consumption Chart ─────────────────────────────────────── */}
{chartData.length > 0 && (
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Droplets className="w-4 h-4" />
Consumo de Agua (m³)
</h2>
<Card>
<CardContent className="p-4">
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={32}
unit=" m³"
/>
<Tooltip content={<ChartTooltipContent />} />
<Legend
onClick={(e) => {
const id = e.dataKey as string;
setHiddenMeters((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
formatter={(value: string) => (
<span style={{
fontSize: 12,
color: hiddenMeters.has(value) ? 'var(--muted-foreground)' : 'var(--foreground)',
opacity: hiddenMeters.has(value) ? 0.4 : 1,
cursor: 'pointer',
}}>
Medidor {value}
</span>
)}
/>
{meterIds.map((id) => (
<Bar
key={id}
dataKey={id}
name={id}
fill={meterColor(id)}
radius={[3, 3, 0, 0]}
maxBarSize={32}
hide={hiddenMeters.has(id)}
/>
))}
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</section>
)}
{/* ── Charge Trend Chart ─────────────────────────────────────────── */}
{chargeTrendData.length > 1 && (
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Receipt className="w-4 h-4" />
Evolución de Cargos
</h2>
<Card>
<CardContent className="p-4">
<ResponsiveContainer width="100%" height={320}>
<LineChart data={chargeTrendData} margin={{ top: 4, right: 8, left: 8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={{ stroke: 'var(--border)' }}
tickLine={false}
/>
<YAxis
tickFormatter={(v: number) => v >= 1000 ? `${(v / 1000).toFixed(0)}k` : `${v}`}
tick={{ fontSize: 11, fill: 'var(--muted-foreground)' }}
axisLine={false}
tickLine={false}
width={52}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div className="bg-popover border border-border rounded-lg p-3 shadow-lg text-sm min-w-[220px]">
<p className="font-semibold mb-2 text-foreground">{label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex items-center justify-between gap-4 py-0.5">
<span className="flex items-center gap-1.5">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: entry.color }}
/>
<span className="text-muted-foreground text-xs">{entry.name}</span>
</span>
<span data-sensitive className="font-mono font-medium text-foreground text-xs">
{formatCRC(entry.value as number)}
</span>
</div>
))}
</div>
);
}}
/>
<Legend
onClick={(e) => {
const name = e.dataKey as string;
setHiddenCharges((prev) => {
const next = new Set(prev);
next.has(name) ? next.delete(name) : next.add(name);
return next;
});
}}
formatter={(value: string) => (
<span style={{
fontSize: 11,
color: hiddenCharges.has(value) ? 'var(--muted-foreground)' : 'var(--foreground)',
opacity: hiddenCharges.has(value) ? 0.4 : 1,
cursor: 'pointer',
}}>{value}</span>
)}
wrapperStyle={{ fontSize: 11 }}
/>
{chargeNames.map((name, i) => (
<Line
key={name}
type="monotone"
dataKey={name}
name={name}
stroke={CHARGE_COLORS[i % CHARGE_COLORS.length]}
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
connectNulls
hide={hiddenCharges.has(name)}
/>
))}
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</section>
)}
{/* ── Receipt History ──────────────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Receipt className="w-4 h-4" />
Historial de Recibos
</h2>
{loading && receipts.length === 0 ? (
<Card>
<CardContent className="p-8 flex items-center justify-center text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Cargando...
</CardContent>
</Card>
) : receipts.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
<Receipt className="w-8 h-8 mx-auto mb-2 opacity-40" />
<p className="text-sm">No hay recibos aún. Sube un PDF para comenzar.</p>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<Accordion>
{receipts.map((receipt) => (
<AccordionItem key={receipt.id} value={String(receipt.id)}>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center justify-between w-full pr-2">
<div className="flex items-center gap-3">
<Badge variant="outline" className="font-mono text-xs">
{periodLabel(receipt.period)}
</Badge>
<span className="text-sm text-muted-foreground hidden sm:inline">
Vence{' '}
{new Date(receipt.due_date).toLocaleDateString('es-CR', {
day: 'numeric',
month: 'short',
})}
</span>
</div>
<span data-sensitive className="font-mono font-bold text-sm">
{formatCRC(receipt.total)}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-3">
{/* Charges breakdown */}
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50">
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Detalle
</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Monto
</th>
</tr>
</thead>
<tbody>
{receipt.raw_charges.map((charge, i) => (
<tr key={i} className="border-t border-border">
<td className="px-3 py-2 text-foreground">{charge.detail}</td>
<td data-sensitive className="px-3 py-2 text-right font-mono">
{formatCRC(charge.amount)}
</td>
</tr>
))}
</tbody>
<tfoot>
{receipt.interests > 0 && (
<tr className="border-t border-border bg-muted/30">
<td className="px-3 py-1.5 text-xs text-muted-foreground">Intereses</td>
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
{formatCRC(receipt.interests)}
</td>
</tr>
)}
{receipt.iva > 0 && (
<tr className="border-t border-border bg-muted/30">
<td className="px-3 py-1.5 text-xs text-muted-foreground">IVA</td>
<td data-sensitive className="px-3 py-1.5 text-right font-mono text-xs">
{formatCRC(receipt.iva)}
</td>
</tr>
)}
<tr className="border-t-2 border-border font-bold">
<td className="px-3 py-2">Total</td>
<td data-sensitive className="px-3 py-2 text-right font-mono">
{formatCRC(receipt.total)}
</td>
</tr>
</tfoot>
</table>
</div>
{/* Meta info */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>Cuenta: {receipt.account}</span>
<span>Finca: {receipt.finca}</span>
<span>
Fecha:{' '}
{new Date(receipt.receipt_date).toLocaleDateString('es-CR')}
</span>
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
)}
</section>
{/* ── PDF Upload ──────────────────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<FileText className="w-4 h-4" />
Subir Recibos
</h2>
<Card>
<CardContent className="p-6 space-y-4">
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
onDrop={(e) => { e.preventDefault(); setIsDragging(false); handleFiles(e.dataTransfer.files); }}
onClick={() => fileInputRef.current?.click()}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && fileInputRef.current?.click()}
aria-label="Seleccionar archivos PDF"
className={[
'border-2 border-dashed rounded-lg p-8',
'flex flex-col items-center justify-center gap-3',
'cursor-pointer transition-colors select-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50 hover:bg-muted/30',
].join(' ')}
>
<Upload
className={`w-8 h-8 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`}
/>
<div className="text-center">
<p className="text-sm font-medium">
{isDragging
? 'Suelta los archivos aquí'
: 'Arrastra PDFs aquí o toca para seleccionar'}
</p>
<p className="text-xs text-muted-foreground mt-1">
Solo archivos PDF · Múltiples archivos soportados
</p>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="application/pdf"
multiple
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
{/* File list */}
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground font-medium">
{uploadedFiles.length}{' '}
{uploadedFiles.length === 1 ? 'archivo en cola' : 'archivos en cola'}
</p>
<div className="space-y-1.5">
{uploadedFiles.map((file, i) => (
<div
key={i}
className="flex items-center justify-between gap-3 p-2.5 rounded-lg bg-muted/50 border border-border"
>
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={() => removeFile(i)}
className="flex-shrink-0 p-1 rounded hover:bg-destructive/10 hover:text-destructive transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Eliminar ${file.name}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
{/* Submit */}
<Button
onClick={handleUpload}
disabled={uploadedFiles.length === 0 || isUploading}
className="w-full"
>
{isUploading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploading ? 'Extrayendo datos...' : `Subir ${uploadedFiles.length > 1 ? `${uploadedFiles.length} Recibos` : 'Recibo'}`}
</Button>
{/* Upload results */}
{uploadResults.length > 0 && (
<div className="space-y-2">
{uploadResults.map((result, i) => (
<div
key={i}
className={[
'rounded-lg border p-3 space-y-1',
result.errors.length > 0 && !result.receipt
? 'border-destructive/50 bg-destructive/5'
: 'border-emerald-500/50 bg-emerald-500/5',
].join(' ')}
>
<div className="flex items-center gap-2">
{result.receipt ? (
<CheckCircle2 className="w-4 h-4 text-emerald-500 flex-shrink-0" />
) : (
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
)}
<span className="text-sm font-medium">
{result.imported > 0 && 'Recibo importado'}
{result.updated > 0 && 'Recibo actualizado'}
{!result.receipt && 'Error al procesar'}
</span>
</div>
{result.receipt && (
<div className="flex items-center justify-between text-xs pl-6">
<span className="text-muted-foreground">
{periodLabel(result.receipt.period)}
</span>
<span data-sensitive className="font-mono font-medium">
{formatCRC(result.receipt.total)}
</span>
</div>
)}
{result.errors.map((err, j) => (
<p key={j} className="text-xs text-destructive pl-6">{err}</p>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
</section>
</div>
);
}

View File

@@ -1,131 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { Plus, ClipboardPaste } from 'lucide-react';
import api, { type Transaction, type Category } from '../api';
import PasteImportModal from '../components/PasteImportModal';
import BillingCycleSelector from '../components/BillingCycleSelector';
import TransactionList from '../components/TransactionList';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
function formatAmount(amount: number, currency: string) {
const abs = Math.abs(amount);
if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export default function Transactions() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [search, setSearch] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
const [loading, setLoading] = useState(true);
const [importOpen, setImportOpen] = useState(false);
const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source: 'CREDIT_CARD', limit: '200' };
if (search) params.search = search;
if (categoryFilter) params.category_id = categoryFilter;
if (cycle) {
params.cycle_year = String(cycle.year);
params.cycle_month = String(cycle.month);
}
const { data } = await api.get('/transactions/', { params });
setTransactions(data);
} finally {
setLoading(false);
}
}, [search, categoryFilter, cycle]);
useEffect(() => {
api.get('/categories/').then((r) => setCategories(r.data));
}, []);
useEffect(() => {
const timer = setTimeout(fetchTransactions, 300);
return () => clearTimeout(timer);
}, [fetchTransactions]);
const totalCRC = transactions
.filter((tx) => tx.currency === 'CRC')
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
const totalUSD = transactions
.filter((tx) => tx.currency === 'USD')
.reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0);
return (
<div className="space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold font-heading">Credit Card Transactions</h1>
<p className="text-sm text-muted-foreground mt-1">
{transactions.length} transactions
{totalCRC !== 0 && (
<> &middot; <span className="font-mono text-foreground">{formatAmount(totalCRC, 'CRC')}</span></>
)}
{totalUSD !== 0 && (
<> &middot; <span className="font-mono text-foreground">{formatAmount(totalUSD, 'USD')}</span></>
)}
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setImportOpen(true)}>
<ClipboardPaste className="w-4 h-4" />
Import
</Button>
</div>
</div>
{/* Billing cycle */}
<BillingCycleSelector value={cycle} onChange={setCycle} />
{/* Category filter */}
<div className="flex items-center gap-3">
<Select
value={categoryFilter || 'all'}
onValueChange={(v) => setCategoryFilter(v === 'all' ? '' : v)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<TransactionList
transactions={transactions}
loading={loading}
source="CREDIT_CARD"
search={search}
onSearchChange={setSearch}
onRefresh={fetchTransactions}
showCategory
/>
{importOpen && (
<PasteImportModal
onClose={() => setImportOpen(false)}
onImported={fetchTransactions}
/>
)}
</div>
);
}

View File

@@ -1,65 +0,0 @@
import { useEffect, useState, useCallback } from 'react';
import { ArrowLeftRight } from 'lucide-react';
import api, { type Transaction } from '../api';
import TransactionList from '../components/TransactionList';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
type SourceTab = 'CASH' | 'TRANSFER';
export default function Transfers() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [search, setSearch] = useState('');
const [sourceTab, setSourceTab] = useState<SourceTab>('CASH');
const [loading, setLoading] = useState(true);
const fetchTransactions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source: sourceTab, limit: '200' };
if (search) params.search = search;
const { data } = await api.get('/transactions/', { params });
setTransactions(data);
} finally {
setLoading(false);
}
}, [search, sourceTab]);
useEffect(() => {
const timer = setTimeout(fetchTransactions, 300);
return () => clearTimeout(timer);
}, [fetchTransactions]);
return (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-bold font-heading">Cash & Transfers</h1>
<p className="text-sm text-muted-foreground mt-1">
Track non-credit-card expenses
</p>
</div>
<Tabs value={sourceTab} onValueChange={(v) => setSourceTab(v as SourceTab)}>
<TabsList>
<TabsTrigger value="CASH">Cash</TabsTrigger>
<TabsTrigger value="TRANSFER">Transfers</TabsTrigger>
</TabsList>
<TabsContent value={sourceTab} className="mt-5 space-y-5">
<TransactionList
transactions={transactions}
loading={loading}
source={sourceTab}
search={search}
onSearchChange={setSearch}
onRefresh={fetchTransactions}
showCategory={false}
addLabel={sourceTab === 'CASH' ? 'Add Cash Expense' : 'Add Transfer'}
emptyIcon={<ArrowLeftRight className="w-8 h-8 mx-auto mb-3 text-muted-foreground/50" />}
emptyMessage={`No ${sourceTab.toLowerCase()} transactions yet`}
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -7,17 +7,15 @@
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
"include": ["src", "server.ts"],
"exclude": ["node_modules"]
}

View File

@@ -1,19 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8001',
// CopilotKit runtime (Hono server, dev only)
"/api/copilotkit": {
target: "http://localhost:3001",
changeOrigin: true,
},
// All other API calls → Python backend
"/api": {
target: "http://localhost:8001",
changeOrigin: true,
},
},

99
scripts/sync-db.sh Executable file
View File

@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -euo pipefail
# ── Configuration ────────────────────────────────────────────────
PROD_SSH_ALIAS="old-vps"
PROD_CONTAINER="wealthysmart-db-prod"
PROD_DB="wealthysmart"
PROD_USER="wealthy_user"
LOCAL_CONTAINER="wealthysmart-db-dev"
LOCAL_DB="wealthysmart"
LOCAL_USER="wealthy_user"
LOCAL_PASS="wealthy_pass"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
DUMP_FILE="$(mktemp -t wealthysmart-dump-XXXXXX)"
# ── Cleanup on exit ─────────────────────────────────────────────
cleanup() { rm -f "$DUMP_FILE"; }
trap cleanup EXIT
# ── Confirmation ─────────────────────────────────────────────────
echo "=== WealthySmart Database Sync ==="
echo ""
echo "This will DESTROY your local dev database and replace it"
echo "with a copy of production data."
echo ""
read -r -p "Continue? [y/N] " confirm
if [[ "$confirm" != [yY] ]]; then
echo "Aborted."
exit 0
fi
# ── 1. Dump production ──────────────────────────────────────────
echo ""
echo "[1/5] Dumping production database..."
ssh "$PROD_SSH_ALIAS" \
"docker exec $PROD_CONTAINER pg_dump \
--format=custom \
--no-owner \
--no-acl \
-U $PROD_USER \
$PROD_DB" > "$DUMP_FILE"
if [[ ! -s "$DUMP_FILE" ]]; then
echo "ERROR: Dump file is empty. SSH or pg_dump may have failed."
exit 1
fi
DUMP_SIZE=$(du -h "$DUMP_FILE" | cut -f1)
echo " Done. Dump size: $DUMP_SIZE"
# ── 2. Ensure local DB container is running ─────────────────────
echo "[2/5] Ensuring local dev database is running..."
cd "$PROJECT_ROOT"
if ! docker inspect --format='{{.State.Running}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "true"; then
echo " Starting db service..."
docker compose up -d db
fi
for i in $(seq 1 30); do
if docker inspect --format='{{.State.Health.Status}}' "$LOCAL_CONTAINER" 2>/dev/null | grep -q "healthy"; then
break
fi
if [[ $i -eq 30 ]]; then
echo "ERROR: Local DB container did not become healthy within 30s."
exit 1
fi
sleep 1
done
echo " Local DB is running and healthy."
# ── 3. Drop and recreate local database ─────────────────────────
echo "[3/5] Dropping and recreating local dev database..."
docker exec "$LOCAL_CONTAINER" bash -c \
"PGPASSWORD='$LOCAL_PASS' dropdb -U $LOCAL_USER --if-exists $LOCAL_DB && \
PGPASSWORD='$LOCAL_PASS' createdb -U $LOCAL_USER $LOCAL_DB"
echo " Done."
# ── 4. Restore ──────────────────────────────────────────────────
echo "[4/5] Restoring dump into local dev database..."
docker exec -i "$LOCAL_CONTAINER" pg_restore \
--no-owner \
--no-acl \
--dbname="$LOCAL_DB" \
-U "$LOCAL_USER" < "$DUMP_FILE"
# ── 5. Run pending migrations ───────────────────────────────────
echo "[5/5] Running pending migrations..."
docker exec "$LOCAL_CONTAINER" psql -U "$LOCAL_USER" -d "$LOCAL_DB" -c \
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false;" \
2>/dev/null || true
echo " Done."
echo ""
echo "=== Sync complete! ==="
echo "Local dev database now mirrors production."