Compare commits

...

23 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
85 changed files with 9710 additions and 3388 deletions

View File

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

6
.gitignore vendored
View File

@@ -8,3 +8,9 @@ __pycache__/
.env.* .env.*
!.env.example !.env.example
docs/legacy_budget_analysis.md 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 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 ## Deployment
- Deployed via Gitea Actions (self-hosted runner on VPS) - Deployed via Gitea Actions (self-hosted runner on VPS)
- Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy - Push to `main` triggers: GitHub → webhook → Gitea mirror sync → Actions workflow → Docker build & deploy
- Domain: wealth.cescalante.dev - Domain: wealth.cescalante.dev
- Reverse proxy: nginx-proxy + acme-companion (auto TLS) - 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 WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --pre -r requirements.txt
COPY . . COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] 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

@@ -10,6 +10,7 @@ from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.models.models import Category, Transaction from app.models.models import Category, Transaction
from app.services.budget_projection 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"]) router = APIRouter(prefix="/analytics", tags=["analytics"])
@@ -44,10 +45,12 @@ def spending_by_category(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
amount_crc = get_converted_amount_expr(session)
query = ( query = (
select( select(
Transaction.category_id, Transaction.category_id,
func.sum(Transaction.amount).label("total"), func.sum(amount_crc).label("total"),
func.count().label("count"), func.count().label("count"),
) )
.where(Transaction.transaction_type == "COMPRA") .where(Transaction.transaction_type == "COMPRA")
@@ -88,7 +91,12 @@ def monthly_trend(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _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() now = datetime.now()
results = [] results = []
month_names = [ month_names = [
@@ -103,15 +111,7 @@ def monthly_trend(
row = session.exec( row = session.exec(
select( select(
func.count(), func.count(),
func.coalesce( func.coalesce(func.sum(amount_crc), 0),
func.sum(
case(
(Transaction.currency == "CRC", Transaction.amount),
else_=0,
)
),
0,
),
func.coalesce( func.coalesce(
func.sum( func.sum(
case( case(
@@ -163,10 +163,12 @@ def daily_spending(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
amount_crc = get_converted_amount_expr(session)
query = ( query = (
select( select(
func.date(Transaction.date).label("day"), func.date(Transaction.date).label("day"),
func.sum(Transaction.amount).label("total"), func.sum(amount_crc).label("total"),
func.count().label("count"), func.count().label("count"),
) )
.where(Transaction.transaction_type == "COMPRA") .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.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 from app.config import settings
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@@ -20,3 +19,8 @@ def login(form_data: OAuth2PasswordRequestForm = Depends()):
) )
token = create_access_token(form_data.username) token = create_access_token(form_data.username)
return {"access_token": token, "token_type": "bearer"} 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

@@ -39,7 +39,9 @@ def list_recurring_items(
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: str = Depends(get_current_user), _user: str = Depends(get_current_user),
): ):
query = select(RecurringItem) query = select(RecurringItem).where(
RecurringItem.item_type != RecurringItemType.SAVINGS
)
if item_type: if item_type:
query = query.where(RecurringItem.item_type == item_type) query = query.where(RecurringItem.item_type == item_type)
if is_active is not None: if is_active is not None:
@@ -101,7 +103,6 @@ class MonthlyProjectionResponse(BaseModel):
year: int year: int
projected_income: float projected_income: float
projected_fixed_expenses: float projected_fixed_expenses: float
projected_savings: float
actual_credit_card: float actual_credit_card: float
actual_cash: float actual_cash: float
actual_transfers: float actual_transfers: float
@@ -118,7 +119,6 @@ class YearlyProjectionResponse(BaseModel):
months: list[MonthlyProjectionResponse] months: list[MonthlyProjectionResponse]
annual_income: float annual_income: float
annual_expenses: float annual_expenses: float
annual_savings: float
annual_net: float annual_net: float
@@ -138,7 +138,6 @@ def get_yearly_projection(
months = [] months = []
annual_income = 0.0 annual_income = 0.0
annual_expenses = 0.0 annual_expenses = 0.0
annual_savings = 0.0
annual_net = 0.0 annual_net = 0.0
for data in months_data: for data in months_data:
@@ -147,7 +146,6 @@ def get_yearly_projection(
year=data["year"], year=data["year"],
projected_income=data["projected_income"], projected_income=data["projected_income"],
projected_fixed_expenses=data["projected_fixed_expenses"], projected_fixed_expenses=data["projected_fixed_expenses"],
projected_savings=data["projected_savings"],
actual_credit_card=data["actual_credit_card"], actual_credit_card=data["actual_credit_card"],
actual_cash=data["actual_cash"], actual_cash=data["actual_cash"],
actual_transfers=data["actual_transfers"], actual_transfers=data["actual_transfers"],
@@ -161,7 +159,6 @@ def get_yearly_projection(
months.append(monthly) months.append(monthly)
annual_income += data["projected_income"] annual_income += data["projected_income"]
annual_expenses += data["gran_total_egresos"] annual_expenses += data["gran_total_egresos"]
annual_savings += data["projected_savings"]
annual_net += data["net_balance"] annual_net += data["net_balance"]
return YearlyProjectionResponse( return YearlyProjectionResponse(
@@ -169,7 +166,6 @@ def get_yearly_projection(
months=months, months=months,
annual_income=annual_income, annual_income=annual_income,
annual_expenses=annual_expenses, annual_expenses=annual_expenses,
annual_savings=annual_savings,
annual_net=annual_net, annual_net=annual_net,
) )
@@ -204,11 +200,9 @@ class MonthlyDetailResponse(BaseModel):
month: int month: int
income_items: list[RecurringItemDetail] income_items: list[RecurringItemDetail]
expense_items: list[RecurringItemDetail] expense_items: list[RecurringItemDetail]
savings_items: list[RecurringItemDetail]
actuals_by_source: list[ActualsBySource] actuals_by_source: list[ActualsBySource]
total_projected_income: float total_projected_income: float
total_projected_expenses: float total_projected_expenses: float
total_projected_savings: float
uncovered_actual: float uncovered_actual: float
gran_total_egresos: float gran_total_egresos: float
net_balance: float net_balance: float
@@ -228,11 +222,9 @@ def get_monthly_detail(
month=data["month"], month=data["month"],
income_items=[RecurringItemDetail(**i) for i in data["income_items"]], income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
expense_items=[RecurringItemDetail(**i) for i in data["expense_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"]], actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
total_projected_income=data["projected_income"], total_projected_income=data["projected_income"],
total_projected_expenses=data["projected_fixed_expenses"], total_projected_expenses=data["projected_fixed_expenses"],
total_projected_savings=data["projected_savings"],
uncovered_actual=data["uncovered_actual"], uncovered_actual=data["uncovered_actual"],
gran_total_egresos=data["gran_total_egresos"], gran_total_egresos=data["gran_total_egresos"],
net_balance=data["net_balance"], net_balance=data["net_balance"],

View File

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

@@ -20,6 +20,7 @@ from app.models.models import (
) )
from app.services.budget_projection import get_cycle_range, get_previous_cycle 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"]) router = APIRouter(prefix="/transactions", tags=["transactions"])
@@ -110,6 +111,7 @@ def list_billing_cycles(
return [] return []
min_date, max_date = result min_date, max_date = result
amount_crc = get_converted_amount_expr(session)
cycles = [] cycles = []
# Determine which cycle the min_date falls into # Determine which cycle the min_date falls into
@@ -129,7 +131,7 @@ def list_billing_cycles(
# Count transactions in this cycle # Count transactions in this cycle
count_result = session.exec( 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 Transaction.date >= start, Transaction.date < end
) )
).first() ).first()
@@ -194,16 +196,23 @@ def create_transaction(
session.refresh(tx) session.refresh(tx)
# Send push notification # 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}" 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( send_push_to_all(
session, session,
title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}", title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
body=f"{amount_str}{tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}", body=f"{amount_str}{tx.bank.value} {label}",
url="/salarios" if is_deposit else "/budget", 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 return tx

View File

@@ -12,6 +12,7 @@ from app.api.v1.endpoints import (
notifications, notifications,
pensions, pensions,
salarios, salarios,
savings_accrual,
settings, settings,
tokens, tokens,
transactions, transactions,
@@ -32,3 +33,4 @@ api_router.include_router(notifications.router)
api_router.include_router(salarios.router) api_router.include_router(salarios.router)
api_router.include_router(pensions.router) api_router.include_router(pensions.router)
api_router.include_router(municipal_receipts.router) api_router.include_router(municipal_receipts.router)
api_router.include_router(savings_accrual.router)

View File

@@ -1,7 +1,9 @@
import hashlib import hashlib
import re
from datetime import datetime, timedelta 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 fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import JWTError, jwt
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -22,8 +24,8 @@ def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
def get_current_user(token: str = Depends(oauth2_scheme)) -> str: def _validate_token(token: str) -> str:
# Try JWT first """Validate JWT and return subject, or raise 401."""
try: try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub") 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}" return f"api:{api_token.name}"
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) 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_PRIVATE_KEY: str = ""
VAPID_PUBLIC_KEY: str = "" VAPID_PUBLIC_KEY: str = ""
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev" VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
OPENAI_API_KEY: str = ""
AGENT_MODEL: str = "gpt-5.4-mini"
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -16,7 +16,59 @@ def run_migrations():
try: try:
conn.execute( conn.execute(
text( text(
"ALTER TABLE transaction ADD COLUMN deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT 0" "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() conn.commit()

View File

@@ -1,12 +1,61 @@
import asyncio
import json
import re
import uuid
from contextlib import asynccontextmanager 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 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.api.v1.router import api_router
from app.auth import ALGORITHM, create_access_token
from app.config import settings from app.config import settings
from app.db import init_db, run_migrations from app.db import get_session, init_db, run_migrations
from app.seed import seed_db 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 @asynccontextmanager
@@ -14,7 +63,15 @@ async def lifespan(app: FastAPI):
init_db() init_db()
run_migrations() run_migrations()
seed_db() seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
try:
yield 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) app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
@@ -27,9 +84,106 @@ app.add_middleware(
allow_headers=["*"], 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) app.include_router(api_router)
# Mount the AG-UI agent endpoint.
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
@app.get("/") @app.get("/")
def root(): def root():
return {"app": "WealthySmart", "version": "0.1.0"} 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" COMPRA = "COMPRA"
DEVOLUCION = "DEVOLUCION" DEVOLUCION = "DEVOLUCION"
DEPOSITO = "DEPOSITO" DEPOSITO = "DEPOSITO"
SALARY = "SALARY"
class TransactionSource(str, enum.Enum): class TransactionSource(str, enum.Enum):
@@ -35,6 +36,7 @@ class TransactionSource(str, enum.Enum):
class Currency(str, enum.Enum): class Currency(str, enum.Enum):
CRC = "CRC" CRC = "CRC"
USD = "USD" USD = "USD"
EUR = "EUR"
BTC = "BTC" BTC = "BTC"
XMR = "XMR" XMR = "XMR"
@@ -362,6 +364,39 @@ class BalanceOverrideRead(SQLModel):
updated_at: datetime 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 --- # --- Municipal Receipt ---

View File

@@ -1,7 +1,7 @@
import calendar import calendar
from datetime import datetime from datetime import datetime
from sqlmodel import Session, func, select from sqlmodel import Session, col, func, select
from app.models.models import ( from app.models.models import (
BalanceOverride, BalanceOverride,
@@ -12,6 +12,7 @@ from app.models.models import (
TransactionSource, TransactionSource,
TransactionType, TransactionType,
) )
from app.services.exchange_rate import get_converted_amount_expr
MIN_YEAR = 2026 MIN_YEAR = 2026
MAX_YEAR = 2030 MAX_YEAR = 2030
@@ -19,6 +20,9 @@ MAX_YEAR = 2030
FRESH_START_YEAR = 2026 FRESH_START_YEAR = 2026
FRESH_START_MONTH = 3 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: def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
"""Return the effective amount for a recurring item in a given month, or None if inactive.""" """Return the effective amount for a recurring item in a given month, or None if inactive."""
@@ -104,13 +108,15 @@ def compute_actuals_by_source(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month) cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
results = {} results = {}
for source in TransactionSource: for source in TransactionSource:
if source == TransactionSource.CREDIT_CARD: if source == TransactionSource.CREDIT_CARD:
start, end = cc_start, cc_end start, end = cc_start, cc_end
# Normal transactions in this cycle (not deferred) # Normal transactions in this cycle (not deferred)
compra_normal = session.exec( compra_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date >= start,
Transaction.date < end, Transaction.date < end,
Transaction.source == source, Transaction.source == source,
@@ -120,7 +126,7 @@ def compute_actuals_by_source(
).one() ).one()
# Deferred from previous cycle # Deferred from previous cycle
compra_deferred = session.exec( compra_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == source, Transaction.source == source,
@@ -131,7 +137,7 @@ def compute_actuals_by_source(
compra = float(compra_normal) + float(compra_deferred) compra = float(compra_normal) + float(compra_deferred)
dev_normal = session.exec( dev_normal = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= start, Transaction.date >= start,
Transaction.date < end, Transaction.date < end,
Transaction.source == source, Transaction.source == source,
@@ -140,7 +146,7 @@ def compute_actuals_by_source(
) )
).one() ).one()
dev_deferred = session.exec( dev_deferred = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == source, Transaction.source == source,
@@ -155,7 +161,7 @@ def compute_actuals_by_source(
Transaction.date >= start, Transaction.date >= start,
Transaction.date < end, Transaction.date < end,
Transaction.source == source, Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
).one() ).one()
@@ -164,7 +170,7 @@ def compute_actuals_by_source(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == source, Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
).one() ).one()
@@ -180,7 +186,7 @@ def compute_actuals_by_source(
else: else:
# Cash / Transfer: calendar month, no deferred logic # Cash / Transfer: calendar month, no deferred logic
compra = session.exec( compra = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source == source, Transaction.source == source,
@@ -188,7 +194,7 @@ def compute_actuals_by_source(
) )
).one() ).one()
devolucion = session.exec( devolucion = session.exec(
select(func.coalesce(func.sum(Transaction.amount), 0)).where( select(func.coalesce(func.sum(amount_crc), 0)).where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source == source, Transaction.source == source,
@@ -200,7 +206,7 @@ def compute_actuals_by_source(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source == source, Transaction.source == source,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
) )
).one() ).one()
@@ -230,6 +236,8 @@ def compute_actuals_by_category(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month) cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
totals: dict[int, float] = {} totals: dict[int, float] = {}
def _merge_rows(rows: list) -> None: def _merge_rows(rows: list) -> None:
@@ -245,14 +253,14 @@ def compute_actuals_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= cc_start, Transaction.date >= cc_start,
Transaction.date < cc_end, Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr] Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -265,14 +273,14 @@ def compute_actuals_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr] Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -285,14 +293,14 @@ def compute_actuals_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD, Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_not(None), # type: ignore[union-attr] Transaction.category_id.is_not(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
).all() ).all()
@@ -310,6 +318,8 @@ def compute_cc_by_category(
prev_cc_y, prev_cc_m = get_previous_cycle(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) 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] = {} totals: dict[int | None, float] = {}
def _merge(rows: list) -> None: def _merge(rows: list) -> None:
@@ -325,13 +335,13 @@ def compute_cc_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= cc_start, Transaction.date >= cc_start,
Transaction.date < cc_end, Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -343,13 +353,13 @@ def compute_cc_by_category(
select( select(
Transaction.category_id, Transaction.category_id,
Transaction.transaction_type, Transaction.transaction_type,
func.sum(Transaction.amount), func.sum(amount_crc),
) )
.where( .where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
.group_by(Transaction.category_id, Transaction.transaction_type) .group_by(Transaction.category_id, Transaction.transaction_type)
@@ -378,7 +388,10 @@ def compute_monthly_projection(
) -> dict: ) -> dict:
"""Compute full monthly projection with no-double-count logic.""" """Compute full monthly projection with no-double-count logic."""
items = session.exec( 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() ).all()
actuals_by_source = compute_actuals_by_source(session, year, month) actuals_by_source = compute_actuals_by_source(session, year, month)
@@ -386,11 +399,9 @@ def compute_monthly_projection(
income_items = [] income_items = []
expense_items = [] expense_items = []
savings_items = []
total_income = 0.0 total_income = 0.0
total_fixed_expenses = 0.0 total_fixed_expenses = 0.0
total_savings = 0.0
for item in items: for item in items:
effective = get_effective_amount(item, month, year) effective = get_effective_amount(item, month, year)
@@ -424,10 +435,6 @@ def compute_monthly_projection(
total_fixed_expenses += effective total_fixed_expenses += effective
expense_items.append(detail) 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 # Sum actuals from sources for categories NOT covered by recurring items
covered_category_ids = { covered_category_ids = {
item.category_id item.category_id
@@ -449,6 +456,8 @@ def compute_monthly_projection(
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m) prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
cal_start, cal_end = get_month_range(year, month) cal_start, cal_end = get_month_range(year, month)
amount_crc = get_converted_amount_expr(session)
def _sum_uncategorized(rows: list) -> float: def _sum_uncategorized(rows: list) -> float:
total = 0.0 total = 0.0
for tx_type, amount in rows: for tx_type, amount in rows:
@@ -461,13 +470,13 @@ def compute_monthly_projection(
# CC uncategorized: this cycle (not deferred) # CC uncategorized: this cycle (not deferred)
uncovered_actual += _sum_uncategorized( uncovered_actual += _sum_uncategorized(
session.exec( session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount)) select(Transaction.transaction_type, func.sum(amount_crc))
.where( .where(
Transaction.date >= cc_start, Transaction.date >= cc_start,
Transaction.date < cc_end, Transaction.date < cc_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr] Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == False, # noqa: E712 Transaction.deferred_to_next_cycle == False, # noqa: E712
) )
.group_by(Transaction.transaction_type) .group_by(Transaction.transaction_type)
@@ -476,13 +485,13 @@ def compute_monthly_projection(
# CC uncategorized: deferred from previous cycle # CC uncategorized: deferred from previous cycle
uncovered_actual += _sum_uncategorized( uncovered_actual += _sum_uncategorized(
session.exec( session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount)) select(Transaction.transaction_type, func.sum(amount_crc))
.where( .where(
Transaction.date >= prev_start, Transaction.date >= prev_start,
Transaction.date < prev_end, Transaction.date < prev_end,
Transaction.source == TransactionSource.CREDIT_CARD, Transaction.source == TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr] Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
Transaction.deferred_to_next_cycle == True, # noqa: E712 Transaction.deferred_to_next_cycle == True, # noqa: E712
) )
.group_by(Transaction.transaction_type) .group_by(Transaction.transaction_type)
@@ -491,13 +500,13 @@ def compute_monthly_projection(
# Non-CC uncategorized: calendar month # Non-CC uncategorized: calendar month
uncovered_actual += _sum_uncategorized( uncovered_actual += _sum_uncategorized(
session.exec( session.exec(
select(Transaction.transaction_type, func.sum(Transaction.amount)) select(Transaction.transaction_type, func.sum(amount_crc))
.where( .where(
Transaction.date >= cal_start, Transaction.date >= cal_start,
Transaction.date < cal_end, Transaction.date < cal_end,
Transaction.source != TransactionSource.CREDIT_CARD, Transaction.source != TransactionSource.CREDIT_CARD,
Transaction.category_id.is_(None), # type: ignore[union-attr] Transaction.category_id.is_(None), # type: ignore[union-attr]
Transaction.transaction_type != TransactionType.DEPOSITO, col(Transaction.transaction_type).notin_(INCOME_TYPES),
) )
.group_by(Transaction.transaction_type) .group_by(Transaction.transaction_type)
).all() ).all()
@@ -509,8 +518,6 @@ def compute_monthly_projection(
cc_by_category = compute_cc_by_category(session, year, month) cc_by_category = compute_cc_by_category(session, year, month)
gran_total = total_fixed_expenses + uncovered_actual 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 net_balance = total_income - gran_total
return { return {
@@ -518,7 +525,6 @@ def compute_monthly_projection(
"month": month, "month": month,
"projected_income": total_income, "projected_income": total_income,
"projected_fixed_expenses": total_fixed_expenses, "projected_fixed_expenses": total_fixed_expenses,
"projected_savings": total_savings,
"actual_credit_card": actual_credit_card, "actual_credit_card": actual_credit_card,
"actual_cash": actual_cash, "actual_cash": actual_cash,
"actual_transfers": actual_transfers, "actual_transfers": actual_transfers,
@@ -527,7 +533,6 @@ def compute_monthly_projection(
"net_balance": net_balance, "net_balance": net_balance,
"income_items": income_items, "income_items": income_items,
"expense_items": expense_items, "expense_items": expense_items,
"savings_items": savings_items,
"actuals_by_source": list(actuals_by_source.values()), "actuals_by_source": list(actuals_by_source.values()),
"cc_by_category": cc_by_category, "cc_by_category": cc_by_category,
} }

View File

@@ -1,12 +1,21 @@
import asyncio
import logging
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime, timedelta from datetime import datetime, timedelta
import httpx import httpx
from sqlalchemy import case
from sqlmodel import Session, col, select from sqlmodel import Session, col, select
from app.config import settings from app.config import settings
from app.db import engine
from app.models.models import ExchangeRate 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 indicators: 317 = buy, 318 = sell
BCCR_URL = "https://gee.bccr.fi.cr/Indicadores/Suscripciones/WS/wsindicadoreseconomicos.asmx/ObtenerIndicadoresEconomicos" 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 _last_known: ExchangeRate | None = None # survives cache expiry — always holds the last successful rate
CACHE_TTL = timedelta(hours=1) 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: def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
"""Fetch a single indicator from BCCR API.""" """Fetch a single indicator from BCCR API."""
@@ -167,6 +184,188 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
return 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]: def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
"""Get historical exchange rates.""" """Get historical exchange rates."""
cutoff = datetime.utcnow() - timedelta(days=days) cutoff = datetime.utcnow() - timedelta(days=days)

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

@@ -11,3 +11,6 @@ httpx
pywebpush pywebpush
py-vapid py-vapid
python-dateutil 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} ADMIN_PASSWORD: ${ADMIN_PASSWORD}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
OPENAI_API_KEY: ${OPENAI_API_KEY}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
expose: expose:
- "8000" - "8000"
networks: networks:
@@ -47,22 +49,32 @@ services:
frontend: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile.prod dockerfile: Dockerfile
target: runner
args:
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
container_name: wealthysmart-frontend-prod container_name: wealthysmart-frontend-prod
restart: unless-stopped restart: unless-stopped
environment: 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_HOST: wealth.cescalante.dev
VIRTUAL_PORT: "3000"
LETSENCRYPT_HOST: wealth.cescalante.dev LETSENCRYPT_HOST: wealth.cescalante.dev
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL} LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL}
expose: expose:
- "80" - "3000"
networks: networks:
- wealthysmart-network - wealthysmart-network
- nginx-prod-network - nginx-prod-network
depends_on: depends_on:
- backend - backend
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -25,6 +25,8 @@ services:
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
ports: ports:
- "8001:8000" - "8001:8000"
volumes: volumes:
@@ -32,17 +34,52 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy 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: frontend:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
target: dev
container_name: wealthysmart-frontend-dev container_name: wealthysmart-frontend-dev
ports: ports:
- "5175:5173" - "5175:3000"
volumes: environment:
- ./frontend:/app NODE_ENV: development
- /app/node_modules 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: volumes:
postgres_data: 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 # syntax=docker/dockerfile:1.7
RUN corepack enable && corepack prepare pnpm@latest --activate
FROM node:22-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile 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 . . 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="description" content="WealthySmart — Smart personal finance management" />
<meta name="theme-color" content="#0f172a" /> <meta name="theme-color" content="#0f172a" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <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> <title>WealthySmart</title>
</head> </head>
<body> <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,36 +1,46 @@
{ {
"name": "wealthysmart-frontend", "name": "frontend",
"private": true,
"version": "0.1.0", "version": "0.1.0",
"private": true,
"type": "module", "type": "module",
"scripts": { "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", "build": "vite build",
"preview": "vite preview" "preview": "tsx server.ts",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "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/ibm-plex-sans": "^5.2.8",
"@fontsource-variable/noto-sans": "^5.2.10", "@fontsource-variable/noto-sans": "^5.2.10",
"@hono/node-server": "^1.14.4",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.562.0", "concurrently": "^9.1.2",
"react": "^19.2.0", "hono": "^4.12.15",
"react-dom": "^19.2.0", "lucide-react": "^1.12.0",
"react-router-dom": "^7.12.0", "react": "19.2.5",
"recharts": "^2.15.4", "react-dom": "19.2.5",
"shadcn": "^4.1.0", "react-router-dom": "^7.6.0",
"recharts": "^3.8.1",
"rxjs": "^7.8.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tsx": "^4.19.4",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4",
"@types/react": "^19.2.8", "@types/node": "^20",
"@types/react-dom": "^19.2.3", "@types/react": "^19",
"@vitejs/plugin-react-swc": "^4.2.2", "@types/react-dom": "^19",
"tailwindcss": "^4.1.18", "@vitejs/plugin-react-swc": "^3.9.0",
"typescript": "^5.9.3", "tailwindcss": "^4",
"vite": "^7.2.4" "typescript": "^5",
"vite": "^6.3.5"
} }
} }

8478
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'; self.addEventListener("install", () => self.skipWaiting());
const STATIC_ASSETS = ['/', '/index.html']; self.addEventListener("activate", async () => {
await self.registration.unregister();
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);
})
);
}); });

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

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 { interface AuthCtx {
isAuthenticated: boolean; isAuthenticated: boolean;
logout: () => void; isLoading: boolean;
logout: () => Promise<void>;
setAuthenticated: (v: boolean) => void; setAuthenticated: (v: boolean) => void;
} }
const AuthContext = createContext<AuthCtx>({ const AuthContext = createContext<AuthCtx>({
isAuthenticated: false, isAuthenticated: false,
logout: () => {}, isLoading: true,
logout: async () => {},
setAuthenticated: () => {}, setAuthenticated: () => {},
}); });
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setAuthenticated] = useState(!!localStorage.getItem('token')); const [isAuthenticated, setAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
const check = () => setAuthenticated(!!localStorage.getItem('token')); // Probe auth state by hitting a protected endpoint.
window.addEventListener('storage', check); // If the ws_token cookie is valid, the server returns 200; else 401.
return () => window.removeEventListener('storage', check); fetch("/api/v1/auth/me", { credentials: "include" })
.then((r) => setAuthenticated(r.ok))
.catch(() => setAuthenticated(false))
.finally(() => setIsLoading(false));
}, []); }, []);
const logout = () => { const logout = async () => {
localStorage.removeItem('token'); await apiLogout();
setAuthenticated(false); setAuthenticated(false);
}; };
return ( return (
<AuthContext.Provider value={{ isAuthenticated, logout, setAuthenticated }}> <AuthContext.Provider value={{ isAuthenticated, isLoading, logout, setAuthenticated }}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );

View File

@@ -1,27 +0,0 @@
import { createContext, useContext, useEffect, useState } from 'react';
const PrivacyContext = createContext<{
privacyMode: boolean;
togglePrivacy: () => void;
}>({ privacyMode: false, togglePrivacy: () => {} });
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
const [privacyMode, setPrivacyMode] = useState<boolean>(() => {
return 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

@@ -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

@@ -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 { useEffect, useState } from 'react';
import { Calendar } from 'lucide-react'; import { Calendar } from 'lucide-react';
import api from '../api'; import api from '@/lib/api';
import { import {
Select, Select,
SelectContent, 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 data-sensitive 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,6 +1,7 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { useState, useEffect } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { import {
LayoutDashboard, Sparkles,
Calculator, Calculator,
BarChart3, BarChart3,
Landmark, Landmark,
@@ -15,24 +16,20 @@ import {
Eye, Eye,
EyeOff, EyeOff,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from "lucide-react";
import { useEffect, useState } from 'react'; import { useTheme } from "@/contexts/theme-context";
import { useAuth } from '../AuthContext'; import { usePrivacy } from "@/contexts/privacy-context";
import { useTheme } from '../ThemeContext'; import { useAuth } from "@/AuthContext";
import { usePrivacy } from '../PrivacyContext'; import { Button } from "@/components/ui/button";
import { subscribeToPush } from '../pushNotifications';
import { Button } from '@/components/ui/button';
import { import {
Sheet, Sheet,
SheetContent, SheetContent,
SheetHeader, SheetHeader,
SheetTitle, SheetTitle,
SheetClose, SheetClose,
} from '@/components/ui/sheet'; } from "@/components/ui/sheet";
import { Separator } from '@/components/ui/separator'; import { Separator } from "@/components/ui/separator";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
// ─── Navigation Structure ────────────────────────────────────────────────────
interface NavSection { interface NavSection {
label: string; label: string;
@@ -41,32 +38,32 @@ interface NavSection {
const navSections: NavSection[] = [ const navSections: NavSection[] = [
{ {
label: 'General', label: "General",
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
},
{
label: "Finanzas",
items: [ items: [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { 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: 'Finanzas', label: "Servicios",
items: [ items: [
{ to: '/budget', icon: Calculator, label: 'Presupuesto' }, { to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
{ 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' },
], ],
}, },
]; ];
// ─── Shared Nav Renderer ─────────────────────────────────────────────────────
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
const { pathname } = useLocation();
const isActive = (to: string) =>
pathname === to || pathname.startsWith(`${to}/`);
return ( return (
<nav className="flex flex-col gap-0.5 px-3"> <nav className="flex flex-col gap-0.5 px-3">
{navSections.map((section) => ( {navSections.map((section) => (
@@ -75,23 +72,20 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
{section.label} {section.label}
</p> </p>
{section.items.map(({ to, icon: Icon, label }) => ( {section.items.map(({ to, icon: Icon, label }) => (
<NavLink <Link
key={to} key={to}
to={to} to={to}
end={to === '/'}
onClick={onNavigate} onClick={onNavigate}
className={({ isActive }) => className={cn(
cn( "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', isActive(to)
isActive ? "bg-primary/10 text-primary"
? 'bg-primary/10 text-primary' : "text-muted-foreground hover:text-foreground hover:bg-muted",
: 'text-muted-foreground hover:text-foreground hover:bg-muted' )}
)
}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
{label} {label}
</NavLink> </Link>
))} ))}
</div> </div>
))} ))}
@@ -99,27 +93,20 @@ function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
); );
} }
// ─── Main Layout ─────────────────────────────────────────────────────────────
export default function Layout() { export default function Layout() {
const { logout } = useAuth();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { privacyMode, togglePrivacy } = usePrivacy(); const { privacyMode, togglePrivacy } = usePrivacy();
const { logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
useEffect(() => { const handleLogout = async () => {
subscribeToPush(); await logout();
}, []); navigate("/login", { replace: true });
const handleLogout = () => {
logout();
navigate('/login');
}; };
return ( return (
<div className="min-h-screen bg-background text-foreground"> <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"> <header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
<div className="px-4 sm:px-6 py-3 flex items-center justify-between"> <div className="px-4 sm:px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
@@ -136,7 +123,7 @@ export default function Layout() {
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center"> <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} /> <Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div> </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> Wealthy<span className="text-primary">Smart</span>
</span> </span>
</div> </div>
@@ -146,7 +133,7 @@ export default function Layout() {
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} {privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button> </Button>
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme"> <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>
<Button <Button
variant="ghost" variant="ghost"
@@ -163,7 +150,6 @@ export default function Layout() {
</header> </header>
<div className="flex"> <div className="flex">
{/* ── Desktop sidebar ───────────────────────────────────────── */}
<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"> <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"> <div className="flex-1">
<SidebarNav /> <SidebarNav />
@@ -180,7 +166,6 @@ export default function Layout() {
</div> </div>
</aside> </aside>
{/* ── Mobile nav sheet ──────────────────────────────────────── */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}> <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="p-0 w-64"> <SheetContent side="left" className="p-0 w-64">
<SheetHeader className="p-4"> <SheetHeader className="p-4">
@@ -188,7 +173,7 @@ export default function Layout() {
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center"> <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} /> <Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
</div> </div>
<span className="font-heading"> <span style={{ fontFamily: "var(--font-heading)" }}>
Wealthy<span className="text-primary">Smart</span> Wealthy<span className="text-primary">Smart</span>
</span> </span>
</SheetTitle> </SheetTitle>
@@ -202,7 +187,10 @@ export default function Layout() {
<Separator className="mb-2" /> <Separator className="mb-2" />
<SheetClose render={<span />}> <SheetClose render={<span />}>
<button <button
onClick={() => { setMobileOpen(false); handleLogout(); }} 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" 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" /> <LogOut className="w-4 h-4" />
@@ -214,7 +202,6 @@ export default function Layout() {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
{/* ── Main content ──────────────────────────────────────────── */}
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 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"> <div className="max-w-6xl mx-auto">
<Outlet /> <Outlet />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-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 { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';

View File

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

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

@@ -11,7 +11,7 @@ import {
Banknote, Banknote,
} from 'lucide-react'; } from 'lucide-react';
import api, { type Transaction } from '../api'; import api, { type Transaction } from '@/lib/api';
import TransactionModal from './TransactionModal'; import TransactionModal from './TransactionModal';
import ConfirmDialog from './ConfirmDialog'; import ConfirmDialog from './ConfirmDialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; 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 { formatLocalDatetime } from '@/lib/format';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -151,6 +151,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
<SelectContent> <SelectContent>
<SelectItem value="CRC">CRC ()</SelectItem> <SelectItem value="CRC">CRC ()</SelectItem>
<SelectItem value="USD">USD ($)</SelectItem> <SelectItem value="USD">USD ($)</SelectItem>
<SelectItem value="EUR">EUR ()</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -1,12 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { PieChart, Pie, Cell } from 'recharts'; import { PieChart, Pie, Cell } from 'recharts';
import { type MonthlyDetail as MonthlyDetailType } from '@/api'; import { type MonthlyDetail as MonthlyDetailType } from '@/lib/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { import {
ChartContainer, ChartContainer,
ChartTooltip, ChartTooltip,
@@ -16,7 +17,6 @@ import {
import { import {
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
PiggyBank,
CreditCard, CreditCard,
Banknote, Banknote,
ArrowLeftRight, ArrowLeftRight,
@@ -52,23 +52,123 @@ const SOURCE_LABELS: Record<string, { label: string; icon: typeof Banknote }> =
}; };
interface MonthlyDetailProps { interface MonthlyDetailProps {
detail: MonthlyDetailType; detail: MonthlyDetailType | null;
loading?: boolean; loading?: boolean;
onNavigateToTransactions?: () => void; onNavigateToTransactions?: () => void;
} }
function PieCardSkeleton({ titleIcon: TitleIcon, title }: { titleIcon: typeof TrendingUp; title: string }) {
return (
<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) { export default function MonthlyDetail({ detail, loading, onNavigateToTransactions }: MonthlyDetailProps) {
const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt'); const [paletteMode, setPaletteMode] = useState<PaletteMode>('chatgpt');
if (loading) { if (loading || !detail) {
return ( return (
<div className="grid gap-4 md:grid-cols-3"> <div className="space-y-4">
{[1, 2, 3].map((i) => ( <div className="flex items-center justify-end gap-1">
<Card key={i} className="animate-pulse"> <span className="text-xs text-muted-foreground mr-1">Paleta:</span>
<CardContent className="h-48" /> <Skeleton className="h-6 w-16" />
</Card> <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>
</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>
); );
} }
@@ -346,8 +446,8 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</Card> </Card>
)} )}
{/* Actuals + Savings + Summary */} {/* Actuals + Summary */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2">
{/* Cash & Transfer Actuals Card */} {/* Cash & Transfer Actuals Card */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -401,33 +501,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
</CardContent> </CardContent>
</Card> </Card>
{/* 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 data-sensitive 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 data-sensitive className="font-mono">
{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
</CardContent>
</Card>
)}
{/* Summary */} {/* Summary */}
<Card className={cn( <Card className={cn(
'border-2', 'border-2',
@@ -446,12 +519,6 @@ export default function MonthlyDetail({ detail, loading, onNavigateToTransaction
-{formatAmount(detail.gran_total_egresos, 'CRC')} -{formatAmount(detail.gran_total_egresos, 'CRC')}
</span> </span>
</div> </div>
<div className="flex items-center justify-between text-sm">
<span>Ahorro</span>
<span data-sensitive className="font-mono font-medium">
-{formatAmount(detail.total_projected_savings, 'CRC')}
</span>
</div>
<Separator /> <Separator />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-semibold">Balance Neto</span> <span className="font-semibold">Balance Neto</span>

View File

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

View File

@@ -4,7 +4,7 @@ import {
type RecurringItem, type RecurringItem,
type RecurringItemCreate, type RecurringItemCreate,
type RecurringItemUpdate, type RecurringItemUpdate,
} from '@/api'; } from '@/lib/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; 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' }> = { const TYPE_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'outline' }> = {
INCOME: { label: 'Ingreso', variant: 'default' }, INCOME: { label: 'Ingreso', variant: 'default' },
EXPENSE: { label: 'Egreso', variant: 'secondary' }, EXPENSE: { label: 'Egreso', variant: 'secondary' },
SAVINGS: { label: 'Ahorro', variant: 'outline' },
}; };
const FREQ_LABELS: Record<string, string> = { const FREQ_LABELS: Record<string, string> = {

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Pencil } from 'lucide-react'; import { Pencil } from 'lucide-react';
import { type MonthlyProjection } from '@/api'; import { type MonthlyProjection } from '@/lib/api';
import { formatAmount } from '@/lib/format'; import { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -89,7 +89,6 @@ export default function YearlyOverview({
<TableHead className="text-right">Egresos Fijos</TableHead> <TableHead className="text-right">Egresos Fijos</TableHead>
<TableHead className="text-right">Otros Gastos</TableHead> <TableHead className="text-right">Otros Gastos</TableHead>
<TableHead className="text-right">Gran Total</TableHead> <TableHead className="text-right">Gran Total</TableHead>
<TableHead className="text-right">Ahorro</TableHead>
<TableHead className="text-right">Acum. Anterior</TableHead> <TableHead className="text-right">Acum. Anterior</TableHead>
<TableHead className="text-right">Neto Mes</TableHead> <TableHead className="text-right">Neto Mes</TableHead>
<TableHead className="text-right">Balance Acum.</TableHead> <TableHead className="text-right">Balance Acum.</TableHead>
@@ -132,9 +131,6 @@ export default function YearlyOverview({
<TableCell data-sensitive 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')} {formatAmount(m.gran_total_egresos, 'CRC')}
</TableCell> </TableCell>
<TableCell data-sensitive className="text-right font-mono text-sm text-muted-foreground">
{formatAmount(m.projected_savings, 'CRC')}
</TableCell>
<TableCell <TableCell
className={cn( className={cn(
'text-right font-mono text-sm', 'text-right font-mono text-sm',

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 { type ColumnDef } from '@tanstack/react-table';
import { ArrowLeftRight, ArrowRightFromLine, Banknote, 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 { formatAmount } from '@/lib/format';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';

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-slot="tabs"
data-orientation={orientation} data-orientation={orientation}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -22,7 +22,7 @@ function Tabs({
} }
const tabsListVariants = cva( 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: { variants: {
variant: { variant: {
@@ -56,10 +56,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab <TabsPrimitive.Tab
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( 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", "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", "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 className
)} )}
{...props} {...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

@@ -13,7 +13,7 @@ import {
deleteRecurringItem as apiDeleteItem, deleteRecurringItem as apiDeleteItem,
upsertBalanceOverride, upsertBalanceOverride,
deleteBalanceOverride, deleteBalanceOverride,
} from '@/api'; } from '@/lib/api';
export function useBudget(initialYear: number) { export function useBudget(initialYear: number) {
const [year, setYear] = useState(initialYear); const [year, setYear] = useState(initialYear);

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/noto-sans";
@import "@fontsource-variable/ibm-plex-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 *)); @custom-variant dark (&:is(.dark *));
:root { :root {
--font-sans: "Noto Sans Variable", sans-serif;
--font-heading: "IBM Plex Sans Variable", sans-serif;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0); --card: oklch(1 0 0);
@@ -39,6 +42,8 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067); --sidebar-ring: oklch(0.705 0.015 286.067);
--copilot-kit-primary-color: var(--primary);
} }
.dark { .dark {
@@ -73,11 +78,39 @@
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938); --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 { @theme inline {
--font-sans: 'Noto Sans Variable', sans-serif; --font-sans: var(--font-sans);
--font-heading: 'IBM Plex Sans Variable', sans-serif; --font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -124,9 +157,7 @@
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} font-family: var(--font-sans);
html {
@apply font-sans;
} }
} }

View File

@@ -1,4 +1,4 @@
const BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'; const BASE_URL = "/api/v1";
class ApiError extends Error { class ApiError extends Error {
response: { status: number; data: unknown }; response: { status: number; data: unknown };
@@ -12,7 +12,12 @@ interface RequestConfig {
params?: Record<string, string | number | boolean | undefined>; params?: Record<string, string | number | boolean | undefined>;
} }
async function request<T>(method: string, url: string, body?: unknown, config?: RequestConfig): Promise<{ data: T }> { async function request<T>(
method: string,
url: string,
body?: unknown,
config?: RequestConfig,
): Promise<{ data: T }> {
let fullUrl = `${BASE_URL}${url}`; let fullUrl = `${BASE_URL}${url}`;
if (config?.params) { if (config?.params) {
@@ -25,28 +30,32 @@ async function request<T>(method: string, url: string, body?: unknown, config?:
} }
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
const token = localStorage.getItem('token');
if (token) headers['Authorization'] = `Bearer ${token}`;
let fetchBody: BodyInit | undefined; let fetchBody: BodyInit | undefined;
if (body instanceof FormData || body instanceof URLSearchParams) { if (body instanceof FormData || body instanceof URLSearchParams) {
fetchBody = body; fetchBody = body;
} else if (body !== undefined) { } else if (body !== undefined) {
headers['Content-Type'] = 'application/json'; headers["Content-Type"] = "application/json";
fetchBody = JSON.stringify(body); fetchBody = JSON.stringify(body);
} }
const res = await fetch(fullUrl, { method, headers, body: fetchBody }); const res = await fetch(fullUrl, {
method,
headers,
body: fetchBody,
credentials: "same-origin",
});
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem('token'); await fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
window.location.href = '/login'; if (typeof window !== "undefined") window.location.replace("/login");
throw new ApiError(401, null); throw new ApiError(401, null);
} }
if (!res.ok) { if (!res.ok) {
let data: unknown = null; let data: unknown = null;
try { data = await res.json(); } catch {} try {
data = await res.json();
} catch {}
throw new ApiError(res.status, data); throw new ApiError(res.status, data);
} }
@@ -57,33 +66,47 @@ async function request<T>(method: string, url: string, body?: unknown, config?:
} }
const api = { const api = {
get<T = any>(url: string, config?: RequestConfig) { get<T = unknown>(url: string, config?: RequestConfig) {
return request<T>('GET', url, undefined, config); return request<T>("GET", url, undefined, config);
}, },
post<T = any>(url: string, body?: unknown, config?: RequestConfig) { post<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('POST', url, body, config); return request<T>("POST", url, body, config);
}, },
patch<T = any>(url: string, body?: unknown, config?: RequestConfig) { patch<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('PATCH', url, body, config); return request<T>("PATCH", url, body, config);
}, },
put<T = any>(url: string, body?: unknown, config?: RequestConfig) { put<T = unknown>(url: string, body?: unknown, config?: RequestConfig) {
return request<T>('PUT', url, body, config); return request<T>("PUT", url, body, config);
}, },
delete<T = any>(url: string, config?: RequestConfig) { delete<T = unknown>(url: string, config?: RequestConfig) {
return request<T>('DELETE', url, undefined, config); return request<T>("DELETE", url, undefined, config);
}, },
}; };
export default api; export default api;
export async function login(username: string, password: string) { export async function login(username: string, password: string) {
const form = new URLSearchParams(); const res = await fetch("/api/auth/login", {
form.append('username', username); method: "POST",
form.append('password', password); headers: { "Content-Type": "application/json" },
const { data } = await api.post('/auth/login', form); body: JSON.stringify({ username, password }),
localStorage.setItem('token', data.access_token); credentials: "same-origin",
return data; });
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 { export interface Account {
id: number; id: number;
@@ -109,34 +132,6 @@ export interface ImportResult {
errors: string[]; 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 { export interface Transaction {
id: number; id: number;
amount: number; amount: number;
@@ -160,8 +155,13 @@ export interface Transaction {
// --- Budget / Recurring Items --- // --- Budget / Recurring Items ---
export type RecurringItemType = 'INCOME' | 'EXPENSE' | 'SAVINGS'; export type RecurringItemType = "INCOME" | "EXPENSE";
export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY'; export type RecurringFrequency =
| "WEEKLY"
| "MONTHLY"
| "QUARTERLY"
| "BIANNUAL"
| "YEARLY";
export interface RecurringItem { export interface RecurringItem {
id: number; id: number;
@@ -233,7 +233,6 @@ export interface MonthlyProjection {
year: number; year: number;
projected_income: number; projected_income: number;
projected_fixed_expenses: number; projected_fixed_expenses: number;
projected_savings: number;
actual_credit_card: number; actual_credit_card: number;
actual_cash: number; actual_cash: number;
actual_transfers: number; actual_transfers: number;
@@ -250,7 +249,6 @@ export interface YearlyProjection {
months: MonthlyProjection[]; months: MonthlyProjection[];
annual_income: number; annual_income: number;
annual_expenses: number; annual_expenses: number;
annual_savings: number;
annual_net: number; annual_net: number;
} }
@@ -259,22 +257,60 @@ export interface MonthlyDetail {
month: number; month: number;
income_items: RecurringItemDetail[]; income_items: RecurringItemDetail[];
expense_items: RecurringItemDetail[]; expense_items: RecurringItemDetail[];
savings_items: RecurringItemDetail[];
actuals_by_source: ActualsBySource[]; actuals_by_source: ActualsBySource[];
total_projected_income: number; total_projected_income: number;
total_projected_expenses: number; total_projected_expenses: number;
total_projected_savings: number;
uncovered_actual: number; uncovered_actual: number;
gran_total_egresos: number; gran_total_egresos: number;
net_balance: number; net_balance: number;
cc_by_category: { category_name: string; amount: number }[]; cc_by_category: { category_name: string; amount: number }[];
} }
// Budget API functions // --- Savings Accrual ---
export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) =>
api.get<RecurringItem[]>('/budget/recurring', { params }); 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) => export const createRecurringItem = (data: RecurringItemCreate) =>
api.post<RecurringItem>('/budget/recurring', data); api.post<RecurringItem>("/budget/recurring", data);
export const updateRecurringItem = (id: number, data: RecurringItemUpdate) => export const updateRecurringItem = (id: number, data: RecurringItemUpdate) =>
api.patch<RecurringItem>(`/budget/recurring/${id}`, data); api.patch<RecurringItem>(`/budget/recurring/${id}`, data);
export const deleteRecurringItem = (id: number) => export const deleteRecurringItem = (id: number) =>
@@ -283,7 +319,11 @@ export const getYearlyProjection = (year: number) =>
api.get<YearlyProjection>(`/budget/projection/${year}`); api.get<YearlyProjection>(`/budget/projection/${year}`);
export const getMonthlyDetail = (year: number, month: number) => export const getMonthlyDetail = (year: number, month: number) =>
api.get<MonthlyDetail>(`/budget/month/${year}/${month}`); api.get<MonthlyDetail>(`/budget/month/${year}/${month}`);
export const upsertBalanceOverride = (year: number, month: number, override_balance: number) => export const upsertBalanceOverride = (
year: number,
month: number,
override_balance: number,
) =>
api.put(`/budget/balance-override/${year}/${month}`, { override_balance }); api.put(`/budget/balance-override/${year}/${month}`, { override_balance });
export const deleteBalanceOverride = (year: number, month: number) => export const deleteBalanceOverride = (year: number, month: number) =>
api.delete(`/budget/balance-override/${year}/${month}`); api.delete(`/budget/balance-override/${year}/${month}`);
@@ -297,9 +337,9 @@ export interface SalariosSummary {
} }
export const getSalarios = (params?: { limit?: number; offset?: number }) => export const getSalarios = (params?: { limit?: number; offset?: number }) =>
api.get<Transaction[]>('/salarios/', { params }); api.get<Transaction[]>("/salarios/", { params });
export const getSalariosSummary = () => export const getSalariosSummary = () =>
api.get<SalariosSummary>('/salarios/summary'); api.get<SalariosSummary>("/salarios/summary");
// --- Pensions --- // --- Pensions ---
@@ -347,18 +387,16 @@ export interface PensionManualEntry {
export const uploadPensionPDFs = (files: File[]) => { export const uploadPensionPDFs = (files: File[]) => {
const form = new FormData(); const form = new FormData();
files.forEach((f) => form.append('files', f)); files.forEach((f) => form.append("files", f));
return api.post<PensionUploadResult>('/pensions/upload', form); return api.post<PensionUploadResult>("/pensions/upload", form);
}; };
export const getPensionSnapshots = () => export const getPensionSnapshots = () =>
api.get<PensionSnapshot[]>('/pensions/snapshots'); api.get<PensionSnapshot[]>("/pensions/snapshots");
export const getPensionFundSummary = () => export const getPensionFundSummary = () =>
api.get<PensionSnapshot[]>('/pensions/fund-summary'); api.get<PensionSnapshot[]>("/pensions/fund-summary");
export const submitPensionManualEntries = (entries: PensionManualEntry[]) => export const submitPensionManualEntries = (entries: PensionManualEntry[]) =>
api.post<PensionUploadResult>('/pensions/manual', { entries }); api.post<PensionUploadResult>("/pensions/manual", { entries });
// --- Municipal Receipts --- // --- Municipal Receipts ---
@@ -415,17 +453,18 @@ export interface MunicipalReceiptUploadResult {
export const uploadMunicipalReceipt = (file: File) => { export const uploadMunicipalReceipt = (file: File) => {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append("file", file);
return api.post<MunicipalReceiptUploadResult>('/municipal-receipts/upload', form); return api.post<MunicipalReceiptUploadResult>(
"/municipal-receipts/upload",
form,
);
}; };
export const getMunicipalReceipts = () => export const getMunicipalReceipts = () =>
api.get<MunicipalReceipt[]>('/municipal-receipts/'); api.get<MunicipalReceipt[]>("/municipal-receipts/");
export const getMunicipalReceiptDetail = (id: number) => export const getMunicipalReceiptDetail = (id: number) =>
api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`); api.get<MunicipalReceiptDetail>(`/municipal-receipts/${id}`);
export const getWaterConsumption = (months?: number) => export const getWaterConsumption = (months?: number) =>
api.get<WaterMeterReading[]>('/municipal-receipts/water-consumption', { api.get<WaterMeterReading[]>("/municipal-receipts/water-consumption", {
params: months ? { months } : undefined, params: months ? { months } : undefined,
}); });

View File

@@ -5,6 +5,9 @@ export function formatAmount(amount: number, currency: string) {
if (currency === 'USD') { if (currency === 'USD') {
return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; 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 })}`; return `${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
} }

View File

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

View File

@@ -12,8 +12,8 @@ import {
} from 'recharts'; } from 'recharts';
import { BarChart3 } from 'lucide-react'; import { BarChart3 } from 'lucide-react';
import api from '../api'; import api from '@/lib/api';
import BillingCycleSelector from '../components/BillingCycleSelector'; import BillingCycleSelector from '@/components/BillingCycleSelector';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { import {
ChartContainer, ChartContainer,

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,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { ChevronLeft, ChevronRight, Calculator } 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 { useBudget } from '@/hooks/useBudget';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -68,7 +68,9 @@ export default function Budget() {
} }
const { data } = await api.get<Transaction[]>('/transactions/', { params }); const { data } = await api.get<Transaction[]>('/transactions/', { params });
setTransactions(data); const INCOME_TYPES = ['DEPOSITO', 'SALARY'];
const filtered = data.filter((tx) => !INCOME_TYPES.includes(tx.transaction_type));
setTransactions(filtered);
} finally { } finally {
setTxLoading(false); setTxLoading(false);
} }
@@ -152,13 +154,11 @@ export default function Budget() {
</div> </div>
<TabsContent value="detail" className="space-y-6 mt-4"> <TabsContent value="detail" className="space-y-6 mt-4">
{monthDetail && (
<MonthlyDetail <MonthlyDetail
detail={monthDetail} detail={monthDetail}
loading={monthLoading} loading={monthLoading || !monthDetail}
onNavigateToTransactions={handleNavigateToTransactions} onNavigateToTransactions={handleNavigateToTransactions}
/> />
)}
</TabsContent> </TabsContent>
<TabsContent value="transactions" className="space-y-3 mt-4"> <TabsContent value="transactions" className="space-y-3 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, formatLocalDatetime } 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 data-sensitive 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 data-sensitive 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 data-sensitive className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
<div className="flex gap-4">
<span>Assets <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
<span>Liabilities <span data-sensitive 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 data-sensitive className="text-lg font-bold font-mono">Buy: {exchangeRate.buy_rate.toFixed(2)}</span>
<span data-sensitive 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 data-sensitive 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: formatLocalDatetime(new Date()),
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 { useState, type FormEvent } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { Wallet, ArrowRight, AlertCircle } from 'lucide-react'; 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'; export default function LoginPage() {
import { useAuth } from '../AuthContext'; const [username, setUsername] = useState("");
import { subscribeToPush } from '../pushNotifications'; const [password, setPassword] = useState("");
import { Button } from '@/components/ui/button'; const [error, setError] = useState("");
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('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { setAuthenticated } = useAuth(); const { setAuthenticated } = useAuth();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError("");
try { try {
await login(username, password); await login(username, password);
setAuthenticated(true); setAuthenticated(true);
subscribeToPush(); navigate("/asistente", { replace: true });
navigate('/');
} catch { } catch {
setError('Invalid credentials'); setError("Invalid credentials");
} finally { } finally {
setLoading(false); 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"> <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} /> <Wallet className="w-6 h-6 text-primary-foreground" strokeWidth={2.5} />
</div> </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> Wealthy<span className="text-primary">Smart</span>
</span> </span>
</div> </div>
@@ -84,7 +81,7 @@ export default function Login() {
)} )}
<Button type="submit" disabled={loading} className="w-full h-10"> <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" />} {!loading && <ArrowRight className="w-4 h-4" />}
</Button> </Button>
</form> </form>

View File

@@ -34,7 +34,7 @@ import {
getPensionSnapshots, getPensionSnapshots,
type PensionSnapshot, type PensionSnapshot,
type PensionUploadResult, type PensionUploadResult,
} from '@/api'; } from '@/lib/api';
import PensionManualEntryModal from '@/components/PensionManualEntryModal'; import PensionManualEntryModal from '@/components/PensionManualEntryModal';
import { ClipboardPaste } from 'lucide-react'; import { ClipboardPaste } from 'lucide-react';

View File

@@ -46,7 +46,7 @@ export default function Proyecciones() {
{/* Annual summary cards */} {/* Annual summary cards */}
{projection && ( {projection && (
<div className="grid gap-3 grid-cols-2 md:grid-cols-4"> <div className="grid gap-3 grid-cols-1 md:grid-cols-3">
<Card> <Card>
<CardContent className="pt-4 pb-3 px-4"> <CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ingresos Anuales</p> <p className="text-xs text-muted-foreground">Ingresos Anuales</p>
@@ -63,14 +63,6 @@ export default function Proyecciones() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Ahorro Anual</p>
<p data-sensitive className="text-lg font-bold font-mono">
{formatAmount(projection.annual_savings, 'CRC')}
</p>
</CardContent>
</Card>
<Card> <Card>
<CardContent className="pt-4 pb-3 px-4"> <CardContent className="pt-4 pb-3 px-4">
<p className="text-xs text-muted-foreground">Balance Neto Anual</p> <p className="text-xs text-muted-foreground">Balance Neto Anual</p>

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Landmark, RefreshCw, Hash, CalendarDays, Banknote } from 'lucide-react'; 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 { formatAmount, formatDate } from '@/lib/format';
import { DataTable } from '@/components/ui/data-table'; import { DataTable } from '@/components/ui/data-table';
import { DataTableColumnHeader } from '@/components/ui/data-table-column-header'; import { DataTableColumnHeader } from '@/components/ui/data-table-column-header';

View File

@@ -41,7 +41,7 @@ import {
type MunicipalReceipt, type MunicipalReceipt,
type MunicipalReceiptUploadResult, type MunicipalReceiptUploadResult,
type WaterMeterReading, type WaterMeterReading,
} from '@/api'; } from '@/lib/api';
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────

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 data-sensitive className="font-mono text-foreground">{formatAmount(totalCRC, 'CRC')}</span></>
)}
{totalUSD !== 0 && (
<> &middot; <span data-sensitive 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, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src"] "include": ["src", "server.ts"],
"exclude": ["node_modules"]
} }

View File

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

View File

@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
# ── Configuration ──────────────────────────────────────────────── # ── Configuration ────────────────────────────────────────────────
PROD_SSH_ALIAS="production" PROD_SSH_ALIAS="old-vps"
PROD_CONTAINER="wealthysmart-db-prod" PROD_CONTAINER="wealthysmart-db-prod"
PROD_DB="wealthysmart" PROD_DB="wealthysmart"
PROD_USER="wealthy_user" PROD_USER="wealthy_user"