mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 15:48:46 +02:00
Compare commits
105 Commits
20b4ad102d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d0547743f | ||
|
|
8ceed0ab2e | ||
|
|
ca3115e99c | ||
|
|
3518ff58c4 | ||
|
|
352b77ea07 | ||
|
|
fbc0816be6 | ||
|
|
301a0b687a | ||
|
|
b57a899634 | ||
|
|
2d6a3f83eb | ||
|
|
00e841ad3f | ||
|
|
c527b3d6d0 | ||
|
|
1e6ebab2b2 | ||
|
|
17a0c63abe | ||
|
|
47cb1826a8 | ||
|
|
8595e74566 | ||
|
|
0d0d60600d | ||
|
|
700e1edbb4 | ||
|
|
15fa18411a | ||
|
|
3f4df6f16b | ||
|
|
0f34a64e51 | ||
|
|
7b7c741ba2 | ||
|
|
8f51e33fe0 | ||
|
|
965e30a2d2 | ||
|
|
334d41bd9a | ||
|
|
eb400ab1e9 | ||
|
|
bbcfaa7808 | ||
|
|
6bce5539f7 | ||
|
|
0e03284c95 | ||
|
|
4687fa9669 | ||
|
|
bbae121fec | ||
|
|
033a920746 | ||
|
|
0d24c7f9be | ||
|
|
00f059ee91 | ||
|
|
254b4c751e | ||
|
|
f2cacedc20 | ||
|
|
a9adb410d0 | ||
|
|
37c2b18300 | ||
|
|
ce7073cf24 | ||
|
|
5033348320 | ||
|
|
061a49f3b1 | ||
|
|
3a258537fc | ||
|
|
41d4306e86 | ||
|
|
9907ff265e | ||
|
|
2cf19f1880 | ||
|
|
a95486481b | ||
|
|
10d9bd209f | ||
|
|
b1c387c415 | ||
|
|
7ba70ae524 | ||
|
|
d9039c76b9 | ||
|
|
d117fb79ea | ||
|
|
d2610b2eef | ||
|
|
3d71234a61 | ||
|
|
72a5840752 | ||
|
|
f832138b41 | ||
|
|
5296230416 | ||
|
|
f2bcce702a | ||
|
|
d3b5188b67 | ||
|
|
c563618957 | ||
|
|
3cd2927b5f | ||
|
|
3b147bf8be | ||
|
|
8b4961d257 | ||
|
|
088062f757 | ||
|
|
248417dbab | ||
|
|
fc3461c377 | ||
|
|
c3c14f54c5 | ||
|
|
84f0256b7c | ||
|
|
fe62e50a85 | ||
|
|
7dfe2da2a9 | ||
|
|
3ea1ef0fa0 | ||
|
|
8713eaa4d8 | ||
|
|
0dfac6a285 | ||
|
|
ce3cc69846 | ||
|
|
5612f69f11 | ||
|
|
b3a7f5185a | ||
|
|
629d1181ae | ||
|
|
571428f5ac | ||
|
|
4ceb67564a | ||
|
|
8b7f3dff40 | ||
|
|
cdfa4ad90a | ||
|
|
b5bb2e8562 | ||
|
|
2a68a05ffd | ||
|
|
7a1733bf7e | ||
|
|
65dffdac06 | ||
|
|
396c492f27 | ||
|
|
440da3e394 | ||
|
|
82f10a5d7c | ||
|
|
82a7bfc4c5 | ||
|
|
3cdd7d9eab | ||
|
|
74886fbf98 | ||
|
|
881d879ed1 | ||
|
|
49b96ed49c | ||
|
|
9a25c4baab | ||
|
|
c725c1487d | ||
|
|
996bd437a1 | ||
|
|
0a4c3e9436 | ||
|
|
fa19ac1988 | ||
|
|
d874cf540d | ||
|
|
3e56f1ca66 | ||
|
|
4a758b29a7 | ||
|
|
ad29cce6ac | ||
|
|
bebd7b563e | ||
|
|
3a357e4f0b | ||
|
|
b4123703ef | ||
|
|
15ea1ef4c2 | ||
|
|
5c265129e8 |
26
.env.example
Normal file
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# WealthySmart environment template.
|
||||
# Copy to .env for local development. Production values live in Gitea Actions
|
||||
# secrets and are written to .env.prod by .github/workflows/deploy.yml on every
|
||||
# deploy — new variables must be added BOTH there and here.
|
||||
|
||||
# ── Database (dev compose; must match the wealthysmart-db-dev volume) ────────
|
||||
POSTGRES_USER=wealthy_user
|
||||
POSTGRES_PASSWORD=wealthy_pass
|
||||
POSTGRES_DB=wealthysmart
|
||||
|
||||
# ── Auth (REQUIRED — the backend refuses to boot without these) ──────────────
|
||||
# Generate with: openssl rand -hex 32
|
||||
SECRET_KEY=
|
||||
ADMIN_USERNAME=admin
|
||||
# Must not be "admin".
|
||||
ADMIN_PASSWORD=
|
||||
# Cookies require HTTPS when true. Set false for local HTTP development.
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# ── Push notifications (optional in dev) ─────────────────────────────────────
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_PUBLIC_KEY=
|
||||
|
||||
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
|
||||
OPENAI_API_KEY=
|
||||
AGENT_MODEL=gpt-5.6-luna
|
||||
27
.github/workflows/deploy.yml
vendored
27
.github/workflows/deploy.yml
vendored
@@ -5,8 +5,35 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Backend tests
|
||||
run: |
|
||||
cd backend
|
||||
python3 -m venv /tmp/test-venv
|
||||
/tmp/test-venv/bin/pip install --quiet -r requirements.txt -r requirements-dev.txt -c constraints.txt
|
||||
/tmp/test-venv/bin/python -m pytest tests/ -q
|
||||
|
||||
- name: Frontend typecheck + API type drift gate
|
||||
run: |
|
||||
cd backend
|
||||
DATABASE_URL='sqlite://' SECRET_KEY='ci-only-0123456789abcdef0123456789abcdef0123456789abcdef' \
|
||||
ADMIN_USERNAME=ci ADMIN_PASSWORD=ci-not-admin OPENAI_API_KEY=dummy \
|
||||
/tmp/test-venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
|
||||
cd ../frontend
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec openapi-typescript openapi.json -o src/lib/api-types.gen.ts
|
||||
git diff --exit-code src/lib/api-types.gen.ts
|
||||
pnpm typecheck
|
||||
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
needs: test
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -14,3 +14,10 @@ tech_docs/
|
||||
|
||||
# Claude Code local state
|
||||
.claude/
|
||||
.venv/
|
||||
frontend/openapi.json
|
||||
|
||||
# playwright (demo artifacts are generated, not committed)
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
frontend/e2e-docs/
|
||||
|
||||
39
CLAUDE.md
39
CLAUDE.md
@@ -13,6 +13,31 @@ Personal finance management web app.
|
||||
cd frontend && pnpm install && pnpm run dev
|
||||
```
|
||||
|
||||
The backend refuses to boot without `SECRET_KEY`, `ADMIN_USERNAME`, and
|
||||
`ADMIN_PASSWORD` (no insecure defaults). Copy `.env.example` to `.env` and fill
|
||||
it in; the dev docker-compose reads it.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Schema changes go through Alembic — never edit the schema by hand and never
|
||||
reset the prod DB. Workflow:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
# 1. Edit app/models/models.py
|
||||
# 2. Autogenerate against the dev DB, then REVIEW the generated file
|
||||
DATABASE_URL='postgresql://wealthy_user:wealthy_pass@localhost:5433/wealthysmart' \
|
||||
.venv/bin/alembic revision --autogenerate -m "describe the change"
|
||||
# 3. Migrations apply automatically at app startup (run_alembic_upgrade)
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # backend (55+ tests)
|
||||
cd frontend && pnpm typecheck # frontend
|
||||
```
|
||||
|
||||
## Local Docker
|
||||
|
||||
```bash
|
||||
@@ -43,3 +68,17 @@ Four automated flows on old-vps feed data into WealthySmart:
|
||||
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
|
||||
|
||||
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
|
||||
|
||||
## API Type Generation
|
||||
|
||||
Frontend read types are generated from the backend OpenAPI spec. After changing
|
||||
backend response models:
|
||||
|
||||
```bash
|
||||
cd backend && DATABASE_URL='sqlite://' SECRET_KEY=$(openssl rand -hex 32) \
|
||||
ADMIN_USERNAME=x ADMIN_PASSWORD=x-x OPENAI_API_KEY=dummy \
|
||||
.venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
|
||||
cd ../frontend && pnpm gen:api # regenerates src/lib/api-types.gen.ts
|
||||
```
|
||||
|
||||
CI regenerates and fails the deploy if `api-types.gen.ts` is stale.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --pre -r requirements.txt
|
||||
COPY requirements.txt constraints.txt ./
|
||||
RUN pip install --no-cache-dir --pre -r requirements.txt -c constraints.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends poppler-utils && rm -rf /var/lib/apt/lists/*
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY requirements.txt constraints.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt -c constraints.txt
|
||||
COPY . .
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||
|
||||
149
backend/alembic.ini
Normal file
149
backend/alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
49
backend/alembic/env.py
Normal file
49
backend/alembic/env.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
from app.config import settings
|
||||
import app.models.models # noqa: F401 — register all tables on the metadata
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Single source of truth for the DB URL: the app settings.
|
||||
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
29
backend/alembic/script.py.mako
Normal file
29
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,29 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""track agui run id in chat logs
|
||||
|
||||
Revision ID: 10f4a4f71964
|
||||
Revises: ecb99a158fbf
|
||||
Create Date: 2026-07-14 22:32:16.771655
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '10f4a4f71964'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ecb99a158fbf'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('chatrunlog', sa.Column('agui_run_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
|
||||
op.create_index(op.f('ix_chatrunlog_agui_run_id'), 'chatrunlog', ['agui_run_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_chatrunlog_agui_run_id'), table_name='chatrunlog')
|
||||
op.drop_column('chatrunlog', 'agui_run_id')
|
||||
# ### end Alembic commands ###
|
||||
61
backend/alembic/versions/7884505b16b3_fk_delete_rules.py
Normal file
61
backend/alembic/versions/7884505b16b3_fk_delete_rules.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""fk delete rules
|
||||
|
||||
Revision ID: 7884505b16b3
|
||||
Revises: 7f567c8bafdb
|
||||
Create Date: 2026-06-10 13:50:39.153585
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7884505b16b3'
|
||||
down_revision: Union[str, Sequence[str], None] = '7f567c8bafdb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add delete rules to the three FKs (review ARCH-09/BE-11).
|
||||
|
||||
Transactions and recurring items outlive their category (SET NULL);
|
||||
water readings die with their receipt (CASCADE). Constraint names match
|
||||
both pre-Alembic databases and fresh baseline builds (Postgres default
|
||||
naming).
|
||||
"""
|
||||
op.drop_constraint("transaction_category_id_fkey", "transaction", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"transaction_category_id_fkey", "transaction", "category",
|
||||
["category_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
op.drop_constraint("recurringitem_category_id_fkey", "recurringitem", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"recurringitem_category_id_fkey", "recurringitem", "category",
|
||||
["category_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
op.drop_constraint("watermeterreading_receipt_id_fkey", "watermeterreading", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"watermeterreading_receipt_id_fkey", "watermeterreading", "municipalreceipt",
|
||||
["receipt_id"], ["id"], ondelete="CASCADE",
|
||||
)
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("watermeterreading_receipt_id_fkey", "watermeterreading", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"watermeterreading_receipt_id_fkey", "watermeterreading", "municipalreceipt",
|
||||
["receipt_id"], ["id"],
|
||||
)
|
||||
op.drop_constraint("recurringitem_category_id_fkey", "recurringitem", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"recurringitem_category_id_fkey", "recurringitem", "category",
|
||||
["category_id"], ["id"],
|
||||
)
|
||||
op.drop_constraint("transaction_category_id_fkey", "transaction", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"transaction_category_id_fkey", "transaction", "category",
|
||||
["category_id"], ["id"],
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""chat thread snapshot
|
||||
|
||||
Revision ID: 794baf50635b
|
||||
Revises: 961802a2f50d
|
||||
Create Date: 2026-07-04 17:49:14.914103
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '794baf50635b'
|
||||
down_revision: Union[str, Sequence[str], None] = '961802a2f50d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('chatthreadsnapshot',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('scope', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('messages', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
|
||||
sa.Column('state', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||
sa.Column('interrupt', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('scope', 'thread_id')
|
||||
)
|
||||
op.create_index(op.f('ix_chatthreadsnapshot_scope'), 'chatthreadsnapshot', ['scope'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_chatthreadsnapshot_scope'), table_name='chatthreadsnapshot')
|
||||
op.drop_table('chatthreadsnapshot')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,263 @@
|
||||
"""money columns to numeric
|
||||
|
||||
Revision ID: 7f567c8bafdb
|
||||
Revises: c3da001a0eb3
|
||||
Create Date: 2026-06-10 13:37:26.281634
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7f567c8bafdb'
|
||||
down_revision: Union[str, Sequence[str], None] = 'c3da001a0eb3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('account', 'balance',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('account', 'next_payment',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=True)
|
||||
op.alter_column('balanceoverride', 'override_balance',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('exchangerate', 'buy_rate',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=6),
|
||||
existing_nullable=False)
|
||||
op.alter_column('exchangerate', 'sell_rate',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=6),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'subtotal',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'interests',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'iva',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'total',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'saldo_anterior',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'aportes',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'rendimientos',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'retiros',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'traslados',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'comision',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'correccion',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'bonificacion',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'saldo_final',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('recurringitem', 'amount',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('savingsaccrual', 'memp_amount',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('savingsaccrual', 'mpat_amount',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('transaction', 'amount',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'reading_previous',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'reading_current',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'consumption_m3',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'agua_potable',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'serv_ambientales',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'alcant_sanitario',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'iva',
|
||||
existing_type=sa.DOUBLE_PRECISION(precision=53),
|
||||
type_=sa.Numeric(precision=15, scale=2),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('watermeterreading', 'iva',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'alcant_sanitario',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'serv_ambientales',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'agua_potable',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'consumption_m3',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'reading_current',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('watermeterreading', 'reading_previous',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('transaction', 'amount',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('savingsaccrual', 'mpat_amount',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('savingsaccrual', 'memp_amount',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('recurringitem', 'amount',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'saldo_final',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'bonificacion',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'correccion',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'comision',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'traslados',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'retiros',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'rendimientos',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'aportes',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('pensionsnapshot', 'saldo_anterior',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'total',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'iva',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'interests',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('municipalreceipt', 'subtotal',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('exchangerate', 'sell_rate',
|
||||
existing_type=sa.Numeric(precision=15, scale=6),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('exchangerate', 'buy_rate',
|
||||
existing_type=sa.Numeric(precision=15, scale=6),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('balanceoverride', 'override_balance',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
op.alter_column('account', 'next_payment',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=True)
|
||||
op.alter_column('account', 'balance',
|
||||
existing_type=sa.Numeric(precision=15, scale=2),
|
||||
type_=sa.DOUBLE_PRECISION(precision=53),
|
||||
existing_nullable=False)
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,37 @@
|
||||
"""record chat client details
|
||||
|
||||
Revision ID: 86104b4ff6ae
|
||||
Revises: 10f4a4f71964
|
||||
Create Date: 2026-07-14 22:54:55.519380
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '86104b4ff6ae'
|
||||
down_revision: Union[str, Sequence[str], None] = '10f4a4f71964'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('chatrunlog', sa.Column('client_ip', sa.Text(), nullable=True))
|
||||
op.add_column('chatrunlog', sa.Column('user_agent', sa.Text(), nullable=True))
|
||||
op.add_column('chatrunlog', sa.Column('browser', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('chatrunlog', 'browser')
|
||||
op.drop_column('chatrunlog', 'user_agent')
|
||||
op.drop_column('chatrunlog', 'client_ip')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,55 @@
|
||||
"""installment plans (tasa cero)
|
||||
|
||||
Revision ID: 961802a2f50d
|
||||
Revises: 7884505b16b3
|
||||
Create Date: 2026-07-03 16:00:55.026771
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '961802a2f50d'
|
||||
down_revision: Union[str, Sequence[str], None] = '7884505b16b3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('installmentplan',
|
||||
sa.Column('num_installments', sa.Integer(), nullable=False),
|
||||
sa.Column('first_installment_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('total_amount', sa.Numeric(precision=15, scale=2), nullable=False),
|
||||
sa.Column('installment_amount', sa.Numeric(precision=15, scale=2), nullable=False),
|
||||
# Reuse the existing 'currency' enum type — do NOT re-create it.
|
||||
sa.Column('currency', postgresql.ENUM('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency', create_type=False), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('anchor_transaction_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['anchor_transaction_id'], ['transaction.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_installmentplan_anchor_transaction_id'), 'installmentplan', ['anchor_transaction_id'], unique=True)
|
||||
op.add_column('transaction', sa.Column('installment_plan_id', sa.Integer(), nullable=True))
|
||||
op.add_column('transaction', sa.Column('is_installment_anchor', sa.Boolean(), server_default='false', nullable=False))
|
||||
op.create_index(op.f('ix_transaction_installment_plan_id'), 'transaction', ['installment_plan_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_transaction_installment_plan_id'), table_name='transaction')
|
||||
op.drop_column('transaction', 'is_installment_anchor')
|
||||
op.drop_column('transaction', 'installment_plan_id')
|
||||
op.drop_index(op.f('ix_installmentplan_anchor_transaction_id'), table_name='installmentplan')
|
||||
op.drop_table('installmentplan')
|
||||
# ### end Alembic commands ###
|
||||
221
backend/alembic/versions/c3da001a0eb3_baseline.py
Normal file
221
backend/alembic/versions/c3da001a0eb3_baseline.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""baseline
|
||||
|
||||
Revision ID: c3da001a0eb3
|
||||
Revises:
|
||||
Create Date: 2026-06-09 20:00:18.301439
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c3da001a0eb3'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('account',
|
||||
sa.Column('bank', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False),
|
||||
sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False),
|
||||
sa.Column('label', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('balance', sa.Float(), nullable=False),
|
||||
sa.Column('account_type', sa.Enum('BANK', 'PENSION', 'CRYPTO', 'SAVINGS', 'LIABILITY', name='accounttype'), nullable=False),
|
||||
sa.Column('next_payment', sa.Float(), nullable=True),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('apitoken',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('token_hash', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_apitoken_token_hash'), 'apitoken', ['token_hash'], unique=False)
|
||||
op.create_table('balanceoverride',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('year', sa.Integer(), nullable=False),
|
||||
sa.Column('month', sa.Integer(), nullable=False),
|
||||
sa.Column('override_balance', sa.Float(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('year', 'month')
|
||||
)
|
||||
op.create_table('category',
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('icon', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('auto_match_patterns', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_category_name'), 'category', ['name'], unique=True)
|
||||
op.create_table('exchangerate',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('date', sa.DateTime(), nullable=False),
|
||||
sa.Column('buy_rate', sa.Float(), nullable=False),
|
||||
sa.Column('sell_rate', sa.Float(), nullable=False),
|
||||
sa.Column('fetched_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('municipalreceipt',
|
||||
sa.Column('receipt_date', sa.Date(), nullable=False),
|
||||
sa.Column('due_date', sa.Date(), nullable=False),
|
||||
sa.Column('period', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('account', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('finca', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('holder_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('holder_cedula', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('holder_address', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('subtotal', sa.Float(), nullable=False),
|
||||
sa.Column('interests', sa.Float(), nullable=False),
|
||||
sa.Column('iva', sa.Float(), nullable=False),
|
||||
sa.Column('total', sa.Float(), nullable=False),
|
||||
sa.Column('raw_charges', sa.JSON(), server_default='[]', nullable=False),
|
||||
sa.Column('source_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('account', 'period')
|
||||
)
|
||||
op.create_table('pensionsnapshot',
|
||||
sa.Column('fund', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False),
|
||||
sa.Column('contract_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('period_start', sa.Date(), nullable=False),
|
||||
sa.Column('period_end', sa.Date(), nullable=False),
|
||||
sa.Column('saldo_anterior', sa.Float(), nullable=False),
|
||||
sa.Column('aportes', sa.Float(), nullable=False),
|
||||
sa.Column('rendimientos', sa.Float(), nullable=False),
|
||||
sa.Column('retiros', sa.Float(), nullable=False),
|
||||
sa.Column('traslados', sa.Float(), nullable=False),
|
||||
sa.Column('comision', sa.Float(), nullable=False),
|
||||
sa.Column('correccion', sa.Float(), nullable=False),
|
||||
sa.Column('bonificacion', sa.Float(), nullable=False),
|
||||
sa.Column('saldo_final', sa.Float(), nullable=False),
|
||||
sa.Column('source_filename', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('fund', 'period_start', 'period_end')
|
||||
)
|
||||
op.create_table('pushsubscription',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('endpoint', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('p256dh', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('auth', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('endpoint')
|
||||
)
|
||||
op.create_table('savingsaccrual',
|
||||
sa.Column('year', sa.Integer(), nullable=False),
|
||||
sa.Column('month', sa.Integer(), nullable=False),
|
||||
sa.Column('memp_amount', sa.Float(), nullable=False),
|
||||
sa.Column('mpat_amount', sa.Float(), nullable=False),
|
||||
sa.Column('trigger_transaction_id', sa.Integer(), nullable=True),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('applied_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('year', 'month')
|
||||
)
|
||||
op.create_table('usersettings',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('data', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='{}', nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_usersettings_key'), 'usersettings', ['key'], unique=True)
|
||||
op.create_table('recurringitem',
|
||||
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('amount', sa.Float(), nullable=False),
|
||||
sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False),
|
||||
sa.Column('item_type', sa.Enum('INCOME', 'EXPENSE', 'SAVINGS', name='recurringitemtype'), nullable=False),
|
||||
sa.Column('frequency', sa.Enum('WEEKLY', 'MONTHLY', 'QUARTERLY', 'BIANNUAL', 'YEARLY', name='recurringfrequency'), nullable=False),
|
||||
sa.Column('day_of_month', sa.Integer(), nullable=True),
|
||||
sa.Column('month_of_year', sa.Integer(), nullable=True),
|
||||
sa.Column('override_amounts', sa.JSON(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('transaction',
|
||||
sa.Column('amount', sa.Float(), nullable=False),
|
||||
sa.Column('currency', sa.Enum('CRC', 'USD', 'EUR', 'BTC', 'XMR', name='currency'), nullable=False),
|
||||
sa.Column('merchant', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('city', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('date', sa.DateTime(), nullable=False),
|
||||
sa.Column('card_type', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('card_last4', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('authorization_code', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('reference', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('transaction_type', sa.Enum('COMPRA', 'DEVOLUCION', 'DEPOSITO', 'SALARY', name='transactiontype'), nullable=False),
|
||||
sa.Column('source', sa.Enum('CREDIT_CARD', 'CASH', 'TRANSFER', name='transactionsource'), nullable=False),
|
||||
sa.Column('bank', sa.Enum('BAC', 'BCR', 'DAVIVIENDA', 'FCL', 'ROP', 'VOL', 'MEMP', 'MPAT', 'MORTGAGE', name='bank'), nullable=False),
|
||||
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('category_id', sa.Integer(), nullable=True),
|
||||
sa.Column('deferred_to_next_cycle', sa.Boolean(), server_default='false', nullable=False),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['category.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_transaction_reference'), 'transaction', ['reference'], unique=False)
|
||||
op.create_table('watermeterreading',
|
||||
sa.Column('meter_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('period', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('reading_previous', sa.Float(), nullable=False),
|
||||
sa.Column('reading_current', sa.Float(), nullable=False),
|
||||
sa.Column('consumption_m3', sa.Float(), nullable=False),
|
||||
sa.Column('agua_potable', sa.Float(), nullable=False),
|
||||
sa.Column('serv_ambientales', sa.Float(), nullable=False),
|
||||
sa.Column('alcant_sanitario', sa.Float(), nullable=False),
|
||||
sa.Column('iva', sa.Float(), nullable=False),
|
||||
sa.Column('is_historical', sa.Boolean(), nullable=False),
|
||||
sa.Column('receipt_id', sa.Integer(), nullable=True),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['receipt_id'], ['municipalreceipt.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('meter_id', 'period', 'is_historical')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('watermeterreading')
|
||||
op.drop_index(op.f('ix_transaction_reference'), table_name='transaction')
|
||||
op.drop_table('transaction')
|
||||
op.drop_table('recurringitem')
|
||||
op.drop_index(op.f('ix_usersettings_key'), table_name='usersettings')
|
||||
op.drop_table('usersettings')
|
||||
op.drop_table('savingsaccrual')
|
||||
op.drop_table('pushsubscription')
|
||||
op.drop_table('pensionsnapshot')
|
||||
op.drop_table('municipalreceipt')
|
||||
op.drop_table('exchangerate')
|
||||
op.drop_index(op.f('ix_category_name'), table_name='category')
|
||||
op.drop_table('category')
|
||||
op.drop_table('balanceoverride')
|
||||
op.drop_index(op.f('ix_apitoken_token_hash'), table_name='apitoken')
|
||||
op.drop_table('apitoken')
|
||||
op.drop_table('account')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,58 @@
|
||||
"""add chat run audit logs
|
||||
|
||||
Revision ID: ecb99a158fbf
|
||||
Revises: 794baf50635b
|
||||
Create Date: 2026-07-14 22:22:06.035643
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ecb99a158fbf'
|
||||
down_revision: Union[str, Sequence[str], None] = '794baf50635b'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('chatrunlog',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('request_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('requested_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('response_thread_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
|
||||
sa.Column('model', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('user_prompt', sa.Text(), nullable=True),
|
||||
sa.Column('tool_calls', sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), 'postgresql'), server_default='[]', nullable=False),
|
||||
sa.Column('assistant_response', sa.Text(), nullable=True),
|
||||
sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
|
||||
sa.Column('error', sa.Text(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_chatrunlog_expires_at'), 'chatrunlog', ['expires_at'], unique=False)
|
||||
op.create_index(op.f('ix_chatrunlog_request_id'), 'chatrunlog', ['request_id'], unique=True)
|
||||
op.create_index(op.f('ix_chatrunlog_requested_thread_id'), 'chatrunlog', ['requested_thread_id'], unique=False)
|
||||
op.create_index(op.f('ix_chatrunlog_response_thread_id'), 'chatrunlog', ['response_thread_id'], unique=False)
|
||||
op.create_index(op.f('ix_chatrunlog_status'), 'chatrunlog', ['status'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_chatrunlog_status'), table_name='chatrunlog')
|
||||
op.drop_index(op.f('ix_chatrunlog_response_thread_id'), table_name='chatrunlog')
|
||||
op.drop_index(op.f('ix_chatrunlog_requested_thread_id'), table_name='chatrunlog')
|
||||
op.drop_index(op.f('ix_chatrunlog_request_id'), table_name='chatrunlog')
|
||||
op.drop_index(op.f('ix_chatrunlog_expires_at'), table_name='chatrunlog')
|
||||
op.drop_table('chatrunlog')
|
||||
# ### end Alembic commands ###
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatCompletionClient
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
from app.config import settings
|
||||
from app.agent.tools import TOOLS
|
||||
@@ -22,19 +20,46 @@ Context you can rely on:
|
||||
following month. When the user says "this month" or "last month" without
|
||||
qualifiers, assume they mean the calendar month unless they mention
|
||||
"cycle", "corte", or their credit card.
|
||||
- Today's date is {today}. Use it when the user says "this month", "last
|
||||
month", "last year", etc.
|
||||
- You do NOT have a reliable clock, and the server runs in UTC while the
|
||||
user may be anywhere (their browser reports their timezone per request).
|
||||
Whenever the question involves a relative date — "hoy", "ayer", "este
|
||||
mes", "este ciclo", "el año pasado" — call get_current_date FIRST and
|
||||
derive ranges from its answer.
|
||||
- Amounts are stored as raw numbers in their native currency (see `currency`
|
||||
field on transactions/accounts). Tools that return `total_crc` are already
|
||||
converted; tools that return per-transaction amounts are NOT.
|
||||
|
||||
How to answer:
|
||||
- ALWAYS call a tool to get data. Do not invent balances, dates or merchants.
|
||||
- Match the tool to the question's time grain: day-scoped questions ("hoy",
|
||||
"ayer", "el martes", a specific date) need get_daily_spending with that
|
||||
day's bounds — cycle or category summaries have NO per-day data, so never
|
||||
answer a day question from them.
|
||||
- For a question about a specific salary deposit or its amount (including
|
||||
"cuánto recibí hoy"), call get_salary_deposits with the relevant date range;
|
||||
get_salary_summary is aggregate-only and cannot answer it.
|
||||
- Answer like an analyst, not a calculator. An aggregate number on its own
|
||||
is not an answer — show what it is made of, right away, without waiting
|
||||
to be asked:
|
||||
· saldo neto / net worth → headline, then the assets-vs-liabilities
|
||||
split and the accounts behind each side (get_accounts + get_net_worth).
|
||||
· "cuánto gasté hoy/ayer/el martes" → the total, then the purchases
|
||||
behind it (get_recent_transactions with the same date bounds:
|
||||
merchant, amount, source).
|
||||
· cycle or category totals → the top components and how much each
|
||||
contributes.
|
||||
- Structure: one headline sentence with the number, a compact markdown
|
||||
table with the breakdown, and — only when the data genuinely supports
|
||||
it — one closing observation (an unusually large item, a comparison to
|
||||
the previous day/cycle). Never pad with filler or generic offers like
|
||||
"si quieres te puedo mostrar…" when you could just show it.
|
||||
- Call multiple tools in parallel when the question spans domains
|
||||
(e.g. net worth + recent transactions).
|
||||
- Format currency with the appropriate symbol: ₡ for CRC (no decimals), $ for
|
||||
USD (two decimals), € for EUR (two decimals).
|
||||
- When showing lists, prefer markdown tables over prose.
|
||||
- When showing lists or structured data (transactions, breakdowns, funds),
|
||||
format them as clean markdown tables: right-align amounts, include a total
|
||||
row when summing, keep merchant names as-is.
|
||||
- If a tool returns no data, say so explicitly — do not fill in zeros.
|
||||
- You are read-only in this version. If asked to create, edit or delete
|
||||
anything, explain that write actions aren't available yet and offer to
|
||||
@@ -47,28 +72,32 @@ Generative UI — render tools:
|
||||
- When showing spending totals, cycle summaries, or category breakdowns →
|
||||
call render_spending_summary. Source data: get_cycle_summary (by_source,
|
||||
grand_total_crc) + get_analytics_by_category (by_category).
|
||||
- When showing transaction lists or other structured data →
|
||||
call render_a2ui in a SEPARATE tool-call step, only after all data-fetching
|
||||
calls have returned. NEVER call render_a2ui in the same batch as any other
|
||||
tool.
|
||||
- Do NOT use markdown tables for data a render tool can display.
|
||||
- CRITICAL RULE: When you call a render tool (render_spending_summary or
|
||||
render_a2ui), that tool call MUST be the ONLY content in your message.
|
||||
Do NOT include any text content alongside the tool call — no introduction,
|
||||
no list, no explanation, nothing. The rendered card IS the complete
|
||||
response. Any text you write in the same message as a render call will
|
||||
appear as a duplicate below the card, which is wrong.
|
||||
- Do NOT use markdown tables for data render_spending_summary can display.
|
||||
- CRITICAL RULE: When you call render_spending_summary, that tool call MUST
|
||||
be the ONLY content in your message. Do NOT include any text content
|
||||
alongside the tool call — no introduction, no list, no explanation,
|
||||
nothing. The rendered card IS the complete response. Any text you write in
|
||||
the same message as a render call will appear as a duplicate below the
|
||||
card, which is wrong.
|
||||
- When a render_spending_summary tool result ("ok") is already present for
|
||||
the current question, the card is already on screen. Do NOT call the tool
|
||||
again and do NOT add text — end your response with no further output.
|
||||
"""
|
||||
|
||||
|
||||
def build_agent() -> Agent:
|
||||
client = OpenAIChatCompletionClient(
|
||||
# Use the Responses API for every configured model. It preserves one tool
|
||||
# transport across model changes and supports Luna's tool-result turns.
|
||||
client = OpenAIChatClient(
|
||||
api_key=settings.OPENAI_API_KEY,
|
||||
model=settings.AGENT_MODEL,
|
||||
)
|
||||
return Agent(
|
||||
name="wealthysmart",
|
||||
instructions=SYSTEM_PROMPT.replace("{today}", date.today().isoformat()),
|
||||
# No date baked in: build_agent() runs once at startup, so anything
|
||||
# substituted here freezes at boot (and in server-UTC). The model
|
||||
# gets "today" from the get_current_date tool instead.
|
||||
instructions=SYSTEM_PROMPT,
|
||||
client=client,
|
||||
tools=TOOLS,
|
||||
)
|
||||
|
||||
153
backend/app/agent/chat_audit.py
Normal file
153
backend/app/agent/chat_audit.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Durable diagnostics for individual AG-UI agent runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session, delete, select
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.models.models import ChatRunLog
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
CHAT_LOG_RETENTION_DAYS = 90
|
||||
_chat_run_id: contextvars.ContextVar[int | None] = contextvars.ContextVar(
|
||||
"chat_run_id", default=None
|
||||
)
|
||||
|
||||
|
||||
def set_current_chat_run(run_id: int) -> contextvars.Token:
|
||||
return _chat_run_id.set(run_id)
|
||||
|
||||
|
||||
def reset_current_chat_run(token: contextvars.Token) -> None:
|
||||
_chat_run_id.reset(token)
|
||||
|
||||
|
||||
def current_chat_run_id() -> int | None:
|
||||
return _chat_run_id.get()
|
||||
|
||||
|
||||
def _last_user_prompt(messages: list[dict[str, Any]]) -> str | None:
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "user" and isinstance(message.get("content"), str):
|
||||
return message["content"]
|
||||
return None
|
||||
|
||||
|
||||
def create_chat_run(
|
||||
*,
|
||||
request_id: str,
|
||||
agui_run_id: str | None,
|
||||
thread_id: str | None,
|
||||
messages: list[dict[str, Any]],
|
||||
client_ip: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
browser: str | None = None,
|
||||
) -> int:
|
||||
now = utcnow()
|
||||
with Session(engine) as session:
|
||||
row = ChatRunLog(
|
||||
request_id=request_id,
|
||||
agui_run_id=agui_run_id,
|
||||
requested_thread_id=thread_id,
|
||||
model=settings.AGENT_MODEL,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
browser=browser,
|
||||
user_prompt=_last_user_prompt(messages),
|
||||
expires_at=now + timedelta(days=CHAT_LOG_RETENTION_DAYS),
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
assert row.id is not None
|
||||
return row.id
|
||||
|
||||
|
||||
def _tool_activity(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
calls: dict[str, dict[str, Any]] = {}
|
||||
order: list[str] = []
|
||||
for message in messages:
|
||||
if message.get("role") == "assistant":
|
||||
for call in message.get("toolCalls") or message.get("tool_calls") or []:
|
||||
call_id = str(call.get("id") or "")
|
||||
if not call_id or call_id in calls:
|
||||
continue
|
||||
function = call.get("function") or {}
|
||||
calls[call_id] = {
|
||||
"id": call_id,
|
||||
"name": function.get("name"),
|
||||
"arguments": function.get("arguments"),
|
||||
}
|
||||
order.append(call_id)
|
||||
elif message.get("role") == "tool":
|
||||
call_id = str(message.get("toolCallId") or message.get("tool_call_id") or "")
|
||||
if call_id in calls:
|
||||
calls[call_id]["result"] = message.get("content")
|
||||
if message.get("error") is not None:
|
||||
calls[call_id]["error"] = message["error"]
|
||||
return [calls[call_id] for call_id in order]
|
||||
|
||||
|
||||
def _last_assistant_response(messages: list[dict[str, Any]]) -> str | None:
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant" and isinstance(message.get("content"), str):
|
||||
return message["content"]
|
||||
return None
|
||||
|
||||
|
||||
def finalize_chat_run_in_session(
|
||||
session: Session, *, run_id: int, response_thread_id: str, messages: list[dict[str, Any]]
|
||||
) -> None:
|
||||
row = session.get(ChatRunLog, run_id)
|
||||
if row is None:
|
||||
return
|
||||
row.response_thread_id = response_thread_id
|
||||
row.tool_calls = _tool_activity(messages)
|
||||
row.assistant_response = _last_assistant_response(messages)
|
||||
row.status = "completed"
|
||||
row.completed_at = utcnow()
|
||||
session.add(row)
|
||||
|
||||
|
||||
def mark_chat_run_failed(run_id: int, error: str) -> None:
|
||||
with Session(engine) as session:
|
||||
row = session.get(ChatRunLog, run_id)
|
||||
if row is None or row.status == "completed":
|
||||
return
|
||||
row.status = "failed"
|
||||
row.error = error[:4000]
|
||||
row.completed_at = utcnow()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
|
||||
|
||||
def finish_pending_chat_run(run_id: int) -> None:
|
||||
with Session(engine) as session:
|
||||
row = session.get(ChatRunLog, run_id)
|
||||
if row is None or row.status != "started":
|
||||
return
|
||||
row.status = "completed"
|
||||
row.completed_at = utcnow()
|
||||
session.add(row)
|
||||
session.commit()
|
||||
|
||||
|
||||
def purge_expired_chat_runs() -> int:
|
||||
with Session(engine) as session:
|
||||
result = session.exec(delete(ChatRunLog).where(ChatRunLog.expires_at < utcnow()))
|
||||
session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def purge_expired_chat_runs_periodically() -> None:
|
||||
"""Purge at startup and then once per day for a bounded audit footprint."""
|
||||
while True:
|
||||
await asyncio.to_thread(purge_expired_chat_runs)
|
||||
await asyncio.sleep(24 * 60 * 60)
|
||||
194
backend/app/agent/snapshot_store.py
Normal file
194
backend/app/agent/snapshot_store.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Postgres-backed AG-UI thread snapshot store.
|
||||
|
||||
Implements the AGUIThreadSnapshotStore protocol from agent-framework-ag-ui so
|
||||
the assistant conversation survives page reloads: MAF calls `save` with the
|
||||
full cumulative message list at each run end, and `get` on the next request
|
||||
(server-side context rebuild, or a client hydration run with empty messages).
|
||||
|
||||
Two deliberate choices:
|
||||
- Own sessions per call: MAF saves during SSE streaming, after the
|
||||
agent_auth_and_session middleware has already closed the request-bound
|
||||
ContextVar session. Sync SQLModel work runs in a thread so the event loop
|
||||
keeps streaming.
|
||||
- `save` normalizes assistant `tool_calls` -> `toolCalls`: @ag-ui/client's
|
||||
Zod schema silently strips the snake_case field, so a replayed snapshot
|
||||
would lose tool calls (and the spending-summary card) on the way to the
|
||||
browser. MAF reads both casings back, so storing camelCase is safe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.db import engine
|
||||
from app.agent.chat_audit import current_chat_run_id, finalize_chat_run_in_session
|
||||
from app.models.models import ChatThreadSnapshot
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
def _normalize_message(msg: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(msg)
|
||||
tool_calls = out.pop("tool_calls", None)
|
||||
if tool_calls is None:
|
||||
tool_calls = out.pop("toolCalls", None)
|
||||
else:
|
||||
out.pop("toolCalls", None)
|
||||
# MAF writes tool_calls: null on plain-text assistant messages; only a
|
||||
# non-empty list is meaningful downstream.
|
||||
if isinstance(tool_calls, list) and tool_calls:
|
||||
out["toolCalls"] = tool_calls
|
||||
if "tool_call_id" in out:
|
||||
out.setdefault("toolCallId", out["tool_call_id"])
|
||||
del out["tool_call_id"]
|
||||
return out
|
||||
|
||||
|
||||
def _clean_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Sanitize the cumulative snapshot before storing it.
|
||||
|
||||
MAF's history merge re-records the resent suffix of multi-run turns
|
||||
(frontend-tool roundtrips) without deduplicating, and the model
|
||||
occasionally re-issues an identical render tool call on the follow-up
|
||||
run. Left as-is, the snapshot grows duplicates that replay as repeated
|
||||
bubbles/cards after a reload.
|
||||
"""
|
||||
# Pass 1: drop BFF-injected ctx-* system messages, duplicate message
|
||||
# ids, and repeated identical tool calls (same function + arguments)
|
||||
# within one user turn.
|
||||
seen_ids: set[str] = set()
|
||||
turn_calls: set[tuple[Any, Any]] = set()
|
||||
kept_call_ids: set[str] = set()
|
||||
first_pass: list[dict[str, Any]] = []
|
||||
for raw in messages:
|
||||
m = _normalize_message(raw)
|
||||
mid = str(m.get("id", ""))
|
||||
if mid.startswith("ctx-"):
|
||||
continue
|
||||
if mid and mid in seen_ids:
|
||||
continue
|
||||
role = m.get("role")
|
||||
if role == "user":
|
||||
turn_calls = set()
|
||||
if role == "assistant" and isinstance(m.get("toolCalls"), list):
|
||||
kept: list[dict[str, Any]] = []
|
||||
for tc in m["toolCalls"]:
|
||||
fn = tc.get("function") or {}
|
||||
key = (fn.get("name"), fn.get("arguments"))
|
||||
if key in turn_calls:
|
||||
continue
|
||||
turn_calls.add(key)
|
||||
kept.append(tc)
|
||||
if tc.get("id"):
|
||||
kept_call_ids.add(str(tc["id"]))
|
||||
if kept:
|
||||
m["toolCalls"] = kept
|
||||
else:
|
||||
m.pop("toolCalls", None)
|
||||
if not m.get("content"):
|
||||
continue # nothing left worth replaying
|
||||
if mid:
|
||||
seen_ids.add(mid)
|
||||
first_pass.append(m)
|
||||
|
||||
# Pass 2: one tool result per surviving call id. Results whose call was
|
||||
# deduplicated away must go too — an orphan tool message in the stored
|
||||
# history would 400 the next OpenAI request when MAF rebuilds context.
|
||||
answered: set[str] = set()
|
||||
out: list[dict[str, Any]] = []
|
||||
for m in first_pass:
|
||||
if m.get("role") == "tool":
|
||||
tcid = str(m.get("toolCallId") or "")
|
||||
if tcid and (tcid not in kept_call_ids or tcid in answered):
|
||||
continue
|
||||
if tcid:
|
||||
answered.add(tcid)
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
class PostgresSnapshotStore:
|
||||
"""One upserted row per (scope, thread_id); latest snapshot only."""
|
||||
|
||||
async def save(
|
||||
self, *, scope: str, thread_id: str, snapshot: AGUIThreadSnapshot
|
||||
) -> None:
|
||||
def _save() -> None:
|
||||
with Session(engine) as session:
|
||||
row = session.exec(
|
||||
select(ChatThreadSnapshot).where(
|
||||
ChatThreadSnapshot.scope == scope,
|
||||
ChatThreadSnapshot.thread_id == thread_id,
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
row = ChatThreadSnapshot(scope=scope, thread_id=thread_id)
|
||||
row.messages = _clean_messages(snapshot.messages)
|
||||
row.state = snapshot.state
|
||||
row.interrupt = snapshot.interrupt
|
||||
row.updated_at = utcnow()
|
||||
session.add(row)
|
||||
run_id = current_chat_run_id()
|
||||
if run_id is not None:
|
||||
finalize_chat_run_in_session(
|
||||
session,
|
||||
run_id=run_id,
|
||||
response_thread_id=thread_id,
|
||||
messages=row.messages,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
await asyncio.to_thread(_save)
|
||||
|
||||
async def get(
|
||||
self, *, scope: str, thread_id: str
|
||||
) -> AGUIThreadSnapshot | None:
|
||||
def _get() -> AGUIThreadSnapshot | None:
|
||||
with Session(engine) as session:
|
||||
row = session.exec(
|
||||
select(ChatThreadSnapshot).where(
|
||||
ChatThreadSnapshot.scope == scope,
|
||||
ChatThreadSnapshot.thread_id == thread_id,
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
return AGUIThreadSnapshot(
|
||||
messages=row.messages or [],
|
||||
state=row.state,
|
||||
interrupt=row.interrupt,
|
||||
)
|
||||
|
||||
return await asyncio.to_thread(_get)
|
||||
|
||||
async def delete(self, *, scope: str, thread_id: str) -> bool:
|
||||
def _delete() -> bool:
|
||||
with Session(engine) as session:
|
||||
row = session.exec(
|
||||
select(ChatThreadSnapshot).where(
|
||||
ChatThreadSnapshot.scope == scope,
|
||||
ChatThreadSnapshot.thread_id == thread_id,
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return False
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
|
||||
async def clear(self, *, scope: str | None = None) -> None:
|
||||
def _clear() -> None:
|
||||
with Session(engine) as session:
|
||||
stmt = select(ChatThreadSnapshot)
|
||||
if scope is not None:
|
||||
stmt = stmt.where(ChatThreadSnapshot.scope == scope)
|
||||
for row in session.exec(stmt).all():
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
|
||||
await asyncio.to_thread(_clear)
|
||||
@@ -3,12 +3,17 @@ Read-only tools exposed to the MAF ChatAgent. Each tool is a thin wrapper
|
||||
around existing SQLModel queries / service helpers — they do NOT duplicate
|
||||
business logic. The active DB session is resolved via a ContextVar so tool
|
||||
signatures stay clean for the LLM.
|
||||
|
||||
POLICY: every tool here must stay READ-ONLY. Transaction descriptions reach
|
||||
the model from external emails (indirect prompt-injection surface), so a
|
||||
write-capable tool would let crafted text mutate financial data. Any future
|
||||
write tool requires an explicit user-confirmation step in the UI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextvars
|
||||
from datetime import datetime
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import Field
|
||||
@@ -28,16 +33,22 @@ from app.models.models import (
|
||||
WaterMeterReading,
|
||||
)
|
||||
from app.services.budget_projection import (
|
||||
MAX_YEAR,
|
||||
MIN_YEAR,
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
compute_monthly_projection,
|
||||
compute_yearly_projection_with_cumulative,
|
||||
get_cycle_range,
|
||||
)
|
||||
from app.services import exchange_rate as fx
|
||||
from app.timeutil import client_tz, today_client
|
||||
from app.services.exchange_rate import (
|
||||
get_converted_amount_expr,
|
||||
get_current_rate,
|
||||
)
|
||||
|
||||
_session_ctx: contextvars.ContextVar[Session] = contextvars.ContextVar("agent_session")
|
||||
SALARY_TRANSACTION_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
|
||||
|
||||
|
||||
def set_session(session: Session) -> contextvars.Token:
|
||||
@@ -49,7 +60,13 @@ def reset_session(token: contextvars.Token) -> None:
|
||||
|
||||
|
||||
def _s() -> Session:
|
||||
return _session_ctx.get()
|
||||
try:
|
||||
return _session_ctx.get()
|
||||
except LookupError as exc:
|
||||
raise RuntimeError(
|
||||
"DB session not bound to agent context — tool called outside an "
|
||||
"HTTP request (the AG-UI middleware binds it per request)"
|
||||
) from exc
|
||||
|
||||
|
||||
# ─── Tools ──────────────────────────────────────────────────────────────────
|
||||
@@ -66,9 +83,9 @@ def get_accounts() -> list[dict]:
|
||||
"bank": a.bank.value,
|
||||
"label": a.label,
|
||||
"currency": a.currency.value,
|
||||
"balance": a.balance,
|
||||
"balance": float(a.balance),
|
||||
"account_type": a.account_type.value,
|
||||
"next_payment": a.next_payment,
|
||||
"next_payment": float(a.next_payment) if a.next_payment is not None else None,
|
||||
}
|
||||
for a in rows
|
||||
]
|
||||
@@ -77,26 +94,31 @@ def get_accounts() -> list[dict]:
|
||||
def get_net_worth() -> dict:
|
||||
"""Return total assets, liabilities and net worth in CRC (primary currency).
|
||||
USD/EUR balances are converted at the latest exchange rate."""
|
||||
accounts = _s().exec(select(Account)).all()
|
||||
rate = get_current_rate(_s())
|
||||
sell = rate.sell_rate if rate else 600.0
|
||||
session = _s()
|
||||
accounts = session.exec(select(Account)).all()
|
||||
multipliers = fx.get_crc_multipliers(session)
|
||||
assets_crc = 0.0
|
||||
liabilities_crc = 0.0
|
||||
excluded: list[str] = []
|
||||
for a in accounts:
|
||||
amt = a.balance
|
||||
if a.currency.value == "USD":
|
||||
amt = a.balance * sell
|
||||
elif a.currency.value == "EUR":
|
||||
amt = a.balance * sell * 1.08 # rough; real conversion is endpoint-side
|
||||
mult = multipliers.get(a.currency.value)
|
||||
if mult is None:
|
||||
# No live or last-known rate: say so instead of inventing a number
|
||||
excluded.append(f"{a.label} ({a.currency.value})")
|
||||
continue
|
||||
amt = float(a.balance) * float(mult)
|
||||
if a.account_type.value == "LIABILITY":
|
||||
liabilities_crc += amt
|
||||
else:
|
||||
assets_crc += amt
|
||||
return {
|
||||
result = {
|
||||
"assets_crc": round(assets_crc, 2),
|
||||
"liabilities_crc": round(liabilities_crc, 2),
|
||||
"net_crc": round(assets_crc - liabilities_crc, 2),
|
||||
}
|
||||
if excluded:
|
||||
result["excluded_accounts_no_rate"] = excluded
|
||||
return result
|
||||
|
||||
|
||||
def get_recent_transactions(
|
||||
@@ -121,7 +143,11 @@ def get_recent_transactions(
|
||||
q = select(Transaction).where(
|
||||
col(Transaction.transaction_type).notin_(
|
||||
[TransactionType.SALARY, TransactionType.DEPOSITO]
|
||||
)
|
||||
),
|
||||
# Tasa Cero generates future-dated cuotas; "recent" means already
|
||||
# billed (same rule as /transactions/recent). Bound is end of the
|
||||
# user's today in their request timezone (CR fallback).
|
||||
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
|
||||
)
|
||||
if source:
|
||||
q = q.where(Transaction.source == TransactionSource(source))
|
||||
@@ -139,7 +165,7 @@ def get_recent_transactions(
|
||||
"id": t.id,
|
||||
"date": t.date.isoformat(),
|
||||
"merchant": t.merchant,
|
||||
"amount": t.amount,
|
||||
"amount": float(t.amount),
|
||||
"currency": t.currency.value,
|
||||
"source": t.source.value,
|
||||
"transaction_type": t.transaction_type.value,
|
||||
@@ -218,6 +244,8 @@ def get_budget_projection(
|
||||
"""Budget projection. If month is omitted, returns the yearly rollup; if
|
||||
given, returns the monthly detail with income items, expense items and
|
||||
actuals by source."""
|
||||
if not MIN_YEAR <= year <= MAX_YEAR:
|
||||
return {"error": f"year must be between {MIN_YEAR} and {MAX_YEAR}"}
|
||||
session = _s()
|
||||
if month is None:
|
||||
months_data = compute_yearly_projection_with_cumulative(session, year)
|
||||
@@ -243,7 +271,7 @@ def list_recurring_items() -> list[dict]:
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"amount": r.amount,
|
||||
"amount": float(r.amount),
|
||||
"currency": r.currency.value,
|
||||
"item_type": r.item_type.value,
|
||||
"frequency": r.frequency.value,
|
||||
@@ -266,27 +294,38 @@ def get_pension_snapshots(
|
||||
) -> list[dict]:
|
||||
"""Pension fund snapshots. Each snapshot covers a period with balances,
|
||||
contributions, returns, fees and the ending balance (saldo_final)."""
|
||||
q = select(PensionSnapshot).order_by(col(PensionSnapshot.period_end).desc())
|
||||
if latest_only:
|
||||
# Latest snapshot per fund resolved in SQL instead of scanning all rows
|
||||
latest = (
|
||||
select(
|
||||
PensionSnapshot.fund.label("fund"),
|
||||
func.max(PensionSnapshot.period_end).label("period_end"),
|
||||
)
|
||||
.group_by(PensionSnapshot.fund)
|
||||
.subquery()
|
||||
)
|
||||
q = select(PensionSnapshot).join(
|
||||
latest,
|
||||
(PensionSnapshot.fund == latest.c.fund)
|
||||
& (PensionSnapshot.period_end == latest.c.period_end),
|
||||
)
|
||||
else:
|
||||
q = select(PensionSnapshot)
|
||||
q = q.order_by(col(PensionSnapshot.period_end).desc())
|
||||
if fund:
|
||||
q = q.where(PensionSnapshot.fund == fund)
|
||||
rows = _s().exec(q).all()
|
||||
if latest_only:
|
||||
seen: dict[str, PensionSnapshot] = {}
|
||||
for r in rows:
|
||||
if r.fund.value not in seen:
|
||||
seen[r.fund.value] = r
|
||||
rows = list(seen.values())
|
||||
return [
|
||||
{
|
||||
"fund": r.fund.value,
|
||||
"period_start": r.period_start.isoformat(),
|
||||
"period_end": r.period_end.isoformat(),
|
||||
"saldo_anterior": r.saldo_anterior,
|
||||
"aportes": r.aportes,
|
||||
"rendimientos": r.rendimientos,
|
||||
"retiros": r.retiros,
|
||||
"comision": r.comision,
|
||||
"saldo_final": r.saldo_final,
|
||||
"saldo_anterior": float(r.saldo_anterior),
|
||||
"aportes": float(r.aportes),
|
||||
"rendimientos": float(r.rendimientos),
|
||||
"retiros": float(r.retiros),
|
||||
"comision": float(r.comision),
|
||||
"saldo_final": float(r.saldo_final),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -301,7 +340,7 @@ def get_salary_summary() -> dict:
|
||||
func.count(),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.max(Transaction.date),
|
||||
).where(Transaction.transaction_type == TransactionType.SALARY)
|
||||
).where(col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES))
|
||||
).first()
|
||||
count = row[0] if row else 0
|
||||
total = float(row[1]) if row else 0.0
|
||||
@@ -309,8 +348,47 @@ def get_salary_summary() -> dict:
|
||||
return {"count": count, "total_crc": total, "latest_date": latest}
|
||||
|
||||
|
||||
def get_salary_deposits(
|
||||
limit: Annotated[int, Field(ge=1, le=100, description="How many deposits to return")] = 20,
|
||||
start_date: Annotated[
|
||||
Optional[str], Field(description="ISO date lower bound, inclusive")
|
||||
] = None,
|
||||
end_date: Annotated[
|
||||
Optional[str], Field(description="ISO date upper bound, exclusive")
|
||||
] = None,
|
||||
) -> list[dict]:
|
||||
"""Individual salary deposits, newest first. Use this for questions about
|
||||
a specific salary or a day/range (for example, "cuánto recibí hoy").
|
||||
Amounts remain in their recorded currency; use the currency field when
|
||||
presenting them."""
|
||||
q = select(Transaction).where(
|
||||
col(Transaction.transaction_type).in_(SALARY_TRANSACTION_TYPES)
|
||||
)
|
||||
if start_date:
|
||||
q = q.where(Transaction.date >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
q = q.where(Transaction.date < datetime.fromisoformat(end_date))
|
||||
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).limit(limit)
|
||||
return [
|
||||
{
|
||||
"id": t.id,
|
||||
"date": t.date.isoformat(),
|
||||
"amount": float(t.amount),
|
||||
"currency": t.currency.value,
|
||||
"merchant": t.merchant,
|
||||
"source": t.source.value,
|
||||
"bank": t.bank.value,
|
||||
"transaction_type": t.transaction_type.value,
|
||||
"reference": t.reference,
|
||||
"notes": t.notes,
|
||||
}
|
||||
for t in _s().exec(q).all()
|
||||
]
|
||||
|
||||
|
||||
def get_municipal_receipts(
|
||||
limit: Annotated[int, Field(ge=1, le=50)] = 12,
|
||||
offset: Annotated[int, Field(ge=0, description="Skip the N most recent")] = 0,
|
||||
account: Annotated[
|
||||
Optional[str], Field(description="Municipal account/contract id")
|
||||
] = None,
|
||||
@@ -320,13 +398,23 @@ def get_municipal_receipts(
|
||||
q = select(MunicipalReceipt).order_by(col(MunicipalReceipt.receipt_date).desc())
|
||||
if account:
|
||||
q = q.where(MunicipalReceipt.account == account)
|
||||
q = q.limit(limit)
|
||||
q = q.offset(offset).limit(limit)
|
||||
rows = _s().exec(q).all()
|
||||
# One grouped query for all receipts instead of one per receipt
|
||||
consumption: dict[int, float] = {}
|
||||
ids = [r.id for r in rows if r.id is not None]
|
||||
if ids:
|
||||
grouped = _s().exec(
|
||||
select(
|
||||
WaterMeterReading.receipt_id,
|
||||
func.sum(WaterMeterReading.consumption_m3),
|
||||
)
|
||||
.where(col(WaterMeterReading.receipt_id).in_(ids))
|
||||
.group_by(WaterMeterReading.receipt_id)
|
||||
).all()
|
||||
consumption = {rid: float(total) for rid, total in grouped}
|
||||
out: list[dict] = []
|
||||
for r in rows:
|
||||
readings = _s().exec(
|
||||
select(WaterMeterReading).where(WaterMeterReading.receipt_id == r.id)
|
||||
).all()
|
||||
out.append(
|
||||
{
|
||||
"id": r.id,
|
||||
@@ -334,11 +422,11 @@ def get_municipal_receipts(
|
||||
"period": r.period,
|
||||
"account": r.account,
|
||||
"finca": r.finca,
|
||||
"subtotal": r.subtotal,
|
||||
"interests": r.interests,
|
||||
"iva": r.iva,
|
||||
"total": r.total,
|
||||
"water_consumption_m3": sum(w.consumption_m3 for w in readings),
|
||||
"subtotal": float(r.subtotal),
|
||||
"interests": float(r.interests),
|
||||
"iva": float(r.iva),
|
||||
"total": float(r.total),
|
||||
"water_consumption_m3": consumption.get(r.id, 0.0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -366,13 +454,10 @@ def get_analytics_by_category(
|
||||
q = q.where(Transaction.date >= start, Transaction.date < end)
|
||||
rows = session.exec(q).all()
|
||||
grand = sum(float(r[1]) for r in rows) or 1.0
|
||||
names = {c.id: c.name for c in session.exec(select(Category)).all()}
|
||||
out = []
|
||||
for cat_id, total, count in rows:
|
||||
name = "Uncategorized"
|
||||
if cat_id:
|
||||
cat = session.get(Category, cat_id)
|
||||
if cat:
|
||||
name = cat.name
|
||||
name = names.get(cat_id, "Uncategorized") if cat_id else "Uncategorized"
|
||||
out.append(
|
||||
{
|
||||
"category_id": cat_id,
|
||||
@@ -436,9 +521,10 @@ def get_exchange_rate() -> dict:
|
||||
if not rate:
|
||||
return {"buy_rate": None, "sell_rate": None, "date": None}
|
||||
return {
|
||||
"buy_rate": rate.buy_rate,
|
||||
"sell_rate": rate.sell_rate,
|
||||
"buy_rate": float(rate.buy_rate),
|
||||
"sell_rate": float(rate.sell_rate),
|
||||
"date": rate.date.isoformat(),
|
||||
"fetched_at": rate.fetched_at.isoformat() if rate.fetched_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -450,7 +536,68 @@ def list_categories() -> list[dict]:
|
||||
|
||||
|
||||
# Registered with the agent in agent.py
|
||||
def get_daily_spending(
|
||||
start_date: Annotated[str, Field(description="ISO date lower bound, inclusive")],
|
||||
end_date: Annotated[str, Field(description="ISO date upper bound, exclusive")],
|
||||
) -> list[dict]:
|
||||
"""Spending per day in CRC (converted), COMPRA only, excluding future
|
||||
Tasa Cero cuotas and installment anchors. THE tool for day-scoped
|
||||
questions: 'cuánto gasté hoy / ayer / el martes'. Days with no spending
|
||||
are simply absent from the result."""
|
||||
session = _s()
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
rows = session.exec(
|
||||
select(
|
||||
func.date(Transaction.date).label("day"),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.count(),
|
||||
)
|
||||
.where(
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
Transaction.date < datetime.fromisoformat(end_date),
|
||||
# Future-dated Tasa Cero cuotas are not money already spent.
|
||||
Transaction.date < datetime.combine(today_client() + timedelta(days=1), time.min),
|
||||
)
|
||||
.group_by(func.date(Transaction.date))
|
||||
.order_by(func.date(Transaction.date))
|
||||
).all()
|
||||
return [
|
||||
{"date": str(day), "total_crc": float(total), "count": count}
|
||||
for day, total, count in rows
|
||||
]
|
||||
|
||||
|
||||
def get_current_date() -> dict:
|
||||
"""Today's date in the user's own timezone (sent by their browser;
|
||||
Costa Rica fallback) and the active credit-card billing cycle. ALWAYS
|
||||
call this before resolving any relative date reference — 'hoy', 'ayer',
|
||||
'este mes', 'este ciclo', 'el ciclo pasado' — the server clock and your
|
||||
own assumptions about today are unreliable."""
|
||||
today = today_client()
|
||||
# get_cycle_range(y, m) is the cycle STARTING on the 18th of m; before
|
||||
# the 18th we are still in the cycle that started last month.
|
||||
if today.day >= 18:
|
||||
cycle_year, cycle_month = today.year, today.month
|
||||
elif today.month == 1:
|
||||
cycle_year, cycle_month = today.year - 1, 12
|
||||
else:
|
||||
cycle_year, cycle_month = today.year, today.month - 1
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
return {
|
||||
"date": today.isoformat(),
|
||||
"weekday": today.strftime("%A"),
|
||||
"timezone": str(client_tz()),
|
||||
"cycle_year": cycle_year,
|
||||
"cycle_month": cycle_month,
|
||||
"cycle_range": [start.date().isoformat(), end.date().isoformat()],
|
||||
}
|
||||
|
||||
|
||||
TOOLS = [
|
||||
get_current_date,
|
||||
get_daily_spending,
|
||||
get_accounts,
|
||||
get_net_worth,
|
||||
get_recent_transactions,
|
||||
@@ -459,6 +606,7 @@ TOOLS = [
|
||||
list_recurring_items,
|
||||
get_pension_snapshots,
|
||||
get_salary_summary,
|
||||
get_salary_deposits,
|
||||
get_municipal_receipts,
|
||||
get_analytics_by_category,
|
||||
get_monthly_trend,
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.timeutil import utcnow
|
||||
from app.models.models import Account, AccountCreate, AccountRead, AccountUpdate
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
@@ -44,7 +45,7 @@ def update_account(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(account, key, value)
|
||||
account.updated_at = datetime.utcnow()
|
||||
account.updated_at = utcnow()
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
|
||||
@@ -8,8 +8,9 @@ from sqlmodel import Session, func, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import Category, Transaction
|
||||
from app.services.budget_projection import get_cycle_range
|
||||
from app.models.models import Category, Transaction, TransactionType
|
||||
from app.services.budget_projection import NOT_INSTALLMENT_ANCHOR, get_cycle_range
|
||||
from app.timeutil import utcnow
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
@@ -42,6 +43,8 @@ class DailySpending(BaseModel):
|
||||
def spending_by_category(
|
||||
cycle_year: Optional[int] = None,
|
||||
cycle_month: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
@@ -53,17 +56,26 @@ def spending_by_category(
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
.where(
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
)
|
||||
.group_by(Transaction.category_id)
|
||||
)
|
||||
|
||||
if cycle_year and cycle_month:
|
||||
# Arbitrary date range takes precedence over the cycle filter.
|
||||
if start_date and end_date:
|
||||
query = query.where(
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
Transaction.date < datetime.fromisoformat(end_date),
|
||||
)
|
||||
elif cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||
|
||||
rows = session.exec(query).all()
|
||||
|
||||
grand_total = sum(r[1] for r in rows) or 1
|
||||
grand_total = float(sum(float(r[1]) for r in rows)) or 1.0
|
||||
|
||||
results = []
|
||||
for category_id, total, count in rows:
|
||||
@@ -123,9 +135,10 @@ def monthly_trend(
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Transaction.transaction_type == "COMPRA",
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
)
|
||||
).first()
|
||||
|
||||
@@ -160,6 +173,8 @@ def monthly_trend(
|
||||
def daily_spending(
|
||||
cycle_year: Optional[int] = None,
|
||||
cycle_month: Optional[int] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
@@ -171,12 +186,23 @@ def daily_spending(
|
||||
func.sum(amount_crc).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
.where(
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
# Tasa Cero generates future-dated cuotas; daily spending is
|
||||
# about money already spent.
|
||||
Transaction.date <= utcnow(),
|
||||
)
|
||||
.group_by(func.date(Transaction.date))
|
||||
.order_by(func.date(Transaction.date))
|
||||
)
|
||||
|
||||
if cycle_year and cycle_month:
|
||||
if start_date and end_date:
|
||||
query = query.where(
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
Transaction.date < datetime.fromisoformat(end_date),
|
||||
)
|
||||
elif cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.auth import create_access_token, get_current_user, get_current_user_cookie_or_bearer
|
||||
from app.config import settings
|
||||
from app.auth import (
|
||||
check_login_rate_limit,
|
||||
create_access_token,
|
||||
get_current_user_cookie_or_bearer,
|
||||
verify_admin_credentials,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
if (
|
||||
form_data.username != settings.ADMIN_USERNAME
|
||||
or form_data.password != settings.ADMIN_PASSWORD
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
check_login_rate_limit(request)
|
||||
verify_admin_credentials(form_data.username, form_data.password)
|
||||
token = create_access_token(form_data.username)
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||
return {"username": username}
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.timeutil import utcnow
|
||||
from app.models.models import (
|
||||
BalanceOverride,
|
||||
BalanceOverrideCreate,
|
||||
@@ -247,9 +248,9 @@ def upsert_balance_override(
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
if year < MIN_YEAR or year > MAX_YEAR:
|
||||
raise HTTPException(400, f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
|
||||
raise HTTPException(status_code=400, detail=f"Year must be between {MIN_YEAR} and {MAX_YEAR}")
|
||||
if year == FRESH_START_YEAR and month < FRESH_START_MONTH:
|
||||
raise HTTPException(400, f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
|
||||
raise HTTPException(status_code=400, detail=f"Cannot override before {FRESH_START_YEAR}-{FRESH_START_MONTH:02d}")
|
||||
|
||||
existing = session.exec(
|
||||
select(BalanceOverride).where(
|
||||
@@ -259,7 +260,7 @@ def upsert_balance_override(
|
||||
|
||||
if existing:
|
||||
existing.override_balance = data.override_balance
|
||||
existing.updated_at = datetime.utcnow()
|
||||
existing.updated_at = utcnow()
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
@@ -287,6 +288,6 @@ def delete_balance_override(
|
||||
)
|
||||
).first()
|
||||
if not existing:
|
||||
raise HTTPException(404, "No override found for this month")
|
||||
raise HTTPException(status_code=404, detail="No override found for this month")
|
||||
session.delete(existing)
|
||||
session.commit()
|
||||
|
||||
41
backend/app/api/v1/endpoints/chat.py
Normal file
41
backend/app/api/v1/endpoints/chat.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Assistant chat thread management.
|
||||
|
||||
The conversation itself is persisted by the AG-UI snapshot store
|
||||
(app/agent/snapshot_store.py) under a single ("default", "main") thread;
|
||||
this router only exposes the reset used by "Nueva conversación".
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import ChatThreadSnapshot
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
CHAT_SCOPE = "default"
|
||||
CHAT_THREAD_ID = "main"
|
||||
|
||||
|
||||
class ClearThreadResponse(BaseModel):
|
||||
cleared: bool
|
||||
|
||||
|
||||
@router.delete("/thread", response_model=ClearThreadResponse)
|
||||
def clear_thread(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
row = session.exec(
|
||||
select(ChatThreadSnapshot).where(
|
||||
ChatThreadSnapshot.scope == CHAT_SCOPE,
|
||||
ChatThreadSnapshot.thread_id == CHAT_THREAD_ID,
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return {"cleared": False}
|
||||
session.delete(row)
|
||||
session.commit()
|
||||
return {"cleared": True}
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
@@ -22,15 +22,33 @@ router = APIRouter(prefix="/import", tags=["import"])
|
||||
|
||||
|
||||
class PasteImportRequest(BaseModel):
|
||||
text: str
|
||||
# ~1 MB cap: a full statement paste is a few KB; anything larger is abuse.
|
||||
text: str = Field(max_length=1_000_000)
|
||||
bank: Bank = Bank.BAC
|
||||
source: TransactionSource = TransactionSource.CREDIT_CARD
|
||||
# Override for false-positive dedup (legitimate same-day, same-amount
|
||||
# repeat purchases hash identically) — review UX-20/BE-08.
|
||||
allow_duplicates: bool = False
|
||||
|
||||
|
||||
class SkippedDuplicate(BaseModel):
|
||||
line: int
|
||||
merchant: str
|
||||
date: str
|
||||
amount: float
|
||||
currency: str
|
||||
existing_id: int
|
||||
existing_merchant: str
|
||||
existing_date: str
|
||||
existing_amount: float
|
||||
existing_source: str
|
||||
|
||||
|
||||
class PasteImportResult(BaseModel):
|
||||
imported: int
|
||||
duplicates: int
|
||||
errors: list[str]
|
||||
skipped: list[SkippedDuplicate] = []
|
||||
|
||||
|
||||
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
|
||||
@@ -102,6 +120,7 @@ def paste_import(
|
||||
imported = 0
|
||||
duplicates = 0
|
||||
errors: list[str] = []
|
||||
skipped: list[SkippedDuplicate] = []
|
||||
|
||||
lines = req.text.strip().splitlines()
|
||||
|
||||
@@ -119,13 +138,29 @@ def paste_import(
|
||||
parsed["date"], parsed["merchant"], parsed["amount"], parsed["currency"]
|
||||
)
|
||||
|
||||
# Check for duplicates
|
||||
existing = session.exec(
|
||||
select(Transaction).where(Transaction.reference == ref_hash)
|
||||
).first()
|
||||
if existing:
|
||||
duplicates += 1
|
||||
continue
|
||||
# Check for duplicates — and SHOW the user what matched instead of
|
||||
# silently dropping the line (UX-20).
|
||||
if not req.allow_duplicates:
|
||||
existing = session.exec(
|
||||
select(Transaction).where(Transaction.reference == ref_hash)
|
||||
).first()
|
||||
if existing:
|
||||
duplicates += 1
|
||||
skipped.append(
|
||||
SkippedDuplicate(
|
||||
line=i,
|
||||
merchant=parsed["merchant"],
|
||||
date=parsed["date"].isoformat(),
|
||||
amount=parsed["amount"],
|
||||
currency=parsed["currency"],
|
||||
existing_id=existing.id or 0,
|
||||
existing_merchant=existing.merchant,
|
||||
existing_date=existing.date.isoformat(),
|
||||
existing_amount=float(existing.amount),
|
||||
existing_source=existing.source.value,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
tx = Transaction(
|
||||
amount=parsed["amount"],
|
||||
@@ -146,4 +181,6 @@ def paste_import(
|
||||
if imported > 0:
|
||||
session.commit()
|
||||
|
||||
return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors)
|
||||
return PasteImportResult(
|
||||
imported=imported, duplicates=duplicates, errors=errors, skipped=skipped
|
||||
)
|
||||
|
||||
234
backend/app/api/v1/endpoints/installments.py
Normal file
234
backend/app/api/v1/endpoints/installments.py
Normal file
@@ -0,0 +1,234 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import (
|
||||
InstallmentPlan,
|
||||
InstallmentPlanCreate,
|
||||
InstallmentPlanUpdate,
|
||||
Transaction,
|
||||
TransactionRead,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.installments import (
|
||||
compute_installment_amounts,
|
||||
create_plan,
|
||||
delete_plan,
|
||||
regenerate_cuotas,
|
||||
)
|
||||
from app.timeutil import utcnow
|
||||
|
||||
router = APIRouter(prefix="/installment-plans", tags=["installments"])
|
||||
|
||||
MIN_INSTALLMENTS = 2
|
||||
MAX_INSTALLMENTS = 48
|
||||
|
||||
|
||||
class InstallmentPlanRead(BaseModel):
|
||||
id: int
|
||||
anchor_transaction_id: int
|
||||
merchant: str
|
||||
purchase_date: datetime
|
||||
num_installments: int
|
||||
first_installment_date: datetime
|
||||
total_amount: float
|
||||
installment_amount: float
|
||||
last_installment_amount: float
|
||||
currency: str
|
||||
notes: Optional[str] = None
|
||||
cuotas_billed: int
|
||||
paid_amount: float
|
||||
remaining_amount: float
|
||||
next_cuota_date: Optional[datetime] = None
|
||||
is_completed: bool
|
||||
|
||||
|
||||
class InstallmentPlanDetail(InstallmentPlanRead):
|
||||
cuotas: list[TransactionRead] = []
|
||||
|
||||
|
||||
class InstallmentPlanListResponse(BaseModel):
|
||||
plans: list[InstallmentPlanRead]
|
||||
total_remaining: float
|
||||
|
||||
|
||||
def _validate_num_installments(n: int) -> None:
|
||||
if not MIN_INSTALLMENTS <= n <= MAX_INSTALLMENTS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"num_installments must be {MIN_INSTALLMENTS}-{MAX_INSTALLMENTS}",
|
||||
)
|
||||
|
||||
|
||||
def _get_plan_and_anchor(
|
||||
session: Session, plan_id: int
|
||||
) -> tuple[InstallmentPlan, Transaction]:
|
||||
plan = session.get(InstallmentPlan, plan_id)
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Installment plan not found")
|
||||
anchor = session.get(Transaction, plan.anchor_transaction_id)
|
||||
if not anchor:
|
||||
raise HTTPException(status_code=404, detail="Anchor transaction not found")
|
||||
return plan, anchor
|
||||
|
||||
|
||||
def _to_read(
|
||||
plan: InstallmentPlan, anchor: Transaction, cuotas: list[Transaction]
|
||||
) -> InstallmentPlanRead:
|
||||
now = utcnow()
|
||||
billed = [c for c in cuotas if c.date <= now]
|
||||
future = [c for c in cuotas if c.date > now]
|
||||
paid = float(sum(c.amount for c in billed))
|
||||
total = float(plan.total_amount)
|
||||
amounts = compute_installment_amounts(
|
||||
plan.total_amount, plan.num_installments
|
||||
)
|
||||
return InstallmentPlanRead(
|
||||
id=plan.id,
|
||||
anchor_transaction_id=plan.anchor_transaction_id,
|
||||
merchant=anchor.merchant,
|
||||
purchase_date=anchor.date,
|
||||
num_installments=plan.num_installments,
|
||||
first_installment_date=plan.first_installment_date,
|
||||
total_amount=total,
|
||||
installment_amount=float(plan.installment_amount),
|
||||
last_installment_amount=float(amounts[-1]),
|
||||
currency=plan.currency.value,
|
||||
notes=plan.notes,
|
||||
cuotas_billed=len(billed),
|
||||
paid_amount=paid,
|
||||
remaining_amount=total - paid,
|
||||
next_cuota_date=min((c.date for c in future), default=None),
|
||||
is_completed=len(billed) == plan.num_installments,
|
||||
)
|
||||
|
||||
|
||||
def _cuotas_by_plan(
|
||||
session: Session, plan_ids: list[int]
|
||||
) -> dict[int, list[Transaction]]:
|
||||
grouped: dict[int, list[Transaction]] = {pid: [] for pid in plan_ids}
|
||||
if plan_ids:
|
||||
rows = session.exec(
|
||||
select(Transaction)
|
||||
.where(col(Transaction.installment_plan_id).in_(plan_ids))
|
||||
.order_by(col(Transaction.date))
|
||||
).all()
|
||||
for row in rows:
|
||||
grouped[row.installment_plan_id].append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
@router.get("/", response_model=InstallmentPlanListResponse)
|
||||
def list_installment_plans(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
plans = session.exec(select(InstallmentPlan)).all()
|
||||
anchors = {
|
||||
t.id: t
|
||||
for t in session.exec(
|
||||
select(Transaction).where(
|
||||
col(Transaction.id).in_([p.anchor_transaction_id for p in plans])
|
||||
)
|
||||
).all()
|
||||
} if plans else {}
|
||||
cuotas = _cuotas_by_plan(session, [p.id for p in plans])
|
||||
reads = [
|
||||
_to_read(p, anchors[p.anchor_transaction_id], cuotas[p.id])
|
||||
for p in plans
|
||||
if p.anchor_transaction_id in anchors
|
||||
]
|
||||
reads.sort(key=lambda r: r.purchase_date, reverse=True)
|
||||
return InstallmentPlanListResponse(
|
||||
plans=reads,
|
||||
total_remaining=sum(r.remaining_amount for r in reads),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=InstallmentPlanDetail)
|
||||
def get_installment_plan(
|
||||
plan_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
plan, anchor = _get_plan_and_anchor(session, plan_id)
|
||||
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
|
||||
read = _to_read(plan, anchor, cuotas)
|
||||
return InstallmentPlanDetail(**read.model_dump(), cuotas=cuotas)
|
||||
|
||||
|
||||
@router.post("/", response_model=InstallmentPlanRead, status_code=201)
|
||||
def create_installment_plan(
|
||||
data: InstallmentPlanCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Convert an existing credit-card purchase into a Tasa Cero plan."""
|
||||
_validate_num_installments(data.num_installments)
|
||||
anchor = session.get(Transaction, data.transaction_id)
|
||||
if not anchor:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
if anchor.source != TransactionSource.CREDIT_CARD:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Only credit card transactions can be financed"
|
||||
)
|
||||
if anchor.transaction_type != TransactionType.COMPRA:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Only COMPRA transactions can be financed"
|
||||
)
|
||||
if anchor.installment_plan_id:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="A cuota cannot be converted to a plan"
|
||||
)
|
||||
if anchor.is_installment_anchor:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Transaction already has an installment plan"
|
||||
)
|
||||
plan = create_plan(
|
||||
session, anchor, data.num_installments, data.first_installment_date
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(plan)
|
||||
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
|
||||
return _to_read(plan, anchor, cuotas)
|
||||
|
||||
|
||||
@router.patch("/{plan_id}", response_model=InstallmentPlanRead)
|
||||
def update_installment_plan(
|
||||
plan_id: int,
|
||||
data: InstallmentPlanUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Edit a plan; cuota rows are regenerated (per-cuota edits are lost)."""
|
||||
plan, anchor = _get_plan_and_anchor(session, plan_id)
|
||||
if data.num_installments is not None:
|
||||
_validate_num_installments(data.num_installments)
|
||||
if data.notes is not None:
|
||||
plan.notes = data.notes
|
||||
regenerate_cuotas(
|
||||
session, plan, anchor, data.num_installments, data.first_installment_date
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(plan)
|
||||
cuotas = _cuotas_by_plan(session, [plan.id])[plan.id]
|
||||
return _to_read(plan, anchor, cuotas)
|
||||
|
||||
|
||||
@router.delete("/{plan_id}", status_code=204)
|
||||
def delete_installment_plan(
|
||||
plan_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Unconvert: remove the plan and its cuotas; the anchor becomes a normal
|
||||
transaction again."""
|
||||
plan, anchor = _get_plan_and_anchor(session, plan_id)
|
||||
delete_plan(session, plan, anchor)
|
||||
session.commit()
|
||||
@@ -2,11 +2,14 @@ from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from typing import Optional
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.uploads import read_pdf_upload
|
||||
from app.db import get_session
|
||||
from app.models.models import (
|
||||
Category,
|
||||
@@ -204,8 +207,9 @@ async def upload_municipal_receipt(
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
pdf_bytes = await file.read()
|
||||
data = extract_municipal_receipt(pdf_bytes, filename)
|
||||
pdf_bytes = await read_pdf_upload(file)
|
||||
# pdftotext runs as a subprocess; keep the event loop free
|
||||
data = await asyncio.to_thread(extract_municipal_receipt, pdf_bytes, filename)
|
||||
except ValueError as e:
|
||||
return MunicipalReceiptUploadResult(imported=0, updated=0, errors=[str(e)])
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pywebpush import WebPushException, webpush
|
||||
from sqlmodel import Session, select
|
||||
@@ -15,14 +16,22 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("/vapid-public-key")
|
||||
class VapidPublicKeyResponse(BaseModel):
|
||||
publicKey: str
|
||||
|
||||
|
||||
class SubscriptionStatusResponse(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
|
||||
def get_vapid_public_key(_user: str = Depends(get_current_user)):
|
||||
if not settings.VAPID_PUBLIC_KEY:
|
||||
raise HTTPException(status_code=503, detail="Push notifications not configured")
|
||||
return {"publicKey": settings.VAPID_PUBLIC_KEY}
|
||||
|
||||
|
||||
@router.post("/subscribe", status_code=201)
|
||||
@router.post("/subscribe", status_code=201, response_model=SubscriptionStatusResponse)
|
||||
def subscribe(
|
||||
data: PushSubscriptionCreate,
|
||||
session: Session = Depends(get_session),
|
||||
@@ -48,7 +57,7 @@ def subscribe(
|
||||
return {"status": "subscribed"}
|
||||
|
||||
|
||||
@router.delete("/unsubscribe")
|
||||
@router.delete("/unsubscribe", response_model=SubscriptionStatusResponse)
|
||||
def unsubscribe(
|
||||
data: PushSubscriptionCreate,
|
||||
session: Session = Depends(get_session),
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from datetime import date
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.uploads import read_pdf_upload
|
||||
from app.db import get_session
|
||||
from app.models.models import Bank, PensionSnapshot, PensionSnapshotRead
|
||||
from app.services.pension_pdf import parse_pension_pdf
|
||||
@@ -115,8 +118,11 @@ async def upload_pension_pdfs(
|
||||
for file in files:
|
||||
filename = file.filename or "unknown.pdf"
|
||||
try:
|
||||
pdf_bytes = await file.read()
|
||||
fund_snapshots = parse_pension_pdf(pdf_bytes, filename)
|
||||
pdf_bytes = await read_pdf_upload(file)
|
||||
# pdftotext runs as a subprocess (up to 30s); keep the event loop free
|
||||
fund_snapshots = await asyncio.to_thread(
|
||||
parse_pension_pdf, pdf_bytes, filename
|
||||
)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
continue
|
||||
|
||||
@@ -31,7 +31,7 @@ def list_salarios(
|
||||
query = (
|
||||
select(Transaction)
|
||||
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||
.order_by(col(Transaction.date).desc())
|
||||
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlmodel import Session, col, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.timeutil import utcnow
|
||||
from app.models.models import (
|
||||
SavingsAccrual,
|
||||
SavingsAccrualCreate,
|
||||
@@ -44,7 +45,7 @@ def create_accrual(
|
||||
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
|
||||
)
|
||||
accrual = SavingsAccrual.model_validate(data)
|
||||
accrual.applied_at = datetime.utcnow()
|
||||
accrual.applied_at = utcnow()
|
||||
session.add(accrual)
|
||||
session.commit()
|
||||
session.refresh(accrual)
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.timeutil import utcnow
|
||||
from app.models.models import UserSettings, UserSettingsRead, UserSettingsUpdate
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
@@ -52,7 +53,7 @@ def update_settings(
|
||||
settings = UserSettings(key="default", data=body.data)
|
||||
else:
|
||||
settings.data = body.data
|
||||
settings.updated_at = datetime.utcnow()
|
||||
settings.updated_at = utcnow()
|
||||
session.add(settings)
|
||||
session.commit()
|
||||
session.refresh(settings)
|
||||
|
||||
36
backend/app/api/v1/endpoints/sync_status.py
Normal file
36
backend/app/api/v1/endpoints/sync_status.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.services.sync_status import compute_sync_status
|
||||
|
||||
router = APIRouter(prefix="/sync-status", tags=["sync-status"])
|
||||
|
||||
|
||||
class SyncSourceStatus(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
last_received: str | None
|
||||
age_days: float | None
|
||||
warn_after_days: float
|
||||
status: str # 'ok' | 'warning' | 'never'
|
||||
|
||||
|
||||
class SyncStatusResponse(BaseModel):
|
||||
sources: list[SyncSourceStatus]
|
||||
warnings: int
|
||||
|
||||
|
||||
@router.get("/", response_model=SyncStatusResponse)
|
||||
def sync_status(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
sources = compute_sync_status(session)
|
||||
return {
|
||||
"sources": sources,
|
||||
"warnings": sum(1 for s in sources if s["status"] in ("warning", "never")),
|
||||
}
|
||||
@@ -8,6 +8,7 @@ from sqlmodel import Session, select
|
||||
|
||||
from app.auth import get_current_user, hash_token
|
||||
from app.db import get_session
|
||||
from app.timeutil import utcnow
|
||||
from app.models.models import APIToken, APITokenCreate, APITokenRead
|
||||
|
||||
router = APIRouter(prefix="/tokens", tags=["tokens"])
|
||||
@@ -28,7 +29,7 @@ def create_token(
|
||||
plaintext = secrets.token_urlsafe(32)
|
||||
expires_at = None
|
||||
if data.expires_days:
|
||||
expires_at = datetime.utcnow() + timedelta(days=data.expires_days)
|
||||
expires_at = utcnow() + timedelta(days=data.expires_days)
|
||||
|
||||
api_token = APIToken(
|
||||
name=data.name,
|
||||
|
||||
@@ -2,7 +2,9 @@ from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response as FastAPIResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlmodel import Session, col, func, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
@@ -19,8 +21,19 @@ from app.models.models import (
|
||||
TransactionUpdate,
|
||||
)
|
||||
|
||||
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
||||
from app.services.budget_projection import (
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
get_cycle_range,
|
||||
get_previous_cycle,
|
||||
)
|
||||
from app.services.csv_export import build_transactions_csv
|
||||
from app.services.exchange_rate import get_converted_amount_expr
|
||||
from app.services.installments import (
|
||||
DEFAULT_NUM_INSTALLMENTS,
|
||||
create_plan,
|
||||
is_tasa_cero_merchant,
|
||||
teardown_plan_for_anchor,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
@@ -73,7 +86,6 @@ def list_transactions(
|
||||
prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
|
||||
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
|
||||
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
|
||||
from sqlalchemy import or_, and_
|
||||
query = query.where(
|
||||
or_(
|
||||
and_(
|
||||
@@ -93,10 +105,86 @@ def list_transactions(
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
Transaction.date < datetime.fromisoformat(end_date),
|
||||
)
|
||||
query = query.order_by(col(Transaction.date).desc()).offset(offset).limit(limit)
|
||||
query = query.order_by(col(Transaction.date).desc(), col(Transaction.id).desc()).offset(offset).limit(limit)
|
||||
return session.exec(query).all()
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
ids: list[int]
|
||||
action: str # 'delete' | 'set_category' | 'set_deferred'
|
||||
category_id: Optional[int] = None # for set_category (None clears)
|
||||
deferred: Optional[bool] = None # for set_deferred
|
||||
|
||||
|
||||
class BulkActionResponse(BaseModel):
|
||||
affected: int
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=BulkActionResponse)
|
||||
def bulk_action(
|
||||
req: BulkActionRequest,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Apply one action to many transactions atomically (review 4.4)."""
|
||||
if not req.ids or len(req.ids) > 500:
|
||||
raise HTTPException(status_code=400, detail="ids must contain 1-500 entries")
|
||||
if req.action not in ("delete", "set_category", "set_deferred"):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {req.action}")
|
||||
if req.action == "set_deferred" and req.deferred is None:
|
||||
raise HTTPException(status_code=400, detail="deferred is required for set_deferred")
|
||||
if req.action == "set_category" and req.category_id is not None:
|
||||
if session.get(Category, req.category_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
|
||||
txs = session.exec(
|
||||
select(Transaction).where(col(Transaction.id).in_(req.ids))
|
||||
).all()
|
||||
affected = 0
|
||||
for tx in txs:
|
||||
if req.action == "delete":
|
||||
teardown_plan_for_anchor(session, tx)
|
||||
session.delete(tx)
|
||||
elif req.action == "set_category":
|
||||
tx.category_id = req.category_id
|
||||
session.add(tx)
|
||||
elif req.action == "set_deferred":
|
||||
if tx.installment_plan_id:
|
||||
continue # cuotas of a plan cannot be deferred
|
||||
tx.deferred_to_next_cycle = bool(req.deferred)
|
||||
session.add(tx)
|
||||
affected += 1
|
||||
session.commit()
|
||||
return {"affected": affected}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_transactions_csv(
|
||||
source: Optional[TransactionSource] = None,
|
||||
transaction_type: Optional[TransactionType] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Download transactions as CSV (filters optional). Cookie-authenticated,
|
||||
so a plain <a href> download works from the SPA."""
|
||||
csv_text = build_transactions_csv(
|
||||
session,
|
||||
source=source,
|
||||
transaction_type=transaction_type,
|
||||
start_date=datetime.fromisoformat(start_date) if start_date else None,
|
||||
end_date=datetime.fromisoformat(end_date) if end_date else None,
|
||||
)
|
||||
return FastAPIResponse(
|
||||
content=csv_text,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="wealthysmart-transactions.csv"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cycles", response_model=list[BillingCycle])
|
||||
def list_billing_cycles(
|
||||
session: Session = Depends(get_session),
|
||||
@@ -132,7 +220,9 @@ def list_billing_cycles(
|
||||
# Count transactions in this cycle
|
||||
count_result = session.exec(
|
||||
select(func.count(), func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start, Transaction.date < end
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
)
|
||||
).first()
|
||||
count = count_result[0] if count_result else 0
|
||||
@@ -163,10 +253,16 @@ def recent_transactions(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
from app.timeutil import utcnow
|
||||
|
||||
query = (
|
||||
select(Transaction)
|
||||
.where(Transaction.source == TransactionSource.CREDIT_CARD)
|
||||
.order_by(col(Transaction.date).desc())
|
||||
.where(
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
# Tasa Cero generates future-dated cuotas; "recent" means billed.
|
||||
Transaction.date <= utcnow(),
|
||||
)
|
||||
.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return session.exec(query).all()
|
||||
@@ -195,6 +291,20 @@ def create_transaction(
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
|
||||
# Tasa Cero auto-detection: BAC prefixes financed purchases with "CC ".
|
||||
# Default 3 cuotas starting on the purchase date; editable afterwards via
|
||||
# /installment-plans.
|
||||
tasa_cero_note = ""
|
||||
if (
|
||||
tx.source == TransactionSource.CREDIT_CARD
|
||||
and tx.transaction_type == TransactionType.COMPRA
|
||||
and is_tasa_cero_merchant(tx.merchant)
|
||||
):
|
||||
create_plan(session, tx, DEFAULT_NUM_INSTALLMENTS, tx.date)
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
tasa_cero_note = f" — Tasa Cero {DEFAULT_NUM_INSTALLMENTS} cuotas"
|
||||
|
||||
# Send push notification
|
||||
symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"}
|
||||
symbol = symbols.get(tx.currency, tx.currency.value)
|
||||
@@ -205,7 +315,7 @@ def create_transaction(
|
||||
send_push_to_all(
|
||||
session,
|
||||
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
|
||||
body=f"{amount_str} — {tx.bank.value} {label}",
|
||||
body=f"{amount_str} — {tx.bank.value} {label}{tasa_cero_note}",
|
||||
url="/salarios" if is_income else "/budget",
|
||||
)
|
||||
|
||||
@@ -227,6 +337,11 @@ def update_transaction(
|
||||
if not tx:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if update_data.get("deferred_to_next_cycle") is not None and tx.installment_plan_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cuotas of an installment plan cannot be deferred",
|
||||
)
|
||||
for key, value in update_data.items():
|
||||
setattr(tx, key, value)
|
||||
session.add(tx)
|
||||
@@ -244,5 +359,6 @@ def delete_transaction(
|
||||
tx = session.get(Transaction, transaction_id)
|
||||
if not tx:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
teardown_plan_for_anchor(session, tx)
|
||||
session.delete(tx)
|
||||
session.commit()
|
||||
|
||||
@@ -6,14 +6,17 @@ from app.api.v1.endpoints import (
|
||||
auth,
|
||||
budget,
|
||||
categories,
|
||||
chat,
|
||||
exchange_rate,
|
||||
import_transactions,
|
||||
installments,
|
||||
municipal_receipts,
|
||||
notifications,
|
||||
pensions,
|
||||
salarios,
|
||||
savings_accrual,
|
||||
settings,
|
||||
sync_status,
|
||||
tokens,
|
||||
transactions,
|
||||
)
|
||||
@@ -24,6 +27,7 @@ api_router.include_router(accounts.router)
|
||||
api_router.include_router(categories.router)
|
||||
api_router.include_router(transactions.router)
|
||||
api_router.include_router(import_transactions.router)
|
||||
api_router.include_router(installments.router)
|
||||
api_router.include_router(exchange_rate.router)
|
||||
api_router.include_router(tokens.router)
|
||||
api_router.include_router(analytics.router)
|
||||
@@ -34,3 +38,5 @@ api_router.include_router(salarios.router)
|
||||
api_router.include_router(pensions.router)
|
||||
api_router.include_router(municipal_receipts.router)
|
||||
api_router.include_router(savings_accrual.router)
|
||||
api_router.include_router(sync_status.router)
|
||||
api_router.include_router(chat.router)
|
||||
|
||||
@@ -1,22 +1,79 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, status
|
||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_session
|
||||
from app.models.models import APIToken
|
||||
from app.timeutil import utcnow
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
|
||||
# ── Login hardening ──────────────────────────────────────────────────────────
|
||||
# Single-process app: an in-memory sliding window per client IP is sufficient.
|
||||
|
||||
LOGIN_RATE_LIMIT = 5
|
||||
LOGIN_RATE_WINDOW_SECONDS = 60
|
||||
|
||||
_login_attempts: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
# nginx-proxy sets X-Forwarded-For; the Hono proxy forwards it through.
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def check_login_rate_limit(request: Request) -> None:
|
||||
"""Raise 429 if this client has attempted login too often. Call BEFORE
|
||||
verifying credentials so attempts are counted regardless of outcome."""
|
||||
now = time.monotonic()
|
||||
attempts = _login_attempts[_client_ip(request)]
|
||||
while attempts and now - attempts[0] > LOGIN_RATE_WINDOW_SECONDS:
|
||||
attempts.popleft()
|
||||
if len(attempts) >= LOGIN_RATE_LIMIT:
|
||||
retry_after = int(LOGIN_RATE_WINDOW_SECONDS - (now - attempts[0])) + 1
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many login attempts, try again later",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
attempts.append(now)
|
||||
# Bound memory against spoofed X-Forwarded-For values.
|
||||
if len(_login_attempts) > 1000:
|
||||
for key in [k for k, v in _login_attempts.items() if not v]:
|
||||
del _login_attempts[key]
|
||||
|
||||
|
||||
def verify_admin_credentials(username: str, password: str) -> None:
|
||||
"""Constant-time credential check shared by both login endpoints."""
|
||||
username_ok = hmac.compare_digest(
|
||||
username.encode(), settings.ADMIN_USERNAME.encode()
|
||||
)
|
||||
password_ok = hmac.compare_digest(
|
||||
password.encode(), settings.ADMIN_PASSWORD.encode()
|
||||
)
|
||||
if not (username_ok and password_ok):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
@@ -36,9 +93,6 @@ def _validate_token(token: str) -> str:
|
||||
pass
|
||||
|
||||
# Fallback: check API token
|
||||
from app.db import get_session
|
||||
from app.models.models import APIToken
|
||||
|
||||
token_hash = hash_token(token)
|
||||
with next(get_session()) as session:
|
||||
api_token = session.exec(
|
||||
@@ -48,7 +102,7 @@ def _validate_token(token: str) -> str:
|
||||
)
|
||||
).first()
|
||||
if api_token:
|
||||
if api_token.expires_at and api_token.expires_at < datetime.utcnow():
|
||||
if api_token.expires_at and api_token.expires_at < utcnow():
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
return f"api:{api_token.name}"
|
||||
|
||||
|
||||
@@ -1,22 +1,45 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart"
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin"
|
||||
# No defaults: the app must refuse to boot without real values (see _reject_weak_secrets).
|
||||
SECRET_KEY: str
|
||||
ADMIN_USERNAME: str
|
||||
ADMIN_PASSWORD: str
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7 days
|
||||
COOKIE_SECURE: bool = True # dev compose sets "false"; prod serves over TLS
|
||||
CORS_ORIGINS: str = (
|
||||
"https://wealth.cescalante.dev,"
|
||||
"http://localhost:3000,http://localhost:3001,http://localhost:5175"
|
||||
)
|
||||
BCCR_API_EMAIL: str = ""
|
||||
BCCR_API_TOKEN: str = ""
|
||||
VAPID_PRIVATE_KEY: str = ""
|
||||
VAPID_PUBLIC_KEY: str = ""
|
||||
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||
OPENAI_API_KEY: str = ""
|
||||
AGENT_MODEL: str = "gpt-5.4-mini"
|
||||
AGENT_MODEL: str = "gpt-5.6-luna"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reject_weak_secrets(self) -> "Settings":
|
||||
if len(self.SECRET_KEY) < 32 or self.SECRET_KEY == "change-me-in-production":
|
||||
raise ValueError(
|
||||
"SECRET_KEY must be a random value of at least 32 characters "
|
||||
"(generate one with: openssl rand -hex 32)"
|
||||
)
|
||||
if not self.ADMIN_USERNAME:
|
||||
raise ValueError("ADMIN_USERNAME must be set")
|
||||
if not self.ADMIN_PASSWORD or self.ADMIN_PASSWORD == "admin":
|
||||
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
|
||||
return self
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,79 +1,48 @@
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlmodel import Session, create_engine
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_engine(settings.DATABASE_URL)
|
||||
|
||||
|
||||
def init_db():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
_MIGRATION_LOCK_KEY = 727274 # app-wide advisory lock id, arbitrary constant
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Run idempotent schema migrations for columns added after initial create."""
|
||||
def _alembic_config() -> Config:
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
cfg = Config(str(backend_dir / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(backend_dir / "alembic"))
|
||||
return cfg
|
||||
|
||||
|
||||
def run_alembic_upgrade() -> None:
|
||||
"""Bring the schema to head at startup.
|
||||
|
||||
Databases that predate Alembic (prod, existing dev volumes) already match
|
||||
the baseline revision — the baseline was autogenerated from, and
|
||||
schema-diffed against, exactly that schema — so they are adopted by
|
||||
stamping instead of re-running DDL. Serialized with a Postgres advisory
|
||||
lock because prod uvicorn runs 2 workers, each executing the lifespan.
|
||||
"""
|
||||
cfg = _alembic_config()
|
||||
is_postgres = engine.dialect.name == "postgresql"
|
||||
with engine.connect() as conn:
|
||||
if is_postgres:
|
||||
conn.execute(text(f"SELECT pg_advisory_lock({_MIGRATION_LOCK_KEY})"))
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
|
||||
insp = inspect(conn)
|
||||
if not insp.has_table("alembic_version") and insp.has_table("transaction"):
|
||||
command.stamp(cfg, "head")
|
||||
command.upgrade(cfg, "head")
|
||||
finally:
|
||||
if is_postgres:
|
||||
conn.execute(
|
||||
text(f"SELECT pg_advisory_unlock({_MIGRATION_LOCK_KEY})")
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS savingsaccrual (
|
||||
id SERIAL PRIMARY KEY,
|
||||
year INTEGER NOT NULL,
|
||||
month INTEGER NOT NULL,
|
||||
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
|
||||
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
|
||||
trigger_transaction_id INTEGER,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
notes TEXT,
|
||||
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
|
||||
VALUES
|
||||
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
|
||||
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
|
||||
ON CONFLICT (year, month) DO NOTHING
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def get_session():
|
||||
|
||||
31
backend/app/logging.py
Normal file
31
backend/app/logging.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Structured, redacted application logging for container stdout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class _JSONEncoder(json.JSONEncoder):
|
||||
def default(self, value: Any) -> Any:
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
return super().default(value)
|
||||
|
||||
|
||||
def configure_structured_logging() -> None:
|
||||
"""Reserved lifecycle hook for the structured stdout audit channel."""
|
||||
|
||||
|
||||
def log_event(event: str, **fields: Any) -> None:
|
||||
"""Write a single JSON line; callers must not pass credentials or bodies."""
|
||||
# stdout is Docker's durable capture point. `flush=True` also makes this
|
||||
# visible immediately in `docker compose logs` during an active SSE run.
|
||||
print(
|
||||
json.dumps({"event": event, **fields}, cls=_JSONEncoder, separators=(",", ":")),
|
||||
flush=True,
|
||||
)
|
||||
@@ -1,26 +1,78 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from time import perf_counter
|
||||
from http.cookies import SimpleCookie
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
from fastapi import FastAPI, HTTPException, Request, Response, status
|
||||
from fastapi import FastAPI, Request, Response
|
||||
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.chat_audit import (
|
||||
create_chat_run,
|
||||
finish_pending_chat_run,
|
||||
mark_chat_run_failed,
|
||||
purge_expired_chat_runs_periodically,
|
||||
reset_current_chat_run,
|
||||
set_current_chat_run,
|
||||
)
|
||||
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||
from app.agent.tools import reset_session, set_session
|
||||
from app.api.v1.router import api_router
|
||||
from app.auth import ALGORITHM, create_access_token
|
||||
from app.auth import (
|
||||
ALGORITHM,
|
||||
check_login_rate_limit,
|
||||
create_access_token,
|
||||
verify_admin_credentials,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.db import get_session, init_db, run_migrations
|
||||
from app.db import get_session, run_alembic_upgrade
|
||||
from app.seed import seed_db
|
||||
from app.timeutil import reset_client_timezone, set_client_timezone
|
||||
from app.services.exchange_rate import refresh_rates_periodically
|
||||
from app.logging import configure_structured_logging, log_event
|
||||
|
||||
|
||||
AGENT_PATH = "/api/v1/agent/agui"
|
||||
MAX_USER_AGENT_LENGTH = 512
|
||||
|
||||
|
||||
def _request_client_details(request: Request) -> tuple[str, str | None, str | None]:
|
||||
"""Return the browser client details propagated through the frontend BFF.
|
||||
|
||||
Production nginx-proxy supplies X-Real-IP and the frontend forwards it to
|
||||
FastAPI. For direct local development, fall back to X-Forwarded-For and
|
||||
then the socket peer. The BFF is the only production path to the backend,
|
||||
which is not publicly exposed.
|
||||
"""
|
||||
client_ip = request.headers.get("x-real-ip", "").strip()
|
||||
if not client_ip:
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
client_ip = forwarded.split(",", 1)[0].strip()
|
||||
if not client_ip:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None
|
||||
if not user_agent:
|
||||
return client_ip, None, None
|
||||
ua = user_agent.lower()
|
||||
if "edg/" in ua or "edgios/" in ua:
|
||||
browser = "Edge"
|
||||
elif "opr/" in ua or "opera" in ua:
|
||||
browser = "Opera"
|
||||
elif "firefox/" in ua or "fxios/" in ua:
|
||||
browser = "Firefox"
|
||||
elif "chrome/" in ua or "crios/" in ua:
|
||||
browser = "Chrome"
|
||||
elif "safari/" in ua:
|
||||
browser = "Safari"
|
||||
else:
|
||||
browser = "Other"
|
||||
return client_ip, user_agent, browser
|
||||
|
||||
|
||||
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||
@@ -58,33 +110,134 @@ def _pair_orphan_tool_calls(messages: list) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _drop_stale_client_history(session, thread_id, messages: list) -> list:
|
||||
"""Conversation history is server-owned (the AG-UI snapshot store).
|
||||
|
||||
A request that carries assistant/tool history for a thread with NO
|
||||
stored snapshot comes from a stale client — typically a second tab that
|
||||
was open when 'Nueva conversación' cleared the thread. Trusting it
|
||||
would re-persist the cleared conversation ('chunks' resurrecting after
|
||||
every clear). Keep only the last user turn; MAF starts the thread
|
||||
fresh from there.
|
||||
"""
|
||||
if not thread_id or not messages:
|
||||
return messages
|
||||
has_history = any(m.get("role") in ("assistant", "tool") for m in messages)
|
||||
if not has_history:
|
||||
return messages
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models.models import ChatThreadSnapshot
|
||||
|
||||
row = session.exec(
|
||||
select(ChatThreadSnapshot.id).where(
|
||||
ChatThreadSnapshot.thread_id == str(thread_id)
|
||||
)
|
||||
).first()
|
||||
if row is not None:
|
||||
return messages # thread exists; history is legitimate
|
||||
last_user = None
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
last_user = i
|
||||
break
|
||||
return messages[last_user:] if last_user is not None else []
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
run_migrations()
|
||||
configure_structured_logging()
|
||||
run_alembic_upgrade()
|
||||
seed_db()
|
||||
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||
chat_audit_cleanup_task = asyncio.create_task(purge_expired_chat_runs_periodically())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
rate_refresh_task.cancel()
|
||||
chat_audit_cleanup_task.cancel()
|
||||
try:
|
||||
await rate_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await chat_audit_cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["content-type", "authorization"],
|
||||
)
|
||||
|
||||
|
||||
async def log_api_request(request: Request, call_next):
|
||||
"""Emit redacted, one-line diagnostics for API requests only.
|
||||
|
||||
This deliberately records no request/response bodies, headers, tokens,
|
||||
passwords, or financial payloads. Full assistant content is retained only
|
||||
in the bounded chat-run audit table.
|
||||
"""
|
||||
request_id = uuid.uuid4().hex
|
||||
request.state.request_id = request_id
|
||||
client_ip, user_agent, browser = _request_client_details(request)
|
||||
request.state.client_ip = client_ip
|
||||
request.state.user_agent = user_agent
|
||||
request.state.browser = browser
|
||||
started = perf_counter()
|
||||
status_code = 500
|
||||
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
|
||||
|
||||
def emit(error_type: str | None = None) -> None:
|
||||
if not is_logged_api:
|
||||
return
|
||||
route = request.scope.get("route")
|
||||
log_event(
|
||||
"api_request",
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=getattr(route, "path", request.url.path),
|
||||
status_code=status_code,
|
||||
duration_ms=round((perf_counter() - started) * 1000, 1),
|
||||
error_type=error_type,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
browser=browser,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
original_iterator = getattr(response, "body_iterator", None)
|
||||
if original_iterator is None:
|
||||
emit()
|
||||
return response
|
||||
|
||||
async def logged_stream():
|
||||
error_type: str | None = None
|
||||
try:
|
||||
async for chunk in original_iterator:
|
||||
yield chunk
|
||||
except Exception as exc:
|
||||
error_type = type(exc).__name__
|
||||
raise
|
||||
finally:
|
||||
emit(error_type)
|
||||
|
||||
response.body_iterator = logged_stream()
|
||||
return response
|
||||
except Exception as exc:
|
||||
emit(type(exc).__name__)
|
||||
raise
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def agent_auth_and_session(request: Request, call_next):
|
||||
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||
@@ -101,10 +254,10 @@ async def agent_auth_and_session(request: Request, call_next):
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
else:
|
||||
cookie_header = request.headers.get("cookie", "")
|
||||
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
|
||||
if m:
|
||||
token = m.group(1)
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(request.headers.get("cookie", ""))
|
||||
if "ws_token" in cookies:
|
||||
token = cookies["ws_token"].value
|
||||
|
||||
if not token:
|
||||
return Response(status_code=401, content="Missing auth")
|
||||
@@ -115,25 +268,94 @@ async def agent_auth_and_session(request: Request, call_next):
|
||||
except JWTError:
|
||||
return Response(status_code=401, content="Invalid token")
|
||||
|
||||
session_gen = get_session()
|
||||
session = next(session_gen)
|
||||
token_var = set_session(session)
|
||||
|
||||
chat_run_id: int | None = None
|
||||
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
if isinstance(body.get("messages"), list):
|
||||
body["messages"] = _drop_stale_client_history(
|
||||
session,
|
||||
body.get("threadId") or body.get("thread_id"),
|
||||
body["messages"],
|
||||
)
|
||||
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||
raw = json.dumps(body).encode()
|
||||
except Exception:
|
||||
pass
|
||||
log_event(
|
||||
"agent_request",
|
||||
request_id=request.state.request_id,
|
||||
agui_run_id=body.get("runId"),
|
||||
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||
message_count=len(body.get("messages") or []),
|
||||
has_user_message=any(
|
||||
message.get("role") == "user" for message in body.get("messages") or []
|
||||
),
|
||||
client_ip=request.state.client_ip,
|
||||
user_agent=request.state.user_agent,
|
||||
browser=request.state.browser,
|
||||
)
|
||||
# The BFF removes its `method: agent/run` envelope before
|
||||
# forwarding to MAF. A non-empty user message is the reliable
|
||||
# backend-side distinction between an agent run and hydration.
|
||||
if any(message.get("role") == "user" for message in body.get("messages") or []):
|
||||
chat_run_id = create_chat_run(
|
||||
request_id=request.state.request_id,
|
||||
agui_run_id=body.get("runId"),
|
||||
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||
messages=body.get("messages") or [],
|
||||
client_ip=request.state.client_ip,
|
||||
user_agent=request.state.user_agent,
|
||||
browser=request.state.browser,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Parsing/repair must not prevent the agent endpoint from serving;
|
||||
# the access log will still capture a resulting error response.
|
||||
log_event(
|
||||
"chat_audit_start_failed",
|
||||
request_id=request.state.request_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
body = None
|
||||
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||
request._body = raw # type: ignore[attr-defined]
|
||||
|
||||
session_gen = get_session()
|
||||
session = next(session_gen)
|
||||
token_var = set_session(session)
|
||||
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
|
||||
# "today" wherever the user is (falls back to Costa Rica).
|
||||
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
|
||||
audit_token = set_current_chat_run(chat_run_id) if chat_run_id is not None else None
|
||||
try:
|
||||
return await call_next(request)
|
||||
response = await call_next(request)
|
||||
if chat_run_id is None:
|
||||
return response
|
||||
|
||||
original_iterator = response.body_iterator
|
||||
|
||||
async def audit_stream():
|
||||
try:
|
||||
async for chunk in original_iterator:
|
||||
yield chunk
|
||||
except Exception as exc:
|
||||
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||
raise
|
||||
else:
|
||||
# MAF normally finalizes via PostgresSnapshotStore.save(). If
|
||||
# it emitted no snapshot, do not leave a permanently-open row.
|
||||
finish_pending_chat_run(chat_run_id)
|
||||
|
||||
response.body_iterator = audit_stream()
|
||||
return response
|
||||
except Exception as exc:
|
||||
if chat_run_id is not None:
|
||||
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||
raise
|
||||
finally:
|
||||
if audit_token is not None:
|
||||
reset_current_chat_run(audit_token)
|
||||
reset_client_timezone(tz_token)
|
||||
reset_session(token_var)
|
||||
try:
|
||||
next(session_gen)
|
||||
@@ -141,11 +363,26 @@ async def agent_auth_and_session(request: Request, call_next):
|
||||
pass
|
||||
|
||||
|
||||
# FastAPI wraps function middlewares in reverse registration order. Register
|
||||
# this after the AG-UI auth middleware so it observes every API response,
|
||||
# including auth failures that return before calling the inner application.
|
||||
app.middleware("http")(log_api_request)
|
||||
|
||||
|
||||
# Register app routes
|
||||
app.include_router(api_router)
|
||||
|
||||
# Mount the AG-UI agent endpoint.
|
||||
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||
# Mount the AG-UI agent endpoint. The snapshot store persists the chat
|
||||
# thread across page reloads; scope is a constant because this is a
|
||||
# single-user app and auth is already enforced by agent_auth_and_session
|
||||
# (the scope resolver only sees the AGUIRequest, which carries no identity).
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app,
|
||||
build_agent(),
|
||||
AGENT_PATH,
|
||||
snapshot_store=PostgresSnapshotStore(),
|
||||
snapshot_scope_resolver=lambda _request: "default",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -161,12 +398,9 @@ class LoginRequest(BaseModel):
|
||||
|
||||
|
||||
@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")
|
||||
def cookie_login(body: LoginRequest, request: Request, response: Response):
|
||||
check_login_rate_limit(request)
|
||||
verify_admin_credentials(body.username, body.password)
|
||||
token = create_access_token(body.username)
|
||||
response.set_cookie(
|
||||
key="ws_token",
|
||||
@@ -174,7 +408,7 @@ def cookie_login(body: LoginRequest, response: Response):
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
|
||||
secure=False, # set True behind TLS in production via nginx
|
||||
secure=settings.COOKIE_SECURE,
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import enum
|
||||
from datetime import date, datetime
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from sqlalchemy import JSON, Column, UniqueConstraint
|
||||
from pydantic import PlainSerializer
|
||||
from sqlalchemy import JSON, Column, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlmodel import Field, Relationship, SQLModel
|
||||
|
||||
from app.timeutil import utcnow
|
||||
|
||||
# Live DBs store usersettings.data as jsonb; keep plain JSON elsewhere and on
|
||||
# SQLite (tests).
|
||||
JSON_OR_JSONB = JSON().with_variant(JSONB(), "postgresql")
|
||||
|
||||
# Monetary values: exact NUMERIC storage, but serialized to JSON as plain
|
||||
# numbers so the API contract (frontend expects `number`) is unchanged.
|
||||
# Pinned by tests/test_models_serialization.py.
|
||||
Money = Annotated[
|
||||
Decimal, PlainSerializer(float, return_type=float, when_used="json")
|
||||
]
|
||||
|
||||
|
||||
class RecurringItemType(str, enum.Enum):
|
||||
INCOME = "INCOME"
|
||||
@@ -99,14 +115,14 @@ class AccountBase(SQLModel):
|
||||
bank: Bank
|
||||
currency: Currency
|
||||
label: str
|
||||
balance: float = 0.0
|
||||
balance: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
account_type: AccountType = AccountType.BANK
|
||||
next_payment: Optional[float] = None
|
||||
next_payment: Optional[Money] = Field(default=None, max_digits=15, decimal_places=2)
|
||||
|
||||
|
||||
class Account(AccountBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
@@ -119,16 +135,16 @@ class AccountRead(AccountBase):
|
||||
|
||||
|
||||
class AccountUpdate(SQLModel):
|
||||
balance: Optional[float] = None
|
||||
balance: Optional[Money] = None
|
||||
label: Optional[str] = None
|
||||
next_payment: Optional[float] = None
|
||||
next_payment: Optional[Money] = None
|
||||
|
||||
|
||||
# --- Transaction ---
|
||||
|
||||
|
||||
class TransactionBase(SQLModel):
|
||||
amount: float
|
||||
amount: Money = Field(max_digits=15, decimal_places=2)
|
||||
currency: Currency = Currency.CRC
|
||||
merchant: str
|
||||
city: Optional[str] = None
|
||||
@@ -141,14 +157,29 @@ class TransactionBase(SQLModel):
|
||||
source: TransactionSource = TransactionSource.CREDIT_CARD
|
||||
bank: Bank = Bank.BAC
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||
deferred_to_next_cycle: bool = Field(default=False)
|
||||
category_id: Optional[int] = Field(
|
||||
default=None, foreign_key="category.id", ondelete="SET NULL"
|
||||
)
|
||||
deferred_to_next_cycle: bool = Field(
|
||||
default=False, sa_column_kwargs={"server_default": "false"}
|
||||
)
|
||||
|
||||
|
||||
class Transaction(TransactionBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
category: Optional[Category] = Relationship(back_populates="transactions")
|
||||
# Tasa Cero: set on generated cuota rows. Plain int (no DB FK) to avoid a
|
||||
# circular transaction<->installmentplan dependency; cascades are handled
|
||||
# explicitly in app.services.installments (SQLite tests don't enforce FKs
|
||||
# anyway). Table-class only so TransactionCreate/n8n payloads can't set it.
|
||||
installment_plan_id: Optional[int] = Field(default=None, index=True)
|
||||
# Tasa Cero: True on the original purchase once converted to a plan; the
|
||||
# anchor stays visible in listings but is excluded from budget/analytics
|
||||
# aggregates (its generated cuotas are counted instead).
|
||||
is_installment_anchor: bool = Field(
|
||||
default=False, sa_column_kwargs={"server_default": "false"}
|
||||
)
|
||||
|
||||
|
||||
class TransactionCreate(TransactionBase):
|
||||
@@ -159,10 +190,12 @@ class TransactionRead(TransactionBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
category: Optional[CategoryRead] = None
|
||||
installment_plan_id: Optional[int] = None
|
||||
is_installment_anchor: bool = False
|
||||
|
||||
|
||||
class TransactionUpdate(SQLModel):
|
||||
amount: Optional[float] = None
|
||||
amount: Optional[Money] = None
|
||||
currency: Optional[Currency] = None
|
||||
merchant: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
@@ -174,20 +207,53 @@ class TransactionUpdate(SQLModel):
|
||||
deferred_to_next_cycle: Optional[bool] = None
|
||||
|
||||
|
||||
# --- Installment Plan (Tasa Cero) ---
|
||||
|
||||
|
||||
class InstallmentPlanBase(SQLModel):
|
||||
num_installments: int
|
||||
first_installment_date: datetime
|
||||
total_amount: Money = Field(max_digits=15, decimal_places=2)
|
||||
# All cuotas but the last; the last absorbs the rounding remainder.
|
||||
installment_amount: Money = Field(max_digits=15, decimal_places=2)
|
||||
currency: Currency = Currency.CRC
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class InstallmentPlan(InstallmentPlanBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
anchor_transaction_id: int = Field(
|
||||
foreign_key="transaction.id", ondelete="CASCADE", unique=True, index=True
|
||||
)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class InstallmentPlanCreate(SQLModel):
|
||||
transaction_id: int
|
||||
num_installments: int = 3
|
||||
first_installment_date: Optional[datetime] = None # default: anchor.date
|
||||
|
||||
|
||||
class InstallmentPlanUpdate(SQLModel):
|
||||
num_installments: Optional[int] = None
|
||||
first_installment_date: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
# --- Exchange Rate ---
|
||||
|
||||
|
||||
class ExchangeRate(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
date: datetime
|
||||
buy_rate: float
|
||||
sell_rate: float
|
||||
fetched_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
buy_rate: Decimal = Field(max_digits=15, decimal_places=6)
|
||||
sell_rate: Decimal = Field(max_digits=15, decimal_places=6)
|
||||
fetched_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class ExchangeRateRead(SQLModel):
|
||||
buy_rate: float
|
||||
sell_rate: float
|
||||
buy_rate: Money
|
||||
sell_rate: Money
|
||||
date: datetime
|
||||
fetched_at: datetime
|
||||
|
||||
@@ -199,7 +265,7 @@ class APIToken(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
token_hash: str = Field(index=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
expires_at: Optional[datetime] = None
|
||||
is_active: bool = True
|
||||
|
||||
@@ -225,9 +291,9 @@ class UserSettings(SQLModel, table=True):
|
||||
key: str = Field(index=True, unique=True, default="default")
|
||||
data: dict = Field(
|
||||
default_factory=dict,
|
||||
sa_column=Column(JSON, nullable=False, server_default="{}"),
|
||||
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="{}"),
|
||||
)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class UserSettingsRead(SQLModel):
|
||||
@@ -245,7 +311,7 @@ class UserSettingsUpdate(SQLModel):
|
||||
|
||||
class RecurringItemBase(SQLModel):
|
||||
name: str
|
||||
amount: float
|
||||
amount: Money = Field(max_digits=15, decimal_places=2)
|
||||
currency: Currency = Currency.CRC
|
||||
item_type: RecurringItemType
|
||||
frequency: RecurringFrequency = RecurringFrequency.MONTHLY
|
||||
@@ -255,14 +321,16 @@ class RecurringItemBase(SQLModel):
|
||||
default=None,
|
||||
sa_column=Column(JSON, nullable=True),
|
||||
)
|
||||
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||
category_id: Optional[int] = Field(
|
||||
default=None, foreign_key="category.id", ondelete="SET NULL"
|
||||
)
|
||||
is_active: bool = True
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class RecurringItem(RecurringItemBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
category: Optional[Category] = Relationship()
|
||||
|
||||
|
||||
@@ -278,7 +346,7 @@ class RecurringItemRead(RecurringItemBase):
|
||||
|
||||
class RecurringItemUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
amount: Optional[float] = None
|
||||
amount: Optional[Money] = None
|
||||
currency: Optional[Currency] = None
|
||||
item_type: Optional[RecurringItemType] = None
|
||||
frequency: Optional[RecurringFrequency] = None
|
||||
@@ -298,7 +366,7 @@ class PushSubscription(SQLModel, table=True):
|
||||
endpoint: str = Field(unique=True)
|
||||
p256dh: str
|
||||
auth: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class PushSubscriptionCreate(SQLModel):
|
||||
@@ -314,15 +382,15 @@ class PensionSnapshotBase(SQLModel):
|
||||
contract_number: str
|
||||
period_start: date
|
||||
period_end: date
|
||||
saldo_anterior: float
|
||||
aportes: float
|
||||
rendimientos: float
|
||||
retiros: float
|
||||
traslados: float
|
||||
comision: float
|
||||
correccion: float
|
||||
bonificacion: float
|
||||
saldo_final: float
|
||||
saldo_anterior: Money = Field(max_digits=15, decimal_places=2)
|
||||
aportes: Money = Field(max_digits=15, decimal_places=2)
|
||||
rendimientos: Money = Field(max_digits=15, decimal_places=2)
|
||||
retiros: Money = Field(max_digits=15, decimal_places=2)
|
||||
traslados: Money = Field(max_digits=15, decimal_places=2)
|
||||
comision: Money = Field(max_digits=15, decimal_places=2)
|
||||
correccion: Money = Field(max_digits=15, decimal_places=2)
|
||||
bonificacion: Money = Field(max_digits=15, decimal_places=2)
|
||||
saldo_final: Money = Field(max_digits=15, decimal_places=2)
|
||||
source_filename: str
|
||||
|
||||
|
||||
@@ -331,7 +399,7 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
|
||||
UniqueConstraint("fund", "period_start", "period_end"),
|
||||
)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class PensionSnapshotRead(PensionSnapshotBase):
|
||||
@@ -347,20 +415,20 @@ class BalanceOverride(SQLModel, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
year: int
|
||||
month: int
|
||||
override_balance: float
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
override_balance: Money = Field(max_digits=15, decimal_places=2)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class BalanceOverrideCreate(SQLModel):
|
||||
override_balance: float
|
||||
override_balance: Money
|
||||
|
||||
|
||||
class BalanceOverrideRead(SQLModel):
|
||||
id: int
|
||||
year: int
|
||||
month: int
|
||||
override_balance: float
|
||||
override_balance: Money
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -370,8 +438,8 @@ class BalanceOverrideRead(SQLModel):
|
||||
class SavingsAccrualBase(SQLModel):
|
||||
year: int
|
||||
month: int
|
||||
memp_amount: float = 200000.0
|
||||
mpat_amount: float = 200000.0
|
||||
memp_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
|
||||
mpat_amount: Money = Field(default=Decimal("200000"), max_digits=15, decimal_places=2)
|
||||
trigger_transaction_id: Optional[int] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
@@ -379,7 +447,7 @@ class SavingsAccrualBase(SQLModel):
|
||||
class SavingsAccrual(SavingsAccrualBase, table=True):
|
||||
__table_args__ = (UniqueConstraint("year", "month"),)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
applied_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
applied_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
class SavingsAccrualCreate(SavingsAccrualBase):
|
||||
@@ -392,8 +460,8 @@ class SavingsAccrualRead(SavingsAccrualBase):
|
||||
|
||||
|
||||
class SavingsAccrualUpdate(SQLModel):
|
||||
memp_amount: Optional[float] = None
|
||||
mpat_amount: Optional[float] = None
|
||||
memp_amount: Optional[Money] = None
|
||||
mpat_amount: Optional[Money] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
@@ -409,10 +477,10 @@ class MunicipalReceiptBase(SQLModel):
|
||||
holder_name: str
|
||||
holder_cedula: str
|
||||
holder_address: str
|
||||
subtotal: float
|
||||
interests: float
|
||||
iva: float
|
||||
total: float
|
||||
subtotal: Money = Field(max_digits=15, decimal_places=2)
|
||||
interests: Money = Field(max_digits=15, decimal_places=2)
|
||||
iva: Money = Field(max_digits=15, decimal_places=2)
|
||||
total: Money = Field(max_digits=15, decimal_places=2)
|
||||
raw_charges: list[dict] = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON, nullable=False, server_default="[]"),
|
||||
@@ -423,7 +491,7 @@ class MunicipalReceiptBase(SQLModel):
|
||||
class MunicipalReceipt(MunicipalReceiptBase, table=True):
|
||||
__table_args__ = (UniqueConstraint("account", "period"),)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
water_readings: list["WaterMeterReading"] = Relationship(
|
||||
back_populates="receipt",
|
||||
)
|
||||
@@ -444,21 +512,23 @@ class MunicipalReceiptRead(MunicipalReceiptBase):
|
||||
class WaterMeterReadingBase(SQLModel):
|
||||
meter_id: str
|
||||
period: str # "YYYY-MM"
|
||||
reading_previous: float = 0
|
||||
reading_current: float = 0
|
||||
consumption_m3: float
|
||||
agua_potable: float = 0
|
||||
serv_ambientales: float = 0
|
||||
alcant_sanitario: float = 0
|
||||
iva: float = 0
|
||||
reading_previous: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
reading_current: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
consumption_m3: Money = Field(max_digits=15, decimal_places=2)
|
||||
agua_potable: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
serv_ambientales: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
alcant_sanitario: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
iva: Money = Field(default=Decimal("0"), max_digits=15, decimal_places=2)
|
||||
is_historical: bool = False
|
||||
receipt_id: Optional[int] = Field(default=None, foreign_key="municipalreceipt.id")
|
||||
receipt_id: Optional[int] = Field(
|
||||
default=None, foreign_key="municipalreceipt.id", ondelete="CASCADE"
|
||||
)
|
||||
|
||||
|
||||
class WaterMeterReading(WaterMeterReadingBase, table=True):
|
||||
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
created_at: datetime = Field(default_factory=utcnow)
|
||||
receipt: Optional[MunicipalReceipt] = Relationship(
|
||||
back_populates="water_readings",
|
||||
)
|
||||
@@ -467,3 +537,67 @@ class WaterMeterReading(WaterMeterReadingBase, table=True):
|
||||
class WaterMeterReadingRead(WaterMeterReadingBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# --- Assistant Chat Thread ---
|
||||
|
||||
|
||||
class ChatThreadSnapshot(SQLModel, table=True):
|
||||
"""Latest AG-UI thread snapshot per (scope, thread_id).
|
||||
|
||||
Written by app/agent/snapshot_store.py: MAF overwrites the full
|
||||
cumulative message list at each run end — one row per thread, not an
|
||||
append log.
|
||||
"""
|
||||
|
||||
__table_args__ = (UniqueConstraint("scope", "thread_id"),)
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
scope: str = Field(index=True)
|
||||
thread_id: str
|
||||
messages: list = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
|
||||
)
|
||||
state: Optional[dict] = Field(
|
||||
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
|
||||
)
|
||||
interrupt: Optional[list] = Field(
|
||||
default=None, sa_column=Column(JSON_OR_JSONB, nullable=True)
|
||||
)
|
||||
updated_at: datetime = Field(default_factory=utcnow)
|
||||
|
||||
|
||||
# --- Assistant Chat Run Audit ---
|
||||
|
||||
|
||||
class ChatRunLog(SQLModel, table=True):
|
||||
"""Append-only diagnostic record for an AG-UI agent/run request.
|
||||
|
||||
Thread snapshots are mutable conversation state. This table instead keeps
|
||||
the request, model, tool activity, and final emitted answer for a bounded
|
||||
retention period so individual assistant runs can be investigated later.
|
||||
"""
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
request_id: str = Field(index=True, unique=True)
|
||||
agui_run_id: Optional[str] = Field(default=None, index=True)
|
||||
requested_thread_id: Optional[str] = Field(default=None, index=True)
|
||||
response_thread_id: Optional[str] = Field(default=None, index=True)
|
||||
model: str
|
||||
client_ip: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
user_agent: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
browser: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
user_prompt: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
tool_calls: list = Field(
|
||||
default_factory=list,
|
||||
sa_column=Column(JSON_OR_JSONB, nullable=False, server_default="[]"),
|
||||
)
|
||||
assistant_response: Optional[str] = Field(
|
||||
default=None, sa_column=Column(Text, nullable=True)
|
||||
)
|
||||
status: str = Field(default="started", index=True)
|
||||
error: Optional[str] = Field(default=None, sa_column=Column(Text, nullable=True))
|
||||
started_at: datetime = Field(default_factory=utcnow)
|
||||
completed_at: Optional[datetime] = Field(default=None)
|
||||
expires_at: datetime = Field(index=True)
|
||||
|
||||
@@ -23,6 +23,11 @@ FRESH_START_MONTH = 3
|
||||
# Income-like transaction types that should never be counted as expenses
|
||||
INCOME_TYPES = (TransactionType.DEPOSITO, TransactionType.SALARY)
|
||||
|
||||
# Tasa Cero: the original purchase ("anchor") is excluded from all budget
|
||||
# aggregates — its generated cuota transactions are counted instead.
|
||||
# See app/services/installments.py.
|
||||
NOT_INSTALLMENT_ANCHOR = Transaction.is_installment_anchor == False # noqa: E712
|
||||
|
||||
|
||||
def get_effective_amount(item: RecurringItem, month: int, year: int) -> float | None:
|
||||
"""Return the effective amount for a recurring item in a given month, or None if inactive."""
|
||||
@@ -31,7 +36,7 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
|
||||
if freq == RecurringFrequency.MONTHLY:
|
||||
if item.override_amounts and str(month) in item.override_amounts:
|
||||
return float(item.override_amounts[str(month)])
|
||||
return item.amount
|
||||
return float(item.amount)
|
||||
|
||||
if freq == RecurringFrequency.WEEKLY:
|
||||
# Count occurrences of the weekday in this month
|
||||
@@ -39,14 +44,14 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
|
||||
weekday = item.day_of_month if item.day_of_month is not None else 0
|
||||
cal = calendar.monthcalendar(year, month)
|
||||
count = sum(1 for week in cal if week[weekday] != 0)
|
||||
return item.amount * count
|
||||
return float(item.amount) * count
|
||||
|
||||
if freq == RecurringFrequency.QUARTERLY:
|
||||
# Active in months 3, 6, 9, 12 by default
|
||||
if month % 3 == 0:
|
||||
if item.override_amounts and str(month) in item.override_amounts:
|
||||
return float(item.override_amounts[str(month)])
|
||||
return item.amount
|
||||
return float(item.amount)
|
||||
return None
|
||||
|
||||
if freq == RecurringFrequency.BIANNUAL:
|
||||
@@ -54,19 +59,25 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
|
||||
base = item.month_of_year or 1
|
||||
second = base + 6 if base <= 6 else base - 6
|
||||
if month in (base, second):
|
||||
return item.amount
|
||||
return float(item.amount)
|
||||
return None
|
||||
|
||||
if freq == RecurringFrequency.YEARLY:
|
||||
if month == (item.month_of_year or 12):
|
||||
return item.amount
|
||||
return float(item.amount)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _validate_month(month: int) -> None:
|
||||
if not 1 <= month <= 12:
|
||||
raise ValueError(f"month must be 1-12, got {month}")
|
||||
|
||||
|
||||
def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
"""Return (start, end) for a calendar month."""
|
||||
_validate_month(month)
|
||||
start = datetime(year, month, 1)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 1)
|
||||
@@ -76,7 +87,15 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
|
||||
|
||||
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
|
||||
"""Return (start, end) of the billing cycle that STARTS on the 18th of
|
||||
`month`: [month/18, month+1/18).
|
||||
|
||||
Note the labeling convention: budget month M covers the cycle that ENDS
|
||||
on the 18th of M, so callers compose get_previous_cycle(year, M) with
|
||||
this function — see compute_actuals_by_source. Pinned by
|
||||
tests/test_cycle_math.py.
|
||||
"""
|
||||
_validate_month(month)
|
||||
start = datetime(year, month, 18)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 18)
|
||||
@@ -110,115 +129,65 @@ def compute_actuals_by_source(
|
||||
|
||||
amount_crc = get_converted_amount_expr(session)
|
||||
|
||||
def _grouped(*where) -> dict[tuple, tuple[float, int]]:
|
||||
"""{(source, type): (total_crc, count)} for matching transactions."""
|
||||
rows = session.exec(
|
||||
select(
|
||||
Transaction.source,
|
||||
Transaction.transaction_type,
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.count(),
|
||||
)
|
||||
.where(NOT_INSTALLMENT_ANCHOR, *where)
|
||||
.group_by(Transaction.source, Transaction.transaction_type)
|
||||
).all()
|
||||
return {(s, t): (float(total), cnt) for s, t, total, cnt in rows}
|
||||
|
||||
# Three grouped queries replace the previous ten single-aggregate ones.
|
||||
cc_now = _grouped(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
cc_deferred = _grouped(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
non_cc = _grouped(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
)
|
||||
|
||||
results = {}
|
||||
for source in TransactionSource:
|
||||
if source == TransactionSource.CREDIT_CARD:
|
||||
start, end = cc_start, cc_end
|
||||
# Normal transactions in this cycle (not deferred)
|
||||
compra_normal = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
# Deferred from previous cycle
|
||||
compra_deferred = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
compra = float(compra_normal) + float(compra_deferred)
|
||||
|
||||
dev_normal = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
dev_deferred = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
devolucion = float(dev_normal) + float(dev_deferred)
|
||||
|
||||
count_normal = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
count_deferred = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
count = count_normal + count_deferred
|
||||
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra,
|
||||
"total_devolucion": devolucion,
|
||||
"net": compra - devolucion,
|
||||
"count": count,
|
||||
}
|
||||
buckets = (cc_now, cc_deferred)
|
||||
else:
|
||||
# Cash / Transfer: calendar month, no deferred logic
|
||||
compra = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
)
|
||||
).one()
|
||||
devolucion = session.exec(
|
||||
select(func.coalesce(func.sum(amount_crc), 0)).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
)
|
||||
).one()
|
||||
count = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
)
|
||||
).one()
|
||||
|
||||
compra_val = float(compra)
|
||||
devolucion_val = float(devolucion)
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra_val,
|
||||
"total_devolucion": devolucion_val,
|
||||
"net": compra_val - devolucion_val,
|
||||
"count": count,
|
||||
}
|
||||
buckets = (non_cc,)
|
||||
compra = sum(
|
||||
b.get((source, TransactionType.COMPRA), (0.0, 0))[0] for b in buckets
|
||||
)
|
||||
devolucion = sum(
|
||||
b.get((source, TransactionType.DEVOLUCION), (0.0, 0))[0]
|
||||
for b in buckets
|
||||
)
|
||||
count = sum(
|
||||
cnt
|
||||
for b in buckets
|
||||
for (s, t), (_total, cnt) in b.items()
|
||||
if s == source and t not in INCOME_TYPES
|
||||
)
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra,
|
||||
"total_devolucion": devolucion,
|
||||
"net": compra - devolucion,
|
||||
"count": count,
|
||||
}
|
||||
return results
|
||||
|
||||
|
||||
@@ -261,6 +230,7 @@ def compute_actuals_by_category(
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
@@ -281,6 +251,7 @@ def compute_actuals_by_category(
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
@@ -301,6 +272,7 @@ def compute_actuals_by_category(
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
@@ -342,6 +314,7 @@ def compute_cc_by_category(
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
@@ -360,24 +333,24 @@ def compute_cc_by_category(
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# Resolve category names
|
||||
# Resolve category names in one query instead of one per category
|
||||
from app.models.models import Category
|
||||
|
||||
names = {
|
||||
c.id: c.name for c in session.exec(select(Category)).all()
|
||||
}
|
||||
result = []
|
||||
for cat_id, amount in totals.items():
|
||||
if amount <= 0:
|
||||
continue
|
||||
if cat_id is not None:
|
||||
cat = session.get(Category, cat_id)
|
||||
name = cat.name if cat else "Sin categoría"
|
||||
else:
|
||||
name = "Sin categoría"
|
||||
name = names.get(cat_id, "Sin categoría") if cat_id is not None else "Sin categoría"
|
||||
result.append({"category_name": name, "amount": round(amount, 2)})
|
||||
|
||||
return sorted(result, key=lambda x: x["amount"], reverse=True)
|
||||
@@ -477,6 +450,7 @@ def compute_monthly_projection(
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
@@ -492,6 +466,7 @@ def compute_monthly_projection(
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
@@ -507,6 +482,7 @@ def compute_monthly_projection(
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
col(Transaction.transaction_type).notin_(INCOME_TYPES),
|
||||
NOT_INSTALLMENT_ANCHOR,
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
@@ -547,13 +523,13 @@ def _get_december_cumulative(session: Session, year: int) -> float:
|
||||
)
|
||||
).first()
|
||||
if override:
|
||||
return override.override_balance
|
||||
return float(override.override_balance)
|
||||
|
||||
# Compute the full year to get December's cumulative
|
||||
overrides = session.exec(
|
||||
select(BalanceOverride).where(BalanceOverride.year == year)
|
||||
).all()
|
||||
override_map = {o.month: o.override_balance for o in overrides}
|
||||
override_map = {o.month: float(o.override_balance) for o in overrides}
|
||||
|
||||
cumulative = 0.0
|
||||
if year > FRESH_START_YEAR:
|
||||
@@ -577,7 +553,7 @@ def compute_yearly_projection_with_cumulative(
|
||||
overrides = session.exec(
|
||||
select(BalanceOverride).where(BalanceOverride.year == year)
|
||||
).all()
|
||||
override_map = {o.month: o.override_balance for o in overrides}
|
||||
override_map = {o.month: float(o.override_balance) for o in overrides}
|
||||
|
||||
# Determine January carryover
|
||||
if year <= FRESH_START_YEAR:
|
||||
|
||||
57
backend/app/services/csv_export.py
Normal file
57
backend/app/services/csv_export.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""CSV export of transactions (review ARCH-18 / wishlist #4)."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.models.models import Transaction, TransactionSource, TransactionType
|
||||
|
||||
COLUMNS = [
|
||||
"id", "date", "merchant", "city", "amount", "currency",
|
||||
"transaction_type", "source", "bank", "category", "notes", "reference",
|
||||
]
|
||||
|
||||
|
||||
def build_transactions_csv(
|
||||
session: Session,
|
||||
*,
|
||||
source: Optional[TransactionSource] = None,
|
||||
transaction_type: Optional[TransactionType] = None,
|
||||
start_date: Optional[datetime] = None,
|
||||
end_date: Optional[datetime] = None,
|
||||
) -> str:
|
||||
q = select(Transaction)
|
||||
if source:
|
||||
q = q.where(Transaction.source == source)
|
||||
if transaction_type:
|
||||
q = q.where(Transaction.transaction_type == transaction_type)
|
||||
if start_date:
|
||||
q = q.where(Transaction.date >= start_date)
|
||||
if end_date:
|
||||
q = q.where(Transaction.date < end_date)
|
||||
q = q.order_by(col(Transaction.date).desc(), col(Transaction.id).desc())
|
||||
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(COLUMNS)
|
||||
for tx in session.exec(q).all():
|
||||
writer.writerow(
|
||||
[
|
||||
tx.id,
|
||||
tx.date.isoformat(),
|
||||
tx.merchant,
|
||||
tx.city or "",
|
||||
f"{tx.amount:.2f}",
|
||||
tx.currency.value,
|
||||
tx.transaction_type.value,
|
||||
tx.source.value,
|
||||
tx.bank.value,
|
||||
tx.category.name if tx.category else "",
|
||||
tx.notes or "",
|
||||
tx.reference or "",
|
||||
]
|
||||
)
|
||||
return buf.getvalue()
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy import case
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.config import settings
|
||||
from app.timeutil import utcnow
|
||||
from app.db import engine
|
||||
from app.models.models import ExchangeRate
|
||||
|
||||
@@ -60,8 +61,8 @@ def _fetch_bccr_rate(indicator: int, date_str: str) -> float | None:
|
||||
for datos in root.iter():
|
||||
if datos.tag.endswith("NUM_VALOR"):
|
||||
return float(datos.text.strip().replace(",", "."))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("BCCR rate fetch failed (indicator %s): %s", indicator, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -90,8 +91,8 @@ def _fetch_exchangerate_api() -> tuple[float, float] | None:
|
||||
crc = data["rates"].get("CRC")
|
||||
if crc:
|
||||
return _mid_to_buy_sell(float(crc))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("ExchangeRate-API fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -105,7 +106,8 @@ def _fetch_currency_api() -> tuple[float, float] | None:
|
||||
crc = data.get("usd", {}).get("crc")
|
||||
if crc:
|
||||
return _mid_to_buy_sell(float(crc))
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.warning("currency-api fetch failed (%s): %s", url, e)
|
||||
continue
|
||||
return None
|
||||
|
||||
@@ -119,8 +121,8 @@ def _fetch_floatrates() -> tuple[float, float] | None:
|
||||
crc_data = data.get("crc")
|
||||
if crc_data and "rate" in crc_data:
|
||||
return _mid_to_buy_sell(float(crc_data["rate"]))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("FloatRates fetch failed: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -136,7 +138,7 @@ def _fetch_rate_from_apis() -> tuple[float, float] | None:
|
||||
def _remember(rate: ExchangeRate) -> ExchangeRate:
|
||||
"""Store rate in both TTL cache and permanent last-known holder."""
|
||||
global _last_known
|
||||
_cache["current"] = (rate, datetime.utcnow())
|
||||
_cache["current"] = (rate, utcnow())
|
||||
_last_known = rate
|
||||
return rate
|
||||
|
||||
@@ -147,11 +149,11 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||
|
||||
# 1. Fresh memory cache (< 1 hour)
|
||||
cached = _cache.get("current")
|
||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||
if cached and utcnow() - cached[1] < CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
# 2. Fresh DB rate (< 1 hour)
|
||||
one_hour_ago = datetime.utcnow() - CACHE_TTL
|
||||
one_hour_ago = utcnow() - CACHE_TTL
|
||||
db_rate = session.exec(
|
||||
select(ExchangeRate)
|
||||
.where(ExchangeRate.fetched_at > one_hour_ago)
|
||||
@@ -164,7 +166,7 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
|
||||
result = _fetch_rate_from_apis()
|
||||
if result is not None:
|
||||
buy, sell = result
|
||||
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
rate = ExchangeRate(date=utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
@@ -198,8 +200,8 @@ def _fetch_fiat_crc_mid(code: str) -> float | None:
|
||||
x = data["rates"].get(code)
|
||||
if crc and x:
|
||||
return float(crc) / float(x)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("%s/CRC fiat rate fetch failed: %s", code, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -219,8 +221,8 @@ def _fetch_crypto_crc(code: str) -> float | None:
|
||||
price = data.get(coin_id, {}).get("crc")
|
||||
if price:
|
||||
return float(price)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("CoinGecko %s/CRC fetch failed: %s", code, e)
|
||||
return None
|
||||
|
||||
|
||||
@@ -230,7 +232,7 @@ def get_crc_rate(code: str) -> float | None:
|
||||
return 1.0
|
||||
|
||||
cached = _xcrc_cache.get(code)
|
||||
if cached and datetime.utcnow() - cached[1] < CACHE_TTL:
|
||||
if cached and utcnow() - cached[1] < CACHE_TTL:
|
||||
return cached[0]
|
||||
|
||||
if code in _COINGECKO_IDS:
|
||||
@@ -239,7 +241,7 @@ def get_crc_rate(code: str) -> float | None:
|
||||
rate = _fetch_fiat_crc_mid(code)
|
||||
|
||||
if rate is not None:
|
||||
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||
_xcrc_cache[code] = (rate, utcnow())
|
||||
_last_known_xcrc[code] = rate
|
||||
return rate
|
||||
|
||||
@@ -254,7 +256,8 @@ def get_crc_multipliers(session: Session) -> dict[str, float]:
|
||||
|
||||
usd_rate = get_current_rate(session)
|
||||
if usd_rate:
|
||||
multipliers["USD"] = usd_rate.sell_rate
|
||||
# sell_rate is Decimal when loaded from the DB; keep multipliers float
|
||||
multipliers["USD"] = float(usd_rate.sell_rate)
|
||||
|
||||
for code in (c.value for c in Currency):
|
||||
if code in multipliers:
|
||||
@@ -293,7 +296,7 @@ def _refresh_usd_rate() -> bool:
|
||||
return False
|
||||
buy, sell = fetched
|
||||
with Session(engine) as session:
|
||||
rate = ExchangeRate(date=datetime.utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
rate = ExchangeRate(date=utcnow(), buy_rate=buy, sell_rate=sell)
|
||||
session.add(rate)
|
||||
session.commit()
|
||||
session.refresh(rate)
|
||||
@@ -309,7 +312,7 @@ def _refresh_other_rate(code: str) -> bool:
|
||||
rate = _fetch_fiat_crc_mid(code)
|
||||
if rate is None:
|
||||
return False
|
||||
_xcrc_cache[code] = (rate, datetime.utcnow())
|
||||
_xcrc_cache[code] = (rate, utcnow())
|
||||
_last_known_xcrc[code] = rate
|
||||
return True
|
||||
|
||||
@@ -368,7 +371,7 @@ async def refresh_rates_periodically(
|
||||
|
||||
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
|
||||
"""Get historical exchange rates."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
cutoff = utcnow() - timedelta(days=days)
|
||||
return list(
|
||||
session.exec(
|
||||
select(ExchangeRate)
|
||||
|
||||
187
backend/app/services/installments.py
Normal file
187
backend/app/services/installments.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""Tasa Cero (BAC zero-interest installment financing) plans.
|
||||
|
||||
A qualifying credit-card purchase ("anchor") is billed by the bank as N equal
|
||||
monthly cuotas instead of one charge. Here the anchor transaction is flagged
|
||||
(`is_installment_anchor`) and excluded from budget aggregates, and N real
|
||||
cuota Transaction rows are generated so they flow through the existing
|
||||
billing-cycle logic — including future-dated cuotas, which intentionally show
|
||||
up in future months' budgets as committed spend.
|
||||
|
||||
Verified BAC mechanics (real Financiamientos data, 2026-05/07):
|
||||
- cuota = total / N truncated to 0.10; the LAST cuota absorbs the remainder
|
||||
(111,053.60/3 -> 37,017.80 x2 + 37,018.00).
|
||||
- Cuotas post one per 18th-cut billing cycle, on the 19th, except the first
|
||||
one whose date varies per plan (purchase date or a later 19th) — hence
|
||||
`first_installment_date` is stored per plan and user-editable.
|
||||
|
||||
Known v1 gaps: paste-imported real "CUOTA:XX/YY" statement lines won't dedup
|
||||
against the synthetic cuotas; editing the anchor's amount does not recompute
|
||||
the plan (edit the plan instead).
|
||||
|
||||
Cascades are handled explicitly here in Python: tests run on SQLite without
|
||||
FK enforcement, and transaction.installment_plan_id has no DB-level FK (it
|
||||
would be circular with installmentplan.anchor_transaction_id).
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import ROUND_DOWN, Decimal
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models.models import (
|
||||
InstallmentPlan,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.budget_projection import get_previous_cycle
|
||||
|
||||
# BAC prefixes Tasa Cero voucher merchants with "CC " (observed with a double
|
||||
# space: "CC CONSTRUPLAZA"). Non-prefixed plans (e.g. ALMACENES SIMAN) are
|
||||
# converted manually via POST /installment-plans/.
|
||||
TASA_CERO_RE = re.compile(r"^CC\s", re.IGNORECASE)
|
||||
|
||||
DEFAULT_NUM_INSTALLMENTS = 3
|
||||
|
||||
|
||||
def is_tasa_cero_merchant(merchant: str) -> bool:
|
||||
return bool(TASA_CERO_RE.match(merchant))
|
||||
|
||||
|
||||
def compute_installment_amounts(total: Decimal, n: int) -> list[Decimal]:
|
||||
"""Split `total` into n cuotas using BAC's rule: truncate to 0.10, last
|
||||
cuota absorbs the remainder. Invariant: sum(result) == total."""
|
||||
per = (total / n).quantize(Decimal("0.1"), rounding=ROUND_DOWN)
|
||||
return [per] * (n - 1) + [total - per * (n - 1)]
|
||||
|
||||
|
||||
def _add_months(year: int, month: int, delta: int) -> tuple[int, int]:
|
||||
idx = year * 12 + (month - 1) + delta
|
||||
return idx // 12, idx % 12 + 1
|
||||
|
||||
|
||||
def compute_installment_dates(first: datetime, n: int) -> list[datetime]:
|
||||
"""Cuota 1 = `first` verbatim; cuotas 2..n land on the 19th of consecutive
|
||||
billing cycles (cycle = [18th, next 18th), see get_cycle_range)."""
|
||||
if first.day >= 18:
|
||||
cycle_y, cycle_m = first.year, first.month
|
||||
else:
|
||||
cycle_y, cycle_m = get_previous_cycle(first.year, first.month)
|
||||
dates = [first]
|
||||
for i in range(1, n):
|
||||
y, m = _add_months(cycle_y, cycle_m, i)
|
||||
dates.append(datetime(y, m, 19))
|
||||
return dates
|
||||
|
||||
|
||||
def _generate_cuotas(
|
||||
session: Session, plan: InstallmentPlan, anchor: Transaction
|
||||
) -> None:
|
||||
amounts = compute_installment_amounts(anchor.amount, plan.num_installments)
|
||||
dates = compute_installment_dates(
|
||||
plan.first_installment_date, plan.num_installments
|
||||
)
|
||||
merchant_label = " ".join(anchor.merchant.split()) # collapse "CC X"
|
||||
ref_base = anchor.reference or f"TC{anchor.id}"
|
||||
n = plan.num_installments
|
||||
for i, (amount, when) in enumerate(zip(amounts, dates), start=1):
|
||||
session.add(
|
||||
Transaction(
|
||||
amount=amount,
|
||||
currency=anchor.currency,
|
||||
merchant=f"{merchant_label} CUOTA:{i:02d}/{n:02d}",
|
||||
city=anchor.city,
|
||||
date=when,
|
||||
card_type=anchor.card_type,
|
||||
card_last4=anchor.card_last4,
|
||||
reference=f"{ref_base}-C{i:02d}",
|
||||
transaction_type=TransactionType.COMPRA,
|
||||
source=anchor.source,
|
||||
bank=anchor.bank,
|
||||
notes=f"Tasa Cero plan #{plan.id}",
|
||||
category_id=anchor.category_id,
|
||||
deferred_to_next_cycle=False,
|
||||
installment_plan_id=plan.id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _delete_cuotas(session: Session, plan: InstallmentPlan) -> None:
|
||||
cuotas = session.exec(
|
||||
select(Transaction).where(Transaction.installment_plan_id == plan.id)
|
||||
).all()
|
||||
for cuota in cuotas:
|
||||
session.delete(cuota)
|
||||
|
||||
|
||||
def create_plan(
|
||||
session: Session,
|
||||
anchor: Transaction,
|
||||
num_installments: int = DEFAULT_NUM_INSTALLMENTS,
|
||||
first_installment_date: Optional[datetime] = None,
|
||||
) -> InstallmentPlan:
|
||||
"""Convert `anchor` (must be committed, id set) into a Tasa Cero plan and
|
||||
generate its cuota rows. Caller commits."""
|
||||
amounts = compute_installment_amounts(anchor.amount, num_installments)
|
||||
plan = InstallmentPlan(
|
||||
anchor_transaction_id=anchor.id,
|
||||
num_installments=num_installments,
|
||||
first_installment_date=first_installment_date or anchor.date,
|
||||
total_amount=anchor.amount,
|
||||
installment_amount=amounts[0],
|
||||
currency=anchor.currency,
|
||||
)
|
||||
session.add(plan)
|
||||
session.flush() # assign plan.id for the cuota FK/notes
|
||||
anchor.is_installment_anchor = True
|
||||
session.add(anchor)
|
||||
_generate_cuotas(session, plan, anchor)
|
||||
return plan
|
||||
|
||||
|
||||
def regenerate_cuotas(
|
||||
session: Session,
|
||||
plan: InstallmentPlan,
|
||||
anchor: Transaction,
|
||||
num_installments: Optional[int] = None,
|
||||
first_installment_date: Optional[datetime] = None,
|
||||
) -> InstallmentPlan:
|
||||
"""Apply plan edits by delete-and-recreate of the cuota rows. Per-cuota
|
||||
edits (e.g. recategorizations) are lost; category is re-inherited from
|
||||
the anchor. Caller commits."""
|
||||
if num_installments is not None:
|
||||
plan.num_installments = num_installments
|
||||
if first_installment_date is not None:
|
||||
plan.first_installment_date = first_installment_date
|
||||
plan.total_amount = anchor.amount
|
||||
plan.installment_amount = compute_installment_amounts(
|
||||
anchor.amount, plan.num_installments
|
||||
)[0]
|
||||
_delete_cuotas(session, plan)
|
||||
_generate_cuotas(session, plan, anchor)
|
||||
session.add(plan)
|
||||
return plan
|
||||
|
||||
|
||||
def delete_plan(session: Session, plan: InstallmentPlan, anchor: Transaction) -> None:
|
||||
"""Unconvert: remove cuotas + plan, restore the anchor to a normal
|
||||
transaction. Caller commits."""
|
||||
_delete_cuotas(session, plan)
|
||||
anchor.is_installment_anchor = False
|
||||
session.add(anchor)
|
||||
session.delete(plan)
|
||||
|
||||
|
||||
def teardown_plan_for_anchor(session: Session, anchor: Transaction) -> None:
|
||||
"""The anchor itself is being deleted: remove its plan and cuotas."""
|
||||
if not anchor.is_installment_anchor:
|
||||
return
|
||||
plan = session.exec(
|
||||
select(InstallmentPlan).where(
|
||||
InstallmentPlan.anchor_transaction_id == anchor.id
|
||||
)
|
||||
).first()
|
||||
if plan:
|
||||
_delete_cuotas(session, plan)
|
||||
session.delete(plan)
|
||||
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models.models import (
|
||||
@@ -8,8 +10,8 @@ from app.models.models import (
|
||||
Transaction,
|
||||
)
|
||||
|
||||
MEMP_MONTHLY = 200000.0
|
||||
MPAT_MONTHLY = 200000.0
|
||||
MEMP_MONTHLY = Decimal("200000")
|
||||
MPAT_MONTHLY = Decimal("200000")
|
||||
|
||||
|
||||
def _get_savings_account(session: Session, bank: Bank) -> Account | None:
|
||||
|
||||
94
backend/app/services/sync_status.py
Normal file
94
backend/app/services/sync_status.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Per-source ingestion health, derived from existing data (review UX-17).
|
||||
|
||||
The n8n flows fail silently from the app's perspective — a parser break just
|
||||
looks like missing data. This derives "when did we last receive something from
|
||||
each source" plus a cadence threshold, so a stalled flow becomes visible
|
||||
in-app without any n8n integration.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.models.models import (
|
||||
Bank,
|
||||
ExchangeRate,
|
||||
MunicipalReceipt,
|
||||
PensionSnapshot,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceSpec:
|
||||
key: str
|
||||
label: str
|
||||
warn_after_days: float
|
||||
description: str
|
||||
|
||||
|
||||
# Cadences: BAC emails arrive near-daily with normal card use; salary is
|
||||
# biweekly/monthly; municipal and pension are monthly; rates refresh every 6h.
|
||||
SOURCES: list[SourceSpec] = [
|
||||
SourceSpec("bac_credit_card", "BAC Tarjeta de Crédito", 7,
|
||||
"Correos de compra parseados por n8n"),
|
||||
SourceSpec("salary", "Depósitos de Salario", 35,
|
||||
"Correos de depósito parseados por n8n"),
|
||||
SourceSpec("municipal", "Recibos Municipales", 40,
|
||||
"PDF mensual vía n8n (cron)"),
|
||||
SourceSpec("pensions", "Estados de Pensión", 40,
|
||||
"PDFs diarios/mensuales vía n8n"),
|
||||
SourceSpec("exchange_rate", "Tipo de Cambio", 1,
|
||||
"Actualización automática cada 6 horas"),
|
||||
]
|
||||
|
||||
|
||||
def _last_received(session: Session, key: str):
|
||||
if key == "bac_credit_card":
|
||||
return session.exec(
|
||||
select(func.max(Transaction.created_at)).where(
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.bank == Bank.BAC,
|
||||
)
|
||||
).one()
|
||||
if key == "salary":
|
||||
return session.exec(
|
||||
select(func.max(Transaction.created_at)).where(
|
||||
Transaction.transaction_type == TransactionType.SALARY
|
||||
)
|
||||
).one()
|
||||
if key == "municipal":
|
||||
return session.exec(select(func.max(MunicipalReceipt.created_at))).one()
|
||||
if key == "pensions":
|
||||
return session.exec(select(func.max(PensionSnapshot.created_at))).one()
|
||||
if key == "exchange_rate":
|
||||
return session.exec(select(func.max(ExchangeRate.fetched_at))).one()
|
||||
raise ValueError(f"unknown sync source: {key}")
|
||||
|
||||
|
||||
def compute_sync_status(session: Session) -> list[dict]:
|
||||
now = utcnow()
|
||||
out: list[dict] = []
|
||||
for spec in SOURCES:
|
||||
last = _last_received(session, spec.key)
|
||||
if last is None:
|
||||
status = "never"
|
||||
age_days = None
|
||||
else:
|
||||
age_days = (now - last).total_seconds() / 86400
|
||||
status = "ok" if age_days <= spec.warn_after_days else "warning"
|
||||
out.append(
|
||||
{
|
||||
"key": spec.key,
|
||||
"label": spec.label,
|
||||
"description": spec.description,
|
||||
"last_received": last.isoformat() if last else None,
|
||||
"age_days": round(age_days, 1) if age_days is not None else None,
|
||||
"warn_after_days": spec.warn_after_days,
|
||||
"status": status,
|
||||
}
|
||||
)
|
||||
return out
|
||||
65
backend/app/timeutil.py
Normal file
65
backend/app/timeutil.py
Normal file
@@ -0,0 +1,65 @@
|
||||
import contextvars
|
||||
from datetime import date, datetime, timedelta, timezone, tzinfo
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
# Costa Rica has no DST; a fixed offset is exact year-round. This is the
|
||||
# FALLBACK when the request doesn't declare a timezone (n8n posts, tests).
|
||||
CR_TZ = timezone(timedelta(hours=-6))
|
||||
|
||||
# Per-request client timezone, set by the agent middleware from the
|
||||
# X-Client-Timezone header (IANA name sent by the browser). Falls back to
|
||||
# Costa Rica so headless callers keep the owner's local semantics.
|
||||
_client_tz: contextvars.ContextVar[tzinfo] = contextvars.ContextVar(
|
||||
"client_tz", default=CR_TZ
|
||||
)
|
||||
|
||||
|
||||
def set_client_timezone(name: str | None) -> contextvars.Token | None:
|
||||
"""Bind the request's IANA timezone; ignores unknown/absent names.
|
||||
|
||||
Proxies that see the header twice merge the values into a comma list
|
||||
("Zone, Zone") — take the first entry.
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
return _client_tz.set(ZoneInfo(name.split(",")[0].strip()))
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def reset_client_timezone(token: contextvars.Token | None) -> None:
|
||||
if token is not None:
|
||||
_client_tz.reset(token)
|
||||
|
||||
|
||||
def client_tz() -> tzinfo:
|
||||
return _client_tz.get()
|
||||
|
||||
|
||||
def today_client() -> date:
|
||||
"""Current date where the USER is (request timezone, CR fallback).
|
||||
|
||||
The server clock is UTC — at 6 pm in Costa Rica it is already "tomorrow"
|
||||
in UTC. Anything user-facing that means "today" (agent answers, date
|
||||
defaults) must come from here, never date.today().
|
||||
"""
|
||||
return datetime.now(client_tz()).date()
|
||||
|
||||
|
||||
def today_cr() -> date:
|
||||
"""Current date in Costa Rica (UTC-6) — timezone-independent fallback."""
|
||||
return datetime.now(CR_TZ).date()
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Naive UTC now, via the non-deprecated API.
|
||||
|
||||
Every datetime column in the schema is TIMESTAMP WITHOUT TIME ZONE, so the
|
||||
whole app speaks naive-UTC. Replacing datetime.utcnow() call sites with
|
||||
this helper removes the Python 3.12 deprecation without changing any
|
||||
stored value or comparison. When the columns move to TIMESTAMPTZ
|
||||
(Phase 2), this becomes `datetime.now(timezone.utc)` and the type change
|
||||
happens in exactly one place.
|
||||
"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
22
backend/app/uploads.py
Normal file
22
backend/app/uploads.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from fastapi import UploadFile
|
||||
|
||||
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB — statements are a few hundred KB
|
||||
|
||||
|
||||
async def read_pdf_upload(
|
||||
file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES
|
||||
) -> bytes:
|
||||
"""Read an uploaded PDF with a size cap and magic-byte check.
|
||||
|
||||
Raises ValueError with a user-readable, filename-prefixed message —
|
||||
upload endpoints surface these in their per-file `errors` list.
|
||||
"""
|
||||
filename = file.filename or "unknown.pdf"
|
||||
data = await file.read(max_bytes + 1)
|
||||
if len(data) > max_bytes:
|
||||
raise ValueError(
|
||||
f"{filename}: file exceeds the {max_bytes // (1024 * 1024)} MB limit"
|
||||
)
|
||||
if not data.startswith(b"%PDF"):
|
||||
raise ValueError(f"{filename}: not a PDF file")
|
||||
return data
|
||||
182
backend/constraints.txt
Normal file
182
backend/constraints.txt
Normal file
@@ -0,0 +1,182 @@
|
||||
a2a-sdk==0.3.23
|
||||
ag-ui-protocol==0.1.19
|
||||
agent-framework==1.11.0
|
||||
agent-framework-a2a==1.0.0b260428
|
||||
agent-framework-ag-ui==1.0.0rc8
|
||||
agent-framework-anthropic==1.0.0b260428
|
||||
agent-framework-azure-ai-search==1.0.0b260428
|
||||
agent-framework-azure-cosmos==1.0.0b260428
|
||||
agent-framework-azurefunctions==1.0.0b260428
|
||||
agent-framework-bedrock==1.0.0b260428
|
||||
agent-framework-chatkit==1.0.0b260428
|
||||
agent-framework-claude==1.0.0b260428
|
||||
agent-framework-copilotstudio==1.0.0b260428
|
||||
agent-framework-core==1.11.0
|
||||
agent-framework-declarative==1.0.0b260428
|
||||
agent-framework-devui==1.0.0b260428
|
||||
agent-framework-durabletask==1.0.0b260428
|
||||
agent-framework-foundry==1.2.1
|
||||
agent-framework-foundry-local==1.0.0b260428
|
||||
agent-framework-github-copilot==1.0.0b260402
|
||||
agent-framework-lab==1.0.0b251024
|
||||
agent-framework-mem0==1.0.0b260428
|
||||
agent-framework-ollama==1.0.0b260428
|
||||
agent-framework-openai==1.10.1
|
||||
agent-framework-orchestrations==1.0.0b260428
|
||||
agent-framework-purview==1.0.0b260428
|
||||
agent-framework-redis==1.0.0b260428
|
||||
aiohappyeyeballs==2.6.2
|
||||
aiohttp==3.14.1
|
||||
aiosignal==1.4.0
|
||||
alembic==1.18.4
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anthropic==0.80.0
|
||||
anyio==4.13.0
|
||||
async-timeout==5.0.1
|
||||
asyncio==4.0.0
|
||||
attrs==26.1.0
|
||||
azure-ai-inference==1.0.0b9
|
||||
azure-ai-projects==2.2.0
|
||||
azure-common==1.1.28
|
||||
azure-core==1.41.0
|
||||
azure-cosmos==4.16.1
|
||||
azure-functions==1.24.0
|
||||
azure-functions-durable==1.5.0
|
||||
azure-identity==1.25.3
|
||||
azure-search-documents==11.7.0b2
|
||||
azure-storage-blob==12.30.0
|
||||
backoff==2.2.1
|
||||
bcrypt==5.0.0
|
||||
boto3==1.43.26
|
||||
botocore==1.43.26
|
||||
certifi==2026.5.20
|
||||
cffi==2.0.0
|
||||
charset-normalizer==3.4.7
|
||||
claude-agent-sdk==0.1.48
|
||||
click==8.4.1
|
||||
clr_loader==0.2.10
|
||||
cryptography==48.0.1
|
||||
detect-installer==0.1.0
|
||||
distro==1.9.0
|
||||
dnspython==2.8.0
|
||||
docstring_parser==0.18.0
|
||||
durabletask==1.5.0
|
||||
durabletask.azuremanaged==1.5.0
|
||||
ecdsa==0.19.2
|
||||
email-validator==2.3.0
|
||||
fastapi==0.133.0
|
||||
fastapi-cli==0.0.24
|
||||
fastapi-cloud-cli==0.19.0
|
||||
fastar==0.11.0
|
||||
foundry-local-sdk==0.5.1
|
||||
frozenlist==1.8.0
|
||||
furl==2.1.4
|
||||
github-copilot-sdk==0.1.32
|
||||
google-api-core==2.31.0
|
||||
google-auth==2.53.0
|
||||
googleapis-common-protos==1.75.0
|
||||
griffelib==2.0.2
|
||||
grpcio==1.81.0
|
||||
h11==0.16.0
|
||||
h2==4.3.0
|
||||
hpack==4.1.0
|
||||
http-ece==1.2.1
|
||||
httpcore==1.0.9
|
||||
httptools==0.8.0
|
||||
httpx==0.28.1
|
||||
httpx-sse==0.4.3
|
||||
hyperframe==6.1.0
|
||||
idna==3.18
|
||||
iniconfig==2.3.0
|
||||
isodate==0.7.2
|
||||
Jinja2==3.1.6
|
||||
jiter==0.15.0
|
||||
jmespath==1.1.0
|
||||
jsonpath-ng==1.8.0
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
Mako==1.3.12
|
||||
markdown-it-py==4.2.0
|
||||
MarkupSafe==3.0.3
|
||||
mcp==1.27.2
|
||||
mdurl==0.1.2
|
||||
mem0ai==1.0.11
|
||||
microsoft-agents-activity==0.3.1
|
||||
microsoft-agents-copilotstudio-client==0.3.1
|
||||
microsoft-agents-hosting-core==0.3.1
|
||||
ml_dtypes==0.5.4
|
||||
msal==1.37.0
|
||||
msal-extensions==1.3.1
|
||||
multidict==6.7.1
|
||||
numpy==2.4.6
|
||||
ollama==0.5.3
|
||||
openai==2.41.0
|
||||
openai-agents==0.17.4
|
||||
openai-chatkit==1.6.5
|
||||
opentelemetry-api==1.42.1
|
||||
opentelemetry-sdk==1.42.1
|
||||
opentelemetry-semantic-conventions==0.63b1
|
||||
orderedmultidict==1.0.2
|
||||
packaging==26.2
|
||||
passlib==1.7.4
|
||||
pluggy==1.6.0
|
||||
portalocker==3.2.0
|
||||
posthog==7.18.0
|
||||
powerfx==0.0.34
|
||||
propcache==0.5.2
|
||||
proto-plus==1.28.0
|
||||
protobuf==6.33.6
|
||||
psycopg2-binary==2.9.12
|
||||
py-vapid==1.9.4
|
||||
pyasn1==0.6.3
|
||||
pyasn1_modules==0.4.2
|
||||
pycparser==3.0
|
||||
pydantic==2.13.4
|
||||
pydantic-extra-types==2.11.1
|
||||
pydantic-settings==2.14.1
|
||||
pydantic_core==2.46.4
|
||||
Pygments==2.20.0
|
||||
PyJWT==2.13.0
|
||||
pytest==9.0.3
|
||||
python-dateutil==2.9.0.post0
|
||||
python-dotenv==1.2.2
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.32
|
||||
python-ulid==3.1.0
|
||||
pythonnet==3.0.5
|
||||
pytz==2026.2
|
||||
pywebpush==2.3.0
|
||||
PyYAML==6.0.3
|
||||
qdrant-client==1.18.0
|
||||
redis==7.1.1
|
||||
redisvl==0.15.0
|
||||
referencing==0.37.0
|
||||
requests==2.34.2
|
||||
rich==15.0.0
|
||||
rich-toolkit==0.20.1
|
||||
rignore==0.7.6
|
||||
rpds-py==2026.5.1
|
||||
rsa==4.9.1
|
||||
s3transfer==0.18.0
|
||||
sentry-sdk==2.62.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
SQLAlchemy==2.0.50
|
||||
sqlmodel==0.0.38
|
||||
sse-starlette==3.4.5
|
||||
starlette==1.2.1
|
||||
tenacity==9.1.4
|
||||
tqdm==4.68.2
|
||||
typer==0.26.7
|
||||
types-requests==2.33.0.20260518
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
urllib3==2.7.0
|
||||
uvicorn==0.41.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.2.0
|
||||
websockets==16.0
|
||||
Werkzeug==3.1.8
|
||||
yarl==1.24.2
|
||||
1
backend/requirements-dev.txt
Normal file
1
backend/requirements-dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
pytest
|
||||
@@ -11,6 +11,6 @@ httpx
|
||||
pywebpush
|
||||
py-vapid
|
||||
python-dateutil
|
||||
agent-framework==1.2.1
|
||||
agent-framework-ag-ui==1.0.0b260428
|
||||
agent-framework-openai==1.2.1
|
||||
agent-framework==1.11.0
|
||||
agent-framework-ag-ui==1.0.0rc8
|
||||
agent-framework-openai==1.10.1
|
||||
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
51
backend/tests/conftest.py
Normal file
51
backend/tests/conftest.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Shared fixtures. Env vars are set before any app import so config.Settings()
|
||||
fail-fast validation passes with deterministic test values regardless of the
|
||||
developer's .env."""
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault(
|
||||
"SECRET_KEY", "test-secret-key-0123456789abcdef0123456789abcdef"
|
||||
)
|
||||
os.environ.setdefault("ADMIN_USERNAME", "testadmin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD", "test-password")
|
||||
os.environ.setdefault("DATABASE_URL", "sqlite://")
|
||||
# app.main builds the MAF agent at import; the OpenAI client only needs a
|
||||
# syntactically-present key.
|
||||
os.environ.setdefault("OPENAI_API_KEY", "test-dummy-key")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
import app.models.models # noqa: F401 — register all tables on the metadata
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def engine():
|
||||
eng = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
SQLModel.metadata.create_all(eng)
|
||||
yield eng
|
||||
eng.dispose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def session(engine):
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixed_exchange_rates(monkeypatch):
|
||||
"""Never hit the network: CRC passthrough, USD pinned at 500."""
|
||||
from app.services import exchange_rate
|
||||
|
||||
monkeypatch.setattr(
|
||||
exchange_rate,
|
||||
"get_crc_multipliers",
|
||||
lambda session: {"CRC": 1.0, "USD": 500.0},
|
||||
)
|
||||
14
backend/tests/test_agent_config.py
Normal file
14
backend/tests/test_agent_config.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Assistant transport configuration."""
|
||||
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
|
||||
from app.agent import agent as agent_module
|
||||
|
||||
|
||||
def test_agent_uses_responses_client_for_the_configured_model(monkeypatch):
|
||||
monkeypatch.setattr(agent_module.settings, "AGENT_MODEL", "gpt-5.6-luna")
|
||||
|
||||
agent = agent_module.build_agent()
|
||||
|
||||
assert isinstance(agent.client, OpenAIChatClient)
|
||||
assert agent.client.model == "gpt-5.6-luna"
|
||||
286
backend/tests/test_agent_tools.py
Normal file
286
backend/tests/test_agent_tools.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""Agent tool coverage: session binding, JSON-safe output (no Decimal leaks),
|
||||
and the batched queries introduced in Phase 2."""
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from app.agent import tools
|
||||
from app.agent.tools import reset_session, set_session
|
||||
from app.models.models import (
|
||||
Account,
|
||||
AccountType,
|
||||
Bank,
|
||||
Currency,
|
||||
MunicipalReceipt,
|
||||
PensionSnapshot,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
WaterMeterReading,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bound_session(session):
|
||||
token = set_session(session)
|
||||
yield session
|
||||
reset_session(token)
|
||||
|
||||
|
||||
def make_snapshot(fund: Bank, start: date, end: date, saldo: str) -> PensionSnapshot:
|
||||
zero = Decimal("0")
|
||||
return PensionSnapshot(
|
||||
fund=fund,
|
||||
contract_number="C-1",
|
||||
period_start=start,
|
||||
period_end=end,
|
||||
saldo_anterior=zero,
|
||||
aportes=zero,
|
||||
rendimientos=zero,
|
||||
retiros=zero,
|
||||
traslados=zero,
|
||||
comision=zero,
|
||||
correccion=zero,
|
||||
bonificacion=zero,
|
||||
saldo_final=Decimal(saldo),
|
||||
source_filename="t.pdf",
|
||||
)
|
||||
|
||||
|
||||
def make_receipt(period: str) -> MunicipalReceipt:
|
||||
zero = Decimal("0")
|
||||
return MunicipalReceipt(
|
||||
receipt_date=date(2026, 4, 1),
|
||||
due_date=date(2026, 4, 15),
|
||||
period=period,
|
||||
account="A1",
|
||||
finca="F1",
|
||||
holder_name="x",
|
||||
holder_cedula="x",
|
||||
holder_address="x",
|
||||
subtotal=Decimal("100"),
|
||||
interests=zero,
|
||||
iva=Decimal("13"),
|
||||
total=Decimal("113"),
|
||||
source_filename="r.pdf",
|
||||
)
|
||||
|
||||
|
||||
def test_unbound_session_raises(session):
|
||||
with pytest.raises(RuntimeError, match="not bound to agent context"):
|
||||
tools.get_accounts()
|
||||
|
||||
|
||||
def test_accounts_output_is_json_safe(bound_session):
|
||||
bound_session.add(
|
||||
Account(
|
||||
bank=Bank.BAC,
|
||||
currency=Currency.CRC,
|
||||
label="Main",
|
||||
balance=Decimal("1234.56"),
|
||||
account_type=AccountType.BANK,
|
||||
)
|
||||
)
|
||||
bound_session.commit()
|
||||
out = tools.get_accounts()
|
||||
json.dumps(out) # would raise on Decimal leakage
|
||||
assert out[0]["balance"] == 1234.56
|
||||
|
||||
|
||||
def test_pension_latest_only_per_fund(bound_session):
|
||||
bound_session.add(
|
||||
make_snapshot(Bank.FCL, date(2026, 1, 1), date(2026, 1, 31), "100")
|
||||
)
|
||||
bound_session.add(
|
||||
make_snapshot(Bank.FCL, date(2026, 2, 1), date(2026, 2, 28), "200")
|
||||
)
|
||||
bound_session.add(
|
||||
make_snapshot(Bank.ROP, date(2026, 1, 1), date(2026, 1, 31), "900")
|
||||
)
|
||||
bound_session.commit()
|
||||
|
||||
out = tools.get_pension_snapshots(latest_only=True)
|
||||
json.dumps(out)
|
||||
assert len(out) == 2
|
||||
by_fund = {o["fund"]: o for o in out}
|
||||
assert by_fund["FCL"]["saldo_final"] == 200.0 # latest period wins
|
||||
assert by_fund["ROP"]["saldo_final"] == 900.0
|
||||
|
||||
full = tools.get_pension_snapshots(latest_only=False)
|
||||
assert len(full) == 3
|
||||
|
||||
|
||||
def test_municipal_receipts_batched_consumption(bound_session):
|
||||
r1, r2 = make_receipt("2026-03"), make_receipt("2026-04")
|
||||
r2.account = "A2"
|
||||
bound_session.add(r1)
|
||||
bound_session.add(r2)
|
||||
bound_session.commit()
|
||||
bound_session.refresh(r1)
|
||||
bound_session.refresh(r2)
|
||||
for rid, m3 in ((r1.id, "10.5"), (r1.id, "4.5"), (r2.id, "7")):
|
||||
bound_session.add(
|
||||
WaterMeterReading(
|
||||
meter_id="7335",
|
||||
period=f"p{m3}",
|
||||
consumption_m3=Decimal(m3),
|
||||
receipt_id=rid,
|
||||
)
|
||||
)
|
||||
bound_session.commit()
|
||||
|
||||
out = tools.get_municipal_receipts()
|
||||
json.dumps(out)
|
||||
by_account = {o["account"]: o for o in out}
|
||||
assert by_account["A1"]["water_consumption_m3"] == 15.0
|
||||
assert by_account["A2"]["water_consumption_m3"] == 7.0
|
||||
|
||||
|
||||
def test_recent_transactions_json_safe(bound_session):
|
||||
bound_session.add(
|
||||
Transaction(
|
||||
amount=Decimal("42.42"),
|
||||
merchant="M",
|
||||
date=datetime(2026, 4, 1),
|
||||
)
|
||||
)
|
||||
bound_session.commit()
|
||||
out = tools.get_recent_transactions()
|
||||
json.dumps(out)
|
||||
assert out[0]["amount"] == 42.42
|
||||
|
||||
|
||||
def test_recent_transactions_exclude_future_cuotas(bound_session):
|
||||
from datetime import timedelta
|
||||
|
||||
from app.timeutil import today_cr
|
||||
|
||||
today = datetime.combine(today_cr(), datetime.min.time())
|
||||
bound_session.add(
|
||||
Transaction(amount=Decimal("10"), merchant="BILLED", date=today)
|
||||
)
|
||||
bound_session.add(
|
||||
Transaction(
|
||||
amount=Decimal("20"),
|
||||
merchant="FUTURE CUOTA:03/06",
|
||||
date=today + timedelta(days=45),
|
||||
)
|
||||
)
|
||||
bound_session.commit()
|
||||
merchants = [t["merchant"] for t in tools.get_recent_transactions()]
|
||||
assert "BILLED" in merchants
|
||||
assert "FUTURE CUOTA:03/06" not in merchants
|
||||
|
||||
|
||||
def test_salary_deposits_returns_individual_filtered_rows(bound_session):
|
||||
bound_session.add_all(
|
||||
[
|
||||
Transaction(
|
||||
amount=Decimal("1500"),
|
||||
merchant="SALARY JULY",
|
||||
date=datetime(2026, 7, 14, 9),
|
||||
transaction_type=TransactionType.SALARY,
|
||||
reference="PAY-2026-07",
|
||||
),
|
||||
Transaction(
|
||||
amount=Decimal("1400"),
|
||||
merchant="SALARY JUNE",
|
||||
date=datetime(2026, 6, 14, 9),
|
||||
transaction_type=TransactionType.SALARY,
|
||||
),
|
||||
Transaction(
|
||||
amount=Decimal("999"),
|
||||
merchant="PURCHASE",
|
||||
date=datetime(2026, 7, 14, 10),
|
||||
),
|
||||
]
|
||||
)
|
||||
bound_session.commit()
|
||||
|
||||
out = tools.get_salary_deposits(
|
||||
start_date="2026-07-14", end_date="2026-07-15"
|
||||
)
|
||||
|
||||
json.dumps(out)
|
||||
assert out == [
|
||||
{
|
||||
"id": out[0]["id"],
|
||||
"date": "2026-07-14T09:00:00",
|
||||
"amount": 1500.0,
|
||||
"currency": "CRC",
|
||||
"merchant": "SALARY JULY",
|
||||
"source": "CREDIT_CARD",
|
||||
"bank": "BAC",
|
||||
"transaction_type": "SALARY",
|
||||
"reference": "PAY-2026-07",
|
||||
"notes": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_get_current_date_is_costa_rica_and_cycle_consistent():
|
||||
from app.timeutil import today_cr
|
||||
|
||||
out = tools.get_current_date()
|
||||
today = today_cr()
|
||||
assert out["date"] == today.isoformat()
|
||||
# The active cycle must contain today: [start, end)
|
||||
start, end = out["cycle_range"]
|
||||
assert start <= out["date"] < end
|
||||
assert start.endswith("-18") and end.endswith("-18")
|
||||
|
||||
|
||||
def test_daily_spending_day_scoped(bound_session):
|
||||
from datetime import timedelta
|
||||
|
||||
from app.timeutil import today_cr
|
||||
|
||||
today = datetime.combine(today_cr(), datetime.min.time())
|
||||
bound_session.add(
|
||||
Transaction(amount=Decimal("100"), merchant="TODAY-A", date=today.replace(hour=9))
|
||||
)
|
||||
bound_session.add(
|
||||
Transaction(amount=Decimal("50"), merchant="TODAY-B", date=today.replace(hour=15))
|
||||
)
|
||||
bound_session.add(
|
||||
Transaction(
|
||||
amount=Decimal("999"),
|
||||
merchant="FUTURE CUOTA",
|
||||
date=today + timedelta(days=40),
|
||||
)
|
||||
)
|
||||
bound_session.commit()
|
||||
|
||||
out = tools.get_daily_spending(
|
||||
start_date=today.date().isoformat(),
|
||||
end_date=(today.date() + timedelta(days=60)).isoformat(),
|
||||
)
|
||||
assert len(out) == 1
|
||||
assert out[0]["date"] == today.date().isoformat()
|
||||
assert out[0]["total_crc"] == 150.0
|
||||
assert out[0]["count"] == 2
|
||||
|
||||
|
||||
def test_get_current_date_respects_client_timezone():
|
||||
from datetime import datetime, timezone as tz
|
||||
|
||||
from app.timeutil import (
|
||||
client_tz,
|
||||
reset_client_timezone,
|
||||
set_client_timezone,
|
||||
)
|
||||
|
||||
token = set_client_timezone("Asia/Tokyo")
|
||||
try:
|
||||
out = tools.get_current_date()
|
||||
assert out["timezone"] == "Asia/Tokyo"
|
||||
assert out["date"] == datetime.now(client_tz()).date().isoformat()
|
||||
finally:
|
||||
reset_client_timezone(token)
|
||||
|
||||
# Unknown names are ignored — CR fallback stays active.
|
||||
assert set_client_timezone("Not/AZone") is None
|
||||
assert set_client_timezone(None) is None
|
||||
assert tools.get_current_date()["timezone"] == "UTC-06:00"
|
||||
62
backend/tests/test_analytics.py
Normal file
62
backend/tests/test_analytics.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Analytics endpoints: category aggregation (Decimal regression) and the
|
||||
arbitrary date-range filters added for the Analytics range picker."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app.api.v1.endpoints.analytics import daily_spending, spending_by_category
|
||||
from app.models.models import Category, Transaction
|
||||
|
||||
from tests.test_installments import make_cc_tx
|
||||
|
||||
|
||||
def seed(session):
|
||||
cat = Category(name="Groceries")
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
make_cc_tx(session, merchant="A", amount="1000.50", reference="a1",
|
||||
date=datetime(2026, 6, 2), category_id=cat.id)
|
||||
make_cc_tx(session, merchant="B", amount="2000.00", reference="b1",
|
||||
date=datetime(2026, 6, 20))
|
||||
return cat
|
||||
|
||||
|
||||
class TestSpendingByCategory:
|
||||
def test_decimal_sum_does_not_crash_and_percentages_add_up(self, session):
|
||||
"""Regression: Decimal grand total vs float row total raised
|
||||
TypeError, 500ing the endpoint whenever it had data."""
|
||||
seed(session)
|
||||
rows = spending_by_category(session=session, _user="t")
|
||||
assert {r.category_name for r in rows} == {"Groceries", "Uncategorized"}
|
||||
assert sum(r.percentage for r in rows) == 100.0
|
||||
|
||||
def test_date_range_overrides_cycle(self, session):
|
||||
seed(session)
|
||||
rows = spending_by_category(
|
||||
start_date="2026-06-01",
|
||||
end_date="2026-06-10",
|
||||
cycle_year=2026,
|
||||
cycle_month=1, # would match nothing; range must win
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].category_name == "Groceries"
|
||||
assert rows[0].total == 1000.50
|
||||
|
||||
|
||||
class TestDailySpending:
|
||||
def test_date_range_filter(self, session):
|
||||
seed(session)
|
||||
rows = daily_spending(
|
||||
start_date="2026-06-15", end_date="2026-07-01",
|
||||
session=session, _user="t",
|
||||
)
|
||||
assert [r.date for r in rows] == ["2026-06-20"]
|
||||
assert rows[0].total == 2000.00
|
||||
|
||||
def test_future_cuotas_excluded(self, session):
|
||||
make_cc_tx(session, merchant="FUTURE", amount="500.00",
|
||||
reference="f1", date=datetime(2030, 1, 19))
|
||||
rows = daily_spending(session=session, _user="t")
|
||||
assert rows == []
|
||||
154
backend/tests/test_auth.py
Normal file
154
backend/tests/test_auth.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""JWT lifecycle, API-token validation, constant-time creds, login rate limit."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, Request
|
||||
from jose import jwt
|
||||
|
||||
from app import auth as auth_mod
|
||||
from app.auth import (
|
||||
ALGORITHM,
|
||||
LOGIN_RATE_LIMIT,
|
||||
_login_attempts,
|
||||
_validate_token,
|
||||
check_login_rate_limit,
|
||||
create_access_token,
|
||||
hash_token,
|
||||
verify_admin_credentials,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.models.models import APIToken
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
def make_request(ip: str) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", ip.encode())],
|
||||
"client": ("127.0.0.1", 1234),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class TestJWT:
|
||||
def test_roundtrip_and_seven_day_expiry(self):
|
||||
token = create_access_token("charlie")
|
||||
assert _validate_token(token) == "charlie"
|
||||
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
# exp is a true UTC epoch; a naive datetime's .timestamp() would be
|
||||
# interpreted as local time, so compare against an aware now.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
lifetime = payload["exp"] - datetime.now(timezone.utc).timestamp()
|
||||
assert lifetime == pytest.approx(7 * 24 * 3600, abs=60)
|
||||
|
||||
def test_expired_token_rejected(self, engine, monkeypatch):
|
||||
# The API-token fallback path opens a DB session; point it at the
|
||||
# test engine so the lookup runs (and finds nothing).
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
expired = jwt.encode(
|
||||
{"sub": "charlie", "exp": utcnow() - timedelta(minutes=1)},
|
||||
settings.SECRET_KEY,
|
||||
algorithm=ALGORITHM,
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_token(expired)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_garbage_token_rejected(self, engine, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_token("not-a-jwt")
|
||||
|
||||
|
||||
class TestAPIToken:
|
||||
def test_active_api_token_accepted(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(name="n8n", token_hash=hash_token("tok-123"), is_active=True)
|
||||
)
|
||||
session.commit()
|
||||
assert _validate_token("tok-123") == "api:n8n"
|
||||
|
||||
def test_expired_api_token_rejected(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(
|
||||
name="old",
|
||||
token_hash=hash_token("tok-old"),
|
||||
is_active=True,
|
||||
expires_at=utcnow() - timedelta(days=1),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_token("tok-old")
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
def test_revoked_api_token_rejected(self, engine, session, monkeypatch):
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
session.add(
|
||||
APIToken(
|
||||
name="revoked",
|
||||
token_hash=hash_token("tok-rev"),
|
||||
is_active=False,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_token("tok-rev")
|
||||
|
||||
|
||||
class TestCredentials:
|
||||
def test_correct_credentials_pass(self):
|
||||
verify_admin_credentials(
|
||||
settings.ADMIN_USERNAME, settings.ADMIN_PASSWORD
|
||||
) # no exception
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user,pw",
|
||||
[
|
||||
("wrong", "test-password"),
|
||||
("testadmin", "wrong"),
|
||||
("", ""),
|
||||
],
|
||||
)
|
||||
def test_wrong_credentials_rejected(self, user, pw):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
verify_admin_credentials(user, pw)
|
||||
assert exc.value.status_code == 401
|
||||
|
||||
|
||||
class TestLoginRateLimit:
|
||||
def setup_method(self):
|
||||
_login_attempts.clear()
|
||||
|
||||
def test_limit_allows_then_blocks(self):
|
||||
req = make_request("203.0.113.1")
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(req)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
check_login_rate_limit(req)
|
||||
assert exc.value.status_code == 429
|
||||
assert "Retry-After" in exc.value.headers
|
||||
|
||||
def test_limit_is_per_ip(self):
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(make_request("203.0.113.2"))
|
||||
# A different IP is unaffected.
|
||||
check_login_rate_limit(make_request("203.0.113.3"))
|
||||
|
||||
def test_window_slides(self):
|
||||
req = make_request("203.0.113.4")
|
||||
for _ in range(LOGIN_RATE_LIMIT):
|
||||
check_login_rate_limit(req)
|
||||
# Age every recorded attempt past the window; the next attempt passes.
|
||||
attempts = _login_attempts["203.0.113.4"]
|
||||
for i in range(len(attempts)):
|
||||
attempts[i] -= auth_mod.LOGIN_RATE_WINDOW_SECONDS + 1
|
||||
check_login_rate_limit(req) # no exception
|
||||
196
backend/tests/test_budget_projection.py
Normal file
196
backend/tests/test_budget_projection.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""Projection engine: recurring-item schedules, no-double-count, cumulative
|
||||
balances with overrides and the 2026-03 fresh start. Also serves as the
|
||||
float-behavior baseline ahead of the Phase 2 Decimal migration (plan 1.5)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models.models import (
|
||||
BalanceOverride,
|
||||
Category,
|
||||
RecurringFrequency,
|
||||
RecurringItem,
|
||||
RecurringItemType,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.budget_projection import (
|
||||
compute_monthly_projection,
|
||||
compute_yearly_projection_with_cumulative,
|
||||
get_effective_amount,
|
||||
)
|
||||
|
||||
|
||||
def make_item(**kw) -> RecurringItem:
|
||||
defaults = dict(
|
||||
name="item",
|
||||
amount=1000.0,
|
||||
item_type=RecurringItemType.EXPENSE,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return RecurringItem(**defaults)
|
||||
|
||||
|
||||
class TestEffectiveAmount:
|
||||
def test_monthly_base_and_override(self):
|
||||
item = make_item(amount=100.0, override_amounts={"4": 250.0})
|
||||
assert get_effective_amount(item, 3, 2026) == 100.0
|
||||
assert get_effective_amount(item, 4, 2026) == 250.0
|
||||
|
||||
def test_weekly_counts_weekday_occurrences(self):
|
||||
# day_of_month stores weekday, 0=Monday. March 2026 has 5 Mondays
|
||||
# (2, 9, 16, 23, 30); April 2026 has 4 (6, 13, 20, 27).
|
||||
item = make_item(
|
||||
amount=100.0, frequency=RecurringFrequency.WEEKLY, day_of_month=0
|
||||
)
|
||||
assert get_effective_amount(item, 3, 2026) == 500.0
|
||||
assert get_effective_amount(item, 4, 2026) == 400.0
|
||||
|
||||
def test_quarterly_active_in_quarter_end_months_only(self):
|
||||
item = make_item(frequency=RecurringFrequency.QUARTERLY, amount=900.0)
|
||||
for month in (3, 6, 9, 12):
|
||||
assert get_effective_amount(item, month, 2026) == 900.0
|
||||
for month in (1, 2, 4, 5, 7, 8, 10, 11):
|
||||
assert get_effective_amount(item, month, 2026) is None
|
||||
|
||||
def test_biannual_base_and_six_months_later(self):
|
||||
item = make_item(
|
||||
frequency=RecurringFrequency.BIANNUAL, month_of_year=2, amount=70.0
|
||||
)
|
||||
assert get_effective_amount(item, 2, 2026) == 70.0
|
||||
assert get_effective_amount(item, 8, 2026) == 70.0
|
||||
assert get_effective_amount(item, 5, 2026) is None
|
||||
|
||||
late = make_item(
|
||||
frequency=RecurringFrequency.BIANNUAL, month_of_year=9, amount=70.0
|
||||
)
|
||||
assert get_effective_amount(late, 9, 2026) == 70.0
|
||||
assert get_effective_amount(late, 3, 2026) == 70.0
|
||||
|
||||
def test_yearly_defaults_to_december(self):
|
||||
item = make_item(frequency=RecurringFrequency.YEARLY, amount=50.0)
|
||||
assert get_effective_amount(item, 12, 2026) == 50.0
|
||||
assert get_effective_amount(item, 6, 2026) is None
|
||||
july = make_item(
|
||||
frequency=RecurringFrequency.YEARLY, month_of_year=7, amount=50.0
|
||||
)
|
||||
assert get_effective_amount(july, 7, 2026) == 50.0
|
||||
|
||||
|
||||
class TestMonthlyProjection:
|
||||
def _seed(self, session: Session):
|
||||
groceries = Category(name="Groceries")
|
||||
other = Category(name="Other")
|
||||
session.add(groceries)
|
||||
session.add(other)
|
||||
session.commit()
|
||||
session.refresh(groceries)
|
||||
session.refresh(other)
|
||||
|
||||
session.add(
|
||||
RecurringItem(
|
||||
name="Salary",
|
||||
amount=500_000.0,
|
||||
item_type=RecurringItemType.INCOME,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
RecurringItem(
|
||||
name="Groceries budget",
|
||||
amount=100_000.0,
|
||||
item_type=RecurringItemType.EXPENSE,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
category_id=groceries.id,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
return groceries, other
|
||||
|
||||
def _cc(self, date, amount, category_id=None):
|
||||
return Transaction(
|
||||
amount=amount,
|
||||
merchant="X",
|
||||
date=date,
|
||||
transaction_type=TransactionType.COMPRA,
|
||||
source=TransactionSource.CREDIT_CARD,
|
||||
category_id=category_id,
|
||||
)
|
||||
|
||||
def test_actuals_replace_projection_for_covered_category(self, session):
|
||||
groceries, _ = self._seed(session)
|
||||
# April budget ⇒ cycle [Mar 18, Apr 18)
|
||||
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
|
||||
session.commit()
|
||||
|
||||
data = compute_monthly_projection(session, 2026, 4)
|
||||
grocery_line = next(
|
||||
i for i in data["expense_items"] if i["name"] == "Groceries budget"
|
||||
)
|
||||
assert grocery_line["used_actual"] is True
|
||||
assert grocery_line["amount"] == pytest.approx(80_000.0)
|
||||
assert grocery_line["projected_amount"] == pytest.approx(100_000.0)
|
||||
assert data["projected_fixed_expenses"] == pytest.approx(80_000.0)
|
||||
|
||||
def test_projection_used_when_no_actuals(self, session):
|
||||
self._seed(session)
|
||||
data = compute_monthly_projection(session, 2026, 4)
|
||||
assert data["projected_fixed_expenses"] == pytest.approx(100_000.0)
|
||||
assert data["projected_income"] == pytest.approx(500_000.0)
|
||||
assert data["net_balance"] == pytest.approx(400_000.0)
|
||||
|
||||
def test_uncovered_and_uncategorized_actuals_add_up(self, session):
|
||||
groceries, other = self._seed(session)
|
||||
session.add(self._cc(datetime(2026, 4, 1), 80_000.0, groceries.id))
|
||||
session.add(self._cc(datetime(2026, 4, 2), 5_000.0, other.id))
|
||||
session.add(self._cc(datetime(2026, 4, 3), 2_000.0, None))
|
||||
session.commit()
|
||||
|
||||
data = compute_monthly_projection(session, 2026, 4)
|
||||
assert data["uncovered_actual"] == pytest.approx(7_000.0)
|
||||
assert data["gran_total_egresos"] == pytest.approx(87_000.0)
|
||||
assert data["net_balance"] == pytest.approx(500_000.0 - 87_000.0)
|
||||
|
||||
|
||||
class TestYearlyCumulative:
|
||||
def _income_only(self, session: Session, amount=100.0):
|
||||
session.add(
|
||||
RecurringItem(
|
||||
name="Income",
|
||||
amount=amount,
|
||||
item_type=RecurringItemType.INCOME,
|
||||
frequency=RecurringFrequency.MONTHLY,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_fresh_start_zeroes_months_before_march_2026(self, session):
|
||||
self._income_only(session)
|
||||
months = compute_yearly_projection_with_cumulative(session, 2026)
|
||||
assert months[0]["cumulative_balance"] == 0.0 # January
|
||||
assert months[1]["cumulative_balance"] == 0.0 # February
|
||||
assert months[2]["cumulative_balance"] == pytest.approx(100.0) # March
|
||||
assert months[11]["cumulative_balance"] == pytest.approx(1000.0) # Dec
|
||||
|
||||
def test_override_resets_cumulative_chain(self, session):
|
||||
self._income_only(session)
|
||||
session.add(BalanceOverride(year=2026, month=4, override_balance=500.0))
|
||||
session.commit()
|
||||
|
||||
months = compute_yearly_projection_with_cumulative(session, 2026)
|
||||
april, may = months[3], months[4]
|
||||
assert april["balance_overridden"] is True
|
||||
assert april["cumulative_balance"] == pytest.approx(500.0)
|
||||
assert may["carryover_balance"] == pytest.approx(500.0)
|
||||
assert may["cumulative_balance"] == pytest.approx(600.0)
|
||||
|
||||
def test_next_year_carries_december_forward(self, session):
|
||||
self._income_only(session)
|
||||
months_2027 = compute_yearly_projection_with_cumulative(session, 2027)
|
||||
# 2026 accrues 10 months (Mar–Dec) of 100 → Jan 2027 carries 1000.
|
||||
assert months_2027[0]["carryover_balance"] == pytest.approx(1000.0)
|
||||
assert months_2027[0]["cumulative_balance"] == pytest.approx(1100.0)
|
||||
139
backend/tests/test_chat_audit.py
Normal file
139
backend/tests/test_chat_audit.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Chat-run audit persistence and redacted HTTP access logs."""
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
from sqlmodel import select
|
||||
|
||||
import app.agent.chat_audit as chat_audit
|
||||
import app.agent.snapshot_store as snapshot_store_module
|
||||
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||
from app.models.models import ChatRunLog
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
def _audit_store(monkeypatch, engine):
|
||||
monkeypatch.setattr(chat_audit, "engine", engine)
|
||||
monkeypatch.setattr(snapshot_store_module, "engine", engine)
|
||||
return PostgresSnapshotStore()
|
||||
|
||||
|
||||
def test_snapshot_completes_chat_run_with_tools_and_response(monkeypatch, engine, session):
|
||||
store = _audit_store(monkeypatch, engine)
|
||||
run_id = chat_audit.create_chat_run(
|
||||
request_id="req-1",
|
||||
agui_run_id="run-1",
|
||||
thread_id="main",
|
||||
messages=[{"role": "user", "content": "¿Cuál es mi saldo?"}],
|
||||
client_ip="203.0.113.7",
|
||||
user_agent="Mozilla/5.0 TestBrowser/1.0",
|
||||
browser="TestBrowser",
|
||||
)
|
||||
token = chat_audit.set_current_chat_run(run_id)
|
||||
try:
|
||||
asyncio.run(
|
||||
store.save(
|
||||
scope="default",
|
||||
thread_id="resp_123",
|
||||
snapshot=AGUIThreadSnapshot(
|
||||
messages=[
|
||||
{"id": "u1", "role": "user", "content": "¿Cuál es mi saldo?"},
|
||||
{
|
||||
"id": "a1",
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_net_worth", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"id": "t1", "role": "tool", "tool_call_id": "call-1", "content": "{\"net_crc\": 1}"},
|
||||
{"id": "a2", "role": "assistant", "content": "Tu saldo es ₡1."},
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
finally:
|
||||
chat_audit.reset_current_chat_run(token)
|
||||
|
||||
row = session.get(ChatRunLog, run_id)
|
||||
assert row is not None
|
||||
assert row.status == "completed"
|
||||
assert row.agui_run_id == "run-1"
|
||||
assert row.response_thread_id == "resp_123"
|
||||
assert row.user_prompt == "¿Cuál es mi saldo?"
|
||||
assert row.client_ip == "203.0.113.7"
|
||||
assert row.user_agent == "Mozilla/5.0 TestBrowser/1.0"
|
||||
assert row.browser == "TestBrowser"
|
||||
assert row.assistant_response == "Tu saldo es ₡1."
|
||||
assert row.tool_calls == [
|
||||
{
|
||||
"id": "call-1",
|
||||
"name": "get_net_worth",
|
||||
"arguments": "{}",
|
||||
"result": '{"net_crc": 1}',
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_failure_and_retention_cleanup(monkeypatch, engine, session):
|
||||
_audit_store(monkeypatch, engine)
|
||||
failed_id = chat_audit.create_chat_run(
|
||||
request_id="req-failed", agui_run_id="run-failed", thread_id="main", messages=[]
|
||||
)
|
||||
chat_audit.mark_chat_run_failed(failed_id, "RuntimeError: provider unavailable")
|
||||
assert session.get(ChatRunLog, failed_id).status == "failed"
|
||||
|
||||
expired = ChatRunLog(
|
||||
request_id="req-expired",
|
||||
model="gpt-5.6-luna",
|
||||
expires_at=utcnow() - timedelta(seconds=1),
|
||||
)
|
||||
session.add(expired)
|
||||
session.commit()
|
||||
|
||||
assert chat_audit.purge_expired_chat_runs() == 1
|
||||
assert session.exec(select(ChatRunLog).where(ChatRunLog.request_id == "req-expired")).first() is None
|
||||
|
||||
|
||||
def test_api_access_log_never_contains_request_body(monkeypatch):
|
||||
from app.main import log_api_request
|
||||
|
||||
logged = []
|
||||
monkeypatch.setattr("app.main.log_event", lambda event, **fields: logged.append((event, fields)))
|
||||
scope = {
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/api/auth/login",
|
||||
"raw_path": b"/api/auth/login",
|
||||
"query_string": b"",
|
||||
"headers": [
|
||||
(b"x-real-ip", b"203.0.113.9"),
|
||||
(b"user-agent", b"Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"),
|
||||
],
|
||||
"scheme": "http",
|
||||
"server": ("testserver", 80),
|
||||
"client": ("testclient", 1234),
|
||||
}
|
||||
request = Request(scope)
|
||||
|
||||
async def call_next(_request):
|
||||
return Response(status_code=201)
|
||||
|
||||
response = asyncio.run(log_api_request(request, call_next))
|
||||
assert response.headers["X-Request-ID"]
|
||||
assert logged[0][0] == "api_request"
|
||||
fields = logged[0][1]
|
||||
assert fields["path"] == "/api/auth/login"
|
||||
assert fields["status_code"] == 201
|
||||
assert fields["client_ip"] == "203.0.113.9"
|
||||
assert fields["browser"] == "Chrome"
|
||||
assert fields["user_agent"] == "Mozilla/5.0 Chrome/138.0.0.0 Safari/537.36"
|
||||
assert "password" not in fields
|
||||
assert "body" not in fields
|
||||
167
backend/tests/test_chat_persistence.py
Normal file
167
backend/tests/test_chat_persistence.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Chat thread persistence: snapshot store round-trip + clear endpoint."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework_ag_ui import AGUIThreadSnapshot
|
||||
from sqlmodel import select
|
||||
|
||||
import app.agent.snapshot_store as snapshot_store_module
|
||||
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||
from app.api.v1.endpoints.chat import clear_thread
|
||||
from app.models.models import ChatThreadSnapshot
|
||||
|
||||
|
||||
def _store(monkeypatch, engine):
|
||||
# The store opens its own sessions from app.db.engine; point it at the
|
||||
# test engine instead.
|
||||
monkeypatch.setattr(snapshot_store_module, "engine", engine)
|
||||
return PostgresSnapshotStore()
|
||||
|
||||
|
||||
def test_save_get_roundtrip_normalizes_and_filters(monkeypatch, engine):
|
||||
store = _store(monkeypatch, engine)
|
||||
snapshot = AGUIThreadSnapshot(
|
||||
messages=[
|
||||
{"id": "u1", "role": "user", "content": "hola"},
|
||||
{
|
||||
"id": "a1",
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
# MAF emits snake_case; the store must return camelCase so
|
||||
# @ag-ui/client's Zod schema doesn't strip it.
|
||||
"tool_calls": [{"id": "t1", "type": "function"}],
|
||||
},
|
||||
{"id": "t1r", "role": "tool", "content": "ok", "tool_call_id": "t1"},
|
||||
# MAF writes tool_calls: null on plain-text assistant messages —
|
||||
# must not survive as toolCalls: null in the replay.
|
||||
{"id": "a2", "role": "assistant", "content": "done", "tool_calls": None},
|
||||
{"id": "ctx-123", "role": "system", "content": "run context"},
|
||||
],
|
||||
state={"k": "v"},
|
||||
)
|
||||
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
|
||||
|
||||
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
|
||||
assert loaded is not None
|
||||
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1r", "a2"]
|
||||
assistant = loaded.messages[1]
|
||||
assert "tool_calls" not in assistant
|
||||
assert assistant["toolCalls"] == [{"id": "t1", "type": "function"}]
|
||||
tool_msg = loaded.messages[2]
|
||||
assert "tool_call_id" not in tool_msg
|
||||
assert tool_msg["toolCallId"] == "t1"
|
||||
text_msg = loaded.messages[3]
|
||||
assert "toolCalls" not in text_msg and "tool_calls" not in text_msg
|
||||
assert loaded.state == {"k": "v"}
|
||||
|
||||
|
||||
def test_save_upserts_single_row(monkeypatch, engine, session):
|
||||
store = _store(monkeypatch, engine)
|
||||
for n in range(3):
|
||||
snap = AGUIThreadSnapshot(
|
||||
messages=[{"id": f"u{i}", "role": "user", "content": str(i)} for i in range(n + 1)]
|
||||
)
|
||||
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||
|
||||
rows = session.exec(select(ChatThreadSnapshot)).all()
|
||||
assert len(rows) == 1
|
||||
assert len(rows[0].messages) == 3
|
||||
|
||||
|
||||
def test_get_unknown_thread_returns_none(monkeypatch, engine):
|
||||
store = _store(monkeypatch, engine)
|
||||
assert asyncio.run(store.get(scope="default", thread_id="nope")) is None
|
||||
|
||||
|
||||
def test_delete_and_scope_isolation(monkeypatch, engine):
|
||||
store = _store(monkeypatch, engine)
|
||||
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
|
||||
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||
|
||||
assert asyncio.run(store.get(scope="other", thread_id="main")) is None
|
||||
assert asyncio.run(store.delete(scope="default", thread_id="main")) is True
|
||||
assert asyncio.run(store.delete(scope="default", thread_id="main")) is False
|
||||
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
|
||||
|
||||
|
||||
def test_save_dedupes_multi_run_turn(monkeypatch, engine):
|
||||
"""Mirror of MAF's duplicate merge on frontend-tool roundtrips: repeated
|
||||
message ids, a re-issued identical render call, and extra tool results."""
|
||||
store = _store(monkeypatch, engine)
|
||||
|
||||
def call(cid, name, args='{"a":1}'):
|
||||
return {"id": cid, "type": "function", "function": {"name": name, "arguments": args}}
|
||||
|
||||
snapshot = AGUIThreadSnapshot(
|
||||
messages=[
|
||||
{"id": "u1", "role": "user", "content": "resumen"},
|
||||
{"id": "a1", "role": "assistant", "content": "",
|
||||
"tool_calls": [call("c1", "get_cycle_summary"), call("c2", "render_spending_summary")]},
|
||||
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
|
||||
# MAF re-records the resent suffix verbatim…
|
||||
{"id": "u1", "role": "user", "content": "resumen"},
|
||||
{"id": "t1", "role": "tool", "content": "data", "tool_call_id": "c1"},
|
||||
{"id": "t2", "role": "tool", "content": "ok", "tool_call_id": "c2"},
|
||||
# …plus a synthetic second result for an answered call…
|
||||
{"id": "t3", "role": "tool", "content": "", "tool_call_id": "c1"},
|
||||
# …and the model re-issues the same render call with a new id.
|
||||
{"id": "a2", "role": "assistant", "content": "",
|
||||
"tool_calls": [call("c3", "render_spending_summary")]},
|
||||
{"id": "t4", "role": "tool", "content": "ok", "tool_call_id": "c3"},
|
||||
]
|
||||
)
|
||||
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snapshot))
|
||||
|
||||
loaded = asyncio.run(store.get(scope="default", thread_id="main"))
|
||||
assert [m["id"] for m in loaded.messages] == ["u1", "a1", "t1", "t2"]
|
||||
# The duplicate render call (c3) and its result are gone; each surviving
|
||||
# call has exactly one result.
|
||||
assert [tc["id"] for tc in loaded.messages[1]["toolCalls"]] == ["c1", "c2"]
|
||||
assert [(m["toolCallId"]) for m in loaded.messages if m["role"] == "tool"] == ["c1", "c2"]
|
||||
|
||||
|
||||
def test_clear_thread_endpoint(monkeypatch, engine, session):
|
||||
store = _store(monkeypatch, engine)
|
||||
assert clear_thread(session=session, _user="testadmin") == {"cleared": False}
|
||||
|
||||
snap = AGUIThreadSnapshot(messages=[{"id": "u1", "role": "user", "content": "x"}])
|
||||
asyncio.run(store.save(scope="default", thread_id="main", snapshot=snap))
|
||||
|
||||
assert clear_thread(session=session, _user="testadmin") == {"cleared": True}
|
||||
assert asyncio.run(store.get(scope="default", thread_id="main")) is None
|
||||
|
||||
|
||||
def test_drop_stale_client_history(session, monkeypatch, engine):
|
||||
from app.main import _drop_stale_client_history
|
||||
|
||||
history = [
|
||||
{"role": "user", "content": "vieja pregunta"},
|
||||
{"role": "assistant", "content": "vieja respuesta"},
|
||||
{"role": "tool", "content": "x", "toolCallId": "t1"},
|
||||
{"role": "user", "content": "nueva pregunta"},
|
||||
]
|
||||
|
||||
# No stored snapshot for the thread → stale client: keep last user turn.
|
||||
assert _drop_stale_client_history(session, "main", list(history)) == [
|
||||
{"role": "user", "content": "nueva pregunta"}
|
||||
]
|
||||
|
||||
# Fresh conversation (no assistant/tool yet) passes through.
|
||||
fresh = [{"role": "user", "content": "hola"}]
|
||||
assert _drop_stale_client_history(session, "main", list(fresh)) == fresh
|
||||
|
||||
# Empty messages (hydration trigger) pass through.
|
||||
assert _drop_stale_client_history(session, "main", []) == []
|
||||
|
||||
# With a stored snapshot the full history is legitimate.
|
||||
store = _store(monkeypatch, engine)
|
||||
asyncio.run(
|
||||
store.save(
|
||||
scope="default",
|
||||
thread_id="main",
|
||||
snapshot=AGUIThreadSnapshot(
|
||||
messages=[{"id": "u1", "role": "user", "content": "hola"}]
|
||||
),
|
||||
)
|
||||
)
|
||||
assert _drop_stale_client_history(session, "main", list(history)) == history
|
||||
44
backend/tests/test_config.py
Normal file
44
backend/tests/test_config.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Fail-fast settings validation (SEC-01 / BE-15)."""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
GOOD = {
|
||||
"SECRET_KEY": "f" * 64,
|
||||
"ADMIN_USERNAME": "charlie",
|
||||
"ADMIN_PASSWORD": "a-real-password",
|
||||
}
|
||||
|
||||
|
||||
def make(**overrides) -> Settings:
|
||||
return Settings(_env_file=None, **{**GOOD, **overrides})
|
||||
|
||||
|
||||
def test_valid_settings_boot():
|
||||
s = make()
|
||||
assert s.ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24 * 7
|
||||
assert s.COOKIE_SECURE is True
|
||||
assert s.cors_origins_list[0] == "https://wealth.cescalante.dev"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"SECRET_KEY": "change-me-in-production"},
|
||||
{"SECRET_KEY": "short"},
|
||||
{"SECRET_KEY": ""},
|
||||
{"ADMIN_PASSWORD": "admin"},
|
||||
{"ADMIN_PASSWORD": ""},
|
||||
{"ADMIN_USERNAME": ""},
|
||||
],
|
||||
)
|
||||
def test_weak_or_missing_secrets_rejected(overrides):
|
||||
with pytest.raises(ValidationError):
|
||||
make(**overrides)
|
||||
|
||||
|
||||
def test_cors_origins_parse_and_strip():
|
||||
s = make(CORS_ORIGINS=" https://a.example , http://b.example ,")
|
||||
assert s.cors_origins_list == ["https://a.example", "http://b.example"]
|
||||
202
backend/tests/test_cycle_math.py
Normal file
202
backend/tests/test_cycle_math.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""Characterization tests for billing-cycle math (review finding BE-06).
|
||||
|
||||
VERDICT: BE-06 is a false positive. The system is internally consistent:
|
||||
|
||||
- `get_cycle_range(Y, M)` returns the cycle that STARTS on the 18th of M:
|
||||
[M/18, (M+1)/18).
|
||||
- Budget month M covers the credit-card cycle that ENDS on the 18th of M,
|
||||
i.e. `get_previous_cycle(M)` then `get_cycle_range(M-1)` = [(M-1)/18, M/18) —
|
||||
exactly what the comment in compute_actuals_by_source says.
|
||||
- The frontend (Budget.tsx) follows the same convention: viewing month M it
|
||||
queries cycle_month = M-1.
|
||||
|
||||
These tests pin that convention so it can never silently shift.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models.models import (
|
||||
Currency,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.budget_projection import (
|
||||
compute_actuals_by_source,
|
||||
get_cycle_range,
|
||||
get_month_range,
|
||||
get_previous_cycle,
|
||||
)
|
||||
|
||||
|
||||
def cc_tx(
|
||||
date: datetime,
|
||||
amount: float = 1000.0,
|
||||
*,
|
||||
tx_type: TransactionType = TransactionType.COMPRA,
|
||||
source: TransactionSource = TransactionSource.CREDIT_CARD,
|
||||
currency: Currency = Currency.CRC,
|
||||
deferred: bool = False,
|
||||
) -> Transaction:
|
||||
return Transaction(
|
||||
amount=amount,
|
||||
currency=currency,
|
||||
merchant="TEST",
|
||||
date=date,
|
||||
transaction_type=tx_type,
|
||||
source=source,
|
||||
deferred_to_next_cycle=deferred,
|
||||
)
|
||||
|
||||
|
||||
def cc_net(session: Session, year: int, month: int) -> float:
|
||||
return compute_actuals_by_source(session, year, month)["CREDIT_CARD"]["net"]
|
||||
|
||||
|
||||
class TestRangeHelpers:
|
||||
def test_cycle_starts_on_the_18th_of_its_label_month(self):
|
||||
assert get_cycle_range(2026, 3) == (
|
||||
datetime(2026, 3, 18),
|
||||
datetime(2026, 4, 18),
|
||||
)
|
||||
|
||||
def test_december_cycle_wraps_into_next_year(self):
|
||||
assert get_cycle_range(2026, 12) == (
|
||||
datetime(2026, 12, 18),
|
||||
datetime(2027, 1, 18),
|
||||
)
|
||||
|
||||
def test_month_range_normal_and_december(self):
|
||||
assert get_month_range(2026, 4) == (
|
||||
datetime(2026, 4, 1),
|
||||
datetime(2026, 5, 1),
|
||||
)
|
||||
assert get_month_range(2026, 12) == (
|
||||
datetime(2026, 12, 1),
|
||||
datetime(2027, 1, 1),
|
||||
)
|
||||
|
||||
def test_previous_cycle_january_wraps_to_prior_december(self):
|
||||
assert get_previous_cycle(2026, 1) == (2025, 12)
|
||||
assert get_previous_cycle(2026, 7) == (2026, 6)
|
||||
|
||||
@pytest.mark.parametrize("month", [0, 13, -1, 99])
|
||||
def test_invalid_month_rejected(self, month):
|
||||
with pytest.raises(ValueError):
|
||||
get_cycle_range(2026, month)
|
||||
with pytest.raises(ValueError):
|
||||
get_month_range(2026, month)
|
||||
|
||||
|
||||
class TestBudgetMonthCycleConvention:
|
||||
"""Budget month M ← credit-card cycle [(M-1)/18, M/18)."""
|
||||
|
||||
def test_april_budget_covers_march18_to_april17(self, session):
|
||||
included = [
|
||||
cc_tx(datetime(2026, 3, 18, 0, 0), 100.0), # first instant of cycle
|
||||
cc_tx(datetime(2026, 4, 1, 12, 0), 200.0), # mid-cycle
|
||||
cc_tx(datetime(2026, 4, 17, 23, 59), 400.0), # last day of cycle
|
||||
]
|
||||
excluded = [
|
||||
cc_tx(datetime(2026, 3, 17, 23, 59), 7000.0), # previous cycle
|
||||
cc_tx(datetime(2026, 4, 18, 0, 0), 9000.0), # next cycle
|
||||
]
|
||||
for tx in included + excluded:
|
||||
session.add(tx)
|
||||
session.commit()
|
||||
|
||||
assert cc_net(session, 2026, 4) == pytest.approx(700.0)
|
||||
# The boundary transactions land in the adjacent budget months instead.
|
||||
assert cc_net(session, 2026, 3) == pytest.approx(7000.0)
|
||||
assert cc_net(session, 2026, 5) == pytest.approx(9000.0)
|
||||
|
||||
def test_january_budget_reaches_into_prior_year(self, session):
|
||||
session.add(cc_tx(datetime(2025, 12, 20), 500.0))
|
||||
session.commit()
|
||||
assert cc_net(session, 2026, 1) == pytest.approx(500.0)
|
||||
|
||||
def test_cash_uses_calendar_month_not_cycle(self, session):
|
||||
session.add(
|
||||
cc_tx(
|
||||
datetime(2026, 4, 17),
|
||||
300.0,
|
||||
source=TransactionSource.CASH,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
actuals = compute_actuals_by_source(session, 2026, 4)
|
||||
assert actuals["CASH"]["net"] == pytest.approx(300.0)
|
||||
# Not in March's calendar month, despite being in March's... no —
|
||||
# Apr 17 belongs to April calendar month only.
|
||||
assert compute_actuals_by_source(session, 2026, 3)["CASH"][
|
||||
"net"
|
||||
] == pytest.approx(0.0)
|
||||
|
||||
|
||||
class TestDeferredTransactions:
|
||||
def test_deferred_moves_to_next_budget_month(self, session):
|
||||
# 2026-03-10 lies in cycle [Feb 18, Mar 18) → budget month 3.
|
||||
# Deferred, it must count in budget month 4 instead.
|
||||
session.add(cc_tx(datetime(2026, 3, 10), 1500.0, deferred=True))
|
||||
session.commit()
|
||||
|
||||
assert cc_net(session, 2026, 3) == pytest.approx(0.0)
|
||||
assert cc_net(session, 2026, 4) == pytest.approx(1500.0)
|
||||
# And it does not leak into month 5.
|
||||
assert cc_net(session, 2026, 5) == pytest.approx(0.0)
|
||||
|
||||
def test_deferred_devolucion_also_moves(self, session):
|
||||
session.add(cc_tx(datetime(2026, 3, 10), 9000.0)) # normal, month 3
|
||||
session.add(
|
||||
cc_tx(
|
||||
datetime(2026, 3, 10),
|
||||
2000.0,
|
||||
tx_type=TransactionType.DEVOLUCION,
|
||||
deferred=True,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
assert cc_net(session, 2026, 3) == pytest.approx(9000.0)
|
||||
assert cc_net(session, 2026, 4) == pytest.approx(-2000.0)
|
||||
|
||||
|
||||
class TestAmountSemantics:
|
||||
def test_devolucion_subtracts_from_net(self, session):
|
||||
session.add(cc_tx(datetime(2026, 4, 1), 5000.0))
|
||||
session.add(
|
||||
cc_tx(
|
||||
datetime(2026, 4, 2),
|
||||
1200.0,
|
||||
tx_type=TransactionType.DEVOLUCION,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
actuals = compute_actuals_by_source(session, 2026, 4)["CREDIT_CARD"]
|
||||
assert actuals["total_compra"] == pytest.approx(5000.0)
|
||||
assert actuals["total_devolucion"] == pytest.approx(1200.0)
|
||||
assert actuals["net"] == pytest.approx(3800.0)
|
||||
assert actuals["count"] == 2
|
||||
|
||||
def test_usd_converted_at_multiplier(self, session):
|
||||
# conftest pins USD→CRC at 500.
|
||||
session.add(cc_tx(datetime(2026, 4, 1), 10.0, currency=Currency.USD))
|
||||
session.commit()
|
||||
assert cc_net(session, 2026, 4) == pytest.approx(5000.0)
|
||||
|
||||
def test_income_types_excluded_from_counts(self, session):
|
||||
session.add(
|
||||
cc_tx(
|
||||
datetime(2026, 4, 1),
|
||||
999999.0,
|
||||
tx_type=TransactionType.SALARY,
|
||||
source=TransactionSource.TRANSFER,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
actuals = compute_actuals_by_source(session, 2026, 4)["TRANSFER"]
|
||||
assert actuals["count"] == 0
|
||||
# SALARY is not COMPRA/DEVOLUCION so it contributes nothing to net.
|
||||
assert actuals["net"] == pytest.approx(0.0)
|
||||
60
backend/tests/test_import_parse.py
Normal file
60
backend/tests/test_import_parse.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Characterization of the BAC paste-import line parser."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.api.v1.endpoints.import_transactions import (
|
||||
make_reference_hash,
|
||||
parse_bac_line,
|
||||
)
|
||||
from app.models.models import TransactionType
|
||||
|
||||
|
||||
class TestParseBacLine:
|
||||
def test_tab_separated_purchase(self):
|
||||
line = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
|
||||
parsed = parse_bac_line(line)
|
||||
assert parsed is not None
|
||||
assert parsed["date"] == datetime(2026, 4, 15)
|
||||
assert parsed["merchant"] == "WALMART"
|
||||
assert parsed["city"] == "SAN JOSE"
|
||||
assert parsed["amount"] == 25000.50
|
||||
assert parsed["currency"] == "CRC"
|
||||
assert parsed["transaction_type"] == TransactionType.COMPRA
|
||||
|
||||
def test_multispace_separated_usd(self):
|
||||
line = "01/05/2026 AMAZON\\SEATTLE\\US 12.99 USD"
|
||||
parsed = parse_bac_line(line)
|
||||
assert parsed is not None
|
||||
assert parsed["amount"] == 12.99
|
||||
assert parsed["currency"] == "USD"
|
||||
|
||||
def test_negative_amount_is_refund(self):
|
||||
parsed = parse_bac_line("15/04/2026\tSTORE\\X\\CR\t-5,000.00 CRC")
|
||||
assert parsed is not None
|
||||
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
|
||||
assert parsed["amount"] == 5000.0 # stored as absolute value
|
||||
|
||||
def test_pago_recibido_is_refund(self):
|
||||
parsed = parse_bac_line("15/04/2026\tPAGO RECIBIDO\\\\\t100.00 CRC")
|
||||
assert parsed is not None
|
||||
assert parsed["transaction_type"] == TransactionType.DEVOLUCION
|
||||
|
||||
def test_unparseable_lines_return_none(self):
|
||||
assert parse_bac_line("") is None
|
||||
assert parse_bac_line("not a transaction") is None
|
||||
assert parse_bac_line("99/99/2026\tX\\Y\\Z\t1.00 CRC") is None
|
||||
assert parse_bac_line("15/04/2026\tX\\Y\\Z\t1.00 GBP") is None
|
||||
|
||||
|
||||
class TestReferenceHash:
|
||||
def test_deterministic_and_merchant_normalized(self):
|
||||
a = make_reference_hash(datetime(2026, 4, 15), " walmart ", 100.0, "CRC")
|
||||
b = make_reference_hash(datetime(2026, 4, 15), "WALMART", 100.0, "CRC")
|
||||
assert a == b
|
||||
assert len(a) == 16
|
||||
|
||||
def test_different_inputs_differ(self):
|
||||
base = make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "CRC")
|
||||
assert make_reference_hash(datetime(2026, 4, 16), "X", 100.0, "CRC") != base
|
||||
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.1, "CRC") != base
|
||||
assert make_reference_hash(datetime(2026, 4, 15), "X", 100.0, "USD") != base
|
||||
131
backend/tests/test_import_review_and_bulk.py
Normal file
131
backend/tests/test_import_review_and_bulk.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Paste-import dedup transparency + override (UX-20) and bulk actions (4.4).
|
||||
|
||||
These exercise the endpoint functions directly with a session — no HTTP
|
||||
stack needed.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints.import_transactions import (
|
||||
PasteImportRequest,
|
||||
paste_import,
|
||||
)
|
||||
from app.api.v1.endpoints.transactions import BulkActionRequest, bulk_action
|
||||
from app.models.models import Category, Transaction, TransactionSource
|
||||
|
||||
LINE = "15/04/2026\tWALMART\\SAN JOSE\\CR\t25,000.50 CRC"
|
||||
|
||||
|
||||
def do_import(session, text=LINE, **kw):
|
||||
return paste_import(
|
||||
PasteImportRequest(text=text, **kw), session=session, _user="t"
|
||||
)
|
||||
|
||||
|
||||
class TestImportReview:
|
||||
def test_duplicate_is_reported_with_matched_row(self, session):
|
||||
first = do_import(session)
|
||||
assert first.imported == 1 and first.duplicates == 0
|
||||
|
||||
second = do_import(session)
|
||||
assert second.imported == 0
|
||||
assert second.duplicates == 1
|
||||
assert len(second.skipped) == 1
|
||||
skip = second.skipped[0]
|
||||
assert skip.merchant == "WALMART"
|
||||
assert skip.amount == 25000.50
|
||||
assert skip.existing_merchant == "WALMART"
|
||||
assert skip.existing_amount == 25000.50
|
||||
assert skip.existing_id > 0
|
||||
|
||||
def test_allow_duplicates_overrides_dedup(self, session):
|
||||
do_import(session)
|
||||
forced = do_import(session, allow_duplicates=True)
|
||||
assert forced.imported == 1
|
||||
assert forced.duplicates == 0
|
||||
assert forced.skipped == []
|
||||
|
||||
|
||||
def make_tx(session, merchant="M", category_id=None):
|
||||
tx = Transaction(
|
||||
amount=Decimal("10"),
|
||||
merchant=merchant,
|
||||
date=datetime(2026, 4, 1),
|
||||
category_id=category_id,
|
||||
source=TransactionSource.CREDIT_CARD,
|
||||
)
|
||||
session.add(tx)
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
return tx
|
||||
|
||||
|
||||
class TestBulkActions:
|
||||
def test_bulk_set_category(self, session):
|
||||
cat = Category(name="Bulk")
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
txs = [make_tx(session, f"m{i}") for i in range(3)]
|
||||
|
||||
result = bulk_action(
|
||||
BulkActionRequest(
|
||||
ids=[t.id for t in txs], action="set_category", category_id=cat.id
|
||||
),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert result["affected"] == 3
|
||||
for t in txs:
|
||||
session.refresh(t)
|
||||
assert t.category_id == cat.id
|
||||
|
||||
def test_bulk_set_deferred_and_clear_category(self, session):
|
||||
cat = Category(name="X")
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
tx = make_tx(session, category_id=cat.id)
|
||||
|
||||
bulk_action(
|
||||
BulkActionRequest(ids=[tx.id], action="set_deferred", deferred=True),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
session.refresh(tx)
|
||||
assert tx.deferred_to_next_cycle is True
|
||||
|
||||
bulk_action(
|
||||
BulkActionRequest(ids=[tx.id], action="set_category", category_id=None),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
session.refresh(tx)
|
||||
assert tx.category_id is None
|
||||
|
||||
def test_bulk_delete(self, session):
|
||||
txs = [make_tx(session, f"d{i}") for i in range(2)]
|
||||
result = bulk_action(
|
||||
BulkActionRequest(ids=[t.id for t in txs], action="delete"),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert result["affected"] == 2
|
||||
assert session.get(Transaction, txs[0].id) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"req",
|
||||
[
|
||||
BulkActionRequest(ids=[], action="delete"),
|
||||
BulkActionRequest(ids=[1], action="explode"),
|
||||
BulkActionRequest(ids=[1], action="set_deferred"), # missing flag
|
||||
BulkActionRequest(ids=[1], action="set_category", category_id=99999),
|
||||
],
|
||||
)
|
||||
def test_invalid_requests_rejected(self, session, req):
|
||||
with pytest.raises(HTTPException):
|
||||
bulk_action(req, session=session, _user="t")
|
||||
385
backend/tests/test_installments.py
Normal file
385
backend/tests/test_installments.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""Tasa Cero installment plans: rounding, scheduling, budget exclusion,
|
||||
auto-detection at ingestion, regeneration, unconvert, and cascade behavior.
|
||||
|
||||
Rounding and scheduling cases are pinned against real BAC Financiamientos
|
||||
data (2026-05/07). Endpoint functions are exercised directly with a session,
|
||||
same style as test_import_review_and_bulk.py.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints.installments import (
|
||||
create_installment_plan,
|
||||
delete_installment_plan,
|
||||
get_installment_plan,
|
||||
list_installment_plans,
|
||||
update_installment_plan,
|
||||
)
|
||||
from app.api.v1.endpoints.transactions import (
|
||||
BulkActionRequest,
|
||||
bulk_action,
|
||||
create_transaction,
|
||||
delete_transaction,
|
||||
update_transaction,
|
||||
)
|
||||
from app.models.models import (
|
||||
Category,
|
||||
InstallmentPlan,
|
||||
InstallmentPlanCreate,
|
||||
InstallmentPlanUpdate,
|
||||
Transaction,
|
||||
TransactionCreate,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
TransactionUpdate,
|
||||
)
|
||||
from app.services.budget_projection import (
|
||||
compute_actuals_by_source,
|
||||
get_cycle_range,
|
||||
)
|
||||
from app.services.installments import (
|
||||
compute_installment_amounts,
|
||||
compute_installment_dates,
|
||||
is_tasa_cero_merchant,
|
||||
)
|
||||
from sqlmodel import select
|
||||
|
||||
|
||||
def make_cc_tx(
|
||||
session,
|
||||
merchant="CC CONSTRUPLAZA",
|
||||
amount="111053.60",
|
||||
date=datetime(2026, 6, 25, 15, 43),
|
||||
reference="75669363",
|
||||
source=TransactionSource.CREDIT_CARD,
|
||||
**kw,
|
||||
):
|
||||
tx = Transaction(
|
||||
amount=Decimal(amount),
|
||||
merchant=merchant,
|
||||
date=date,
|
||||
reference=reference,
|
||||
source=source,
|
||||
**kw,
|
||||
)
|
||||
session.add(tx)
|
||||
session.commit()
|
||||
session.refresh(tx)
|
||||
return tx
|
||||
|
||||
|
||||
def convert(session, tx, n=3, first=None):
|
||||
return create_installment_plan(
|
||||
InstallmentPlanCreate(
|
||||
transaction_id=tx.id, num_installments=n, first_installment_date=first
|
||||
),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
|
||||
|
||||
def get_cuotas(session, plan_id):
|
||||
return session.exec(
|
||||
select(Transaction)
|
||||
.where(Transaction.installment_plan_id == plan_id)
|
||||
.order_by(Transaction.date)
|
||||
).all()
|
||||
|
||||
|
||||
class TestRounding:
|
||||
"""Pinned against real BAC plans."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"total,n,per,last",
|
||||
[
|
||||
("111053.60", 3, "37017.80", "37018.00"),
|
||||
("425800.00", 6, "70966.60", "70967.00"),
|
||||
("98632.00", 3, "32877.30", "32877.40"),
|
||||
("190864.80", 3, "63621.60", "63621.60"), # divides exactly
|
||||
],
|
||||
)
|
||||
def test_real_bac_plans(self, total, n, per, last):
|
||||
amounts = compute_installment_amounts(Decimal(total), n)
|
||||
assert amounts[:-1] == [Decimal(per)] * (n - 1)
|
||||
assert amounts[-1] == Decimal(last)
|
||||
|
||||
def test_sum_invariant(self):
|
||||
for total, n in [("100.00", 3), ("214800.00", 3), ("99999.99", 7)]:
|
||||
amounts = compute_installment_amounts(Decimal(total), n)
|
||||
assert sum(amounts) == Decimal(total)
|
||||
assert len(amounts) == n
|
||||
|
||||
|
||||
class TestScheduling:
|
||||
def test_first_before_cut_stays_in_purchase_cycle(self):
|
||||
# 14/06 purchase (cycle 18/05-18/06) -> cuotas 19/06, 19/07
|
||||
dates = compute_installment_dates(datetime(2026, 6, 14, 19, 22), 3)
|
||||
assert dates == [
|
||||
datetime(2026, 6, 14, 19, 22),
|
||||
datetime(2026, 6, 19),
|
||||
datetime(2026, 7, 19),
|
||||
]
|
||||
|
||||
def test_first_after_cut(self):
|
||||
# 27/06 purchase (cycle 18/06-18/07) -> cuotas 19/07, 19/08
|
||||
dates = compute_installment_dates(datetime(2026, 6, 27, 19, 43), 3)
|
||||
assert dates == [
|
||||
datetime(2026, 6, 27, 19, 43),
|
||||
datetime(2026, 7, 19),
|
||||
datetime(2026, 8, 19),
|
||||
]
|
||||
|
||||
def test_december_wrap(self):
|
||||
dates = compute_installment_dates(datetime(2026, 12, 20), 3)
|
||||
assert dates == [
|
||||
datetime(2026, 12, 20),
|
||||
datetime(2027, 1, 19),
|
||||
datetime(2027, 2, 19),
|
||||
]
|
||||
|
||||
def test_consecutive_cycles(self):
|
||||
"""Each cuota lands in its own consecutive billing cycle."""
|
||||
dates = compute_installment_dates(datetime(2026, 5, 5, 11, 19), 6)
|
||||
cycles = []
|
||||
for d in dates:
|
||||
if d.day >= 18:
|
||||
cy, cm = d.year, d.month
|
||||
else:
|
||||
cy, cm = (d.year, d.month - 1) if d.month > 1 else (d.year - 1, 12)
|
||||
start, end = get_cycle_range(cy, cm)
|
||||
assert start <= d < end
|
||||
cycles.append((cy, cm))
|
||||
assert cycles == [(2026, m) for m in range(4, 10)]
|
||||
|
||||
|
||||
class TestDetection:
|
||||
@pytest.mark.parametrize(
|
||||
"merchant,expected",
|
||||
[
|
||||
("CC CONSTRUPLAZA", True),
|
||||
("CC TILO.CO", True),
|
||||
("CONSTRUPLAZA S", False),
|
||||
("ALMACENES SIMAN", False),
|
||||
("MERCCADO", False),
|
||||
],
|
||||
)
|
||||
def test_merchant_regex(self, merchant, expected):
|
||||
assert is_tasa_cero_merchant(merchant) is expected
|
||||
|
||||
def test_ingestion_auto_creates_plan(self, session):
|
||||
tx = create_transaction(
|
||||
TransactionCreate(
|
||||
amount=Decimal("301614.60"),
|
||||
merchant="CC CONSTRUPLAZA",
|
||||
date=datetime(2026, 6, 27, 19, 43),
|
||||
reference="24724909",
|
||||
),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert tx.is_installment_anchor is True
|
||||
plan = session.exec(
|
||||
select(InstallmentPlan).where(
|
||||
InstallmentPlan.anchor_transaction_id == tx.id
|
||||
)
|
||||
).one()
|
||||
assert plan.num_installments == 3
|
||||
assert plan.first_installment_date == tx.date
|
||||
cuotas = get_cuotas(session, plan.id)
|
||||
assert [c.amount for c in cuotas] == [
|
||||
Decimal("100538.20"),
|
||||
Decimal("100538.20"),
|
||||
Decimal("100538.20"),
|
||||
]
|
||||
assert cuotas[0].merchant == "CC CONSTRUPLAZA CUOTA:01/03"
|
||||
assert cuotas[0].reference == "24724909-C01"
|
||||
assert all(c.installment_plan_id == plan.id for c in cuotas)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kw",
|
||||
[
|
||||
{"merchant": "WALMART"},
|
||||
{"transaction_type": TransactionType.DEVOLUCION},
|
||||
{"source": TransactionSource.CASH},
|
||||
],
|
||||
)
|
||||
def test_ingestion_ignores_non_tasa_cero(self, session, kw):
|
||||
data = dict(
|
||||
amount=Decimal("1000"),
|
||||
merchant="CC CONSTRUPLAZA",
|
||||
date=datetime(2026, 6, 1),
|
||||
)
|
||||
data.update(kw)
|
||||
tx = create_transaction(
|
||||
TransactionCreate(**data), session=session, _user="t"
|
||||
)
|
||||
assert tx.is_installment_anchor is False
|
||||
assert session.exec(select(InstallmentPlan)).all() == []
|
||||
|
||||
def test_duplicate_reference_still_409_and_creates_nothing(self, session):
|
||||
make_cc_tx(session)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
create_transaction(
|
||||
TransactionCreate(
|
||||
amount=Decimal("1"),
|
||||
merchant="CC CONSTRUPLAZA",
|
||||
date=datetime(2026, 7, 1),
|
||||
reference="75669363",
|
||||
),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert exc.value.status_code == 409
|
||||
assert session.exec(select(InstallmentPlan)).all() == []
|
||||
|
||||
|
||||
class TestBudgetExclusion:
|
||||
def test_anchor_excluded_cuotas_counted_per_month(self, session):
|
||||
tx = make_cc_tx(session, date=datetime(2026, 6, 25))
|
||||
convert(session, tx) # cuotas: 25/06, 19/07, 19/08
|
||||
# Budget month = cycle ENDING on its 18th: 25/06 -> July, 19/07 -> Aug,
|
||||
# 19/08 -> Sep. The full 111,053.60 must appear nowhere.
|
||||
by_month = {
|
||||
m: compute_actuals_by_source(session, 2026, m)["CREDIT_CARD"]["net"]
|
||||
for m in (6, 7, 8, 9, 10)
|
||||
}
|
||||
assert by_month[6] == 0
|
||||
assert by_month[7] == pytest.approx(37017.80)
|
||||
assert by_month[8] == pytest.approx(37017.80)
|
||||
assert by_month[9] == pytest.approx(37018.00)
|
||||
assert by_month[10] == 0
|
||||
|
||||
def test_cuotas_inherit_anchor_category(self, session):
|
||||
cat = Category(name="Ferretería")
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
tx = make_cc_tx(session, category_id=cat.id)
|
||||
plan = convert(session, tx)
|
||||
assert all(
|
||||
c.category_id == cat.id for c in get_cuotas(session, plan.id)
|
||||
)
|
||||
|
||||
|
||||
class TestPlanLifecycle:
|
||||
def test_manual_convert_validations(self, session):
|
||||
tx = make_cc_tx(session)
|
||||
convert(session, tx)
|
||||
# already converted
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
convert(session, tx)
|
||||
assert exc.value.status_code == 409
|
||||
# a cuota cannot itself be converted
|
||||
cuota = session.exec(
|
||||
select(Transaction).where(Transaction.installment_plan_id.is_not(None))
|
||||
).first()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
convert(session, cuota)
|
||||
assert exc.value.status_code == 400
|
||||
# non-CC rejected
|
||||
cash = make_cc_tx(
|
||||
session, reference="r-cash", source=TransactionSource.CASH
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
convert(session, cash)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_regenerate_3_to_6(self, session):
|
||||
tx = make_cc_tx(session, amount="425800.00", reference="58710871")
|
||||
plan = convert(session, tx)
|
||||
updated = update_installment_plan(
|
||||
plan.id,
|
||||
InstallmentPlanUpdate(
|
||||
num_installments=6,
|
||||
first_installment_date=datetime(2026, 5, 19),
|
||||
),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert updated.num_installments == 6
|
||||
assert updated.installment_amount == pytest.approx(70966.60)
|
||||
assert updated.last_installment_amount == pytest.approx(70967.00)
|
||||
cuotas = get_cuotas(session, plan.id)
|
||||
assert len(cuotas) == 6
|
||||
assert cuotas[0].date == datetime(2026, 5, 19)
|
||||
assert cuotas[-1].date == datetime(2026, 10, 19)
|
||||
assert cuotas[0].reference == "58710871-C01"
|
||||
assert cuotas[-1].merchant == "CC CONSTRUPLAZA CUOTA:06/06"
|
||||
|
||||
def test_unconvert_restores_anchor(self, session):
|
||||
tx = make_cc_tx(session)
|
||||
plan = convert(session, tx)
|
||||
delete_installment_plan(plan.id, session=session, _user="t")
|
||||
session.refresh(tx)
|
||||
assert tx.is_installment_anchor is False
|
||||
assert get_cuotas(session, plan.id) == []
|
||||
assert session.exec(select(InstallmentPlan)).all() == []
|
||||
|
||||
def test_delete_anchor_tears_down_plan(self, session):
|
||||
tx = make_cc_tx(session)
|
||||
plan = convert(session, tx)
|
||||
delete_transaction(tx.id, session=session, _user="t")
|
||||
assert session.exec(select(InstallmentPlan)).all() == []
|
||||
assert get_cuotas(session, plan.id) == []
|
||||
|
||||
def test_bulk_delete_anchor_tears_down_plan(self, session):
|
||||
tx = make_cc_tx(session)
|
||||
convert(session, tx)
|
||||
bulk_action(
|
||||
BulkActionRequest(ids=[tx.id], action="delete"),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert session.exec(select(InstallmentPlan)).all() == []
|
||||
assert session.exec(select(Transaction)).all() == []
|
||||
|
||||
def test_list_and_detail(self, session):
|
||||
tx = make_cc_tx(session, date=datetime(2026, 5, 12, 15, 41))
|
||||
convert(session, tx, first=datetime(2026, 5, 19))
|
||||
resp = list_installment_plans(session=session, _user="t")
|
||||
assert len(resp.plans) == 1
|
||||
plan = resp.plans[0]
|
||||
assert plan.merchant == "CC CONSTRUPLAZA"
|
||||
assert plan.total_amount == pytest.approx(111053.60)
|
||||
assert plan.remaining_amount + plan.paid_amount == pytest.approx(
|
||||
111053.60
|
||||
)
|
||||
assert resp.total_remaining == pytest.approx(plan.remaining_amount)
|
||||
detail = get_installment_plan(plan.id, session=session, _user="t")
|
||||
assert len(detail.cuotas) == 3
|
||||
|
||||
|
||||
class TestDeferredGuards:
|
||||
def test_patch_cuota_deferred_rejected(self, session):
|
||||
tx = make_cc_tx(session)
|
||||
plan = convert(session, tx)
|
||||
cuota = get_cuotas(session, plan.id)[0]
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
update_transaction(
|
||||
cuota.id,
|
||||
TransactionUpdate(deferred_to_next_cycle=True),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
def test_bulk_set_deferred_skips_cuotas(self, session):
|
||||
tx = make_cc_tx(session)
|
||||
plan = convert(session, tx)
|
||||
cuota = get_cuotas(session, plan.id)[0]
|
||||
normal = make_cc_tx(session, merchant="WALMART", reference="w1")
|
||||
resp = bulk_action(
|
||||
BulkActionRequest(
|
||||
ids=[cuota.id, normal.id], action="set_deferred", deferred=True
|
||||
),
|
||||
session=session,
|
||||
_user="t",
|
||||
)
|
||||
assert resp["affected"] == 1
|
||||
session.refresh(cuota)
|
||||
session.refresh(normal)
|
||||
assert cuota.deferred_to_next_cycle is False
|
||||
assert normal.deferred_to_next_cycle is True
|
||||
67
backend/tests/test_models_serialization.py
Normal file
67
backend/tests/test_models_serialization.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Pin the Money serialization contract (Phase 2 Decimal migration, BE-02):
|
||||
NUMERIC storage, but JSON emits plain numbers so the frontend's `number`
|
||||
types keep working."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models.models import (
|
||||
Account,
|
||||
AccountRead,
|
||||
Bank,
|
||||
Currency,
|
||||
Transaction,
|
||||
TransactionRead,
|
||||
)
|
||||
|
||||
|
||||
def test_money_columns_are_numeric():
|
||||
assert str(Transaction.__table__.c.amount.type) == "NUMERIC(15, 2)"
|
||||
assert str(Account.__table__.c.balance.type) == "NUMERIC(15, 2)"
|
||||
|
||||
|
||||
def test_money_serializes_as_json_number():
|
||||
read = TransactionRead(
|
||||
id=1,
|
||||
amount=Decimal("25000.50"),
|
||||
merchant="X",
|
||||
date=datetime(2026, 4, 1),
|
||||
created_at=datetime(2026, 4, 1),
|
||||
)
|
||||
dumped = read.model_dump(mode="json")
|
||||
assert dumped["amount"] == 25000.5
|
||||
assert isinstance(dumped["amount"], float)
|
||||
# and the whole payload is json.dumps-able (no Decimal leaks)
|
||||
json.dumps(dumped)
|
||||
|
||||
|
||||
def test_money_accepts_float_input():
|
||||
# n8n and the SPA POST plain JSON numbers; they must coerce to Decimal.
|
||||
read = AccountRead(
|
||||
id=1,
|
||||
bank=Bank.BAC,
|
||||
currency=Currency.CRC,
|
||||
label="x",
|
||||
balance=1234.56,
|
||||
updated_at=datetime(2026, 4, 1),
|
||||
)
|
||||
assert read.balance == Decimal("1234.56")
|
||||
|
||||
|
||||
def test_decimal_roundtrip_through_db(session):
|
||||
session.add(
|
||||
Transaction(
|
||||
amount=Decimal("9999.99"),
|
||||
merchant="ROUNDTRIP",
|
||||
date=datetime(2026, 4, 1),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
row = session.exec(
|
||||
select(Transaction).where(Transaction.merchant == "ROUNDTRIP")
|
||||
).one()
|
||||
assert isinstance(row.amount, Decimal)
|
||||
assert row.amount == Decimal("9999.99")
|
||||
112
backend/tests/test_sync_and_export.py
Normal file
112
backend/tests/test_sync_and_export.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Sync-status derivation (UX-17) and CSV export (ARCH-18)."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models.models import (
|
||||
Bank,
|
||||
Category,
|
||||
Currency,
|
||||
ExchangeRate,
|
||||
Transaction,
|
||||
TransactionSource,
|
||||
TransactionType,
|
||||
)
|
||||
from app.services.csv_export import build_transactions_csv
|
||||
from app.services.sync_status import compute_sync_status
|
||||
from app.timeutil import utcnow
|
||||
|
||||
|
||||
def by_key(statuses: list[dict]) -> dict[str, dict]:
|
||||
return {s["key"]: s for s in statuses}
|
||||
|
||||
|
||||
class TestSyncStatus:
|
||||
def test_empty_db_reports_never(self, session):
|
||||
statuses = by_key(compute_sync_status(session))
|
||||
assert len(statuses) == 5
|
||||
assert all(s["status"] == "never" for s in statuses.values())
|
||||
assert all(s["last_received"] is None for s in statuses.values())
|
||||
|
||||
def test_fresh_data_is_ok_and_stale_warns(self, session):
|
||||
fresh = Transaction(
|
||||
amount=Decimal("1"),
|
||||
merchant="X",
|
||||
date=utcnow(),
|
||||
source=TransactionSource.CREDIT_CARD,
|
||||
bank=Bank.BAC,
|
||||
)
|
||||
fresh.created_at = utcnow() - timedelta(days=1)
|
||||
stale_salary = Transaction(
|
||||
amount=Decimal("1"),
|
||||
merchant="EMPLOYER",
|
||||
date=utcnow(),
|
||||
transaction_type=TransactionType.SALARY,
|
||||
source=TransactionSource.TRANSFER,
|
||||
)
|
||||
stale_salary.created_at = utcnow() - timedelta(days=60)
|
||||
session.add(fresh)
|
||||
session.add(stale_salary)
|
||||
session.add(
|
||||
ExchangeRate(
|
||||
date=utcnow(),
|
||||
buy_rate=Decimal("500"),
|
||||
sell_rate=Decimal("510"),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
statuses = by_key(compute_sync_status(session))
|
||||
assert statuses["bac_credit_card"]["status"] == "ok"
|
||||
assert statuses["bac_credit_card"]["age_days"] == 1.0
|
||||
assert statuses["salary"]["status"] == "warning"
|
||||
assert statuses["exchange_rate"]["status"] == "ok"
|
||||
assert statuses["municipal"]["status"] == "never"
|
||||
|
||||
|
||||
class TestCsvExport:
|
||||
def _seed(self, session):
|
||||
cat = Category(name="Food")
|
||||
session.add(cat)
|
||||
session.commit()
|
||||
session.refresh(cat)
|
||||
session.add(
|
||||
Transaction(
|
||||
amount=Decimal("1500.50"),
|
||||
merchant="SODA, LA \"BUENA\"", # exercises CSV quoting
|
||||
date=datetime(2026, 4, 2),
|
||||
category_id=cat.id,
|
||||
currency=Currency.CRC,
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Transaction(
|
||||
amount=Decimal("10"),
|
||||
merchant="CASHTHING",
|
||||
date=datetime(2026, 4, 3),
|
||||
source=TransactionSource.CASH,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
def test_full_export_with_quoting_and_category(self, session):
|
||||
self._seed(session)
|
||||
text = build_transactions_csv(session)
|
||||
rows = list(csv.reader(io.StringIO(text)))
|
||||
assert rows[0][:3] == ["id", "date", "merchant"]
|
||||
assert len(rows) == 3 # header + 2
|
||||
newest_first = rows[1]
|
||||
assert newest_first[2] == "CASHTHING"
|
||||
soda = rows[2]
|
||||
assert soda[2] == 'SODA, LA "BUENA"' # round-trips through quoting
|
||||
assert soda[4] == "1500.50"
|
||||
assert soda[9] == "Food"
|
||||
|
||||
def test_source_filter(self, session):
|
||||
self._seed(session)
|
||||
text = build_transactions_csv(session, source=TransactionSource.CASH)
|
||||
rows = list(csv.reader(io.StringIO(text)))
|
||||
assert len(rows) == 2
|
||||
assert rows[1][2] == "CASHTHING"
|
||||
41
backend/tests/test_uploads.py
Normal file
41
backend/tests/test_uploads.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Upload validation: size cap and PDF magic-byte check (SEC-06)."""
|
||||
|
||||
import asyncio
|
||||
from io import BytesIO
|
||||
|
||||
import pytest
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.uploads import read_pdf_upload
|
||||
|
||||
|
||||
def make_upload(data: bytes, filename: str = "test.pdf") -> UploadFile:
|
||||
return UploadFile(file=BytesIO(data), filename=filename)
|
||||
|
||||
|
||||
def test_valid_pdf_accepted():
|
||||
data = b"%PDF-1.7 fake content"
|
||||
out = asyncio.run(read_pdf_upload(make_upload(data)))
|
||||
assert out == data
|
||||
|
||||
|
||||
def test_non_pdf_rejected_with_filename():
|
||||
with pytest.raises(ValueError, match=r"evil\.pdf: not a PDF"):
|
||||
asyncio.run(read_pdf_upload(make_upload(b"MZ\x90\x00binary", "evil.pdf")))
|
||||
|
||||
|
||||
def test_oversized_rejected():
|
||||
big = b"%PDF" + b"x" * 100
|
||||
with pytest.raises(ValueError, match="exceeds the 0 MB limit"):
|
||||
asyncio.run(read_pdf_upload(make_upload(big), max_bytes=50))
|
||||
|
||||
|
||||
def test_exact_limit_accepted():
|
||||
data = b"%PDF" + b"x" * 46 # exactly 50 bytes
|
||||
out = asyncio.run(read_pdf_upload(make_upload(data), max_bytes=50))
|
||||
assert len(out) == 50
|
||||
|
||||
|
||||
def test_empty_file_rejected():
|
||||
with pytest.raises(ValueError, match="not a PDF"):
|
||||
asyncio.run(read_pdf_upload(make_upload(b"")))
|
||||
211
codebase-review/01-architect.md
Normal file
211
codebase-review/01-architect.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Architect Review — WealthySmart
|
||||
|
||||
> Hat: Staff-level Software Architect. Focus: structure, layering, data model, migration story, API consistency, deploy topology.
|
||||
>
|
||||
> **Editorial note:** ARCH-01 was corrected after verification — see the finding itself and `README.md`.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
WealthySmart is a single-user personal finance management application with a modern full-stack architecture: FastAPI + SQLModel backend (PostgreSQL) paired with a React 19 + Vite frontend served through a Hono proxy, plus an LLM-powered AI agent interface. The system handles multi-currency transactions (CRC/USD/EUR/BTC/XMR), budget forecasting with complex billing-cycle logic, pension/municipal bill tracking, and push notifications. The architecture is sound overall — no fundamental redesign is needed — but there are weak defaults in configuration, zero test coverage, an ad-hoc migration story, and consistency gaps in validation and response schemas that should be addressed incrementally.
|
||||
|
||||
## How the System Fits Together
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ DEPLOYMENT (Docker Compose) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Frontend (Node) │ │ Backend (FastAPI) │ │
|
||||
│ │ ┌─────────────────┐ │ │ ┌───────────────┐ │ │
|
||||
│ │ │ Vite (port 3000)│ │ │ │ Uvicorn │ │ │
|
||||
│ │ │ React 19 + TS │ │ │ │ (port 8000) │ │ │
|
||||
│ │ └─────────────────┘ │ │ └───────────────┘ │ │
|
||||
│ │ ┌─────────────────┐ │ │ ┌───────────────┐ │ │
|
||||
│ │ │ Hono Server │ │ │ │ 15 endpoints │ │ │
|
||||
│ │ │ (port 3001) │ │ │ │ + LLM agent │ │ │
|
||||
│ │ │ • SPA serving │ │ │ └───────────────┘ │ │
|
||||
│ │ │ • CopilotKit │ │ │ │ │
|
||||
│ │ │ runtime │ │ │ │ │
|
||||
│ │ │ • /api proxy │ │ │ │ │
|
||||
│ │ └─────────────────┘ │ │ │ │
|
||||
│ └──────────────────────┘ └─────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ PostgreSQL 16 (port 5432) │ │
|
||||
│ │ • 15 tables + enums │ │
|
||||
│ │ • No Alembic migrations │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Production: nginx-proxy + Let's Encrypt on single VPS │
|
||||
│ Ingestion: n8n flows POST to /api/v1 with API tokens │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key data flows:**
|
||||
- Frontend requests `/api/v1/...` (proxied by Hono to FastAPI)
|
||||
- Agent requests go to `/api/v1/agent/agui` (Microsoft Agent Framework + OpenAI)
|
||||
- Exchange rates refreshed every 6 hours from multiple fallback APIs
|
||||
- Auth via httpOnly cookies (SPA) or Bearer tokens (API clients / n8n)
|
||||
|
||||
## Strengths
|
||||
|
||||
1. **Modular endpoint structure** — 15 focused routers (accounts, transactions, budget, analytics, etc.) with clear single responsibilities.
|
||||
2. **Sophisticated budget projection logic** — `budget_projection.py` correctly handles billing cycles (18th–18th for credit cards vs. 1st–1st calendar month), deferred transactions, recurring items with override amounts, and cumulative balance tracking across years, with a clean API (`compute_monthly_projection`, `compute_yearly_projection_with_cumulative`).
|
||||
3. **Resilient exchange-rate handling** — fallback chain (BCCR → ExchangeRate-API → currency-api → FloatRates → CoinGecko) with in-memory and database caching; uses a `_last_known_*` pattern to retain previous values during API outages.
|
||||
4. **Clean auth model** — dual-mode authentication (httpOnly cookie for SPA, Bearer token for API clients) with JWT + API-token support. Token revocation marks rows inactive rather than deleting them.
|
||||
5. **Seed data for development** — comprehensive default categories, accounts, and recurring items; single-user design appropriate for the app.
|
||||
6. **Type-safe models** — SQLModel schemas enforce some constraints (unique categories, year/month combinations) at the DB level.
|
||||
7. **REST conventions mostly respected** — POSTs return 201, pagination via `offset`/`limit` is consistently applied.
|
||||
8. **Consistent error surface** — `HTTPException` with clear status codes and detail messages throughout.
|
||||
|
||||
## Findings
|
||||
|
||||
### [ARCH-01] Weak secret hygiene around `.env` files and config defaults
|
||||
**Severity:** High (downgraded from Critical after verification)
|
||||
**Location:** `.env`, `.env.prod` (working tree), `backend/app/config.py:6–9`, `docker-compose.yml`
|
||||
**Evidence (corrected):** `.env` and `.env.prod` exist in the repo root and contain real secrets (OpenAI API key, DB password, admin credentials, VAPID keys), but **they are gitignored and were never committed** — `git ls-files` and `git log --all --diff-filter=A` confirm no history. The genuine issues are: (a) `config.py` ships working fallback credentials (`SECRET_KEY="change-me-in-production"`, `ADMIN_USERNAME/PASSWORD = "admin"`), (b) `docker-compose.yml` hardcodes dev DB credentials, and (c) plaintext secrets on the dev machine with no rotation discipline.
|
||||
**Why it matters:** If a deploy ever runs without its env file (typo in compose, CI regression — note `reference_gitea_deploy`: `.env.prod` is regenerated each deploy), the app silently boots with `admin/admin` and a known JWT signing key.
|
||||
**Recommendation:** Remove the defaults and fail fast at startup (see SEC-01). Keep `.env.example` as the documented template. Rotate any key that has been pasted into chats/logs.
|
||||
|
||||
### [ARCH-02] No Formal Database Migration Strategy
|
||||
**Severity:** High
|
||||
**Location:** `backend/app/db.py:13–76`; no `alembic/` directory
|
||||
**Evidence:** Migrations are ad-hoc SQL strings in `run_migrations()` with `IF NOT EXISTS` clauses. Adding a column requires hand-written `ALTER TABLE` code. Schema evolution is not version-controlled or reversible.
|
||||
**Why it matters:** Manual migrations are error-prone and have no rollback. Dev and prod can diverge silently. The project rule "never reset prod DB; use migrations" makes this the single most important infrastructure gap.
|
||||
**Recommendation:** Introduce Alembic: `alembic init`, autogenerate a baseline from current models, convert the existing manual statements, run `alembic upgrade head` at container startup. Effort: **M** (1–2 days).
|
||||
|
||||
### [ARCH-03] Validation & Error-Handling Inconsistencies
|
||||
**Severity:** Medium
|
||||
**Location:** `endpoints/transactions.py:71–90`, `endpoints/budget.py:108–109`, `endpoints/analytics.py:56`
|
||||
**Evidence:** `list_transactions()` mixes `cycle_year/cycle_month` and `start_date/end_date` filters with `or_` in a nested query — unclear precedence. `budget.py` uses positional `HTTPException(400, ...)` while others use keyword args. `analytics.py:56` filters with the string literal `"COMPRA"` instead of `TransactionType.COMPRA`.
|
||||
**Why it matters:** Mixing filter modes risks undefined behavior; hardcoded enum strings break refactoring safety; inconsistent error style hampers debugging.
|
||||
**Recommendation:** Extract a `build_transaction_query()` helper with explicit precedence; standardize on `status_code=` kwargs; replace string literals with enum members. Effort: **S**.
|
||||
|
||||
### [ARCH-04] Agent Tools Duplicate Service Logic
|
||||
**Severity:** Medium
|
||||
**Location:** `backend/app/agent/tools.py`, `backend/app/services/budget_projection.py`
|
||||
**Evidence:** `tools.py` re-implements queries (`get_accounts()`, `get_net_worth()`, `get_cycle_summary()`) that overlap with the service layer instead of delegating to it.
|
||||
**Why it matters:** Budget logic changes must be applied in two places or the assistant and the UI disagree about the user's money.
|
||||
**Recommendation:** Make agent tools thin wrappers over service functions. Effort: **M** (1 day).
|
||||
|
||||
### [ARCH-05] No Input Limits on Bulk Operations
|
||||
**Severity:** Medium
|
||||
**Location:** `endpoints/import_transactions.py:96–149`
|
||||
**Evidence:** `paste_import()` accepts arbitrary text with no size cap; a 10 MB paste is parsed entirely in memory before any validation.
|
||||
**Why it matters:** Memory-exhaustion DoS vector; bulk operations should validate request size upfront.
|
||||
**Recommendation:** `Field(..., max_length=1_000_000)` on the request model; pre-validate before opening the write loop. Effort: **S**.
|
||||
|
||||
### [ARCH-06] Missing Test Coverage
|
||||
**Severity:** High
|
||||
**Location:** No `tests/` directory anywhere; `requirements.txt` lacks pytest
|
||||
**Evidence:** Zero test files in the repository.
|
||||
**Why it matters:** Budget projection is the most intricate logic in the app (billing-cycle edges, deferred carryover, cumulative balances with overrides) and is entirely unguarded against regression.
|
||||
**Recommendation:** `backend/tests/` with `test_budget_projection.py` (Feb 28/29, year boundaries, overrides), `test_auth.py`, `test_exchange_rate.py`; Vitest for `useBudget`. Effort: **L** (3–5 days for meaningful coverage).
|
||||
|
||||
### [ARCH-07] Single Hono Instance Is a Single Point of Failure
|
||||
**Severity:** Low (revised for a single-user app)
|
||||
**Location:** `frontend/server.ts:1–20`, `frontend/Dockerfile`, `docker-compose.prod.yml`
|
||||
**Evidence:** One Hono container serves SPA + CopilotKit runtime + proxy. The Dockerfile healthcheck probes the backend's `/api/health`, not Hono's own readiness.
|
||||
**Why it matters:** If Hono crashes the whole app is down even with a healthy backend. For a single-user app, replicas are over-engineering; a correct healthcheck + restart policy is the right-sized fix.
|
||||
**Recommendation:** Healthcheck Hono itself; `restart: unless-stopped`; add 5xx retry in the frontend API client. Skip load balancing.
|
||||
|
||||
### [ARCH-08] Exchange-Rate Refresh Loop Swallows Cancellation
|
||||
**Severity:** Low
|
||||
**Location:** `backend/app/services/exchange_rate.py:348–366`, `backend/app/main.py:66`
|
||||
**Evidence:** `refresh_rates_periodically()` runs `while True` with a broad except that logs and continues; cancellation during shutdown can be silently swallowed.
|
||||
**Recommendation:** Catch `asyncio.CancelledError` explicitly and `break`; keep the broad handler for everything else. Effort: **S**.
|
||||
|
||||
### [ARCH-09] No Foreign-Key Delete Rules
|
||||
**Severity:** Medium
|
||||
**Location:** `backend/app/models/models.py:144, 258, 427`
|
||||
**Evidence:** `Transaction.category_id` and similar FKs declare `foreign_key=` with no `ondelete` behavior; deleting a category leaves dangling references.
|
||||
**Why it matters:** Silent referential inconsistency — budget rows pointing at categories that no longer exist.
|
||||
**Recommendation:** Decide per-FK between `SET NULL` (transactions should outlive categories) and `CASCADE` (water readings die with their receipt); apply via Alembic migration. Effort: **S**.
|
||||
|
||||
### [ARCH-10] Fragile Cookie Parsing in Agent Middleware
|
||||
**Severity:** Medium
|
||||
**Location:** `backend/app/main.py:88–142`
|
||||
**Evidence:** Cookie extraction uses regex `r"(?:^|;\s*)ws_token=([^;]+)"`, which mishandles edge cases (multiple cookies of the same name, unusual encodings).
|
||||
**Recommendation:** Use stdlib `http.cookies.SimpleCookie`. Effort: **S**.
|
||||
|
||||
### [ARCH-11] `deferred_to_next_cycle` Implemented in Backend but Not Exposed in UI
|
||||
**Severity:** Medium
|
||||
**Location:** `backend/app/models/models.py:145`, `endpoints/transactions.py:219–235`
|
||||
**Evidence:** The field is settable via PATCH and respected by budget projection; the deferral toggle exists in row actions but `TransactionModal.tsx` has no field for it, and the affordance is undiscoverable (see UX-05).
|
||||
**Recommendation:** Add the checkbox to `TransactionModal.tsx` and make the row-action state visible. Effort: **S**.
|
||||
|
||||
### [ARCH-12] Pagination Lacks a Deterministic Tiebreaker
|
||||
**Severity:** Low
|
||||
**Location:** `endpoints/transactions.py:96`, `endpoints/salarios.py:35–36`
|
||||
**Evidence:** Paginated queries order by `date DESC` only; equal dates yield non-deterministic page boundaries.
|
||||
**Recommendation:** `.order_by(col(...).date.desc(), col(...).id.desc())` everywhere paginated. Effort: **S**.
|
||||
|
||||
### [ARCH-13] Mutation → Refetch-Everything Pattern
|
||||
**Severity:** Low
|
||||
**Location:** `frontend/src/hooks/useBudget.ts:61–74`
|
||||
**Evidence:** Every mutation triggers `Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()])` regardless of what changed.
|
||||
**Why it matters:** Wasteful, and the pattern will multiply as modules grow. Acceptable as a simplicity tradeoff today; the durable fix is a query library (see FE-10 / Plan Phase 3).
|
||||
|
||||
### [ARCH-14] Hardcoded Dev DB Credentials in Compose
|
||||
**Severity:** Medium
|
||||
**Location:** `docker-compose.yml` (db service `environment:` block)
|
||||
**Evidence:** `POSTGRES_PASSWORD: wealthy_pass` hardcoded in source control (prod compose correctly uses `${POSTGRES_PASSWORD}`).
|
||||
**Recommendation:** Move dev credentials to the gitignored `.env`; reference via `${...}` in both compose files. Effort: **S**.
|
||||
|
||||
### [ARCH-15] N+1 Query in Category Aggregation
|
||||
**Severity:** Medium
|
||||
**Location:** `backend/app/services/budget_projection.py:370–383`
|
||||
**Evidence:** `compute_cc_by_category()` calls `session.get(Category, cat_id)` inside a loop — one query per category with spending.
|
||||
**Recommendation:** Prefetch `{c.id: c for c in session.exec(select(Category)).all()}` and look up in the dict. Effort: **S** (minutes).
|
||||
|
||||
### [ARCH-16] API Response Schema Inconsistency
|
||||
**Severity:** Low
|
||||
**Location:** `endpoints/transactions.py:28–33` (Pydantic models) vs `endpoints/budget.py:100–157` (raw dicts) vs `endpoints/analytics.py:18–23`
|
||||
**Evidence:** Some endpoints return typed models, others plain dicts; OpenAPI schema generation is incomplete and frontend types are maintained by hand.
|
||||
**Recommendation:** Shared models in `app/api/schemas.py` + `response_model=` on every route; this also unlocks frontend type codegen (see FE-14). Effort: **M**.
|
||||
|
||||
### [ARCH-17] Agent Session via ContextVar Couples Tools to HTTP Middleware
|
||||
**Severity:** Low
|
||||
**Location:** `backend/app/agent/tools.py:40–52`, `backend/app/main.py:131–141`
|
||||
**Evidence:** Tools fetch the DB session from a `contextvars.ContextVar` set by middleware; calling a tool outside a request raises `LookupError`.
|
||||
**Recommendation:** Document the constraint and add a clear error message (see BE-12); full refactor only if tools ever need to run outside HTTP. Effort: **S** for the guard.
|
||||
|
||||
### [ARCH-18] No Data Export / Archival Path
|
||||
**Severity:** Low
|
||||
**Location:** All tables are append-only with no export endpoint
|
||||
**Why it matters:** Acceptable growth for a personal app, but there is no way to get historical data out (taxes, accountant, backup verification).
|
||||
**Recommendation:** CSV export endpoint per major table (also UX wishlist #5). Effort: **M**.
|
||||
|
||||
### [ARCH-19] Hono Proxy Trusts Upstream Blindly
|
||||
**Severity:** Low
|
||||
**Location:** `frontend/server.ts` proxy handlers
|
||||
**Evidence:** Responses are piped through without shape validation; combined with FE-14 (no runtime validation client-side), schema drift fails confusingly.
|
||||
**Recommendation:** Don't validate in the proxy (wrong layer); fix via shared response schemas (ARCH-16) and optional Zod at the API-client boundary.
|
||||
|
||||
### [ARCH-20] Lazy Imports Papering Over a Circular Dependency
|
||||
**Severity:** Low
|
||||
**Location:** `backend/app/auth.py:39–43`
|
||||
**Evidence:** `_validate_token()` imports `get_session` and `APIToken` inside the function body to dodge a cycle with `db.py`.
|
||||
**Recommendation:** Split token utilities into their own module to break the cycle properly. Effort: **S**.
|
||||
|
||||
## Recommended Refactors (prioritized)
|
||||
|
||||
| # | Item | Effort | Phase |
|
||||
|---|---|---|---|
|
||||
| 1 | ARCH-01/SEC-01: fail-fast on default secrets, fix dev compose creds | S | 0 |
|
||||
| 2 | ARCH-02: Alembic migrations | M | 1 |
|
||||
| 3 | ARCH-06: test suite (budget projection first) | L | 1 |
|
||||
| 4 | ARCH-15: N+1 fix in `compute_cc_by_category` | S | 2 |
|
||||
| 5 | ARCH-03: standardize validation/error handling/enums | S | 2 |
|
||||
| 6 | ARCH-09: FK delete rules via migration | S | 2 |
|
||||
| 7 | ARCH-04: agent tools delegate to services | M | 2 |
|
||||
| 8 | ARCH-16: shared response schemas (`response_model=` everywhere) | M | 3 |
|
||||
| 9 | ARCH-13/FE-10: query library on the frontend | M | 3 |
|
||||
| 10 | ARCH-11: expose deferred-transaction UI | S | 4 |
|
||||
| 11 | ARCH-18: CSV export | M | 4 |
|
||||
| 12 | ARCH-05/08/10/12/14/17/20: small hardening fixes | S each | 0–2 |
|
||||
|
||||
**Overall assessment:** Production-ready for a single-user personal app, with the caveat that configuration defaults must fail fast before anything else. The architecture needs no redesign — invest in migrations, tests, and consistency.
|
||||
166
codebase-review/02-backend.md
Normal file
166
codebase-review/02-backend.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# Backend Review — WealthySmart
|
||||
|
||||
> Hat: Senior Backend Engineer (Python / FastAPI / SQLModel). Focus: correctness, money & date math, query efficiency, PDF parsing, agent tools, migrations, tests.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The WealthySmart backend is a well-structured FastAPI application with a PostgreSQL database and SQLModel ORM. The codebase demonstrates solid architectural patterns (dependency injection, a real service layer, API tokenization) but contains several correctness and robustness gaps that deserve attention: timezone-naive `datetime.utcnow()` everywhere, `float` for all monetary values, a suspected off-by-one in billing-cycle math, N+1 query patterns in the agent tools, non-transactional multi-file PDF uploads, blocking subprocess calls inside `async` routes, and broad exception handlers that can mask failures. The codebase has no tests at all, which compounds every other risk — especially around the budget-cycle logic.
|
||||
|
||||
## Strengths
|
||||
|
||||
1. **Clean architecture** — well-organized service layer (`exchange_rate.py`, `budget_projection.py`), clear separation with one endpoint file per domain.
|
||||
2. **Robust exchange-rate handling** — multi-source fallback (BCCR → ExchangeRate-API → CoinGecko) with in-memory caching and last-known fallbacks.
|
||||
3. **Flexible budget system** — projection engine handles recurring items, 18th–18th billing cycles, and deferred transactions with carryover logic.
|
||||
4. **Security practices** — SHA-256 token hashing, OAuth2 bearer support, dual auth (JWT + API tokens), credentials hidden from logs.
|
||||
5. **API token management** — secure generation, expiration support, hash-based lookup.
|
||||
6. **Graceful degradation** — push-notification failures don't crash the request path; stale rates keep the app usable during API outages.
|
||||
7. **Comprehensive PDF services** — municipal-receipt and pension parsing with layered regex extraction.
|
||||
|
||||
## Findings
|
||||
|
||||
### [BE-01] `datetime.utcnow()` — Deprecated and Timezone-Naive — High
|
||||
**Location:** `auth.py:19`, `auth.py:51`, `budget.py:262`, `services/exchange_rate.py:139+`, 10+ call sites
|
||||
**Evidence:** `datetime.utcnow()` used throughout; deprecated since Python 3.12 in favor of `datetime.now(timezone.utc)`.
|
||||
**Why it matters:** Mixing naive and aware datetimes causes silent comparison bugs, and Postgres timestamp semantics depend on consistency. Costa Rica is UTC-6 year-round — naive "UTC" timestamps compared against local dates shift transactions across cycle boundaries near midnight.
|
||||
**Recommendation:** Mechanical replacement with `datetime.now(timezone.utc)`; audit each comparison site for naive/aware mixing; prefer `TIMESTAMPTZ` columns going forward.
|
||||
|
||||
### [BE-02] Float Instead of Decimal for Money — High
|
||||
**Location:** `models/models.py:102, 104, 131, 184+` (all financial fields)
|
||||
**Evidence:** `amount: float`, `balance: float`, `buy_rate: float`, `saldo_final: float` across the schema.
|
||||
**Why it matters:** Binary floats can't represent most decimal amounts exactly; cumulative budget math, projections, and historical audits accumulate error. Industry standard for money is `Decimal`/`NUMERIC`.
|
||||
**Recommendation:** Migrate monetary fields to `Decimal` (`Field(max_digits=15, decimal_places=2)` → Postgres `NUMERIC`); update `budget_projection.py` and `exchange_rate.py` arithmetic. Do this *after* Alembic exists (one clean migration) and *with* tests in place.
|
||||
|
||||
### [BE-03] Savings Accrual Lacks Atomicity — Medium
|
||||
**Location:** `services/savings_accrual.py:60`
|
||||
**Evidence:** `session.commit()` with no rollback path; if MEMP or MPAT accounts are missing, the function returns early with partial state possible.
|
||||
**Recommendation:** Raise early if required accounts are absent; wrap the mutation block so it commits entirely or not at all.
|
||||
|
||||
### [BE-04] Pension Snapshot Dedup Done in Python — Medium
|
||||
**Location:** `agent/tools.py:269–278`
|
||||
**Evidence:** Loads all `PensionSnapshot` rows, then deduplicates by fund in a Python loop.
|
||||
**Recommendation:** `SELECT DISTINCT ON (fund) ... ORDER BY fund, period_end DESC` (PostgreSQL) or a window-function query.
|
||||
|
||||
### [BE-05] N+1 in Municipal Receipts Water Readings — Medium
|
||||
**Location:** `agent/tools.py:320–344`
|
||||
**Evidence:** Loads receipts (line 324), then queries `WaterMeterReading` per receipt inside the loop (line 328) — 13 queries where 1–2 would do.
|
||||
**Recommendation:** Eager-load via `selectinload` or fetch all readings for the receipt IDs in one `IN (...)` query.
|
||||
|
||||
### [BE-06] Possible Off-by-One in Billing-Cycle Calculation — High
|
||||
**Location:** `services/budget_projection.py:78–85, 103–108`
|
||||
**Evidence:** `get_cycle_range(year, month)` returns `(month/18, month+1/18)` while the comment says "cycle (M-1): from (M-1)/18 to M/18, paid with month M salary" — comment and code disagree about which cycle a month's budget uses.
|
||||
**Why it matters:** If wrong, every monthly budget is aligned to the wrong statement period — salary and expenses misreported by a month. If right, the comment is wrong and the next maintainer will "fix" working code.
|
||||
**Recommendation:** This is the **first thing to pin down with tests**: assert which cycle a Feb-15 transaction lands in for the March budget against known-good real data, then fix code or comment accordingly.
|
||||
|
||||
### [BE-07] No Month/Year Validation in Date Helpers — Medium
|
||||
**Location:** `services/budget_projection.py:73–85`
|
||||
**Evidence:** `datetime(year, month, 18)` with no range check; month 13 raises an unhandled `ValueError` from deep inside projection math.
|
||||
**Recommendation:** Validate `1 <= month <= 12` and a sane year range at the helper boundary with a clear error message.
|
||||
|
||||
### [BE-08] Duplicate Detection Is Incomplete — Medium
|
||||
**Location:** `endpoints/transactions.py:182–191`, `import_transactions.py:118–128`
|
||||
**Evidence:** Dedup keys off the nullable `reference` field; manual transactions and hash collisions/misses can create duplicates that skew analytics.
|
||||
**Recommendation:** Tighten the reference-hash inputs (include `card_last4`), and consider a unique constraint on `(date, merchant, amount, currency, source)` with an explicit override path for genuine same-day repeat purchases.
|
||||
|
||||
### [BE-09] PDF Temp Files Not Cleaned on Timeout — Medium
|
||||
**Location:** `services/pension_pdf.py:46–50`, `services/municipal_receipt_pdf.py:79`
|
||||
**Evidence:** `subprocess.run(..., timeout=30/10)` is good, but on timeout/exception the `NamedTemporaryFile` cleanup path isn't guaranteed.
|
||||
**Recommendation:** Use `tempfile.TemporaryDirectory()` as a context manager so cleanup is unconditional.
|
||||
|
||||
### [BE-10] Multi-File Uploads Are Not Transactional — High
|
||||
**Location:** `endpoints/pensions.py:104–162`, `municipal_receipts.py:197–228`
|
||||
**Evidence:** The loop commits per file; if file 2 fails after file 1 committed, the batch is half-applied with no undo.
|
||||
**Why it matters:** Partial uploads create inconsistent state the user can't easily see or revert.
|
||||
**Recommendation:** Either wrap the batch in one transaction (all-or-nothing) or keep per-file commits but return a precise per-file success/failure manifest (the response models already have `errors[]` — make the frontend show it, see UX-09).
|
||||
|
||||
### [BE-11] Nullable FKs Without Delete Behavior — Medium
|
||||
**Location:** `models/models.py:144` (Transaction.category_id), `:258` (RecurringItem.category_id)
|
||||
**Evidence:** Optional FKs with no `ondelete` rule; deleting a category strands references.
|
||||
**Recommendation:** Same as ARCH-09 — explicit `SET NULL`/`CASCADE` per relationship, applied via migration.
|
||||
|
||||
### [BE-12] Agent Session ContextVar Failure Is Cryptic — Medium
|
||||
**Location:** `agent/tools.py:51–52`
|
||||
**Evidence:** `_session_ctx.get()` raises bare `LookupError` if middleware didn't bind a session.
|
||||
**Recommendation:** Catch and re-raise as `RuntimeError("DB session not bound to agent context — tool called outside request scope")`.
|
||||
|
||||
### [BE-13] Locale-Naive Amount Parsing in Paste Import — Low
|
||||
**Location:** `endpoints/import_transactions.py:77`
|
||||
**Evidence:** `match.group(1).replace(",", "")` assumes `,` is always a thousands separator.
|
||||
**Recommendation:** Document the assumed format (BAC statement format) in the docstring; add a sanity check (e.g., reject if the result is 100× off from a `.` interpretation).
|
||||
|
||||
### [BE-14] Blocking Subprocess Inside Async Routes — Medium
|
||||
**Location:** `endpoints/pensions.py:105–162`, `municipal_receipts.py:197–228`
|
||||
**Evidence:** `async def upload_...()` calls `parse_pension_pdf()` which runs `subprocess.run()` synchronously — blocks the event loop for up to 30s per file.
|
||||
**Recommendation:** `await asyncio.to_thread(parse_pension_pdf, pdf_bytes)` (the codebase already uses `asyncio.to_thread` for rate refresh, so the pattern exists).
|
||||
|
||||
### [BE-15] Hardcoded Default Credentials — High
|
||||
**Location:** `config.py:6, 8–9`
|
||||
**Evidence:** `SECRET_KEY: str = "change-me-in-production"`, `ADMIN_USERNAME: str = "admin"`, `ADMIN_PASSWORD: str = "admin"` (verified).
|
||||
**Why it matters:** A deploy that loses its env file boots wide open. See SEC-01 for the exploit framing.
|
||||
**Recommendation:** Remove defaults; raise at startup if unset or still default.
|
||||
|
||||
### [BE-16] Agent Budget Tool Has No Year Bounds — Low
|
||||
**Location:** `agent/tools.py:212` (vs `endpoints/budget.py:131` which checks MIN_YEAR/MAX_YEAR)
|
||||
**Evidence:** REST endpoint validates year range; the agent tool calling the same projection logic does not — year 9999 reaches `compute_yearly_projection_with_cumulative()`.
|
||||
**Recommendation:** Apply the same MIN/MAX_YEAR bounds in the tool.
|
||||
|
||||
### [BE-17] Agent Tools Have Fixed Limits, No Pagination — Low
|
||||
**Location:** `agent/tools.py:269, 327–344`
|
||||
**Evidence:** Hardcoded `limit=12` / `limit=50` with no offset parameter.
|
||||
**Recommendation:** Add optional `offset`/`limit` tool parameters so the assistant can answer questions about older history.
|
||||
|
||||
### [BE-18] `except Exception: pass` Swallows Errors — Low
|
||||
**Location:** `services/exchange_rate.py:63–64, 93–94, 108–109, 122–123`
|
||||
**Evidence:** Bare pass on all exceptions in each rate-source fetcher.
|
||||
**Why it matters:** Network failures, schema changes in upstream APIs, and genuine bugs all fail identically and invisibly.
|
||||
**Recommendation:** Catch specific exceptions (`httpx.TimeoutException`, `json.JSONDecodeError`, `KeyError`) and `logger.warning(...)` per source so a dead source is observable.
|
||||
|
||||
### [BE-19] Ad-hoc Migrations Without Atomicity — Medium
|
||||
**Location:** `db.py:13–77`
|
||||
**Evidence:** Sequential `ALTER TABLE` strings with per-statement try/except/rollback; a mid-sequence failure leaves a partially migrated schema with no version record.
|
||||
**Recommendation:** Alembic (duplicate of ARCH-02; listed here because the per-statement rollback pattern is itself a hazard).
|
||||
|
||||
### [BE-20] Stale-Rate Cache Has No Timestamp — Low
|
||||
**Location:** `services/exchange_rate.py:31–37, 136–142`
|
||||
**Evidence:** `_last_known` caches carry no `last_updated`; consumers can't tell a fresh rate from a week-old one.
|
||||
**Recommendation:** Store a timestamp with the cached value and expose freshness (`{"rate": X, "as_of": ...}`) — the agent and UI can then warn on stale data (pairs with UX data-trust concerns).
|
||||
|
||||
### [BE-21] Projection Runs ~15+ Queries Per Call — Medium
|
||||
**Location:** `services/budget_projection.py:114–222`
|
||||
**Evidence:** For each `TransactionSource`, 4–6 separate aggregate queries; 3 sources ≈ 15+ round-trips per month, ×12 for the yearly view.
|
||||
**Recommendation:** Collapse into one or two `GROUP BY source, transaction_type` aggregate queries.
|
||||
|
||||
### [BE-22] PensionSnapshot Upsert Has a Race Window — Low
|
||||
**Location:** `models/models.py:329–335`
|
||||
**Evidence:** `UniqueConstraint("fund", "period_start", "period_end")` exists, but the upsert is an application-level check-then-insert; concurrent uploads of the same PDF can collide.
|
||||
**Recommendation:** Use `INSERT ... ON CONFLICT DO UPDATE` (`sqlalchemy.dialects.postgresql.insert`). Low priority — n8n is the only concurrent writer and runs daily.
|
||||
|
||||
### [BE-23] Account Labels Not Unique — Low
|
||||
**Location:** `models/models.py:98–110`
|
||||
**Evidence:** `label: str` without a unique constraint; two accounts named "BAC" are allowed.
|
||||
**Recommendation:** Unique on `(bank, currency, label)` or at least surface duplicates in the UI.
|
||||
|
||||
### [BE-24] Hardcoded Fallback Exchange Rates in Agent — Low
|
||||
**Location:** `agent/tools.py:77–99`
|
||||
**Evidence:** `sell = rate.sell_rate if rate else 600.0` and a magic `1.08` EUR multiplier in `get_net_worth`.
|
||||
**Why it matters:** The assistant reports a wrong net worth when the rate service is down — silently.
|
||||
**Recommendation:** Fall back to the latest DB-persisted rate; if none, say so in the tool output instead of inventing a number.
|
||||
|
||||
### [BE-25] No Tests — Critical
|
||||
**Location:** `backend/` (no `tests/`, no pytest in `requirements.txt`)
|
||||
**Evidence:** Zero coverage on budget cycles, deferred logic, PDF parsing, auth.
|
||||
**Recommendation:** pytest + fixtures (in-memory SQLite or a test Postgres). Minimum first targets: cycle boundaries (resolves BE-06), deferred carryover, PDF parser golden files, token auth paths.
|
||||
|
||||
## Quick Wins (under 1 hour each)
|
||||
|
||||
1. Startup check rejecting default `SECRET_KEY`/admin creds (`config.py`) — BE-15
|
||||
2. `s/datetime.utcnow()/datetime.now(timezone.utc)/` sweep — BE-01 (verify comparisons after)
|
||||
3. Month/year bounds in `budget_projection.py` helpers — BE-07
|
||||
4. `DISTINCT ON` for pension snapshots — BE-04
|
||||
5. Eager-load water readings — BE-05
|
||||
6. `TemporaryDirectory()` in both PDF parsers — BE-09
|
||||
7. Year bounds in agent budget tool — BE-16
|
||||
8. Specific exceptions + logging in `exchange_rate.py` — BE-18
|
||||
9. `asyncio.to_thread` around PDF parsing — BE-14
|
||||
10. Clear error message for unbound agent session — BE-12
|
||||
|
||||
**Recommended sequence:** tests for cycle math first (BE-25 → BE-06), then the Decimal migration (BE-02) once Alembic (BE-19/ARCH-02) exists to carry it.
|
||||
158
codebase-review/03-frontend.md
Normal file
158
codebase-review/03-frontend.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Frontend Review — WealthySmart
|
||||
|
||||
> Hat: Senior Frontend Engineer (React 19 / TypeScript / Vite / Tailwind 4 / Hono / CopilotKit). Focus: data fetching, state, type safety, auth flow, accessibility, the Hono gateway.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
WealthySmart's frontend is a well-structured React 19 + Vite + TypeScript app behind a Hono gateway server. The codebase shows good practices in component organization, Tailwind styling, and accessibility basics. The dominant weakness is the hand-rolled data-fetching layer: no request cancellation, no caching/deduplication, and inconsistent error/loading states — several pages swallow API errors into `console.error` and render empty UI. The CopilotKit integration is sophisticated but leans on heavy middleware workarounds (message dedup, snapshot reconciliation) that indicate upstream fragility and currently swallow their own errors. Auth uses httpOnly cookies (`ws_token`) over same-origin requests — a solid choice — but the 401 handling can redirect-loop and the auth probe treats network failure as logged-out.
|
||||
|
||||
## Strengths
|
||||
|
||||
- **Type safety** — comprehensive interfaces in `api.ts` for Transaction, RecurringItem, Budget types; only a couple of `any` escapes.
|
||||
- **Component architecture** — clean decomposition into pages, hooks (`useBudget`), and contexts (Theme, Privacy, Auth).
|
||||
- **Accessibility fundamentals** — focus rings, aria-labels, title attributes on interactive elements; privacy mode via `data-sensitive` masking; logical heading hierarchy.
|
||||
- **Error messages where they exist** — `TransactionModal` handles 409 conflicts with a user-readable message.
|
||||
- **Format helpers** — locale-aware CRC formatting (`formatAmount`, `formatCRC`) used consistently in most modules.
|
||||
- **Hono gateway** — cleanly proxies FastAPI and hosts the CopilotKit runtime; `DeduplicateToolCallMiddleware` / `ReconcileSnapshotMiddleware` address known CopilotKit issues head-on.
|
||||
- **Responsive baseline** — mobile menu, adaptive layouts; tables are the weak spot (see FE/UX findings).
|
||||
|
||||
## Findings
|
||||
|
||||
### [FE-01] No Request Cancellation in `useBudget` — High
|
||||
**Location:** `src/hooks/useBudget.ts:52–59`
|
||||
**Evidence:**
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
fetchProjection();
|
||||
fetchRecurringItems();
|
||||
}, [fetchProjection, fetchRecurringItems]);
|
||||
```
|
||||
**Why it matters:** Changing `year`/`selectedMonth` while a request is in flight lets the stale response overwrite newer state. No AbortController or request-ID tracking anywhere in the app.
|
||||
**Recommendation:** Abort on dependency change (`return () => ctrl.abort()`), or adopt TanStack Query which solves this class wholesale (see FE-10).
|
||||
|
||||
### [FE-02] Analytics Swallows Errors, No Loading State — Medium
|
||||
**Location:** `src/pages/Analytics.tsx:77–95`
|
||||
**Evidence:** `Promise.all([...]).then(...).catch(console.error)` — failures leave silently empty charts; no skeleton during fetch.
|
||||
**Recommendation:** `loading` / `error` / retry states with visible UI.
|
||||
|
||||
### [FE-03] Transaction Fetch Races in Budget Page — High
|
||||
**Location:** `src/pages/Budget.tsx:44–77`
|
||||
**Evidence:** `fetchTransactions` re-fires on `txSearch`/`txSource`/`selectedMonth` changes with no cancellation; last-to-resolve wins even if stale.
|
||||
**Recommendation:** AbortController per request keyed to the effect; or query library.
|
||||
|
||||
### [FE-04] Unguarded Array Indexing in Pensions ROI Fallback — Medium
|
||||
**Location:** `src/pages/Pensions.tsx:292–315`
|
||||
**Evidence:** Fallback computes `chartData[len - 1][key] - chartData[len - 2][key] - ...`; with `len < 2` this silently yields 0/negative garbage.
|
||||
**Recommendation:** Guard `if (len < 2) { acc[key] = 0; }` before indexing.
|
||||
|
||||
### [FE-05] Post-Upload Refetch Can Hang the Upload UI — Medium
|
||||
**Location:** `src/pages/Pensions.tsx:343–365`
|
||||
**Evidence:** `await loadData()` after upload has no timeout; "Procesando…" persists indefinitely if the backend stalls.
|
||||
**Recommendation:** Timeout the refetch and surface an actionable message.
|
||||
|
||||
### [FE-06] `catch (err: any)` in TransactionModal — Medium
|
||||
**Location:** `src/components/TransactionModal.tsx:97`
|
||||
**Evidence:** `err.response?.status === 409` assumes a shape; non-HTTP errors (network) silently fall through to `console.error` with no user message.
|
||||
**Recommendation:** Type-guard with the app's `ApiError`; always set a user-visible error in the else branch.
|
||||
|
||||
### [FE-07] localStorage Read in Effect Instead of Lazy Initializer — Low
|
||||
**Location:** `src/contexts/theme-context.tsx:16–24`, `src/contexts/privacy-context.tsx:13–15`
|
||||
**Evidence:** Initial theme read happens in `useEffect`, causing a flash of wrong theme on mount.
|
||||
**Recommendation:** `useState(() => localStorage.getItem("theme") ?? ...)` lazy initializer.
|
||||
|
||||
### [FE-08] No Security Headers from the Hono Gateway — Low
|
||||
**Location:** `frontend/server.ts:379–416`
|
||||
**Evidence:** No CSP, `X-Frame-Options`, `X-Content-Type-Options`, or `Referrer-Policy` on served responses.
|
||||
**Recommendation:** One small Hono middleware (pairs with backend SEC-07; the user-facing HTML is served from here, so this is the layer that matters most for CSP/frame protection).
|
||||
|
||||
### [FE-09] Coupled Effects in `useBudget` — Medium
|
||||
**Location:** `src/hooks/useBudget.ts:27–35`
|
||||
**Evidence:** Projection and recurring-items fetches share one effect; failure modes and re-run triggers are entangled.
|
||||
**Recommendation:** One effect per concern with explicit dependencies.
|
||||
|
||||
### [FE-10] No Query Caching or Deduplication — High
|
||||
**Location:** `src/lib/api.ts:15–66` and all consumers
|
||||
**Evidence:** Every `api.get()` is a fresh fetch; parallel consumers of the same endpoint each hit the network; every page re-fetches everything on mount.
|
||||
**Why it matters:** This is the root cause behind FE-01/02/03/09/24 and ARCH-13. One architectural decision fixes the whole class.
|
||||
**Recommendation:** Adopt **TanStack Query**: per-key caching, dedup, cancellation, retries, `isLoading`/`isError` states, and mutation invalidation. Migrate page by page (Budget first).
|
||||
|
||||
### [FE-11] CopilotKit Middleware Swallows Parse Errors — Medium
|
||||
**Location:** `frontend/server.ts:350–368`
|
||||
**Evidence:** `catch { return; }` in `beforeRequestMiddleware` — malformed bodies silently skip orphan-tool-call pairing, producing confusing downstream runtime failures.
|
||||
**Recommendation:** `console.error` with context before returning; given the documented history of card-persistence bugs (see `reference_agui_client` memory), observability here pays for itself.
|
||||
|
||||
### [FE-12] Month Detail Rendered Without Existence Check — Medium
|
||||
**Location:** `src/pages/Budget.tsx:115–154`
|
||||
**Evidence:** Month navigation renders detail UI without checking `monthDetail` exists for the selection.
|
||||
**Recommendation:** `monthDetail ? <MonthlyDetail/> : loading ? <Skeleton/> : <EmptyState/>`.
|
||||
|
||||
### [FE-13] XSS Posture Is Fine — Watch `dangerouslySetInnerHTML` — Informational
|
||||
**Location:** `src/components/TransactionList.tsx:129`, `src/components/chat/ChatCards.tsx:128`
|
||||
**Evidence:** Merchant/category strings render through JSX (auto-escaped). No `dangerouslySetInnerHTML` found. Not a current bug — recorded as a guardrail since this data originates from emails (attacker-influenceable).
|
||||
|
||||
### [FE-14] No Runtime Validation of API Responses — Medium
|
||||
**Location:** `src/lib/api.ts` and all consumers
|
||||
**Evidence:** Response types are compile-time assertions only; backend schema drift produces `undefined` rendering or crashes far from the cause.
|
||||
**Recommendation:** Either Zod-parse at the API-client boundary for the few critical shapes, or (better long-term) generate types from FastAPI's OpenAPI schema once ARCH-16 lands (`openapi-typescript`).
|
||||
|
||||
### [FE-15] Auth Probe Treats Network Failure as Logged Out — Medium
|
||||
**Location:** `src/AuthContext.tsx:22–29`
|
||||
**Evidence:** `.catch(() => setAuthenticated(false))` — a transient network blip on app load kicks an authenticated user to the login screen.
|
||||
**Recommendation:** Distinguish network errors (retry once, or keep an "unknown" state) from a real 401.
|
||||
|
||||
### [FE-16] Missing `aria-pressed` on Toggle Buttons — Low
|
||||
**Location:** `src/pages/Pensions.tsx:515–530` (fund visibility toggles)
|
||||
**Evidence:** Toggles convey state visually only.
|
||||
**Recommendation:** `aria-pressed={visibleFunds.has(key)}`; audit other toggles (privacy mode, theme).
|
||||
|
||||
### [FE-17] `en-US` Dates in an es-CR App — Low
|
||||
**Location:** `src/pages/Analytics.tsx:238–240` (chart `labelFormatter`), also `src/lib/format.ts:19–21`
|
||||
**Evidence:** Chart tooltips show "Jan", "Feb" while the page is in Spanish.
|
||||
**Recommendation:** Centralize on `es-CR` (see UX-19 for the full localization sweep).
|
||||
|
||||
### [FE-18] `useBudget` Return Object Not Memoized — Low
|
||||
**Location:** `src/hooks/useBudget.ts:86–103`
|
||||
**Evidence:** Fresh object identity every render; consumers using it as a dependency re-run unnecessarily.
|
||||
**Recommendation:** Wrap in `useMemo` (moot if migrated to TanStack Query).
|
||||
|
||||
### [FE-19] Unbounded Upload-Result List — Low
|
||||
**Location:** `src/pages/Pensions.tsx:822–865`
|
||||
**Evidence:** All returned snapshots render without scroll containment or truncation.
|
||||
**Recommendation:** Slice to ~10 with "and N more".
|
||||
|
||||
### [FE-20] Dead Code: `AgentHomeClient.tsx` — Low
|
||||
**Location:** `src/components/AgentHomeClient.tsx` vs `src/pages/Asistente.tsx`
|
||||
**Evidence:** Two near-identical components; only `Asistente.tsx` is routed.
|
||||
**Recommendation:** Delete `AgentHomeClient.tsx`.
|
||||
|
||||
### [FE-21] (Withdrawn) — the shadcn re-export concern was speculative; no concrete evidence found. Dropped.
|
||||
|
||||
### [FE-22] 401 Handler Can Redirect-Loop — Medium
|
||||
**Location:** `src/lib/api.ts:48–52`
|
||||
**Evidence:** On 401: call logout, `window.location.replace("/login")`. If any call from the login page itself 401s, this loops.
|
||||
**Recommendation:** Skip the redirect when already on `/login`.
|
||||
|
||||
### [FE-23] Proxy Has No Error Handling for Backend Failures — Medium
|
||||
**Location:** `frontend/server.ts:385–402`
|
||||
**Evidence:** `proxyToBackend` returns `fetch(target, init)` with no try/catch; a connection refusal becomes an opaque 500.
|
||||
**Recommendation:** try/catch → log → `c.json({ error: "Backend unavailable" }, 502)`.
|
||||
|
||||
### [FE-24] No Timeouts on Any Fetch — Medium
|
||||
**Location:** All pages
|
||||
**Evidence:** No `AbortSignal.timeout()` anywhere; hung backends mean indefinite spinners.
|
||||
**Recommendation:** Default `AbortSignal.timeout(30_000)` in the shared `request()` helper in `api.ts` — one change covers the app.
|
||||
|
||||
## Quick Wins (under 1 hour each)
|
||||
|
||||
1. `es-CR` locale in Analytics chart labels — FE-17
|
||||
2. Delete `AgentHomeClient.tsx` — FE-20
|
||||
3. Security-headers middleware in `server.ts` — FE-08
|
||||
4. Guard `/login` in the 401 redirect — FE-22
|
||||
5. try/catch + 502 in `proxyToBackend` — FE-23
|
||||
6. `AbortSignal.timeout(30000)` in the shared request helper — FE-24
|
||||
7. Guard `chartData.length >= 2` in Pensions ROI — FE-04
|
||||
8. `aria-pressed` on fund toggles — FE-16
|
||||
9. Log CopilotKit middleware parse failures — FE-11
|
||||
10. Lazy initializers for theme/privacy contexts — FE-07
|
||||
|
||||
**Strategic recommendation:** Most Medium/High findings here share one root cause — the absence of a query layer. Adopting TanStack Query (Plan Phase 3) retires FE-01, FE-03, FE-09, FE-10, FE-18, FE-24 and ARCH-13 in one move, and makes FE-02-style error states the default rather than per-page work.
|
||||
132
codebase-review/04-security.md
Normal file
132
codebase-review/04-security.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Security Review — WealthySmart
|
||||
|
||||
> Hat: Senior Application Security Engineer. Defensive review of the owner's own app (wealth.cescalante.dev) for hardening. All findings verified against actual code; key claims re-checked by the synthesizer (see README corrections).
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The most important finding is configuration, not code: `backend/app/config.py` ships **working fallback credentials** (`admin`/`admin`, a known `SECRET_KEY`) that take effect silently if a deploy ever runs without its env file — and the deploy pipeline regenerates `.env.prod` on every deploy, making that scenario plausible. Beyond that: login endpoints use non-constant-time `!=` comparison with **no rate limiting**, CORS is `allow_origins=["*"]` with credentials enabled, JWTs live for 30 days, and PDF upload endpoints accept files of any size and type. On the positive side, the app uses parameterized queries throughout, safe subprocess invocation (no `shell=True`, controlled args, timeouts), hashed API tokens with expiry checks, and httpOnly + SameSite=Lax cookies. All critical fixes are low-effort.
|
||||
|
||||
**Note (good news, verified):** `.env` and `.env.prod` are properly gitignored and have never been committed — no secret exposure via the repository.
|
||||
|
||||
## Threat Model
|
||||
|
||||
**Assets:** personal financial data (accounts, transactions, balances, pensions, water usage), admin credentials, JWT signing key, n8n API tokens, OpenAI API key, VAPID keys.
|
||||
|
||||
**Actors:**
|
||||
- Internet attacker reaching wealth.cescalante.dev (it is publicly exposed)
|
||||
- Malicious website visited by the logged-in user (CORS/CSRF surface)
|
||||
- Credential brute-forcer (no rate limit, fixed username)
|
||||
- Holder of a stolen token (30-day validity window)
|
||||
- **Indirect prompt injector** — transaction descriptions originate from emails parsed by n8n; an attacker who controls a merchant name or email content gets text into the LLM agent's context
|
||||
|
||||
**Entry points:** `/api/auth/login` (×2 implementations), `/api/v1/pensions/upload`, `/api/v1/municipal-receipts/upload`, `/api/copilotkit/*` (Hono), `/api/v1/agent/agui`, n8n token-authenticated POSTs.
|
||||
|
||||
## Findings
|
||||
|
||||
### SEC-01: Working Fallback Credentials in Default Configuration — Critical
|
||||
**Location:** `backend/app/config.py:6, 9, 10` (verified)
|
||||
**Evidence:**
|
||||
```python
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart"
|
||||
SECRET_KEY: str = "change-me-in-production"
|
||||
ADMIN_USERNAME: str = "admin"
|
||||
ADMIN_PASSWORD: str = "admin"
|
||||
```
|
||||
**Exploit scenario:** Deploy pipeline regenerates `.env.prod` from Gitea secrets each deploy (see `reference_gitea_deploy`). A pipeline regression or missing secret silently boots the app accepting `admin:admin`, and the known `SECRET_KEY` lets anyone forge valid JWTs even *without* logging in.
|
||||
**Remediation:** Remove defaults; validate at startup:
|
||||
```python
|
||||
SECRET_KEY: str # no default
|
||||
@model_validator(mode="after")
|
||||
def _no_weak_defaults(self):
|
||||
if self.SECRET_KEY in ("", "change-me-in-production"):
|
||||
raise ValueError("SECRET_KEY must be set")
|
||||
if self.ADMIN_PASSWORD in ("", "admin"):
|
||||
raise ValueError("ADMIN_PASSWORD must be set")
|
||||
return self
|
||||
```
|
||||
A failed boot is loud (healthcheck fails, deploy aborts) — exactly what you want.
|
||||
|
||||
### SEC-02: Non-Constant-Time Credential Comparison — High
|
||||
**Location:** `backend/app/main.py:165–169`, `backend/app/api/v1/endpoints/auth.py:12–19`
|
||||
**Evidence:** Both login implementations:
|
||||
```python
|
||||
if (body.username != settings.ADMIN_USERNAME
|
||||
or body.password != settings.ADMIN_PASSWORD):
|
||||
raise HTTPException(status_code=401, ...)
|
||||
```
|
||||
**Exploit scenario:** Short-circuit `or` plus string `!=` leaks timing information distinguishing username-fail from password-fail and partially leaking prefix matches under many measurements. Marginal for a remote attacker, but the fix is one line and the password is also stored in plaintext config.
|
||||
**Remediation:** `hmac.compare_digest()` on both fields, both endpoints. Better: store a bcrypt/argon2 hash of the admin password (`passlib`) instead of the plaintext in env.
|
||||
|
||||
### SEC-03: No Rate Limiting on Login — High
|
||||
**Location:** Both login endpoints (`main.py:163–179`, `endpoints/auth.py:10–21`)
|
||||
**Evidence:** No limiter, lockout, or backoff anywhere in the codebase.
|
||||
**Exploit scenario:** Username is effectively fixed ("admin"); an attacker brute-forces the password at full line speed against a public endpoint.
|
||||
**Remediation:** `slowapi` with `5/minute` per IP on both login routes, or a simple in-memory failure counter with exponential backoff (single-process app, in-memory is fine). Also consider consolidating to **one** login endpoint — two implementations means two places to harden forever.
|
||||
|
||||
### SEC-04: CORS `["*"]` with Credentials Enabled — Medium
|
||||
**Location:** `backend/app/main.py:79–85` (verified line 81)
|
||||
**Evidence:**
|
||||
```python
|
||||
app.add_middleware(CORSMiddleware,
|
||||
allow_origins=["*"], allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
```
|
||||
**Exploit scenario:** Starlette reflects the request Origin when `allow_credentials=True`, so any website gets credentialed CORS approval. Mitigating factor: the `ws_token` cookie is `SameSite=Lax`, so browsers won't attach it to cross-site XHR/fetch — which currently blunts this from Critical to Medium. But the protection is one cookie-attribute change away from vanishing, and `Authorization`-header flows aren't SameSite-protected.
|
||||
**Remediation:** `allow_origins=["https://wealth.cescalante.dev", "http://localhost:3000"]`, explicit methods and headers.
|
||||
|
||||
### SEC-05: 30-Day JWT Expiry — Medium
|
||||
**Location:** `backend/app/config.py:7` (verified), `backend/app/auth.py:18–20`
|
||||
**Evidence:** `ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 30 # 30 days`
|
||||
**Exploit scenario:** A stolen cookie/token works for a month; there is no revocation mechanism for JWTs (only API tokens are revocable).
|
||||
**Remediation:** For a single-user personal app, 24h–7d is a reasonable convenience/security balance (8h would force daily logins). Pick e.g. 7 days, or implement short access + refresh tokens if login friction matters.
|
||||
|
||||
### SEC-06: No Upload Size/Type Validation — Medium
|
||||
**Location:** `backend/app/api/v1/endpoints/pensions.py:104–162`, `municipal_receipts.py:197–228`
|
||||
**Evidence:** `files: list[UploadFile]` then `await file.read()` — unbounded read, no content-type or magic-byte check (endpoints *are* authenticated).
|
||||
**Exploit scenario:** Memory-exhaustion DoS via huge upload (requires auth — the n8n token or the user's own session — so primarily a robustness issue); junk files reach `pdftotext`.
|
||||
**Remediation:** 10 MB cap via bounded read, require `pdf_bytes.startswith(b"%PDF")`, record per-file rejections in the existing `errors[]` response field.
|
||||
|
||||
### SEC-07: Missing Security Headers — Low
|
||||
**Location:** `backend/app/main.py` (absent middleware); more importantly `frontend/server.ts` since it serves the HTML
|
||||
**Evidence:** No CSP, `X-Content-Type-Options`, `X-Frame-Options`, or HSTS set by either layer.
|
||||
**Remediation:** Set headers at the Hono layer (the user-facing one): `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, and a CSP once inline-script usage is audited. HSTS can be set at nginx-proxy.
|
||||
|
||||
### SEC-08: Cookie `secure=False` Relies on a Comment — Low
|
||||
**Location:** `backend/app/main.py:177`
|
||||
**Evidence:** `secure=False, # set True behind TLS in production via nginx` — but nothing actually sets it to True in production; nginx terminating TLS does not add the Secure attribute to an app-issued cookie.
|
||||
**Exploit scenario:** The cookie would be sent over plain HTTP if the user ever hits an http:// URL for the domain (pre-HSTS first visit, or a downgrade).
|
||||
**Remediation:** Drive it from config: `secure=settings.COOKIE_SECURE` (True in prod env). Pair with HSTS.
|
||||
|
||||
### SEC-09: LLM Agent — Indirect Prompt Injection Surface — Medium (added by synthesis)
|
||||
**Location:** `backend/app/agent/tools.py` (tool outputs include merchant names, notes), data path: email → n8n → `POST /transactions/` → agent context
|
||||
**Evidence:** Transaction descriptions originate from external emails. When the assistant lists recent transactions, attacker-influenceable text (a crafted merchant name) enters the model's context. Mitigating factors: all agent tools are **read-only queries** (verified — no tool mutates data), and the agent endpoint requires authentication, so impact is bounded to misleading the user, not data modification or exfiltration to third parties.
|
||||
**Remediation:** Keep tools read-only as a hard rule (document it); if write-tools are ever added, require explicit user confirmation in the UI (`renderAndWaitForResponse`) for every mutation. Consider delimiting untrusted strings in tool outputs.
|
||||
|
||||
### SEC-10: Positive Findings (No Issue) — Informational
|
||||
- **API token handling is correct** (`auth.py:42–54`): SHA-256 hash lookup, `is_active` flag, expiry checked against now. Tokens are revocable.
|
||||
- **Subprocess usage is safe** (`pension_pdf.py:46–53`, `municipal_receipt_pdf.py:79–86`): list-args, no `shell=True`, timeouts, Python-generated temp filenames — no command-injection path.
|
||||
- **No raw SQL string interpolation found** — ORM/parameterized throughout (the ad-hoc migrations in `db.py` are static strings).
|
||||
- **httpOnly + SameSite=Lax cookie** — correct baseline against XSS token theft and CSRF.
|
||||
- **`.env` files properly gitignored, never committed** (verified via `git ls-files` and history).
|
||||
|
||||
## Hardening Checklist
|
||||
|
||||
**Priority 1 — do before anything else (≈half a day total):**
|
||||
- [ ] SEC-01: remove config defaults; fail-fast startup validation
|
||||
- [ ] SEC-02: `hmac.compare_digest` (or hashed admin password) in both login endpoints
|
||||
- [ ] SEC-03: rate-limit login (5/min/IP); consolidate the duplicate login endpoints
|
||||
- [ ] SEC-04: pin CORS origins
|
||||
|
||||
**Priority 2 — same week:**
|
||||
- [ ] SEC-05: reduce token expiry (7 days suggested)
|
||||
- [ ] SEC-06: upload size cap + `%PDF` magic check
|
||||
- [ ] SEC-07: security headers at the Hono layer; HSTS at nginx
|
||||
- [ ] SEC-08: `secure=True` cookie in production via config
|
||||
|
||||
**Priority 3 — ongoing posture:**
|
||||
- [ ] Keep agent tools read-only by policy; confirm-before-write if that ever changes (SEC-09)
|
||||
- [ ] Audit logging for login attempts and token creation
|
||||
- [ ] Scope n8n API tokens to the endpoints they need (currently any valid token hits any endpoint)
|
||||
- [ ] Database backup with encryption; test restore
|
||||
- [ ] Dependency update cadence (Dependabot/Renovate on `requirements.txt` and `package.json`)
|
||||
161
codebase-review/05-poweruser.md
Normal file
161
codebase-review/05-poweruser.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Poweruser Review — WealthySmart
|
||||
|
||||
> Hat: Daily power user of the app + senior product engineer. Focus: real user journeys, feedback loops, data trust, consistency, missing features.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
WealthySmart has solid fundamentals: clean routing, consistent UI patterns, bilingual support, and thoughtful features like pension projections and municipal receipt tracking. But daily use exposes friction: **no success/error feedback after mutations** (did my delete work?), no error recovery anywhere (failed loads look like empty data), three different month-navigation patterns across modules, a search box that silently does nothing for non-credit-card sources, and — most corrosive to trust — **silent n8n parse failures** that leave invisible gaps in transaction history with no reconciliation view. The data tables are dense and not mobile-adapted. The Asistente is pleasant but read-only and ephemeral. None of these are deep architectural problems; they are a backlog of S/M-effort polish items plus two larger trust-building features (sync status, import review).
|
||||
|
||||
## What Works Well
|
||||
|
||||
- **Navigation & layout** — well-organized sidebar (General, Finanzas, Servicios); smooth mobile menu.
|
||||
- **Strong typed data model** on the client — comprehensive interfaces across all modules.
|
||||
- **Privacy mode** — `data-sensitive` blur with an accessible header toggle. Great for screen-sharing.
|
||||
- **Thoughtful finance features** — pension projections with adjustable assumptions, recurring items with frequency + override logic, billing-cycle awareness, deferred transactions.
|
||||
- **Accessibility baseline** — focus-visible rings, aria-labels, disabled-state management.
|
||||
- **Charts** — Recharts configured properly with tooltips/legends across Analytics, Proyecciones, Pensions, ServiciosMunicipales.
|
||||
- **Import result summaries** — paste-import and PDF upload report imported/updated/duplicate counts.
|
||||
|
||||
## Findings
|
||||
|
||||
### UX-01 | No success feedback after mutations — High
|
||||
**Location:** `Budget.tsx:180–183`, `RecurringItemsManager.tsx:57–69`, `TransactionList.tsx:64–74`, Pensions upload handlers
|
||||
**Evidence:** Mutations call `onRefresh()`/`refresh()` with zero confirmation — no toast, no inline message. `TransactionList.tsx:68` deletes then refreshes silently.
|
||||
**User impact:** "Did the delete go through?" Users infer success from list churn — anxiety for irreversible actions.
|
||||
**Recommendation:** Add a toast layer (Sonner) and emit success/error on every mutation. Effort: **M** (library + sweep).
|
||||
|
||||
### UX-02 | No error recovery flow anywhere — High
|
||||
**Location:** `Analytics.tsx:84–94`, `ServiciosMunicipales.tsx:198–211`, `PasteImportModal.tsx:42–46`, `Budget.tsx:44–76`
|
||||
**Evidence:** `.catch(console.error)` or silent catches; Budget's `fetchTransactions` has no error handling at all.
|
||||
**User impact:** API failure is indistinguishable from "no data." No retry affordance.
|
||||
**Recommendation:** Standard `loading → error(retry) → data/empty` states on every page. Effort: **M** (trivial if TanStack Query lands first).
|
||||
|
||||
### UX-03 | Three different month/date-navigation patterns — Medium
|
||||
**Location:** `Budget.tsx` + `Proyecciones.tsx` (chevrons), `Analytics.tsx` (`BillingCycleSelector` dropdown), `Pensions.tsx` (fixed last-12-months, no navigation)
|
||||
**User impact:** Inconsistent mental model; Pensions history before 12 months is unreachable.
|
||||
**Recommendation:** One shared period-navigator component; add history browsing to Pensions. Effort: **M**.
|
||||
|
||||
### UX-04 | Search silently inert for non-credit-card sources — Medium
|
||||
**Location:** `Budget.tsx:42–43, 164–188`
|
||||
**Evidence:** `txSearch` only feeds CREDIT_CARD queries; on the "Efectivo y Transferencias" tab the visible search box does nothing.
|
||||
**User impact:** Search appears broken.
|
||||
**Recommendation:** Make search work for all sources, or hide the field when inapplicable. Effort: **S**.
|
||||
|
||||
### UX-05 | Deferred-transaction toggle gives no feedback — Medium
|
||||
**Location:** `transaction-columns.tsx:68–72`, `TransactionList.tsx:37`
|
||||
**Evidence:** Toggle PATCHes then refetches; no toast, no row styling change beyond a small "Diferida" badge.
|
||||
**User impact:** User can't tell the deferral took effect without hunting.
|
||||
**Recommendation:** Toast on toggle + muted/strikethrough styling on deferred rows. Effort: **S**. (Pairs with ARCH-11: add the field to `TransactionModal` too.)
|
||||
|
||||
### UX-06 | Dirty-form dismissal loses data silently — Low
|
||||
**Location:** `RecurringItemDialog.tsx:50–100`
|
||||
**Recommendation:** ConfirmDialog on close when dirty. Effort: **S**.
|
||||
|
||||
### UX-07 | "All time" English label in Spanish UI — Low
|
||||
**Location:** `BillingCycleSelector.tsx:52`
|
||||
**Recommendation:** "Todo el período". Effort: **S** (part of the UX-19 locale sweep).
|
||||
|
||||
### UX-08 | Login page is bare — Low
|
||||
**Location:** `Login.tsx`
|
||||
**Evidence:** No remember-me, no error differentiation. (Password reset is N/A for a single-user env-configured credential.)
|
||||
**Recommendation:** Low priority; longer cookie life (bounded by SEC-05) already covers persistence. Effort: **S**.
|
||||
|
||||
### UX-09 | Upload errors returned by the API are never displayed — Medium
|
||||
**Location:** Pensions upload result rendering
|
||||
**Evidence:** Backend returns `errors: string[]`; UI shows counts only — a failed parse looks like "Import complete, 0 imported."
|
||||
**User impact:** User can't tell *why* an upload failed (wrong file? corrupt PDF?).
|
||||
**Recommendation:** Render `result.errors` in an Alert list. Effort: **S**.
|
||||
|
||||
### UX-10 | No undo for destructive operations — Medium
|
||||
**Location:** `TransactionList.tsx:64–74`, RecurringItemsManager delete
|
||||
**Evidence:** ConfirmDialog exists, but post-confirm deletion is immediate and irreversible.
|
||||
**Recommendation:** Toast-with-Undo grace period (delay the API call ~5s), or DB soft-delete + restore endpoint. Effort: **M**.
|
||||
|
||||
### UX-11 | Transaction table is poor on mobile — Medium
|
||||
**Location:** `DataTable.tsx:57–80`, `transaction-columns.tsx`
|
||||
**Evidence:** 5+ columns force horizontal scroll at <640px; tiny text.
|
||||
**Recommendation:** Card-stack rendering on small screens (merchant/amount primary; category/bank secondary) or column-visibility tiers. Effort: **M**.
|
||||
|
||||
### UX-12 | Pension projections use hardcoded assumptions — Medium
|
||||
**Location:** `Pensions.tsx:76–116`
|
||||
**Evidence:** `FUNDS_DEFAULT` hardcodes `startBalance`, `monthlyContribution`, `annualRate`; `applySnapshots()` updates only `saldo_final`.
|
||||
**User impact:** Retirement projections drift from reality as contributions change.
|
||||
**Recommendation:** Editable per-fund assumptions persisted server-side (settings or a `PensionAssumption` table). Effort: **M**.
|
||||
|
||||
### UX-13 | Cycle filter only applies to one of three Analytics charts — Medium
|
||||
**Location:** `Analytics.tsx:71–95`
|
||||
**Evidence:** Category breakdown respects the selected cycle; monthly-trend and daily-spending queries ignore it.
|
||||
**User impact:** User picks a cycle and two charts silently show all-time data.
|
||||
**Recommendation:** Pass cycle params to all three endpoints (backend support needed) or visually scope the selector to the one chart it affects. Effort: **M**.
|
||||
|
||||
### UX-14 | Empty states are dead ends — Low
|
||||
**Location:** `Budget.tsx:186`, `Salarios.tsx:157`, `TransactionList.tsx:81`
|
||||
**Recommendation:** `emptyAction` prop rendering an "Add first item" button. Effort: **S**.
|
||||
|
||||
### UX-15 | Destructive-action confirmation applied inconsistently — Medium
|
||||
**Location:** `YearlyOverview.tsx:70–72` (clearing a balance override has no confirm; transaction/recurring deletes do)
|
||||
**Recommendation:** ConfirmDialog on every destructive action. Effort: **S**.
|
||||
|
||||
### UX-16 | Asistente is read-only with no export — Low
|
||||
**Location:** `Asistente.tsx:56–79`
|
||||
**Evidence:** Only `SpendingSummaryCard` renders; no CSV export, no report download, no chat persistence.
|
||||
**Recommendation:** "Export CSV" on cards; longer-term, persist useful summaries. Effort: **M**.
|
||||
|
||||
### UX-17 | n8n parser failures are invisible — High
|
||||
**Location:** Systemic (n8n → API ingestion path); no sync-status surface anywhere in the app
|
||||
**Evidence:** If BAC changes its email format, the n8n flow fails in n8n's own logs; the app just shows a gap in transactions.
|
||||
**User impact:** The core promise — "your finances, automatically tracked" — fails silently. The user discovers missing weeks at month-end close.
|
||||
**Recommendation:** A Sync Status page: last-received timestamp per source (BAC, salary, municipal, pension), expected-cadence warnings ("no BAC email in 7 days"), n8n error visibility. Backend can derive most of this from existing data (`max(created_at) per source`) — no n8n integration needed for v1. Effort: **M** for derived v1, **L** for full n8n integration.
|
||||
|
||||
### UX-18 | No preview when editing recurring items — Medium
|
||||
**Location:** `Budget.tsx:115–201`, `useBudget.ts`
|
||||
**Evidence:** Changing frequency MONTHLY→QUARTERLY shows impact only after save.
|
||||
**Recommendation:** Before/after monthly-total preview in the dialog. Effort: **M**.
|
||||
|
||||
### UX-19 | Mixed-language dates and labels across the app — Low (but pervasive)
|
||||
**Location:** `format.ts:19–21` ('en-US'), `Budget.tsx:12–15` (Spanish `MONTH_NAMES`), `Salarios.tsx:42–49` ('en-US'), `ServiciosMunicipales.tsx:54–57` (`MONTH_NAMES_ES`)
|
||||
**Evidence:** Three separate month-name implementations in two languages; `formatDate` returns English.
|
||||
**Recommendation:** One locale-aware date/format module (`Intl` with `es-CR`), delete the per-page constants. Effort: **M** (sweep).
|
||||
|
||||
### UX-20 | Duplicates are counted but never reviewable — High
|
||||
**Location:** Import flows (paste-import, PDF upload) — UI shows `duplicates: 3` with no detail
|
||||
**Evidence:** No way to see *which* rows were skipped as duplicates or to override a false-positive dedup.
|
||||
**User impact:** Data-integrity trust gap; a false-positive dedup silently loses a real transaction.
|
||||
**Recommendation:** Import-review response listing skipped rows (merchant/date/amount + matched existing row), with a "import anyway" override. Effort: **L** (backend detail + UI).
|
||||
|
||||
### UX-21 | Balance override input has no sanity check — Low
|
||||
**Location:** `YearlyOverview.tsx:55–72`
|
||||
**Recommendation:** Show "Calculated: ₡X" beside the input; warn on large deviation. Effort: **S**.
|
||||
|
||||
### UX-22 | Proyecciones → Budget navigation is one-way — Low
|
||||
**Location:** `Proyecciones.tsx:96–99`
|
||||
**Recommendation:** Back affordance/breadcrumb when arriving from Proyecciones. Effort: **S**.
|
||||
|
||||
### UX-23 | Chat cards are ephemeral — Low
|
||||
**Location:** `Asistente.tsx`, `ChatCards.tsx`
|
||||
**Evidence:** Refresh loses everything; no save/share affordance on cards.
|
||||
**Recommendation:** Export/copy button on `SpendingSummaryCard`. Effort: **S**.
|
||||
|
||||
### UX-24 | Water-meter chart uses raw meter IDs — Low
|
||||
**Location:** `ServiciosMunicipales.tsx:48–52`
|
||||
**Evidence:** Legend shows '7335'/'7345'/'9345'; hardcoded colors; unknown meters fall back to one default color.
|
||||
**Recommendation:** User-editable meter labels ("Casa", "Cochera") in settings. Effort: **S**.
|
||||
|
||||
### UX-25 | Sticky header/sidebar scroll jank — Low
|
||||
**Location:** `Layout.tsx:110, 153`
|
||||
**Recommendation:** Consistent scroll container (`overflow-y-auto` on main). Effort: **S**.
|
||||
|
||||
## Feature Wishlist (prioritized)
|
||||
|
||||
1. **Transaction search & filtering everywhere** — full-text on merchant/notes/reference; date-range, amount-range, category filters. *High impact, M.*
|
||||
2. **Bulk transaction actions** — multi-select → bulk re-categorize / defer / delete-with-undo. Statement import currently means 50 one-by-one edits. *High impact, M.*
|
||||
3. **Sync status & reconciliation view** — last import per source, cadence warnings, failed-parse visibility, duplicate review (UX-17 + UX-20 together). *High impact, L. This is the single biggest trust feature.*
|
||||
4. **CSV export** — transactions, salarios, pensions; for taxes/accountant. *Medium impact, M.*
|
||||
5. **Budget override history & revert** — when/why an override was set. *Medium impact, S.*
|
||||
6. **What-if scenario planner** — "save ₡50k more/month" against the 5-year projection. *Medium impact, M.*
|
||||
7. **Exchange-rate history & per-date override** for reconciling old statements. *Medium impact, S.*
|
||||
8. **PWA polish** — installable, offline-readable balances. *Medium impact, L.*
|
||||
9. **Pension contribution calculator** — "reach ₡X by age Y." *Low impact, S.*
|
||||
10. **Per-month recurring-item skip/override** — e.g., reduced December salary. *Low impact, S.*
|
||||
|
||||
**Effort key:** S < 4h, one component. M = 4–16h, may need a backend endpoint. L = 16h+, schema or multi-component work.
|
||||
108
codebase-review/06-weak-spots-inventory.md
Normal file
108
codebase-review/06-weak-spots-inventory.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Weak-Spots Inventory — Consolidated Across All Five Hats
|
||||
|
||||
90+ raw findings from the five reviews, deduplicated into **13 themes**. Each theme lists the contributing finding IDs (traceable back to the hat reports), a consolidated severity, and the fix destination in [07-improvement-plan.md](07-improvement-plan.md).
|
||||
|
||||
Severity reflects synthesis judgment for a *single-user, publicly exposed personal finance app* — some agent severities were adjusted (noted inline).
|
||||
|
||||
---
|
||||
|
||||
## Severity Matrix
|
||||
|
||||
| # | Theme | Severity | Findings | Plan Phase |
|
||||
|---|---|---|---|---|
|
||||
| T1 | Auth & config hardening | **Critical** | SEC-01..05, SEC-08, BE-15, ARCH-01, ARCH-14 | 0 |
|
||||
| T2 | Zero test coverage | **Critical** | ARCH-06, BE-25 | 1 |
|
||||
| T3 | No real migration system | **High** | ARCH-02, BE-19 | 1 |
|
||||
| T4 | Billing-cycle math unverified | **High** | BE-06, BE-07 | 1 |
|
||||
| T5 | Money stored as float | **High** | BE-02 | 1–2 |
|
||||
| T6 | Silent failures & missing feedback (frontend) | **High** | FE-02, FE-06, FE-11, UX-01, UX-02, UX-05, UX-09, UX-15 | 3 |
|
||||
| T7 | Hand-rolled data fetching (races, no cache/cancel/timeout) | **High** | FE-01, FE-03, FE-09, FE-10, FE-18, FE-22, FE-24, ARCH-13 | 3 |
|
||||
| T8 | Ingestion trust gap (silent n8n failures, opaque dedup) | **High** | UX-17, UX-20, BE-08 | 4 |
|
||||
| T9 | Upload robustness | **Medium** | SEC-06, BE-09, BE-10, BE-14, FE-05, FE-19 | 2 |
|
||||
| T10 | Datetime & timezone hygiene | **Medium** | BE-01 | 1 |
|
||||
| T11 | Query efficiency & data-integrity constraints | **Medium** | ARCH-09, ARCH-12, ARCH-15, BE-03, BE-04, BE-05, BE-11, BE-21, BE-22, BE-23 | 2 |
|
||||
| T12 | Agent quality & safety | **Medium** | ARCH-04, ARCH-17, BE-12, BE-16, BE-17, BE-24, SEC-09, UX-16, UX-23 | 2/4 |
|
||||
| T13 | Consistency & polish (l10n, a11y, mobile, UX) | **Medium** | FE-07, FE-16, FE-17, UX-03, UX-04, UX-06, UX-07, UX-10..14, UX-18, UX-19, UX-21..25, ARCH-11 | 4 |
|
||||
|
||||
Standalone small items: FE-08/SEC-07 (security headers → Phase 0), FE-20 (dead code → Phase 0 cleanup), FE-23 (proxy 502 → Phase 0), ARCH-05 (paste size cap → Phase 0), ARCH-16/FE-14 (response schemas/codegen → Phase 3), ARCH-18 (CSV export → Phase 4), ARCH-08/10/20 (small backend hardening → Phase 2).
|
||||
|
||||
---
|
||||
|
||||
## T1 — Auth & Config Hardening · Critical
|
||||
|
||||
**The single most important theme.** The app is publicly exposed at wealth.cescalante.dev with:
|
||||
- Working fallback credentials in `config.py` (`admin/admin`, known `SECRET_KEY`) that activate silently if a deploy loses its env file — and `.env.prod` is regenerated every deploy (SEC-01, BE-15)
|
||||
- No rate limiting on login + non-constant-time comparison, against a fixed username (SEC-02, SEC-03)
|
||||
- CORS reflecting any origin with credentials (SEC-04) — currently blunted only by SameSite=Lax
|
||||
- 30-day irrevocable JWTs (SEC-05); `secure=False` cookie relying on a comment (SEC-08)
|
||||
- Hardcoded dev DB credentials in `docker-compose.yml` (ARCH-14)
|
||||
|
||||
**Correction:** the "secrets committed to git" claim (original ARCH-01) was verified **false** — `.env*` is gitignored with no history. The theme remains Critical because of the fallback-credential failure mode, not repo leakage.
|
||||
|
||||
**Why Critical:** every item is individually small, but together they describe a public financial app one pipeline hiccup away from `admin:admin`. Total fix effort: about half a day.
|
||||
|
||||
## T2 — Zero Test Coverage · Critical
|
||||
|
||||
Not one test file in the repository; pytest isn't even a dependency. The riskiest logic in the app — billing-cycle boundaries, deferred-transaction carryover, cumulative balances with manual overrides, PDF regex parsing — is guarded by nothing. Every other theme's fixes (Decimal migration, cycle verification, refactors) are unsafe to attempt without this. **Tests are the enabling investment for the entire plan.**
|
||||
|
||||
## T3 — No Real Migration System · High
|
||||
|
||||
Schema evolution is hand-written `ALTER TABLE` strings in `db.py:13–77` with per-statement try/except. The project's own hard rule — *never reset prod DB* (`feedback_db_reset`) — makes versioned, reversible migrations non-optional. Alembic is the obvious choice; it also unblocks T5 (Decimal) and T11 (constraints), which both need schema changes.
|
||||
|
||||
## T4 — Billing-Cycle Math Unverified · High
|
||||
|
||||
`get_cycle_range()` code and its own comment disagree about which 18th–18th window a month's budget covers (BE-06). Either every monthly budget is shifted by one cycle, or the comment will mislead the next change into breaking working code. Helpers also accept month 13 without complaint (BE-07). **Resolve with characterization tests against known-real statement data — first deliverable of the test suite.**
|
||||
|
||||
## T5 — Money Stored as Float · High
|
||||
|
||||
All monetary columns are `float` (BE-02). For an app whose entire purpose is financial accuracy, `NUMERIC`/`Decimal` is the standard. Requires: Alembic (T3) first, tests (T2) to prove equivalence, then a coordinated model + service-math migration. Highest-effort single item in the backend; schedule deliberately, not as a quick fix.
|
||||
|
||||
## T6 — Silent Failures & Missing Feedback · High
|
||||
|
||||
The frontend's pervasive pattern: errors → `console.error`, success → nothing. Analytics renders empty charts on API failure (FE-02); deletes/edits/uploads complete without confirmation (UX-01); upload error details returned by the backend are never shown (UX-09); CopilotKit middleware eats its own exceptions (FE-11); destructive confirmation is inconsistent (UX-15). One toast system + one standard error/retry pattern resolves the lot.
|
||||
|
||||
## T7 — Hand-Rolled Data Fetching · High
|
||||
|
||||
No cancellation (FE-01, FE-03), no caching/dedup (FE-10), no timeouts (FE-24), refetch-everything-on-mutation (ARCH-13), 401 redirect loop risk (FE-22), coupled effects (FE-09). **Root cause is singular: there is no query layer.** Adopting TanStack Query retires the entire theme and makes T6's error/loading states near-free. This is the highest-leverage frontend decision available.
|
||||
|
||||
## T8 — Ingestion Trust Gap · High
|
||||
|
||||
The app's core promise is automatic ingestion, but: n8n parse failures are invisible inside the app (UX-17); dedup decisions are reported as a bare count with no review or override (UX-20); the dedup key itself has gaps (nullable `reference`, BE-08). A user discovers missing weeks of data only at month-end. Fix in two stages: a derived Sync Status page (cheap — `max(created_at)` per source + cadence thresholds), then an import-review flow with dedup detail.
|
||||
|
||||
## T9 — Upload Robustness · Medium
|
||||
|
||||
Unbounded reads with no type check (SEC-06), blocking `subprocess.run` inside async routes (BE-14), temp files leaking on timeout (BE-09), per-file commits leaving half-applied batches (BE-10), frontend hanging on post-upload refetch (FE-05). One focused pass over the two upload endpoints + their UI closes all of it.
|
||||
|
||||
## T10 — Datetime & Timezone Hygiene · Medium
|
||||
|
||||
`datetime.utcnow()` (deprecated, naive) at 10+ sites (BE-01). Costa Rica is UTC-6; naive-UTC timestamps compared against local dates can shift late-evening transactions across cycle boundaries. Mechanical fix + a comparison audit; do it alongside T4 so the tests catch any behavior change.
|
||||
|
||||
## T11 — Query Efficiency & Data-Integrity Constraints · Medium
|
||||
|
||||
N+1s (ARCH-15, BE-04, BE-05), ~15 queries per projection call (BE-21), FKs without delete rules (ARCH-09/BE-11), missing tiebreaker ordering (ARCH-12), check-then-insert race (BE-22), non-unique account labels (BE-23), non-atomic savings accrual (BE-03). None urgent for single-user load; all cheap; constraints need Alembic (T3) first.
|
||||
|
||||
## T12 — Agent Quality & Safety · Medium
|
||||
|
||||
Tools duplicate service logic so UI and assistant can drift (ARCH-04); hardcoded fallback rates make it confidently wrong when the rate service is down (BE-24); no year bounds (BE-16); cryptic failure when called out of context (BE-12); fixed limits hide older history (BE-17). Safety posture is acceptable *because tools are read-only* — keep that as an explicit invariant (SEC-09). UX: cards are ephemeral and nothing is exportable (UX-16, UX-23).
|
||||
|
||||
## T13 — Consistency & Polish · Medium
|
||||
|
||||
The long tail that makes the app feel finished: three month-navigation patterns (UX-03), three month-name implementations in two languages (UX-19, FE-17, UX-07), inert search on cash tab (UX-04), no-undo deletes (UX-10), dense mobile tables (UX-11), hardcoded pension assumptions (UX-12), cycle filter applying to one of three charts (UX-13), dead-end empty states (UX-14), deferred-transaction UI gap (ARCH-11, UX-05), a11y gaps on toggles (FE-16), meter-ID legends (UX-24). Individually small; batch them into themed polish passes.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Hat Agreement (highest-confidence signals)
|
||||
|
||||
Where independent hats converged on the same problem, confidence is highest:
|
||||
|
||||
| Problem | Flagged independently by |
|
||||
|---|---|
|
||||
| Default credentials / weak config | Architect, Backend, Cybersec |
|
||||
| No tests | Architect, Backend |
|
||||
| No migrations | Architect, Backend |
|
||||
| Upload validation/robustness | Backend, Cybersec, Frontend, Poweruser |
|
||||
| Silent error swallowing | Frontend, Poweruser, Backend (exchange-rate `pass`) |
|
||||
| Agent/service logic duplication | Architect, Backend |
|
||||
| Localization inconsistency | Frontend, Poweruser |
|
||||
| Deferred-transaction UI gap | Architect, Poweruser |
|
||||
| Refetch-everything / no query layer | Architect, Frontend |
|
||||
203
codebase-review/07-improvement-plan.md
Normal file
203
codebase-review/07-improvement-plan.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Improvement Plan — WealthySmart
|
||||
|
||||
Phased plan derived from [06-weak-spots-inventory.md](06-weak-spots-inventory.md). Phases are ordered by dependency and risk: **harden → build foundations → fix correctness → rebuild the frontend data layer → polish and grow**. Each phase is independently shippable; stop-anywhere is safe.
|
||||
|
||||
Conventions respected throughout: small logical commits, push to GitHub origin (Gitea mirrors), `.env.prod` vars must be added in **both** Gitea secrets and the workflow (see `reference_gitea_deploy`), never reset the prod DB.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Security Hardening Sprint
|
||||
|
||||
**Goal:** close every cheap hole in the public-facing surface. **Effort: ~1 day.** No schema changes, no behavior changes for the legitimate user (except needing real env values — verify Gitea secrets are complete *before* deploying).
|
||||
|
||||
### 0.1 Fail-fast configuration (SEC-01, BE-15)
|
||||
- `backend/app/config.py`: remove defaults for `SECRET_KEY`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`; add a pydantic `model_validator` rejecting empty/known-default values.
|
||||
- Keep a documented `.env.example` (no real values) as the template.
|
||||
- **Pre-deploy check:** confirm all three exist in Gitea secrets and the workflow env-file generation step.
|
||||
- *Accept:* app refuses to boot with missing/default secrets; prod deploy still succeeds.
|
||||
|
||||
### 0.2 Login hardening (SEC-02, SEC-03)
|
||||
- Consolidate to a single login implementation (the cookie login in `main.py` is the one the SPA uses; have `endpoints/auth.py` delegate or remove it from the router if unused).
|
||||
- `hmac.compare_digest` on username and password.
|
||||
- Rate limit: `slowapi` 5/min/IP on login (in-memory is fine, single process). Return 429 with a Retry-After.
|
||||
- *Accept:* 6th attempt within a minute → 429; valid login unaffected.
|
||||
|
||||
### 0.3 CORS pinning (SEC-04)
|
||||
- `allow_origins=["https://wealth.cescalante.dev", "http://localhost:3000", "http://localhost:3001"]`; explicit methods/headers.
|
||||
- *Accept:* prod SPA, dev SPA, and n8n flows (server-to-server — CORS-exempt) all still work.
|
||||
|
||||
### 0.4 Token & cookie tightening (SEC-05, SEC-08)
|
||||
- `ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7` (7 days — balance for a personal app).
|
||||
- New `COOKIE_SECURE: bool = True` setting (False in dev env); use it in `set_cookie`. Add to Gitea secrets/workflow if env-driven.
|
||||
- *Accept:* prod cookie has `Secure`; dev login still works over http.
|
||||
|
||||
### 0.5 Security headers (SEC-07, FE-08)
|
||||
- Hono middleware in `frontend/server.ts`: `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`. CSP deferred until inline-script usage is audited (CopilotKit may need allowances).
|
||||
- HSTS at nginx-proxy level (VPS config, not repo).
|
||||
|
||||
### 0.6 Small backend/proxy guards (ARCH-05, FE-23, FE-22)
|
||||
- `max_length` on paste-import request text.
|
||||
- try/catch → 502 JSON in `proxyToBackend`.
|
||||
- Skip 401 redirect when already on `/login` in `api.ts`.
|
||||
|
||||
### 0.7 Housekeeping
|
||||
- Delete dead `AgentHomeClient.tsx` (FE-20).
|
||||
- Move dev DB credentials from `docker-compose.yml` into `.env` references (ARCH-14).
|
||||
- Rotate any secret previously pasted into logs/chats as a precaution.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundations: Tests, Migrations, Time
|
||||
|
||||
**Goal:** make every later change safe. **Effort: ~1 week.** This phase has the highest leverage in the plan.
|
||||
|
||||
### 1.1 Test harness (ARCH-06, BE-25)
|
||||
- Add `pytest`, `httpx` (test client) to a new `requirements-dev.txt`; create `backend/tests/` with a session fixture (SQLite in-memory or dockerized Postgres — prefer Postgres for `DISTINCT ON`/`NUMERIC` fidelity).
|
||||
- CI: extend the Gitea workflow with a test job that gates deploy; add `pnpm typecheck` for the frontend.
|
||||
- *Accept:* `pytest` green locally and in CI; deploy blocked on red.
|
||||
|
||||
### 1.2 Characterization tests for cycle math — **before touching the code** (BE-06, BE-07, T4)
|
||||
- `test_budget_projection.py`: pin current behavior of `get_cycle_range`/`get_month_range` for: mid-month dates, the 18th itself, month/year boundaries, Feb 28/29.
|
||||
- Validate against **known-real data**: pick 2–3 historical months where the correct BAC statement total is known and assert the projection matches.
|
||||
- Then resolve the code/comment contradiction — fix whichever is wrong; add input validation (`1 <= month <= 12`, MIN/MAX_YEAR).
|
||||
- *Accept:* documented, test-pinned answer to "which cycle does month M's budget use," verified against a real statement.
|
||||
|
||||
### 1.3 Alembic (ARCH-02, BE-19, T3)
|
||||
- `alembic init`; baseline autogenerated from current models; `alembic stamp head` against the existing prod schema (no destructive ops).
|
||||
- Port the `run_migrations()` SQL into history as already-applied; reduce `db.py` startup to `alembic upgrade head`.
|
||||
- Document the workflow in CLAUDE.md (`alembic revision --autogenerate` → review → commit).
|
||||
- *Accept:* fresh dev DB builds from migrations alone; prod stamps cleanly with zero data change.
|
||||
|
||||
### 1.4 Datetime sweep (BE-01, T10)
|
||||
- Replace `datetime.utcnow()` → `datetime.now(timezone.utc)`; audit each comparison for naive/aware mixing (token expiry in `auth.py`, rate cache, budget date filters).
|
||||
- Add a test for token expiry comparison.
|
||||
- *Accept:* zero `utcnow` references; tests green.
|
||||
|
||||
### 1.5 Decimal migration — staged start (BE-02, T5)
|
||||
- This phase only: write equivalence tests for projection math (current float behavior) so Phase 2's migration has a baseline. The actual column migration happens in Phase 2 after Alembic is proven in prod once.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Backend Correctness & Robustness
|
||||
|
||||
**Goal:** the backend tells the truth and fails loudly. **Effort: ~1 week.**
|
||||
|
||||
### 2.1 Money → Decimal (BE-02)
|
||||
- Alembic migration: monetary columns → `NUMERIC(15,2)` (rates → `NUMERIC(15,6)`); models → `Decimal`; sweep `budget_projection.py` / `exchange_rate.py` / agent tools for float arithmetic; ensure JSON serialization keeps current shape (FastAPI serializes Decimal → number/string; pick and pin one — test it).
|
||||
- *Accept:* Phase 1.5 equivalence tests pass within tolerance; API responses unchanged in shape; prod migration applied without reset.
|
||||
|
||||
### 2.2 Upload pipeline hardening (SEC-06, BE-09, BE-10, BE-14 — T9)
|
||||
- Both upload endpoints: 10 MB bounded read, `%PDF` magic check, `asyncio.to_thread()` around parsing, `TemporaryDirectory()` for cleanup.
|
||||
- Keep per-file commits but return a precise per-file manifest (filename → imported/updated/skipped/error) — pairs with UX-09 in Phase 3.
|
||||
- *Accept:* oversized/non-PDF rejected with clear errors; event loop unblocked during parse (verify with a concurrent request); no temp files left after a forced timeout.
|
||||
|
||||
### 2.3 Query & constraint pass (T11)
|
||||
- N+1 fixes: category prefetch (ARCH-15), `DISTINCT ON` pensions (BE-04), eager-loaded water readings (BE-05); collapse projection aggregates into grouped queries (BE-21).
|
||||
- Alembic migration: FK delete rules — `SET NULL` for `Transaction.category_id` / `RecurringItem.category_id`, `CASCADE` for `WaterMeterReading.receipt_id` (ARCH-09, BE-11); pagination tiebreaker `, id DESC` (ARCH-12); `ON CONFLICT DO UPDATE` for pension upsert (BE-22).
|
||||
- Atomic savings accrual: raise early on missing accounts (BE-03).
|
||||
- *Accept:* projection endpoint query count measured before/after; constraint behavior covered by tests.
|
||||
|
||||
### 2.4 Error-visibility pass (BE-18, BE-12, ARCH-08)
|
||||
- `exchange_rate.py`: specific exceptions + `logger.warning` per source; timestamp on `_last_known` caches with freshness in responses (BE-20).
|
||||
- Clear `RuntimeError` for unbound agent session; explicit `CancelledError` handling in the refresh loop.
|
||||
|
||||
### 2.5 Agent tool cleanup (T12 backend half)
|
||||
- Tools delegate to service functions (ARCH-04); year bounds (BE-16); remove `600.0`/`1.08` magic fallbacks — use last persisted DB rate or state unavailability (BE-24); optional offset params (BE-17).
|
||||
- Document the invariant: **agent tools are read-only**; any future write tool requires a UI confirmation step (SEC-09).
|
||||
- *Accept:* assistant's cycle summary numerically matches the Budget page for the same month (test).
|
||||
|
||||
### 2.6 Consistency sweep (ARCH-03, ARCH-10, ARCH-20)
|
||||
- Enum members over string literals; keyword `status_code=`; `SimpleCookie` parsing; break the auth/db lazy-import cycle.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend Data Layer & Feedback
|
||||
|
||||
**Goal:** kill the silent-failure class wholesale. **Effort: ~1 week.**
|
||||
|
||||
### 3.1 Adopt TanStack Query (FE-10 → retires FE-01/03/09/18/24, ARCH-13 — T7)
|
||||
- `QueryClientProvider` at the root; wrap the existing `api.ts` request helper (keep `ApiError`, 401 handling); default `AbortSignal.timeout(30_000)`, `staleTime` ~30s, one retry.
|
||||
- Migrate page by page: **Budget first** (worst race conditions), then Analytics, Pensions, Salarios, ServiciosMunicipales. Replace `useBudget`'s manual fetch trio with queries + targeted mutation invalidation.
|
||||
- *Accept:* rapid month/source/search changes never render stale data; switching pages back within 30s renders instantly from cache.
|
||||
|
||||
### 3.2 Toast + error-state standard (T6: UX-01, UX-02, FE-02, FE-06, UX-09, UX-15)
|
||||
- Add Sonner; success/error toast on every mutation; shared `<QueryBoundary>` (or per-page pattern) rendering skeleton → error-with-retry → data/empty.
|
||||
- Render upload `errors[]` from the Phase 2.2 manifest in an Alert list.
|
||||
- ConfirmDialog on *all* destructive actions including balance-override clear.
|
||||
- *Accept:* with the backend stopped, every page shows an explicit error with retry; every mutation produces visible confirmation.
|
||||
|
||||
### 3.3 Auth-flow fixes (FE-15, FE-22)
|
||||
- Auth probe distinguishes network failure (retry once / unknown state) from 401; redirect guard from 0.6 verified.
|
||||
|
||||
### 3.4 Type alignment (ARCH-16 + FE-14)
|
||||
- Backend: shared Pydantic response models + `response_model=` on every route.
|
||||
- Frontend: generate types from OpenAPI (`openapi-typescript`) replacing hand-written interfaces in `api.ts`; CI check that generated types are current.
|
||||
- *Accept:* a backend schema change breaks `pnpm typecheck` instead of production rendering.
|
||||
|
||||
### 3.5 Small fixes batch
|
||||
- Lazy initializers for theme/privacy (FE-07); guard Pensions ROI indexing (FE-04); timeout post-upload refetch (FE-05); cap upload-result list (FE-19); log CopilotKit middleware errors (FE-11); month-detail existence check (FE-12).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — UX, Trust & Features
|
||||
|
||||
**Goal:** the daily-use payoff. Ordered by impact; each item independent. **Effort: 2–3 weeks spread.**
|
||||
|
||||
### 4.1 Sync Status page (UX-17 — the #1 trust feature)
|
||||
- v1 (M): backend endpoint deriving per-source health from existing data — `max(created_at)` per `TransactionSource` + pension/municipal uploads; cadence thresholds (BAC ≈ daily-weekly, salary biweekly/monthly, municipal/pension monthly) → OK/warning per source. Frontend page + sidebar badge on warning.
|
||||
- v2 (L): n8n execution-status integration (n8n DB is already queryable per CLAUDE.md) for actual failure detail.
|
||||
- *Accept:* stop an n8n flow for N days → visible warning in-app.
|
||||
|
||||
### 4.2 Import review & dedup transparency (UX-20, BE-08)
|
||||
- Import responses list skipped duplicates (incoming row + matched existing row); UI renders the comparison with "import anyway" override; tighten the reference-hash inputs (include `card_last4`).
|
||||
- *Accept:* paste-import of a file with near-duplicates shows exactly what was skipped and why; override works.
|
||||
|
||||
### 4.3 Localization & formatting unification (UX-19, FE-17, UX-07 — T13)
|
||||
- Single `src/lib/dates.ts` using `Intl` with `es-CR`; delete the three per-page month-name arrays; sweep `en-US` usages; translate stray English labels.
|
||||
- *Accept:* `grep -r "en-US" src/` returns nothing; one source of truth for month names.
|
||||
|
||||
### 4.4 Transactions power features (Wishlist #1, #2; UX-04)
|
||||
- Search across all sources (fix the inert cash-tab search first — S); backend `q` param searching merchant/notes/reference; filter bar (date range, amount range, category multiselect); multi-select with bulk re-categorize / defer / delete-with-undo.
|
||||
|
||||
### 4.5 Undo & destructive-action safety (UX-10)
|
||||
- Toast-with-Undo (5s delayed API call) for transaction and recurring-item deletes — no schema change needed.
|
||||
|
||||
### 4.6 Mobile table experience (UX-11)
|
||||
- Card-stack rendering under 640px for the transaction DataTable (merchant/amount primary; category/bank secondary).
|
||||
|
||||
### 4.7 Module polish batch (each S–M)
|
||||
- Shared month/period navigator across Budget/Proyecciones/Analytics; Pensions history navigation (UX-03)
|
||||
- Deferred-transaction visibility: modal field + row styling + toast (ARCH-11, UX-05)
|
||||
- Editable pension assumptions persisted server-side (UX-12)
|
||||
- Cycle filter on all three Analytics charts (UX-13)
|
||||
- Empty states with "add first item" actions (UX-14); dirty-form guard (UX-06); balance-override helper text (UX-21); breadcrumb from Proyecciones (UX-22); meter labels (UX-24); a11y `aria-pressed` sweep (FE-16); scroll-container fix (UX-25)
|
||||
|
||||
### 4.8 Export & assistant value (ARCH-18, UX-16, UX-23)
|
||||
- CSV export endpoint + buttons for transactions/salarios/pensions; copy/export on `SpendingSummaryCard`.
|
||||
|
||||
### 4.9 Wishlist backlog (unscheduled)
|
||||
- What-if scenario planner · exchange-rate history & per-date override · PWA polish · pension contribution calculator · per-month recurring-item overrides.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & Dependencies
|
||||
|
||||
```
|
||||
Phase 0 (1 day) ──► Phase 1 (1 wk) ──► Phase 2 (1 wk) ──► Phase 3 (1 wk) ──► Phase 4 (2–3 wks)
|
||||
security tests ◄─────────── needs tests needs 2.2 manifest needs 3.x for
|
||||
(no deps) alembic ◄────────── needs alembic (for UX-09) error patterns
|
||||
cycle truth decimal, uploads, 4.1/4.2 need
|
||||
datetime constraints, agent backend endpoints
|
||||
```
|
||||
|
||||
Hard dependencies: **1.2 before any budget-math edit** · **1.3 before any schema change (2.1, 2.3, 4.2)** · **1.1 before 2.1** · **3.1 before 3.2** (error states come from the query layer) · **2.2 before the UX-09 part of 3.2**.
|
||||
|
||||
Parallelizable: Phase 0 anytime; 4.3 (localization) anytime; 3.x frontend work can overlap 2.x backend work except where noted.
|
||||
|
||||
## Definition of Done (whole plan)
|
||||
|
||||
- App refuses to boot with default secrets; login rate-limited; CORS pinned; uploads validated.
|
||||
- `pytest` + `pnpm typecheck` gate deploys; cycle math is test-pinned against real statements.
|
||||
- Schema changes ship as Alembic migrations only; money is `NUMERIC`/`Decimal` end-to-end.
|
||||
- No silent failures: every page has error+retry; every mutation has visible feedback; ingestion health is observable in-app.
|
||||
- One locale, one date formatter, one month-navigation pattern.
|
||||
205
codebase-review/PROGRESS.md
Normal file
205
codebase-review/PROGRESS.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Implementation Progress
|
||||
|
||||
Tracking execution of [07-improvement-plan.md](07-improvement-plan.md). Newest entries first within each phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Security Hardening Sprint ✅ DONE
|
||||
|
||||
**Completed and deployed to production 2026-06-09** (deploy task 81, verified live).
|
||||
|
||||
| Plan item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 0.1 Fail-fast configuration (SEC-01, BE-15) | ✅ | `15ea1ef` |
|
||||
| 0.2 Login hardening: compare_digest + 5/min/IP rate limit (SEC-02, SEC-03) | ✅ | `b412370` |
|
||||
| 0.3 CORS pinned to prod domain + localhost dev (SEC-04) | ✅ | `b412370` |
|
||||
| 0.4 7-day tokens (was 30) + COOKIE_SECURE setting (SEC-05, SEC-08) | ✅ | `15ea1ef`, `b412370` |
|
||||
| 0.5 Security headers via Hono middleware (SEC-07, FE-08) | ✅ | `bebd7b5` |
|
||||
| 0.6 Paste-import 1 MB cap / proxy 502 / 401 loop guard (ARCH-05, FE-23, FE-22) | ✅ | `3a357e4`, `bebd7b5`, `ad29cce` |
|
||||
| 0.7 Housekeeping: dead AgentHomeClient removed, dev compose creds → .env, .env.example | ✅ | `4a758b2`, `15ea1ef` |
|
||||
|
||||
**Production verification (2026-06-09):**
|
||||
- Health 200 after deploy; frontend+backend containers recreated, healthy
|
||||
- `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy` live on prod responses
|
||||
- Login rate limit live: 6th rapid attempt → **429 with `Retry-After: 58`**
|
||||
- CORS live: `evil.example` origin gets no allow header; app origin allowed
|
||||
- Fail-fast config: behaviorally tested (rejects missing/default/weak secrets in 4/4 cases); prod booted with real Gitea-injected secrets
|
||||
|
||||
**Incidents & discoveries during rollout:**
|
||||
- Deploy task 80 **failed** on a latent CI bug unrelated to Phase 0: unpinned corepack resolved pnpm 11, which hard-errors on unreviewed dependency build scripts (`ERR_PNPM_IGNORED_BUILDS`). Fixed in `d874cf5` by pinning `packageManager: pnpm@10.33.1` and completing the build-script review in `pnpm-workspace.yaml` (approve `@swc/core`, `esbuild`; ignore `@scarf/scarf`). A failed build never touches running containers — prod was unaffected.
|
||||
- The Gitea repo is a **pull-mirror** (no direct pushes). Deploys are triggered by pushing to GitHub and then running a mirror-sync via the Gitea API from the VPS (token via `gitea admin user generate-access-token`, POST `/repos/admin/wealthysmart/mirror-sync` from inside the container — port 3000 is not published on the host).
|
||||
- **Dev credential change:** dev no longer accepts `admin/admin`. The generated dev password lives in the gitignored `.env` (old file backed up as `.env.bak-phase0`). Prod credentials unchanged.
|
||||
- Two temporary `phase0-sync-*` Gitea tokens should be revoked in Gitea UI → Settings → Applications.
|
||||
|
||||
**Corrections to the review docs found during implementation:** none beyond those already noted in [README.md](README.md).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundations: Tests, Migrations, Time ✅ DONE
|
||||
|
||||
Completed 2026-06-09.
|
||||
|
||||
| Plan item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 1.1 pytest harness, 55-test suite (no network, SQLite fixtures) | ✅ | `c725c14` |
|
||||
| 1.1 CI gate: test job (pytest + typecheck) blocks deploy | ✅ | (workflow) |
|
||||
| 1.2 Cycle-math characterization + month validation (BE-07) | ✅ | `0a4c3e9`, `c725c14` |
|
||||
| 1.3 Alembic adoption: autogen baseline + stamp-or-upgrade startup | ✅ | `9a25c4b` |
|
||||
| 1.4 Datetime sweep: 29 utcnow sites → `app.timeutil.utcnow()` | ✅ | `996bd43` |
|
||||
| 1.5 Decimal-equivalence baseline (float behavior pinned by tests) | ✅ | `c725c14` |
|
||||
| Pre-req: all 20 frontend type errors fixed, typecheck green | ✅ | `49b96ed` |
|
||||
|
||||
**Key findings & decisions:**
|
||||
- **BE-06 is a FALSE POSITIVE.** `get_cycle_range(Y,M)` returns the cycle *starting* M/18; budget month M composes `get_previous_cycle` → `[(M-1)/18, M/18)`, exactly as the code comment and the frontend convention state. Empirically validated against prod: May 2026 budget's credit-card figure reproduced by independent SQL over the cycle window (95 transactions exactly, refunds to the colón, 0.13% residual from a single EUR transaction the simplified SQL can't convert). Convention now pinned by `tests/test_cycle_math.py` and the docstring.
|
||||
- **Datetime strategy:** all DB columns are naive `TIMESTAMP`, so the sweep deliberately keeps naive-UTC semantics via a shared `app/timeutil.py::utcnow()` (non-deprecated API, zero behavior change). Going tz-aware would have poisoned naive/aware comparisons. TIMESTAMPTZ migration deferred to Phase 2 — now a one-line change.
|
||||
- **Alembic baseline** (`c3da001a0eb3`) autogenerated against empty Postgres, then schema-diffed against the live schema until column-identical. Two model fixes fell out: `deferred_to_next_cycle` now declares its `server_default` (live DBs have it from the legacy ALTER), `usersettings.data` is JSONB on Postgres (matching live) via a dialect variant. Startup stamps pre-Alembic DBs, upgrades fresh ones, under a pg advisory lock (prod uvicorn runs 2 workers → lifespan runs twice).
|
||||
- Verified locally: adoption + idempotent re-run on dev DB, fresh 13-table build on empty DB, full dev-container boot through the Alembic path, 55/55 tests, typecheck 0 errors, production vite build.
|
||||
|
||||
**Dev environment note:** backend dev tooling lives in `backend/.venv` (gitignored); `pytest` and `alembic` run from there. See CLAUDE.md for the migration workflow.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Backend Correctness & Robustness ✅ DONE
|
||||
|
||||
Completed 2026-06-10.
|
||||
|
||||
| Plan item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 2.1 Money → Decimal/NUMERIC (29 columns, BE-02) | ✅ | `74886fb` |
|
||||
| 2.2 Upload hardening: 10 MB cap, %PDF magic, to_thread (SEC-06, BE-14) | ✅ | (uploads) |
|
||||
| 2.3 FK delete rules, grouped queries (10→3), N+1 fixes, tiebreakers | ✅ | `3cdd7d9` |
|
||||
| 2.4 Error visibility: per-source rate logging, freshness, clear agent errors | ✅ | `82f10a5` |
|
||||
| 2.5 Agent cleanup: real multipliers, year bounds, offsets, read-only policy | ✅ | `82f10a5` |
|
||||
| 2.6 Consistency: enums, keyword status codes, SimpleCookie, import hygiene | ✅ | `440da3e` |
|
||||
|
||||
**Key decisions:**
|
||||
- **Decimal strategy:** `Money = Annotated[Decimal, PlainSerializer(float, when_used="json")]`
|
||||
— storage is exact `NUMERIC(15,2)` (rates 15,6) while JSON keeps emitting plain
|
||||
numbers, so the frontend contract is unchanged (pinned by
|
||||
`test_models_serialization.py`). Services coerce to float at their computation
|
||||
boundaries. Migration `7f567c8bafdb` verified on dev: per-row values and table
|
||||
sums byte-identical across the type change.
|
||||
- **FK rules** (`7884505b16b3`): transactions/recurring items `SET NULL` on category
|
||||
delete — this also fixes the 500 that `DELETE /categories` raised for in-use
|
||||
categories; water readings `CASCADE`. Verified with a rolled-back probe on dev.
|
||||
- **Agent net worth** no longer guesses (hardcoded 600 CRC / 1.08 EUR removed): it
|
||||
converts via `get_crc_multipliers` and reports unconvertible accounts explicitly.
|
||||
- **Read-only tool policy** documented in `agent/tools.py` (SEC-09): ingested email
|
||||
text is a prompt-injection surface; any future write tool needs UI confirmation.
|
||||
|
||||
**Review findings closed as overstated/false during implementation:**
|
||||
- BE-09 (temp-file leak): parsers already use context-managed temp files.
|
||||
- BE-10 (per-file commits): the pension batch already commits atomically.
|
||||
- ARCH-08 (swallowed cancellation): `CancelledError` is a `BaseException` since
|
||||
Python 3.8 — `except Exception` never caught it.
|
||||
- ARCH-03 (filter precedence): `if cycle / elif dates` is already explicit.
|
||||
- BE-22 (upsert race): accepted as-is — single writer (daily n8n cron), batch-
|
||||
scoped commit; ON CONFLICT complexity not warranted.
|
||||
|
||||
Tests: 55 → **69** (new: model serialization, agent tools, upload validation).
|
||||
|
||||
**Production verification (2026-06-10, deploy after CI test gate):** boot logs show
|
||||
both migrations applied in order; `alembic_version = 7884505b16b3`;
|
||||
`transaction.amount` is `numeric(15,2)`; FK rules live (`SET NULL`×2, `CASCADE`);
|
||||
data intact through the type change (238 transactions, sum unchanged); live API
|
||||
emits amounts as JSON numbers.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Frontend Data Layer & Feedback ✅ DONE (3.4 deferred)
|
||||
|
||||
Completed 2026-06-10.
|
||||
|
||||
| Plan item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 3.1 TanStack Query: provider, api timeout/signal, useBudget, Budget | ✅ | `65dffda` |
|
||||
| 3.1 All remaining pages on queries with ErrorState+retry | ✅ | `7a1733b` |
|
||||
| 3.2 Sonner toasts on every mutation; confirm on override clear | ✅ | (toasts) |
|
||||
| 3.3 Auth probe: network failure ≠ logged out (retry once) | ✅ | (auth) |
|
||||
| 3.5 Lazy context init, CK repair logging, upload-result cap | ✅ | (auth/pages) |
|
||||
| 3.4 OpenAPI type generation | ⏸ **deferred** | — |
|
||||
|
||||
**What changed for the user:** every page now shows an explicit error panel with
|
||||
a Reintentar button when the API fails (previously: silent empty charts/tables);
|
||||
every mutation (transaction save/delete, deferred toggle, recurring items,
|
||||
balance overrides) confirms success or failure with a toast; month navigation
|
||||
keeps the previous month visible while loading; rapid filter/month changes can
|
||||
no longer paint stale results (request cancellation); all requests time out at
|
||||
30s instead of hanging; clearing a balance override asks for confirmation; the
|
||||
privacy mask and theme no longer flash wrong on reload; a transient network
|
||||
blip at app start no longer kicks an authenticated user to the login page.
|
||||
|
||||
**Architecture:** TanStack Query with `staleTime` 30s and one retry;
|
||||
`['budget']`, `['transactions']`, `['analytics']`, `['salarios']`,
|
||||
`['pensions']`, `['municipal']` key families; mutations invalidate their family.
|
||||
`useBudget` keeps its original return shape so consumers didn't churn.
|
||||
|
||||
**3.4 (OpenAPI type generation) deferred to its own pass:** it needs the backend
|
||||
`response_model` sweep (ARCH-16) first and touches every endpoint plus the
|
||||
api.ts type exports — a separate, mechanical change with its own verification.
|
||||
The hand-written types are currently accurate (typecheck is green against real
|
||||
responses). Revisit after Phase 4's backend endpoints land.
|
||||
|
||||
**Closed as already-handled during implementation:** FE-04 (the Pensions ROI
|
||||
fallback already guarded `len >= 2`).
|
||||
|
||||
## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED
|
||||
|
||||
## Phase 4 — UX, Trust & Features ✅ DONE (iterations 1 + 2)
|
||||
|
||||
### Iteration 2 (2026-06-11)
|
||||
|
||||
| Plan item | Status |
|
||||
|---|---|
|
||||
| 4.2 Import review: skipped duplicates listed with matched row + import-anyway override (re-imports only skipped lines) | ✅ |
|
||||
| 4.4 Bulk operations: POST /transactions/bulk + selection UI (assign category, defer/restore, delete) | ✅ |
|
||||
| 4.7 Editable pension assumptions (UserSettings-backed) | ✅ |
|
||||
| 4.7 Dirty-form guard (UX-06), empty-state CTA (UX-14) | ✅ |
|
||||
| BE-08 hash tightening | superseded — the override solves the practical harm; rehashing would orphan every existing reference |
|
||||
| UX-11 mobile layout | **false positive** — a card layout already existed (two real bugs in it fixed: undo-pending rows showed, en-US dates) |
|
||||
| UX-13 cycle filter | **mostly false positive** — daily-spending already accepts cycle params; the trend is multi-cycle by design |
|
||||
| UX-24 meter labels, UX-22 breadcrumb, shared period navigator (UX-03) | not done — needs dedicated UI; lowest-value remainder |
|
||||
|
||||
**The improvement plan is complete** — and as of 2026-06-12, so is everything
|
||||
that had been deferred:
|
||||
|
||||
### Final batch (2026-06-12)
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| 3.4 OpenAPI codegen: response_model sweep + generated api-types.gen.ts + CI drift gate (caught 2 real drifts: missing SAVINGS enum value, untyped raw_charges) | ✅ |
|
||||
| UX-03 shared PeriodNavigator · UX-22 breadcrumb · UX-24 meter labels | ✅ |
|
||||
| Wishlist: rate-history chart (Analytics), contribution calculator (Pensiones), what-if Planificador page, PWA installability | ✅ |
|
||||
| Bonus fix: sw.js was a self-unregistering stub — `serviceWorker.ready` never resolved, so web push had been silently dead; replaced with a working worker | ✅ |
|
||||
|
||||
Tests: **82**. Final false-positive tally across the whole plan: BE-06, BE-09,
|
||||
BE-10, ARCH-03, ARCH-08, BE-22 (accepted), FE-04, UX-04, UX-11, UX-13 — the
|
||||
characterization-tests-first approach caught all of them before any "fix"
|
||||
landed. **Nothing from the review or wishlist remains.**
|
||||
|
||||
### Iteration 1 (2026-06-10)
|
||||
|
||||
Iteration 1 completed 2026-06-10. The plan marks 4.x items independent; this
|
||||
iteration delivered the highest-impact slice.
|
||||
|
||||
| Plan item | Status | Commit |
|
||||
|---|---|---|
|
||||
| 4.1 Sync Status page + nav badge (UX-17, the #1 trust feature) | ✅ | `4ceb675` |
|
||||
| 4.3 Localization: shared es-CR dates module, stray labels | ✅ | (l10n) |
|
||||
| 4.5 Undo grace (5s + Deshacer) for transaction deletes | ✅ | `629d118` |
|
||||
| 4.8 CSV export endpoint + Budget/Salarios buttons | ✅ | `571428f` |
|
||||
| 4.7 picks: aria-pressed, deferred row styling, override hint | ✅ | `b3a7f51` |
|
||||
|
||||
**Sync Status design:** per-source health derived entirely from existing data
|
||||
(`max(created_at)` per source + cadence thresholds: BAC 7d, salary 35d,
|
||||
municipal/pensions 40d, exchange rate 1d) — no n8n integration required for v1.
|
||||
Endpoint tested; verified live (it immediately flagged the dev DB's stale data).
|
||||
|
||||
**Deferred to iteration 2** (each independent): 4.2 import review & dedup
|
||||
transparency (L — includes the reference-hash tightening, which must migrate
|
||||
existing hashes to avoid breaking dedup), 4.4 transaction search-everywhere is
|
||||
**not needed** (search already applies to all sources — UX-04 was a false
|
||||
positive) but bulk operations remain (L), 4.6 mobile card layout (M), 4.7
|
||||
remainder: shared period navigator, editable pension assumptions, cycle filter
|
||||
on all Analytics charts, empty-state actions, dirty-form guard, breadcrumb,
|
||||
meter labels. 4.9 wishlist unscheduled.
|
||||
45
codebase-review/README.md
Normal file
45
codebase-review/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# WealthySmart — Five-Hat Codebase Review
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Scope:** Full repository (`backend/`, `frontend/`, compose files, CI workflow). `tech_docs/`, `node_modules/`, and `.claude/` excluded.
|
||||
**Method:** Five independent senior/staff-level review agents, each with a distinct lens, followed by cross-verification of the highest-severity claims against the actual repo state and a consolidated synthesis.
|
||||
|
||||
## The Five Hats
|
||||
|
||||
| Hat | Report | Focus |
|
||||
|---|---|---|
|
||||
| 🏛 Architect | [01-architect.md](01-architect.md) | System structure, layering, data model, migrations, deploy topology |
|
||||
| ⚙️ Backend | [02-backend.md](02-backend.md) | FastAPI/SQLModel correctness, money/date math, queries, PDF parsing, agent tools |
|
||||
| 🎨 Frontend | [03-frontend.md](03-frontend.md) | React data fetching, state, type safety, a11y, Hono server, CopilotKit |
|
||||
| 🔐 Cybersec | [04-security.md](04-security.md) | AuthN/Z, secrets, CORS, rate limiting, uploads, injection surface |
|
||||
| 👤 Poweruser | [05-poweruser.md](05-poweruser.md) | Daily-use UX, feedback loops, data trust, consistency, feature gaps |
|
||||
|
||||
## Synthesis Documents
|
||||
|
||||
- **[06-weak-spots-inventory.md](06-weak-spots-inventory.md)** — All 90+ findings deduplicated into 13 cross-cutting themes with a severity matrix.
|
||||
- **[07-improvement-plan.md](07-improvement-plan.md)** — Phased implementation plan (Phase 0–4) with concrete steps, acceptance criteria, and effort estimates.
|
||||
|
||||
## Editorial Corrections (verified against the repo)
|
||||
|
||||
Agent findings were spot-checked. One material correction:
|
||||
|
||||
- **`.env` / `.env.prod` are NOT committed to git.** Both are covered by `.gitignore` (`.env`, `.env.*`) and `git log --all --diff-filter=A -- .env .env.prod` returns nothing. The Architect's ARCH-01 ("secrets in version control", Critical) is **downgraded**: the files exist only in the working tree, which is expected. The *real* residual risks are (a) hardcoded fallback credentials in `backend/app/config.py` (verified: `SECRET_KEY = "change-me-in-production"`, `ADMIN_PASSWORD = "admin"`) and (b) hardcoded dev DB credentials in `docker-compose.yml`. These are tracked as SEC-01 / BE-15.
|
||||
|
||||
Claims that were verified as accurate: CORS `allow_origins=["*"]` with `allow_credentials=True` (`backend/app/main.py:81`), 30-day token expiry (`backend/app/config.py:7`), default admin credentials (`backend/app/config.py:9`), zero test files in the repo, and no Alembic directory.
|
||||
|
||||
## Top 10 — If You Only Fix Ten Things
|
||||
|
||||
1. **Fail fast on default secrets** — startup must refuse to run with `SECRET_KEY="change-me-in-production"` or `admin/admin` (SEC-01, BE-15). *S*
|
||||
2. **Rate-limit + constant-time login** — both login endpoints have unlimited attempts and `!=` comparison (SEC-02, SEC-03). *S*
|
||||
3. **Restrict CORS** to `https://wealth.cescalante.dev` (SEC-04). *S*
|
||||
4. **Add a test suite** — zero tests guard the most complex logic in the app (billing-cycle math, deferred carryover, PDF parsing) (ARCH-06, BE-25). *L*
|
||||
5. **Adopt Alembic** — schema evolution is ad-hoc `ALTER TABLE` strings in `db.py`; memory says "never reset prod DB," which makes real migrations essential (ARCH-02, BE-19). *M*
|
||||
6. **Verify billing-cycle boundary math** — possible off-by-one in `get_cycle_range` would misalign every monthly budget (BE-06). *S to verify, with tests*
|
||||
7. **Migrate money fields from `float` to `Decimal`/`Numeric`** (BE-02). *M*
|
||||
8. **Validate uploads** — size cap, `%PDF` magic-byte check, per-file transactional rollback (SEC-06, BE-10). *S*
|
||||
9. **Frontend error/feedback layer** — surfaced errors, success toasts, request cancellation; today failures are silent `console.error` (FE-01/02/03, UX-01/02). *M*
|
||||
10. **n8n sync visibility** — silent parser failures mean missing transactions go unnoticed; add a sync-status/reconciliation view (UX-17, UX-20). *L*
|
||||
|
||||
## Reading Order
|
||||
|
||||
For a quick pass: this README → [06-weak-spots-inventory.md](06-weak-spots-inventory.md) → [07-improvement-plan.md](07-improvement-plan.md). The five hat reports hold the file:line evidence behind every consolidated item.
|
||||
@@ -16,6 +16,11 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
backend:
|
||||
build:
|
||||
@@ -31,11 +36,17 @@ services:
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||
ENABLE_INSTRUMENTATION: "false"
|
||||
expose:
|
||||
- "8000"
|
||||
networks:
|
||||
- wealthysmart-network
|
||||
wealthysmart-network:
|
||||
# Avoid the generic `backend` name, which is also used on the shared
|
||||
# nginx network by another application.
|
||||
aliases:
|
||||
- wealthysmart-api
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -45,6 +56,11 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
@@ -57,8 +73,8 @@ services:
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
BACKEND_URL: http://wealthysmart-backend-prod:8000
|
||||
AGENT_URL: http://wealthysmart-backend-prod:8000/api/v1/agent/agui
|
||||
BACKEND_URL: http://wealthysmart-api:8000
|
||||
AGENT_URL: http://wealthysmart-api:8000/api/v1/agent/agui
|
||||
JWT_SECRET: ${SECRET_KEY}
|
||||
COOKIE_DOMAIN: wealth.cescalante.dev
|
||||
COOKIE_SECURE: "true"
|
||||
@@ -78,6 +94,11 @@ services:
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
networks:
|
||||
wealthysmart-network:
|
||||
|
||||
@@ -3,18 +3,23 @@ services:
|
||||
image: postgres:16-alpine
|
||||
container_name: wealthysmart-db-dev
|
||||
environment:
|
||||
POSTGRES_USER: wealthy_user
|
||||
POSTGRES_PASSWORD: wealthy_pass
|
||||
POSTGRES_DB: wealthysmart
|
||||
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U wealthy_user -d wealthysmart"]
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
backend:
|
||||
build:
|
||||
@@ -22,11 +27,17 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: wealthysmart-backend-dev
|
||||
environment:
|
||||
DATABASE_URL: postgresql://wealthy_user:wealthy_pass@db:5432/wealthysmart
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:?}:${POSTGRES_PASSWORD:?}@db:5432/${POSTGRES_DB:?}
|
||||
SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env (openssl rand -hex 32)}
|
||||
ADMIN_USERNAME: ${ADMIN_USERNAME:?set ADMIN_USERNAME in .env}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
COOKIE_SECURE: "false"
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||
ENABLE_INSTRUMENTATION: "false"
|
||||
ports:
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
@@ -34,6 +45,11 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
develop:
|
||||
watch:
|
||||
- path: ./backend/app
|
||||
@@ -54,9 +70,17 @@ services:
|
||||
- "5175:3000"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
# Vite runs inside this container, so localhost would refer to the
|
||||
# frontend rather than the Python service.
|
||||
BACKEND_URL: http://backend:8000
|
||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||
depends_on:
|
||||
- backend
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
develop:
|
||||
watch:
|
||||
- path: ./frontend/src
|
||||
|
||||
@@ -22,7 +22,9 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# Cap node heap so a build on a small VPS can't OOM-kill neighbours.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=1536
|
||||
# 1536 stopped being enough at the 2026-07 UI revamp (cmdk, day-picker,
|
||||
# chart wrappers); rollup now peaks between 1.5G and 2G.
|
||||
ENV NODE_OPTIONS=--max-old-space-size=2048
|
||||
RUN corepack enable && pnpm build
|
||||
|
||||
# Production: Hono serves dist/ + /api/copilotkit on port 3000
|
||||
|
||||
@@ -20,6 +20,5 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default-translucent",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
"menuAccent": "subtle"
|
||||
}
|
||||
|
||||
113
frontend/e2e/asistente.spec.ts
Normal file
113
frontend/e2e/asistente.spec.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
test.setTimeout(150_000);
|
||||
|
||||
async function startFreshAssistant(page: Page) {
|
||||
await page.goto("/login");
|
||||
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await page.waitForURL("/");
|
||||
|
||||
const initialConnect = page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await page.goto("/asistente");
|
||||
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||
await (await initialConnect).finished();
|
||||
|
||||
const newConversation = page.getByRole("button", { name: "Nueva conversación" });
|
||||
if (await newConversation.isEnabled()) {
|
||||
await newConversation.click();
|
||||
const reconnect = page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/copilotkit") || response.request().method() !== "POST") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.request().postData() ?? "{}").method === "agent/connect";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
await page.getByRole("button", { name: "Borrar historial" }).click();
|
||||
await (await reconnect).finished();
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Escribe tu pregunta…" }),
|
||||
).toBeEnabled({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function ask(page: Page, question: string, expectedTool: string) {
|
||||
// The BFF response is the exact CopilotKit stream consumed by the browser.
|
||||
// Checking it catches server-side RUN_ERROR events even if the UI changes.
|
||||
const stream = page.waitForResponse(
|
||||
(response) => {
|
||||
if (
|
||||
!response.url().includes("/api/copilotkit") ||
|
||||
response.request().method() !== "POST"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.request().postData() ?? "{}").method === "agent/run";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
);
|
||||
const composer = page.getByRole("textbox", { name: "Escribe tu pregunta…" });
|
||||
await expect(composer).toBeEnabled({ timeout: 30_000 });
|
||||
await composer.fill(question);
|
||||
const sendButton = page.getByTestId("copilot-send-button");
|
||||
await expect(sendButton).toBeEnabled({ timeout: 30_000 });
|
||||
await sendButton.click();
|
||||
await expect(page.getByText(question, { exact: true }).last()).toBeVisible();
|
||||
|
||||
const response = await stream;
|
||||
await response.finished();
|
||||
const events = await response.text();
|
||||
expect(events).not.toContain("RUN_ERROR");
|
||||
expect(events).toContain(expectedTool);
|
||||
|
||||
// The HTTP stream may finish before CopilotKit has committed the tool run
|
||||
// and released its client-side running state. Waiting for the page action
|
||||
// avoids losing the next Enter keypress during that short interval.
|
||||
await expect(page.getByRole("button", { name: "Nueva conversación" })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test("Asistente completes Luna's read-only tool flows", async ({ page }) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
|
||||
await startFreshAssistant(page);
|
||||
|
||||
await ask(page, "What date is it today?", "get_current_date");
|
||||
await ask(page, "List my last 10 transactions.", "get_recent_transactions");
|
||||
await ask(page, "What is my net worth?", "get_net_worth");
|
||||
await ask(page, "How much did I spend today?", "get_daily_spending");
|
||||
await ask(page, "Show my spending by category this month.", "render_spending_summary");
|
||||
|
||||
const spendingCard = page.getByTestId("spending-summary-card");
|
||||
await expect(spendingCard).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Escribe tu pregunta…" })).toBeEnabled({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "Asistente" })).toBeVisible();
|
||||
await expect(page.getByText("Show my spending by category this month.", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("spending-summary-card")).toBeVisible();
|
||||
|
||||
expect(consoleErrors.join("\n")).not.toContain("ChatClientException");
|
||||
});
|
||||
127
frontend/e2e/feature-doc.ts
Normal file
127
frontend/e2e/feature-doc.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test, type Page, type TestInfo } from "@playwright/test";
|
||||
import * as narration from "./narration";
|
||||
import { renderFinalVideo } from "./video-render";
|
||||
import type { Clip } from "./subtitles";
|
||||
|
||||
const DEMO = !!process.env.PW_DEMO;
|
||||
|
||||
/**
|
||||
* Wraps test.step() so every step of a demo spec produces a captioned
|
||||
* screenshot, and — in PW_DEMO mode — a native Playwright screencast
|
||||
* recording with cursor annotations, chapter-title cards, burned-in
|
||||
* subtitles and (when `say` is available) spoken Spanish narration.
|
||||
* Screenshots/video/subtitles land in e2e-docs/<slug>/ and are also
|
||||
* attached to the Playwright HTML report; save() stitches everything into
|
||||
* a Markdown walkthrough that can be published as-is.
|
||||
*/
|
||||
export class FeatureDoc {
|
||||
private steps: { title: string; caption: string; file: string }[] = [];
|
||||
private clips: Clip[] = [];
|
||||
private readonly dir: string;
|
||||
private recordingStartedAt = 0;
|
||||
|
||||
constructor(
|
||||
private page: Page,
|
||||
private testInfo: TestInfo,
|
||||
private slug: string,
|
||||
private title: string,
|
||||
private intro: string,
|
||||
) {
|
||||
this.dir = path.resolve("e2e-docs", slug);
|
||||
fs.rmSync(this.dir, { recursive: true, force: true });
|
||||
fs.mkdirSync(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
/** Starts the native screencast recording. Call before the first step(). */
|
||||
async start() {
|
||||
if (!DEMO) return;
|
||||
this.recordingStartedAt = Date.now();
|
||||
await this.page.screencast.start({
|
||||
path: path.join(this.dir, "raw.webm"),
|
||||
size: { width: 1920, height: 1080 },
|
||||
});
|
||||
await this.page.screencast.showActions({ cursor: "pointer" });
|
||||
}
|
||||
|
||||
async step(title: string, caption: string, fn: () => Promise<void>) {
|
||||
const narrationPromise = DEMO
|
||||
? narration.synthesize(caption, this.testInfo.outputDir, this.clips.length)
|
||||
: Promise.resolve(null);
|
||||
|
||||
const stepStart = Date.now();
|
||||
if (DEMO) {
|
||||
await this.page.screencast.showChapter(title, { description: caption, duration: 1500 });
|
||||
}
|
||||
|
||||
await test.step(title, fn);
|
||||
|
||||
const narrationResult = await narrationPromise;
|
||||
if (DEMO) {
|
||||
const elapsed = Date.now() - stepStart;
|
||||
const target = (narrationResult?.durationMs ?? 0) + 300;
|
||||
if (target > elapsed) await this.page.waitForTimeout(target - elapsed);
|
||||
}
|
||||
|
||||
const file = `${String(this.steps.length + 1).padStart(2, "0")}-${title
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]+/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")}.png`;
|
||||
const body = await this.page.screenshot({
|
||||
path: path.join(this.dir, file),
|
||||
animations: "disabled",
|
||||
});
|
||||
await this.testInfo.attach(title, { body, contentType: "image/png" });
|
||||
this.steps.push({ title, caption, file });
|
||||
|
||||
if (DEMO) {
|
||||
this.clips.push({
|
||||
title,
|
||||
caption,
|
||||
file,
|
||||
startMs: stepStart - this.recordingStartedAt,
|
||||
endMs: Date.now() - this.recordingStartedAt,
|
||||
narration: narrationResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
let videoName = "video.mp4";
|
||||
if (DEMO) {
|
||||
await this.page.screencast.stop();
|
||||
const { videoFile, degraded } = await renderFinalVideo({
|
||||
rawWebmPath: path.join(this.dir, "raw.webm"),
|
||||
clips: this.clips,
|
||||
outDir: this.dir,
|
||||
});
|
||||
for (const warning of degraded) console.warn(`[${this.slug}] ${warning}`);
|
||||
videoName = path.basename(videoFile);
|
||||
await this.testInfo.attach("video", {
|
||||
path: videoFile,
|
||||
contentType: videoName.endsWith(".mp4") ? "video/mp4" : "video/webm",
|
||||
});
|
||||
}
|
||||
|
||||
const md = [
|
||||
`# ${this.title}`,
|
||||
"",
|
||||
this.intro,
|
||||
"",
|
||||
`> Generado automáticamente por \`${path.basename(this.testInfo.file)}\` — ${this.steps.length} pasos. El video completo del recorrido está en [${videoName}](${videoName}).`,
|
||||
"",
|
||||
...this.steps.flatMap((s, i) => [
|
||||
`## ${i + 1}. ${s.title}`,
|
||||
"",
|
||||
s.caption,
|
||||
"",
|
||||
``,
|
||||
"",
|
||||
]),
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(this.dir, "index.md"), md);
|
||||
}
|
||||
}
|
||||
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
102
frontend/e2e/financiamientos-demo.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { FeatureDoc } from "./feature-doc";
|
||||
|
||||
/**
|
||||
* Demo walkthrough of the Financiamientos (Tasa Cero) feature. Doubles as an
|
||||
* e2e smoke test and as the generator for e2e-docs/financiamientos/ — a
|
||||
* captioned, screenshot-per-step Markdown doc plus the full video (native
|
||||
* cursor/chapters, burned-in subtitles, spoken narration) in the Playwright
|
||||
* HTML report.
|
||||
*/
|
||||
// NOTE: do not `test.use({ video: "off" })` here — disabling the fixture
|
||||
// video breaks page.screencast's internal sizing (verified: it then records
|
||||
// a shrunken frame letterboxed inside the requested canvas). The fixture's
|
||||
// own video ends up redundant/unused but harmless.
|
||||
|
||||
test("@demo Financiamientos — compras Tasa Cero en cuotas", async ({ page }, testInfo) => {
|
||||
const doc = new FeatureDoc(
|
||||
page,
|
||||
testInfo,
|
||||
"financiamientos",
|
||||
"Financiamientos — Tasa Cero",
|
||||
"WealthySmart trackea las compras BAC Tasa Cero como planes de cuotas mensuales: cada cuota futura ya cuenta en el presupuesto del mes que le toca, en lugar de registrar la compra como un solo pago.",
|
||||
);
|
||||
await doc.start();
|
||||
|
||||
await doc.step(
|
||||
"Iniciar sesión",
|
||||
"El acceso está protegido con usuario y contraseña; la sesión vive en una cookie HTTP-only.",
|
||||
async () => {
|
||||
await page.goto("/login");
|
||||
// CopilotKit mounts its dev web-inspector in dev builds; keep it out of
|
||||
// the recording — it isn't part of the product.
|
||||
await page.addStyleTag({ content: "cpk-web-inspector { display: none !important; }" });
|
||||
await page.getByLabel("Username").fill(process.env.ADMIN_USERNAME!);
|
||||
await page.getByLabel("Password").fill(process.env.ADMIN_PASSWORD!);
|
||||
await expect(page.getByRole("button", { name: /sign in/i })).toBeEnabled();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Panel de inicio",
|
||||
"Después del login se llega al dashboard Inicio, con el resumen del mes y la navegación lateral por módulos.",
|
||||
async () => {
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await page.waitForURL("/");
|
||||
await expect(page.getByRole("link", { name: "Financiamientos", exact: true })).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Modo privacidad",
|
||||
"El botón de privacidad difumina todos los montos sensibles — útil para compartir pantalla (y para este demo).",
|
||||
async () => {
|
||||
await page.getByRole("button", { name: "Toggle privacy mode" }).click();
|
||||
await expect(page.locator("html")).toHaveClass(/privacy/);
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Página de Financiamientos",
|
||||
"El módulo resume todos los planes Tasa Cero: saldo faltante total, cuánto suman las cuotas de los planes activos cada mes, y cuántos planes siguen vivos.",
|
||||
async () => {
|
||||
await page.getByRole("link", { name: "Financiamientos", exact: true }).click();
|
||||
await page.waitForURL("/financiamientos");
|
||||
await expect(page.getByText("Saldo faltante total")).toBeVisible();
|
||||
await expect(page.getByText("Cuotas por mes (activos)")).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Tabla de planes",
|
||||
"Cada plan muestra el comercio, el avance de cuotas (facturadas vs. totales), el monto de la cuota, el saldo que falta y cuándo cae la próxima cuota.",
|
||||
async () => {
|
||||
const rows = page.getByRole("button", { name: /editar plan de/i });
|
||||
await expect(rows.first()).toBeVisible();
|
||||
expect(await rows.count()).toBeGreaterThan(0);
|
||||
await expect(page.getByRole("columnheader", { name: "Próxima cuota" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Detalle de un plan",
|
||||
"Al hacer clic en un plan se abre su detalle editable: fecha ancla, número de cuotas y monto. Desde aquí también se puede deshacer la conversión a Tasa Cero.",
|
||||
async () => {
|
||||
await page.getByRole("button", { name: /editar plan de/i }).first().click();
|
||||
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.step(
|
||||
"Cerrar el detalle",
|
||||
"El plan queda igual que estaba — el recorrido es de solo lectura.",
|
||||
async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("heading", { name: "Editar plan Tasa Cero" })).toBeHidden();
|
||||
},
|
||||
);
|
||||
|
||||
await doc.save();
|
||||
});
|
||||
59
frontend/e2e/narration.ts
Normal file
59
frontend/e2e/narration.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
let sayChecked = false;
|
||||
let sayAvailable = false;
|
||||
|
||||
function hasSay() {
|
||||
if (!sayChecked) {
|
||||
sayChecked = true;
|
||||
try {
|
||||
execFileSync("say", ["-v", "?"], { stdio: "ignore" });
|
||||
sayAvailable = true;
|
||||
} catch {
|
||||
console.warn("`say` not found — skipping TTS narration (subtitles still work).");
|
||||
}
|
||||
}
|
||||
return sayAvailable;
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<{ path: string; durationMs: number } | null>>();
|
||||
|
||||
/** Synthesizes Spanish narration for a caption via macOS `say`. Returns null (never throws) if unavailable. */
|
||||
export function synthesize(
|
||||
text: string,
|
||||
outDir: string,
|
||||
index: number,
|
||||
voice = "Paulina",
|
||||
): Promise<{ path: string; durationMs: number } | null> {
|
||||
const key = `${voice}:${text}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const promise = (async () => {
|
||||
if (!hasSay()) return null;
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const file = path.join(outDir, `narration-${String(index).padStart(2, "0")}.aiff`);
|
||||
try {
|
||||
await execFileAsync("say", ["-v", voice, "-o", file, text]);
|
||||
const { stdout } = await execFileAsync("ffprobe", [
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "csv=p=0",
|
||||
file,
|
||||
]);
|
||||
const durationMs = Math.round(parseFloat(stdout.trim()) * 1000);
|
||||
return { path: file, durationMs };
|
||||
} catch (err) {
|
||||
console.warn(`Narration synthesis failed for "${text.slice(0, 40)}…": ${err}`);
|
||||
fs.rmSync(file, { force: true });
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
cache.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
33
frontend/e2e/subtitles.ts
Normal file
33
frontend/e2e/subtitles.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface Clip {
|
||||
title: string;
|
||||
caption: string;
|
||||
file: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
narration: { path: string; durationMs: number } | null;
|
||||
}
|
||||
|
||||
export function msToSrtTimestamp(ms: number): string {
|
||||
const clamped = Math.max(0, Math.round(ms));
|
||||
const hours = Math.floor(clamped / 3_600_000);
|
||||
const minutes = Math.floor((clamped % 3_600_000) / 60_000);
|
||||
const seconds = Math.floor((clamped % 60_000) / 1000);
|
||||
const millis = clamped % 1000;
|
||||
const pad = (n: number, len = 2) => String(n).padStart(len, "0");
|
||||
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)},${pad(millis, 3)}`;
|
||||
}
|
||||
|
||||
/** One subtitle cue per step, spanning its full on-screen duration. */
|
||||
export function buildSrt(clips: Clip[]): string {
|
||||
return clips
|
||||
.map((clip, i) => {
|
||||
const index = i + 1;
|
||||
return [
|
||||
String(index),
|
||||
`${msToSrtTimestamp(clip.startMs)} --> ${msToSrtTimestamp(clip.endMs)}`,
|
||||
clip.caption,
|
||||
"",
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
88
frontend/e2e/video-render.ts
Normal file
88
frontend/e2e/video-render.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { buildSrt, type Clip } from "./subtitles";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function hasFfmpeg() {
|
||||
try {
|
||||
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const SUBTITLE_STYLE =
|
||||
"FontSize=22,PrimaryColour=&H00FFFFFF,BackColour=&H80000000,BorderStyle=3,Outline=1,Shadow=0,MarginV=40";
|
||||
|
||||
function escapeForFilter(p: string) {
|
||||
// ffmpeg filter arguments treat ':' and '\' specially — escape both.
|
||||
return p.replace(/\\/g, "\\\\").replace(/:/g, "\\:");
|
||||
}
|
||||
|
||||
export async function renderFinalVideo({
|
||||
rawWebmPath,
|
||||
clips,
|
||||
outDir,
|
||||
}: {
|
||||
rawWebmPath: string;
|
||||
clips: Clip[];
|
||||
outDir: string;
|
||||
}): Promise<{ videoFile: string; degraded: string[] }> {
|
||||
const degraded: string[] = [];
|
||||
const srtPath = path.join(outDir, "captions.srt");
|
||||
fs.writeFileSync(srtPath, buildSrt(clips));
|
||||
|
||||
if (!hasFfmpeg()) {
|
||||
const dest = path.join(outDir, "video.webm");
|
||||
fs.renameSync(rawWebmPath, dest);
|
||||
degraded.push("ffmpeg not found — kept raw video.webm, no subtitles burned in, no narration.");
|
||||
return { videoFile: dest, degraded };
|
||||
}
|
||||
|
||||
const narrated = clips.filter((c) => c.narration);
|
||||
const mp4Path = path.join(outDir, "video.mp4");
|
||||
const srtArg = `subtitles=${escapeForFilter(srtPath)}:force_style='${SUBTITLE_STYLE}'`;
|
||||
|
||||
if (narrated.length === 0) {
|
||||
degraded.push("No narration available — burned-in subtitles only.");
|
||||
await execFileAsync("ffmpeg", [
|
||||
"-y", "-i", rawWebmPath,
|
||||
"-vf", srtArg,
|
||||
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
"-an",
|
||||
mp4Path,
|
||||
]);
|
||||
} else {
|
||||
const audioInputs = narrated.flatMap((c) => ["-i", c.narration!.path]);
|
||||
const adelayParts = narrated.map(
|
||||
(c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.startMs))}|${Math.max(0, Math.round(c.startMs))}[a${i}]`,
|
||||
);
|
||||
const mixInputs = narrated.map((_, i) => `[a${i}]`).join("");
|
||||
const filterComplex = [
|
||||
`[0:v]${srtArg}[vout]`,
|
||||
...adelayParts,
|
||||
`${mixInputs}amix=inputs=${narrated.length}:normalize=0[aout]`,
|
||||
].join(";");
|
||||
|
||||
await execFileAsync("ffmpeg", [
|
||||
"-y", "-i", rawWebmPath, ...audioInputs,
|
||||
"-filter_complex", filterComplex,
|
||||
"-map", "[vout]", "-map", "[aout]",
|
||||
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-shortest",
|
||||
"-movflags", "+faststart",
|
||||
mp4Path,
|
||||
]);
|
||||
}
|
||||
|
||||
fs.rmSync(rawWebmPath, { force: true });
|
||||
for (const c of narrated) fs.rmSync(c.narration!.path, { force: true });
|
||||
|
||||
return { videoFile: mp4Path, degraded };
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
<meta name="description" content="WealthySmart — Smart personal finance management" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>WealthySmart</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -3,44 +3,55 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.1",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n vite,ck -c cyan,magenta \"vite --host 0.0.0.0 --port 3000\" \"tsx watch server.ts\"",
|
||||
"build": "vite build",
|
||||
"preview": "tsx server.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"e2e": "playwright test",
|
||||
"demo": "PW_DEMO=1 playwright test --grep @demo",
|
||||
"gen:api": "openapi-typescript openapi.json -o src/lib/api-types.gen.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.52",
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@copilotkit/react-core": "1.56.4",
|
||||
"@copilotkit/react-ui": "1.56.4",
|
||||
"@copilotkit/runtime": "1.56.4",
|
||||
"@copilotkit/react-core": "1.62.3",
|
||||
"@copilotkit/react-ui": "1.62.3",
|
||||
"@copilotkit/runtime": "1.62.3",
|
||||
"@fontsource-variable/ibm-plex-sans": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans": "^5.2.10",
|
||||
"@hono/node-server": "^1.14.4",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"concurrently": "^9.1.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"hono": "^4.12.15",
|
||||
"lucide-react": "^1.12.0",
|
||||
"react": "19.2.5",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"recharts": "^3.8.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tsx": "^4.19.4",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"@vitejs/plugin-react-oxc": "^0.4.3",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "npm:rolldown-vite@latest"
|
||||
}
|
||||
}
|
||||
|
||||
52
frontend/playwright.config.ts
Normal file
52
frontend/playwright.config.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// Demo specs log in with the real dev credentials; pull them from the repo
|
||||
// .env so they never live in the test source.
|
||||
const repoEnv = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.env");
|
||||
if (fs.existsSync(repoEnv)) {
|
||||
for (const line of fs.readFileSync(repoEnv, "utf8").split("\n")) {
|
||||
const m = line.match(/^([A-Z_]+)=["']?(.*?)["']?$/);
|
||||
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
outputDir: "./test-results",
|
||||
fullyParallel: false,
|
||||
// Demo runs synthesize narration and run several ffmpeg passes in save();
|
||||
// that can exceed the 30s default, especially with many steps.
|
||||
timeout: process.env.PW_DEMO ? 120_000 : 30_000,
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: "playwright-report", open: "never" }],
|
||||
],
|
||||
use: {
|
||||
baseURL: "http://localhost:3000",
|
||||
// Demo runs (pnpm demo) pace every action so the recorded video is
|
||||
// watchable by a human; functional e2e runs stay at full speed.
|
||||
launchOptions: {
|
||||
slowMo: process.env.PW_DEMO ? 500 : 0,
|
||||
},
|
||||
locale: "es-CR",
|
||||
timezoneId: "America/Costa_Rica",
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
// Retina-density rendering so doc screenshots stay crisp when zoomed.
|
||||
deviceScaleFactor: 2,
|
||||
// Full recordings are only useful for the paced demo. Keeping them off
|
||||
// for regular e2e avoids making test success depend on artifact uploads.
|
||||
video: process.env.PW_DEMO ? { mode: "on", size: { width: 1920, height: 1080 } } : "off",
|
||||
screenshot: "only-on-failure",
|
||||
trace: process.env.PW_DEMO ? "on" : "on-first-retry",
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: true,
|
||||
stdout: "ignore",
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
3319
frontend/pnpm-lock.yaml
generated
3319
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
# Build-script review (pnpm 10 refuses to run unreviewed dependency scripts).
|
||||
# vite needs the native binaries these two postinstalls provide:
|
||||
onlyBuiltDependencies:
|
||||
- "@swc/core"
|
||||
- esbuild
|
||||
|
||||
# Deliberately not built:
|
||||
ignoredBuiltDependencies:
|
||||
- "@scarf/scarf" # telemetry only
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
||||
29
frontend/public/manifest.webmanifest
Normal file
29
frontend/public/manifest.webmanifest
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "WealthySmart",
|
||||
"short_name": "WealthySmart",
|
||||
"description": "Smart personal finance management",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"lang": "es-CR",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,42 @@
|
||||
/* WealthySmart service worker: web push + PWA installability.
|
||||
* (Replaces the old self-unregistering cleanup stub, which also meant
|
||||
* `navigator.serviceWorker.ready` never resolved and push silently no-oped.) */
|
||||
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", async () => {
|
||||
await self.registration.unregister();
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = { title: "WealthySmart", body: "", url: "/" };
|
||||
try {
|
||||
data = { ...data, ...event.data.json() };
|
||||
} catch {
|
||||
if (event.data) data.body = event.data.text();
|
||||
}
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
data: { url: data.url },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url || "/";
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
|
||||
for (const client of clients) {
|
||||
if ("focus" in client) {
|
||||
client.navigate(url);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import {
|
||||
AgentRunner,
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
createCopilotRuntimeHandler,
|
||||
} from "@copilotkit/runtime/v2";
|
||||
import { HttpAgent, Middleware, EventType } from "@ag-ui/client";
|
||||
import { Observable } from "rxjs";
|
||||
import { Observable, ReplaySubject } from "rxjs";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL ?? "http://backend:8000/api/v1/agent/agui";
|
||||
const BACKEND_URL =
|
||||
process.env.BACKEND_URL ?? "http://localhost:8001";
|
||||
// Prod sets AGENT_URL explicitly (compose network); the default derives from
|
||||
// BACKEND_URL so local dev reaches the docker backend published on :8001.
|
||||
const AGENT_URL =
|
||||
process.env.AGENT_URL ?? `${BACKEND_URL}/api/v1/agent/agui`;
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
const PORT = parseInt(process.env.PORT ?? (isProd ? "3000" : "3001"));
|
||||
@@ -118,6 +120,27 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
||||
const open = new Set<string>(); // tool call IDs currently in progress
|
||||
const closed = new Set<string>(); // tool call IDs that already received END
|
||||
|
||||
// Render tools are display-once: after the client returns their result,
|
||||
// the model sometimes re-issues the same call (fresh id) on the
|
||||
// follow-up run, painting a second identical card. Track which render
|
||||
// tools already ran in the current user turn (from the request's own
|
||||
// message history) and suppress repeats — both their streamed events
|
||||
// and their entries in this run's MESSAGES_SNAPSHOT.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const inputMessages: any[] = Array.isArray(input?.messages) ? input.messages : [];
|
||||
let lastUserIdx = -1;
|
||||
inputMessages.forEach((m, i) => { if (m?.role === "user") lastUserIdx = i; });
|
||||
const renderedThisTurn = new Set<string>();
|
||||
for (const m of inputMessages.slice(lastUserIdx + 1)) {
|
||||
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||
if (m?.role !== "assistant" || !Array.isArray(calls)) continue;
|
||||
for (const tc of calls) {
|
||||
const name = tc?.function?.name;
|
||||
if (name && RENDER_TOOLS.has(name)) renderedThisTurn.add(name);
|
||||
}
|
||||
}
|
||||
const suppressed = new Set<string>(); // toolCallIds of dropped repeats
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -130,13 +153,51 @@ class DeduplicateToolCallMiddleware extends (Middleware as any) {
|
||||
|
||||
if (type === EventType.TOOL_CALL_START) {
|
||||
if (!id || closed.has(id) || open.has(id)) return; // duplicate
|
||||
const name: string | undefined = event?.toolCallName;
|
||||
if (name && RENDER_TOOLS.has(name)) {
|
||||
if (renderedThisTurn.has(name)) {
|
||||
suppressed.add(id);
|
||||
return; // repeat render call — drop the whole call
|
||||
}
|
||||
renderedThisTurn.add(name);
|
||||
}
|
||||
open.add(id);
|
||||
} else if (type === EventType.TOOL_CALL_ARGS || type === EventType.TOOL_CALL_END) {
|
||||
if (id && suppressed.has(id)) return;
|
||||
if (id && closed.has(id)) return; // already completed, drop
|
||||
if (type === EventType.TOOL_CALL_END && id) {
|
||||
open.delete(id);
|
||||
closed.add(id);
|
||||
}
|
||||
} else if (type === EventType.MESSAGES_SNAPSHOT && suppressed.size > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||
const cleaned = msgs
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((m: any) => {
|
||||
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||
if (m?.role !== "assistant" || !Array.isArray(calls)) return m;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const kept = calls.filter((tc: any) => !suppressed.has(String(tc?.id ?? "")));
|
||||
if (kept.length === calls.length) return m;
|
||||
const { toolCalls: _a, tool_calls: _b, ...rest } = m;
|
||||
return kept.length > 0 ? { ...rest, toolCalls: kept } : rest;
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.filter((m: any) => {
|
||||
if (m?.role === "tool" && suppressed.has(String(m?.toolCallId ?? m?.tool_call_id ?? ""))) {
|
||||
return false;
|
||||
}
|
||||
if (m?.role === "assistant") {
|
||||
const calls = m?.toolCalls ?? m?.tool_calls;
|
||||
const hasCalls = Array.isArray(calls) && calls.length > 0;
|
||||
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
||||
if (!hasCalls && !hasText) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
observer.next({ ...event, messages: cleaned });
|
||||
return;
|
||||
}
|
||||
|
||||
observer.next(event); // emit raw BaseEvent
|
||||
@@ -189,24 +250,25 @@ class StripModelArtifactsMiddleware extends (Middleware as any) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ReconcileSnapshotMiddleware ──────────────────────────────────────────────
|
||||
// MAF's `_build_messages_snapshot` (agent_framework_ag_ui/_agent_run.py:686)
|
||||
// mints a fresh UUID for the post-tool-call assistant text instead of reusing
|
||||
// the streamed TEXT_MESSAGE_START id. The resulting MESSAGES_SNAPSHOT then
|
||||
// contains TWO assistant entries: the streamed id (holding the toolCalls) and
|
||||
// a brand-new id (holding the duplicated text). ag-ui's snapshot merge replaces
|
||||
// by id then APPENDS unknown ids, so the browser ends up with two assistant
|
||||
// bubbles for the same answer. Dropping the snapshot entirely fixes the dupe
|
||||
// but breaks render_a2ui card persistence (cards rely on the snapshot to keep
|
||||
// the assistant-with-toolCalls message in state past the run). The right fix
|
||||
// is to drop just the orphan text-only assistant message that has no streamed
|
||||
// counterpart. Remove once `_agent_run.py:686` reuses `flow.message_id`.
|
||||
// ── DropRunMessagesSnapshotMiddleware ────────────────────────────────────────
|
||||
// MAF's run-end MESSAGES_SNAPSHOT re-mints ids for post-tool-call text (their
|
||||
// #3619) and, with thread persistence on, rebuilds history under STORE ids.
|
||||
// ag-ui's apply treats the snapshot as authoritative: client messages absent
|
||||
// from it are dropped, matching ids are replaced. Because streamed ids and
|
||||
// snapshot ids never agree for tool+text turns, every mid-session snapshot
|
||||
// either duplicates the current answer (fresh-id append) or wipes earlier
|
||||
// ones (store-id replace with empty content) — both observed live. During a
|
||||
// normal run the client already built the exact turn from streamed events,
|
||||
// so the snapshot adds nothing: swallow it. Hydration replays (empty
|
||||
// input.messages) pass through untouched — there the snapshot IS the
|
||||
// conversation, and the client converges to store ids on every reload.
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
||||
class DropRunMessagesSnapshotMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(input: any, next: any): Observable<any> {
|
||||
const streamedTextIds = new Set<string>();
|
||||
const isHydration =
|
||||
!Array.isArray(input?.messages) || input.messages.length === 0;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return new Observable<any>((observer) => {
|
||||
@@ -215,33 +277,9 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
next: (eventWithState: any) => {
|
||||
const event = eventWithState.event;
|
||||
const type: string = event?.type ?? "";
|
||||
|
||||
if (type === EventType.TEXT_MESSAGE_START && event?.messageId) {
|
||||
streamedTextIds.add(String(event.messageId));
|
||||
if (event?.type === EventType.MESSAGES_SNAPSHOT && !isHydration) {
|
||||
return; // client state from streaming is already correct
|
||||
}
|
||||
|
||||
if (type === EventType.MESSAGES_SNAPSHOT) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const msgs: any[] = Array.isArray(event?.messages) ? event.messages : [];
|
||||
const filtered = msgs.filter((m) => {
|
||||
if (m?.role !== "assistant") return true;
|
||||
const id = String(m?.id ?? "");
|
||||
const hasText = typeof m?.content === "string" && m.content.length > 0;
|
||||
const hasToolCalls =
|
||||
(Array.isArray(m?.toolCalls) && m.toolCalls.length > 0) ||
|
||||
(Array.isArray(m?.tool_calls) && m.tool_calls.length > 0);
|
||||
if (hasText && !hasToolCalls && !streamedTextIds.has(id)) {
|
||||
return false; // drop orphan text-only assistant duplicate
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (filtered.length !== msgs.length) {
|
||||
observer.next({ ...event, messages: filtered });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
observer.next(event);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -259,7 +297,7 @@ class ReconcileSnapshotMiddleware extends (Middleware as any) {
|
||||
// below the card. This middleware buffers TEXT_MESSAGE_* events and discards
|
||||
// them if any render tool call is detected in the same turn.
|
||||
|
||||
const RENDER_TOOLS = new Set(["render_a2ui", "render_spending_summary"]);
|
||||
const RENDER_TOOLS = new Set(["render_spending_summary"]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
||||
@@ -324,58 +362,215 @@ class SuppressRenderToolTextMiddleware extends (Middleware as any) {
|
||||
|
||||
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}` }
|
||||
: {};
|
||||
// Security headers on every response. Responses returned by fetch() (the
|
||||
// backend proxy) carry immutable headers, so rebuild the Response instead of
|
||||
// mutating in place.
|
||||
const SECURITY_HEADERS: Record<string, string> = {
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
app.use("*", async (c, next) => {
|
||||
await next();
|
||||
const res = c.res;
|
||||
const headers = new Headers(res.headers);
|
||||
for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
|
||||
c.res = new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
||||
});
|
||||
|
||||
// ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
|
||||
// Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
|
||||
// wrapper (which was already the v2 single-route handler underneath) onto
|
||||
// createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
|
||||
// run() is a pure proxy to the MAF backend (which persists every turn in
|
||||
// chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
|
||||
// so the ONLY conversation store is the DB. This deletes the in-memory
|
||||
// runner (whose process-local replay resurrected cleared conversations) and
|
||||
// the client-side hydration hack that compensated for it.
|
||||
|
||||
// The v2 handler nags about anonymous telemetry unless disabled.
|
||||
process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
|
||||
|
||||
function makeWealthysmartAgent(headers: Record<string, string> = {}): HttpAgent {
|
||||
const agent = new HttpAgent({ url: AGENT_URL, headers });
|
||||
// CK_MIDDLEWARES=off runs the stack bare — used to audit which of these
|
||||
// workarounds are still needed after CopilotKit/MAF upgrades.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
if (process.env.CK_MIDDLEWARES !== "off") {
|
||||
agent.use(new SuppressRenderToolTextMiddleware() as any);
|
||||
agent.use(new StripModelArtifactsMiddleware() as any);
|
||||
agent.use(new DropRunMessagesSnapshotMiddleware() as any);
|
||||
agent.use(new DeduplicateToolCallMiddleware() as any);
|
||||
}
|
||||
if (process.env.CK_DEBUG_EVENTS === "1" || process.env.CK_MIDDLEWARES === "off") {
|
||||
agent.use(new DebugLogMiddleware() as any);
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
return agent;
|
||||
}
|
||||
|
||||
// ── PostgresAgentRunner ───────────────────────────────────────────────────────
|
||||
// The conversation store is Postgres (chatthreadsnapshot), written by MAF at
|
||||
// every run end. So the runner records NOTHING:
|
||||
// - run() proxies the agent's event stream (the per-request clone the v2
|
||||
// handler prepared, auth headers already forwarded onto it).
|
||||
// - connect() asks MAF for its snapshot hydration — an empty-messages run
|
||||
// returns RUN_STARTED → [STATE_SNAPSHOT] → MESSAGES_SNAPSHOT → RUN_FINISHED
|
||||
// with no LLM call (unknown thread: RUN_STARTED → RUN_FINISHED). The same
|
||||
// stream shape the in-memory runner's replay approximated, but sourced
|
||||
// from the DB, so it is correct across clears, restarts and instances.
|
||||
// - Live runs are tracked in-process only for stop(); the official
|
||||
// sqlite-runner does the same (single-instance limitation acknowledged).
|
||||
// isRunning() has no caller in the v2 SSE path (verified at 1.62.2).
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const liveRuns = new Map<string, any>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function proxyAgentRun(agent: any, input: any, threadId: string): Observable<any> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const subject = new ReplaySubject<any>(Infinity);
|
||||
liveRuns.set(threadId, agent);
|
||||
agent
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.runAgent(input, { onEvent: ({ event }: any) => subject.next(event) })
|
||||
.then(() => subject.complete())
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.catch((err: any) => subject.error(err))
|
||||
.finally(() => {
|
||||
if (liveRuns.get(threadId) === agent) liveRuns.delete(threadId);
|
||||
});
|
||||
return subject.asObservable();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
class PostgresAgentRunner extends (AgentRunner as any) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
run(request: any): Observable<any> {
|
||||
return proxyAgentRun(request.agent, request.input, request.threadId);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
connect(request: any): Observable<any> {
|
||||
// request.headers = authorization + x-* from the incoming browser
|
||||
// request (extractForwardableHeaders) — exactly what MAF needs.
|
||||
const agent = makeWealthysmartAgent(request.headers ?? {});
|
||||
agent.threadId = request.threadId;
|
||||
// No messages + known threadId = MAF's hydration trigger.
|
||||
return proxyAgentRun(agent, {}, `connect:${request.threadId}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
isRunning(request: any): Promise<boolean> {
|
||||
return Promise.resolve(liveRuns.has(request.threadId));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async stop(request: any): Promise<boolean | undefined> {
|
||||
const agent = liveRuns.get(request.threadId);
|
||||
if (!agent) return false;
|
||||
try {
|
||||
agent.abortRun();
|
||||
liveRuns.delete(request.threadId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: { wealthysmart: makeWealthysmartAgent() },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
runner: new PostgresAgentRunner() as any,
|
||||
// a2ui disabled 2026-07-04: CK 1.60+ validates generated ops against a
|
||||
// catalog and our agent's ops fail validation (surface never paints).
|
||||
// Markdown tables cover the use case until the pipeline matures.
|
||||
a2ui: { enabled: false },
|
||||
beforeRequestMiddleware: async ({ request: outbound }) => {
|
||||
if (outbound.method !== "POST") return;
|
||||
const ct = outbound.headers.get("content-type") ?? "";
|
||||
if (!ct.includes("application/json")) return;
|
||||
try {
|
||||
const body = (await outbound.clone().json()) as {
|
||||
messages?: AGUIMessage[];
|
||||
context?: { description?: string; value?: string }[];
|
||||
};
|
||||
if (!Array.isArray(body.messages)) return;
|
||||
// An empty messages array is MAF's hydration trigger (known threadId
|
||||
// + no messages → replay the stored snapshot without invoking the
|
||||
// LLM). Injecting anything would turn it into a normal run.
|
||||
if (body.messages.length === 0) return;
|
||||
let messages = pairOrphanToolCalls(body.messages);
|
||||
|
||||
// MAF's AG-UI endpoint declares `context` on its request model but
|
||||
// never feeds it to the model (agent_framework_ag_ui, checked at
|
||||
// python-1.10.0). CopilotKit 1.60+ ships the A2UI catalog schema and
|
||||
// guidelines in that context — without them the model emits ops the
|
||||
// A2UI validator rejects and the surface never paints. Inject the
|
||||
// context as a system message ourselves. Remove once MAF consumes
|
||||
// RunAgentInput.context.
|
||||
if (Array.isArray(body.context) && body.context.length > 0) {
|
||||
const contextText = body.context
|
||||
.map((c) => `## ${c.description ?? "Context"}\n${c.value ?? ""}`)
|
||||
.join("\n\n");
|
||||
messages = [
|
||||
{
|
||||
id: `ctx-${Date.now()}`,
|
||||
role: "system",
|
||||
content: `Additional run context:\n\n${contextText}`,
|
||||
} as AGUIMessage,
|
||||
...messages,
|
||||
];
|
||||
}
|
||||
|
||||
if (messages === body.messages) return;
|
||||
return new Request(outbound.url, {
|
||||
method: outbound.method,
|
||||
headers: outbound.headers,
|
||||
body: JSON.stringify({ ...body, messages }),
|
||||
});
|
||||
} catch (err) {
|
||||
// Skipping the repair is survivable; doing it silently is not —
|
||||
// orphan tool calls then fail downstream with no trace of why.
|
||||
console.error("[copilotkit] outbound request repair skipped:", err);
|
||||
return;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const ckHandler = createCopilotRuntimeHandler({
|
||||
runtime,
|
||||
basePath: "/api/copilotkit",
|
||||
// Single-route mode: same envelope protocol the client already resolved
|
||||
// via auto-detect (its GET /info probe 405s here, so it stays "single").
|
||||
// Multi-route would only add /threads* REST endpoints we don't use and is
|
||||
// exposed to CopilotKit#4953 (frontend tools under multi-route).
|
||||
mode: "single-route",
|
||||
cors: false,
|
||||
});
|
||||
|
||||
// The v2 handler forwards only `authorization` and `x-*` headers to agents
|
||||
// and to runner.connect — cookies never pass. Translate the auth cookie into
|
||||
// a Bearer header before the handler sees the request.
|
||||
function withBearerFromCookie(req: Request): Request {
|
||||
if (req.headers.get("authorization")) return req;
|
||||
const cookie = req.headers.get("cookie") ?? "";
|
||||
const match = cookie.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
||||
if (!match) return req;
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("Authorization", `Bearer ${match[1]}`);
|
||||
return new Request(req, { headers });
|
||||
}
|
||||
|
||||
app.all("/api/copilotkit/*", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
|
||||
app.all("/api/copilotkit", (c) => ckHandler(withBearerFromCookie(c.req.raw)));
|
||||
|
||||
app.get("/api/health", (c) => c.json({ ok: true }));
|
||||
|
||||
// Proxy backend API calls (FastAPI). In dev these hit Vite's proxy directly,
|
||||
@@ -398,7 +593,12 @@ const proxyToBackend = async (c: import("hono").Context) => {
|
||||
// @ts-expect-error undici requires duplex for streamed bodies
|
||||
init.duplex = "half";
|
||||
}
|
||||
return fetch(target, init);
|
||||
try {
|
||||
return await fetch(target, init);
|
||||
} catch (err) {
|
||||
console.error(`[proxy] ${method} ${url.pathname} → backend failed:`, err);
|
||||
return c.json({ error: "Backend unavailable" }, 502);
|
||||
}
|
||||
};
|
||||
|
||||
app.all("/api/v1/*", proxyToBackend);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user