mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-07-17 12:28:48 +02:00
Compare commits
97 Commits
3e56f1ca66
...
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 |
@@ -23,4 +23,4 @@ VAPID_PUBLIC_KEY=
|
||||
|
||||
# ── AI agent (optional in dev; Asistente won't work without it) ──────────────
|
||||
OPENAI_API_KEY=
|
||||
AGENT_MODEL=gpt-5.4-mini
|
||||
AGENT_MODEL=gpt-5.6-luna
|
||||
|
||||
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/
|
||||
|
||||
35
CLAUDE.md
35
CLAUDE.md
@@ -17,6 +17,27 @@ The backend refuses to boot without `SECRET_KEY`, `ADMIN_USERNAME`, and
|
||||
`ADMIN_PASSWORD` (no insecure defaults). Copy `.env.example` to `.env` and fill
|
||||
it in; the dev docker-compose reads it.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
Schema changes go through Alembic — never edit the schema by hand and never
|
||||
reset the prod DB. Workflow:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
# 1. Edit app/models/models.py
|
||||
# 2. Autogenerate against the dev DB, then REVIEW the generated file
|
||||
DATABASE_URL='postgresql://wealthy_user:wealthy_pass@localhost:5433/wealthysmart' \
|
||||
.venv/bin/alembic revision --autogenerate -m "describe the change"
|
||||
# 3. Migrations apply automatically at app startup (run_alembic_upgrade)
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd backend && .venv/bin/python -m pytest tests/ -q # backend (55+ tests)
|
||||
cd frontend && pnpm typecheck # frontend
|
||||
```
|
||||
|
||||
## Local Docker
|
||||
|
||||
```bash
|
||||
@@ -47,3 +68,17 @@ Four automated flows on old-vps feed data into WealthySmart:
|
||||
4. **Pension PDFs** (`e88c3UhBeo9WCbcy`) — Gmail trigger (daily midnight) → POST /pensions/upload
|
||||
|
||||
Flow export: `docs/WealthySmart_ BAC Pensions Statements parser.json`
|
||||
|
||||
## API Type Generation
|
||||
|
||||
Frontend read types are generated from the backend OpenAPI spec. After changing
|
||||
backend response models:
|
||||
|
||||
```bash
|
||||
cd backend && DATABASE_URL='sqlite://' SECRET_KEY=$(openssl rand -hex 32) \
|
||||
ADMIN_USERNAME=x ADMIN_PASSWORD=x-x OPENAI_API_KEY=dummy \
|
||||
.venv/bin/python -c "import json; from app.main import app; json.dump(app.openapi(), open('../frontend/openapi.json','w'), indent=1, sort_keys=True)"
|
||||
cd ../frontend && pnpm gen:api # regenerates src/lib/api-types.gen.ts
|
||||
```
|
||||
|
||||
CI regenerates and fails the deploy if `api-types.gen.ts` is stale.
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from app.auth import (
|
||||
@@ -11,7 +12,16 @@ from app.auth import (
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
username: str
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
check_login_rate_limit(request)
|
||||
verify_admin_credentials(form_data.username, form_data.password)
|
||||
@@ -19,6 +29,6 @@ def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
return {"access_token": token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
def me(username: str = Depends(get_current_user_cookie_or_bearer)):
|
||||
return {"username": username}
|
||||
|
||||
@@ -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}
|
||||
@@ -26,12 +26,29 @@ class PasteImportRequest(BaseModel):
|
||||
text: str = Field(max_length=1_000_000)
|
||||
bank: Bank = Bank.BAC
|
||||
source: TransactionSource = TransactionSource.CREDIT_CARD
|
||||
# Override for false-positive dedup (legitimate same-day, same-amount
|
||||
# repeat purchases hash identically) — review UX-20/BE-08.
|
||||
allow_duplicates: bool = False
|
||||
|
||||
|
||||
class SkippedDuplicate(BaseModel):
|
||||
line: int
|
||||
merchant: str
|
||||
date: str
|
||||
amount: float
|
||||
currency: str
|
||||
existing_id: int
|
||||
existing_merchant: str
|
||||
existing_date: str
|
||||
existing_amount: float
|
||||
existing_source: str
|
||||
|
||||
|
||||
class PasteImportResult(BaseModel):
|
||||
imported: int
|
||||
duplicates: int
|
||||
errors: list[str]
|
||||
skipped: list[SkippedDuplicate] = []
|
||||
|
||||
|
||||
def make_reference_hash(date: datetime, merchant: str, amount: float, currency: str) -> str:
|
||||
@@ -103,6 +120,7 @@ def paste_import(
|
||||
imported = 0
|
||||
duplicates = 0
|
||||
errors: list[str] = []
|
||||
skipped: list[SkippedDuplicate] = []
|
||||
|
||||
lines = req.text.strip().splitlines()
|
||||
|
||||
@@ -120,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"],
|
||||
@@ -147,4 +181,6 @@ def paste_import(
|
||||
if imported > 0:
|
||||
session.commit()
|
||||
|
||||
return PasteImportResult(imported=imported, duplicates=duplicates, errors=errors)
|
||||
return PasteImportResult(
|
||||
imported=imported, duplicates=duplicates, errors=errors, skipped=skipped
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -12,6 +12,9 @@ from jose import JWTError, jwt
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_session
|
||||
from app.models.models import APIToken
|
||||
from app.timeutil import utcnow
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
@@ -70,7 +73,7 @@ def verify_admin_credentials(username: str, password: str) -> None:
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
@@ -90,9 +93,6 @@ def _validate_token(token: str) -> str:
|
||||
pass
|
||||
|
||||
# Fallback: check API token
|
||||
from app.db import get_session
|
||||
from app.models.models import APIToken
|
||||
|
||||
token_hash = hash_token(token)
|
||||
with next(get_session()) as session:
|
||||
api_token = session.exec(
|
||||
@@ -102,7 +102,7 @@ def _validate_token(token: str) -> str:
|
||||
)
|
||||
).first()
|
||||
if api_token:
|
||||
if api_token.expires_at and api_token.expires_at < datetime.utcnow():
|
||||
if api_token.expires_at and api_token.expires_at < utcnow():
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
|
||||
return f"api:{api_token.name}"
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
|
||||
VAPID_PUBLIC_KEY: str = ""
|
||||
VAPID_CLAIM_EMAIL: str = "mailto:admin@wealth.cescalante.dev"
|
||||
OPENAI_API_KEY: str = ""
|
||||
AGENT_MODEL: str = "gpt-5.4-mini"
|
||||
AGENT_MODEL: str = "gpt-5.6-luna"
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
@@ -39,8 +39,7 @@ class Settings(BaseSettings):
|
||||
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
|
||||
return self
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -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,7 +1,8 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from time import perf_counter
|
||||
from http.cookies import SimpleCookie
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from agent_framework_ag_ui import add_agent_framework_fastapi_endpoint
|
||||
@@ -11,6 +12,15 @@ from jose import JWTError, jwt
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.agent.agent import build_agent
|
||||
from app.agent.chat_audit import (
|
||||
create_chat_run,
|
||||
finish_pending_chat_run,
|
||||
mark_chat_run_failed,
|
||||
purge_expired_chat_runs_periodically,
|
||||
reset_current_chat_run,
|
||||
set_current_chat_run,
|
||||
)
|
||||
from app.agent.snapshot_store import PostgresSnapshotStore
|
||||
from app.agent.tools import reset_session, set_session
|
||||
from app.api.v1.router import api_router
|
||||
from app.auth import (
|
||||
@@ -20,12 +30,49 @@ from app.auth import (
|
||||
verify_admin_credentials,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.db import get_session, init_db, run_migrations
|
||||
from app.db import get_session, run_alembic_upgrade
|
||||
from app.seed import seed_db
|
||||
from app.timeutil import reset_client_timezone, set_client_timezone
|
||||
from app.services.exchange_rate import refresh_rates_periodically
|
||||
from app.logging import configure_structured_logging, log_event
|
||||
|
||||
|
||||
AGENT_PATH = "/api/v1/agent/agui"
|
||||
MAX_USER_AGENT_LENGTH = 512
|
||||
|
||||
|
||||
def _request_client_details(request: Request) -> tuple[str, str | None, str | None]:
|
||||
"""Return the browser client details propagated through the frontend BFF.
|
||||
|
||||
Production nginx-proxy supplies X-Real-IP and the frontend forwards it to
|
||||
FastAPI. For direct local development, fall back to X-Forwarded-For and
|
||||
then the socket peer. The BFF is the only production path to the backend,
|
||||
which is not publicly exposed.
|
||||
"""
|
||||
client_ip = request.headers.get("x-real-ip", "").strip()
|
||||
if not client_ip:
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
client_ip = forwarded.split(",", 1)[0].strip()
|
||||
if not client_ip:
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
user_agent = request.headers.get("user-agent", "").strip()[:MAX_USER_AGENT_LENGTH] or None
|
||||
if not user_agent:
|
||||
return client_ip, None, None
|
||||
ua = user_agent.lower()
|
||||
if "edg/" in ua or "edgios/" in ua:
|
||||
browser = "Edge"
|
||||
elif "opr/" in ua or "opera" in ua:
|
||||
browser = "Opera"
|
||||
elif "firefox/" in ua or "fxios/" in ua:
|
||||
browser = "Firefox"
|
||||
elif "chrome/" in ua or "crios/" in ua:
|
||||
browser = "Chrome"
|
||||
elif "safari/" in ua:
|
||||
browser = "Safari"
|
||||
else:
|
||||
browser = "Other"
|
||||
return client_ip, user_agent, browser
|
||||
|
||||
|
||||
def _pair_orphan_tool_calls(messages: list) -> list:
|
||||
@@ -63,20 +110,60 @@ def _pair_orphan_tool_calls(messages: list) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _drop_stale_client_history(session, thread_id, messages: list) -> list:
|
||||
"""Conversation history is server-owned (the AG-UI snapshot store).
|
||||
|
||||
A request that carries assistant/tool history for a thread with NO
|
||||
stored snapshot comes from a stale client — typically a second tab that
|
||||
was open when 'Nueva conversación' cleared the thread. Trusting it
|
||||
would re-persist the cleared conversation ('chunks' resurrecting after
|
||||
every clear). Keep only the last user turn; MAF starts the thread
|
||||
fresh from there.
|
||||
"""
|
||||
if not thread_id or not messages:
|
||||
return messages
|
||||
has_history = any(m.get("role") in ("assistant", "tool") for m in messages)
|
||||
if not has_history:
|
||||
return messages
|
||||
from sqlmodel import select
|
||||
|
||||
from app.models.models import ChatThreadSnapshot
|
||||
|
||||
row = session.exec(
|
||||
select(ChatThreadSnapshot.id).where(
|
||||
ChatThreadSnapshot.thread_id == str(thread_id)
|
||||
)
|
||||
).first()
|
||||
if row is not None:
|
||||
return messages # thread exists; history is legitimate
|
||||
last_user = None
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
if messages[i].get("role") == "user":
|
||||
last_user = i
|
||||
break
|
||||
return messages[last_user:] if last_user is not None else []
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
run_migrations()
|
||||
configure_structured_logging()
|
||||
run_alembic_upgrade()
|
||||
seed_db()
|
||||
rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
|
||||
chat_audit_cleanup_task = asyncio.create_task(purge_expired_chat_runs_periodically())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
rate_refresh_task.cancel()
|
||||
chat_audit_cleanup_task.cancel()
|
||||
try:
|
||||
await rate_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
try:
|
||||
await chat_audit_cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="WealthySmart API", version="0.1.0", lifespan=lifespan)
|
||||
@@ -90,6 +177,67 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
async def log_api_request(request: Request, call_next):
|
||||
"""Emit redacted, one-line diagnostics for API requests only.
|
||||
|
||||
This deliberately records no request/response bodies, headers, tokens,
|
||||
passwords, or financial payloads. Full assistant content is retained only
|
||||
in the bounded chat-run audit table.
|
||||
"""
|
||||
request_id = uuid.uuid4().hex
|
||||
request.state.request_id = request_id
|
||||
client_ip, user_agent, browser = _request_client_details(request)
|
||||
request.state.client_ip = client_ip
|
||||
request.state.user_agent = user_agent
|
||||
request.state.browser = browser
|
||||
started = perf_counter()
|
||||
status_code = 500
|
||||
is_logged_api = request.url.path.startswith("/api/") and request.url.path != "/api/health"
|
||||
|
||||
def emit(error_type: str | None = None) -> None:
|
||||
if not is_logged_api:
|
||||
return
|
||||
route = request.scope.get("route")
|
||||
log_event(
|
||||
"api_request",
|
||||
request_id=request_id,
|
||||
method=request.method,
|
||||
path=getattr(route, "path", request.url.path),
|
||||
status_code=status_code,
|
||||
duration_ms=round((perf_counter() - started) * 1000, 1),
|
||||
error_type=error_type,
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
browser=browser,
|
||||
)
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
original_iterator = getattr(response, "body_iterator", None)
|
||||
if original_iterator is None:
|
||||
emit()
|
||||
return response
|
||||
|
||||
async def logged_stream():
|
||||
error_type: str | None = None
|
||||
try:
|
||||
async for chunk in original_iterator:
|
||||
yield chunk
|
||||
except Exception as exc:
|
||||
error_type = type(exc).__name__
|
||||
raise
|
||||
finally:
|
||||
emit(error_type)
|
||||
|
||||
response.body_iterator = logged_stream()
|
||||
return response
|
||||
except Exception as exc:
|
||||
emit(type(exc).__name__)
|
||||
raise
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def agent_auth_and_session(request: Request, call_next):
|
||||
"""For the AG-UI route, validate the JWT, repair message history, and
|
||||
@@ -106,10 +254,10 @@ async def agent_auth_and_session(request: Request, call_next):
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
else:
|
||||
cookie_header = request.headers.get("cookie", "")
|
||||
m = re.search(r"(?:^|;\s*)ws_token=([^;]+)", cookie_header)
|
||||
if m:
|
||||
token = m.group(1)
|
||||
cookies = SimpleCookie()
|
||||
cookies.load(request.headers.get("cookie", ""))
|
||||
if "ws_token" in cookies:
|
||||
token = cookies["ws_token"].value
|
||||
|
||||
if not token:
|
||||
return Response(status_code=401, content="Missing auth")
|
||||
@@ -120,25 +268,94 @@ async def agent_auth_and_session(request: Request, call_next):
|
||||
except JWTError:
|
||||
return Response(status_code=401, content="Invalid token")
|
||||
|
||||
session_gen = get_session()
|
||||
session = next(session_gen)
|
||||
token_var = set_session(session)
|
||||
|
||||
chat_run_id: int | None = None
|
||||
# Repair orphan tool_calls before the MAF agent sees the message history.
|
||||
if request.method == "POST" and "application/json" in request.headers.get("content-type", ""):
|
||||
raw = await request.body()
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
if isinstance(body.get("messages"), list):
|
||||
body["messages"] = _drop_stale_client_history(
|
||||
session,
|
||||
body.get("threadId") or body.get("thread_id"),
|
||||
body["messages"],
|
||||
)
|
||||
body["messages"] = _pair_orphan_tool_calls(body["messages"])
|
||||
raw = json.dumps(body).encode()
|
||||
except Exception:
|
||||
pass
|
||||
log_event(
|
||||
"agent_request",
|
||||
request_id=request.state.request_id,
|
||||
agui_run_id=body.get("runId"),
|
||||
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||
message_count=len(body.get("messages") or []),
|
||||
has_user_message=any(
|
||||
message.get("role") == "user" for message in body.get("messages") or []
|
||||
),
|
||||
client_ip=request.state.client_ip,
|
||||
user_agent=request.state.user_agent,
|
||||
browser=request.state.browser,
|
||||
)
|
||||
# The BFF removes its `method: agent/run` envelope before
|
||||
# forwarding to MAF. A non-empty user message is the reliable
|
||||
# backend-side distinction between an agent run and hydration.
|
||||
if any(message.get("role") == "user" for message in body.get("messages") or []):
|
||||
chat_run_id = create_chat_run(
|
||||
request_id=request.state.request_id,
|
||||
agui_run_id=body.get("runId"),
|
||||
thread_id=body.get("threadId") or body.get("thread_id"),
|
||||
messages=body.get("messages") or [],
|
||||
client_ip=request.state.client_ip,
|
||||
user_agent=request.state.user_agent,
|
||||
browser=request.state.browser,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Parsing/repair must not prevent the agent endpoint from serving;
|
||||
# the access log will still capture a resulting error response.
|
||||
log_event(
|
||||
"chat_audit_start_failed",
|
||||
request_id=request.state.request_id,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
body = None
|
||||
# Starlette caches the body; replace it so call_next sees the fixed bytes.
|
||||
request._body = raw # type: ignore[attr-defined]
|
||||
|
||||
session_gen = get_session()
|
||||
session = next(session_gen)
|
||||
token_var = set_session(session)
|
||||
# Browser's IANA timezone, forwarded by the BFF — lets tools resolve
|
||||
# "today" wherever the user is (falls back to Costa Rica).
|
||||
tz_token = set_client_timezone(request.headers.get("x-client-timezone"))
|
||||
audit_token = set_current_chat_run(chat_run_id) if chat_run_id is not None else None
|
||||
try:
|
||||
return await call_next(request)
|
||||
response = await call_next(request)
|
||||
if chat_run_id is None:
|
||||
return response
|
||||
|
||||
original_iterator = response.body_iterator
|
||||
|
||||
async def audit_stream():
|
||||
try:
|
||||
async for chunk in original_iterator:
|
||||
yield chunk
|
||||
except Exception as exc:
|
||||
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||
raise
|
||||
else:
|
||||
# MAF normally finalizes via PostgresSnapshotStore.save(). If
|
||||
# it emitted no snapshot, do not leave a permanently-open row.
|
||||
finish_pending_chat_run(chat_run_id)
|
||||
|
||||
response.body_iterator = audit_stream()
|
||||
return response
|
||||
except Exception as exc:
|
||||
if chat_run_id is not None:
|
||||
mark_chat_run_failed(chat_run_id, f"{type(exc).__name__}: {exc}")
|
||||
raise
|
||||
finally:
|
||||
if audit_token is not None:
|
||||
reset_current_chat_run(audit_token)
|
||||
reset_client_timezone(tz_token)
|
||||
reset_session(token_var)
|
||||
try:
|
||||
next(session_gen)
|
||||
@@ -146,11 +363,26 @@ async def agent_auth_and_session(request: Request, call_next):
|
||||
pass
|
||||
|
||||
|
||||
# FastAPI wraps function middlewares in reverse registration order. Register
|
||||
# this after the AG-UI auth middleware so it observes every API response,
|
||||
# including auth failures that return before calling the inner application.
|
||||
app.middleware("http")(log_api_request)
|
||||
|
||||
|
||||
# Register app routes
|
||||
app.include_router(api_router)
|
||||
|
||||
# Mount the AG-UI agent endpoint.
|
||||
add_agent_framework_fastapi_endpoint(app, build_agent(), AGENT_PATH)
|
||||
# Mount the AG-UI agent endpoint. The snapshot store persists the chat
|
||||
# thread across page reloads; scope is a constant because this is a
|
||||
# single-user app and auth is already enforced by agent_auth_and_session
|
||||
# (the scope resolver only sees the AGUIRequest, which carries no identity).
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app,
|
||||
build_agent(),
|
||||
AGENT_PATH,
|
||||
snapshot_store=PostgresSnapshotStore(),
|
||||
snapshot_scope_resolver=lambda _request: "default",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
@@ -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"")))
|
||||
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.
|
||||
@@ -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:
|
||||
|
||||
@@ -15,6 +15,11 @@ services:
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
backend:
|
||||
build:
|
||||
@@ -30,7 +35,9 @@ services:
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.4-mini}
|
||||
AGENT_MODEL: ${AGENT_MODEL:-gpt-5.6-luna}
|
||||
# MAF 1.6+ enables OTel instrumentation by default; keep it off explicitly.
|
||||
ENABLE_INSTRUMENTATION: "false"
|
||||
ports:
|
||||
- "8001:8000"
|
||||
volumes:
|
||||
@@ -38,6 +45,11 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
develop:
|
||||
watch:
|
||||
- path: ./backend/app
|
||||
@@ -58,9 +70,17 @@ services:
|
||||
- "5175:3000"
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
# Vite runs inside this container, so localhost would refer to the
|
||||
# frontend rather than the Python service.
|
||||
BACKEND_URL: http://backend:8000
|
||||
AGENT_URL: http://backend:8000/api/v1/agent/agui
|
||||
depends_on:
|
||||
- backend
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
develop:
|
||||
watch:
|
||||
- path: ./frontend/src
|
||||
|
||||
@@ -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) {
|
||||
@@ -345,58 +383,194 @@ app.use("*", async (c, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.all("/api/copilotkit/*", async (c) => {
|
||||
const cookieHeader = c.req.header("cookie") ?? "";
|
||||
const match = cookieHeader.match(/(?:^|;\s*)ws_token=([^;]+)/);
|
||||
const token = match?.[1];
|
||||
const agentHeaders: Record<string, string> = token
|
||||
? { Authorization: `Bearer ${token}` }
|
||||
: {};
|
||||
// ── CopilotKit v2 runtime (module scope, built once) ─────────────────────────
|
||||
// Migrated 2026-07-04 off the deprecated copilotRuntimeNextJSAppRouterEndpoint
|
||||
// wrapper (which was already the v2 single-route handler underneath) onto
|
||||
// createCopilotRuntimeHandler directly, with a Postgres-backed AgentRunner:
|
||||
// run() is a pure proxy to the MAF backend (which persists every turn in
|
||||
// chatthreadsnapshot), and connect() forwards to MAF's snapshot hydration —
|
||||
// so the ONLY conversation store is the DB. This deletes the in-memory
|
||||
// runner (whose process-local replay resurrected cleared conversations) and
|
||||
// the client-side hydration hack that compensated for it.
|
||||
|
||||
const agent = new HttpAgent({ url: AGENT_URL, headers: agentHeaders });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
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);
|
||||
// The v2 handler nags about anonymous telemetry unless disabled.
|
||||
process.env.COPILOTKIT_TELEMETRY_DISABLED ??= "true";
|
||||
|
||||
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;
|
||||
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,
|
||||
];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter: new ExperimentalEmptyAdapter(),
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(c.req.raw as Parameters<typeof handleRequest>[0]);
|
||||
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,
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider, useAuth } from "./AuthContext";
|
||||
import { ThemeProvider } from "./contexts/theme-context";
|
||||
import { PrivacyProvider } from "./contexts/privacy-context";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
import Layout from "./components/Layout";
|
||||
import LoginPage from "./pages/Login";
|
||||
import Inicio from "./pages/Inicio";
|
||||
import Asistente from "./pages/Asistente";
|
||||
import Analytics from "./pages/Analytics";
|
||||
import Budget from "./pages/Budget";
|
||||
import Salarios from "./pages/Salarios";
|
||||
import Financiamientos from "./pages/Financiamientos";
|
||||
import Pensions from "./pages/Pensions";
|
||||
import Proyecciones from "./pages/Proyecciones";
|
||||
import ServiciosMunicipales from "./pages/ServiciosMunicipales";
|
||||
import SyncStatus from "./pages/SyncStatus";
|
||||
import Planificador from "./pages/Planificador";
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@@ -28,7 +43,7 @@ function AppRoutes() {
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/asistente" replace /> : <LoginPage />}
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
element={
|
||||
@@ -37,14 +52,17 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/asistente" replace />} />
|
||||
<Route index element={<Inicio />} />
|
||||
<Route path="/asistente" element={<Asistente />} />
|
||||
<Route path="/budget" element={<Budget />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/proyecciones" element={<Proyecciones />} />
|
||||
<Route path="/salarios" element={<Salarios />} />
|
||||
<Route path="/financiamientos" element={<Financiamientos />} />
|
||||
<Route path="/pensions" element={<Pensions />} />
|
||||
<Route path="/servicios-municipales" element={<ServiciosMunicipales />} />
|
||||
<Route path="/sync" element={<SyncStatus />} />
|
||||
<Route path="/planificador" element={<Planificador />} />
|
||||
<Route path="/transactions" element={<Navigate to="/budget" replace />} />
|
||||
<Route path="/transfers" element={<Navigate to="/budget" replace />} />
|
||||
</Route>
|
||||
@@ -58,9 +76,22 @@ export default function App() {
|
||||
<ThemeProvider>
|
||||
<PrivacyProvider>
|
||||
<AuthProvider>
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="wealthysmart" a2ui={{}}>
|
||||
<AppRoutes />
|
||||
</CopilotKit>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CopilotKit
|
||||
runtimeUrl="/api/copilotkit"
|
||||
agent="wealthysmart"
|
||||
headers={{
|
||||
// Lets the agent resolve "hoy"/"este mes" in the user's
|
||||
// local time wherever they are; backend falls back to
|
||||
// Costa Rica when absent.
|
||||
"X-Client-Timezone":
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
}}
|
||||
>
|
||||
<AppRoutes />
|
||||
<Toaster richColors position="top-right" closeButton />
|
||||
</CopilotKit>
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
</PrivacyProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -20,12 +20,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Probe auth state by hitting a protected endpoint.
|
||||
// If the ws_token cookie is valid, the server returns 200; else 401.
|
||||
fetch("/api/v1/auth/me", { credentials: "include" })
|
||||
.then((r) => setAuthenticated(r.ok))
|
||||
.catch(() => setAuthenticated(false))
|
||||
.finally(() => setIsLoading(false));
|
||||
// Probe auth state by hitting a protected endpoint. An HTTP status is an
|
||||
// authoritative answer (200 = in, 401 = out); a thrown fetch is a NETWORK
|
||||
// failure and must not log the user out — retry once before giving up.
|
||||
let cancelled = false;
|
||||
const probe = () => fetch("/api/v1/auth/me", { credentials: "include" });
|
||||
probe()
|
||||
.catch(() => new Promise((r) => setTimeout(r, 1500)).then(probe))
|
||||
.then((r) => {
|
||||
if (!cancelled) setAuthenticated((r as Response).ok);
|
||||
})
|
||||
.catch(() => {
|
||||
// Network still down: leave unauthenticated but log the real cause.
|
||||
console.warn("Auth probe failed twice (network) — backend unreachable");
|
||||
if (!cancelled) setAuthenticated(false);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const logout = async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
|
||||
const [cycles, setCycles] = useState<CycleOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/transactions/cycles').then((r) => setCycles(r.data));
|
||||
api.get<CycleOption[]>('/transactions/cycles').then((r) => setCycles(r.data));
|
||||
}, []);
|
||||
|
||||
const selectedKey = value ? `${value.year}-${value.month}` : 'all';
|
||||
@@ -37,7 +37,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
|
||||
<Select
|
||||
value={selectedKey}
|
||||
onValueChange={(val) => {
|
||||
if (val === 'all') {
|
||||
if (!val || val === 'all') {
|
||||
onChange(null);
|
||||
} else {
|
||||
const [y, m] = val.split('-').map(Number);
|
||||
@@ -49,7 +49,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All time</SelectItem>
|
||||
<SelectItem value="all">Todo el período</SelectItem>
|
||||
{cycles.map((c) => (
|
||||
<SelectItem key={`${c.year}-${c.month}`} value={`${c.year}-${c.month}`}>
|
||||
{c.label} ({c.count})
|
||||
|
||||
28
frontend/src/components/ErrorState.tsx
Normal file
28
frontend/src/components/ErrorState.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { AlertTriangle, RotateCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export default function ErrorState({
|
||||
message = 'No se pudieron cargar los datos',
|
||||
onRetry,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-8 text-center"
|
||||
>
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
{onRetry && (
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Reintentar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,213 +1,105 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Sparkles,
|
||||
Calculator,
|
||||
BarChart3,
|
||||
Landmark,
|
||||
PiggyBank,
|
||||
Droplets,
|
||||
LogOut,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
Menu,
|
||||
Sun,
|
||||
Moon,
|
||||
Eye,
|
||||
EyeOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Link, Outlet, useLocation } from "react-router-dom";
|
||||
import { Eye, EyeOff, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { titleFor } from "@/lib/navigation";
|
||||
import { useTheme } from "@/contexts/theme-context";
|
||||
import { usePrivacy } from "@/contexts/privacy-context";
|
||||
import { useAuth } from "@/AuthContext";
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { CommandMenu } from "@/components/command-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavSection {
|
||||
label: string;
|
||||
items: { to: string; icon: LucideIcon; label: string }[];
|
||||
}
|
||||
|
||||
const navSections: NavSection[] = [
|
||||
{
|
||||
label: "General",
|
||||
items: [{ to: "/asistente", icon: Sparkles, label: "Asistente" }],
|
||||
},
|
||||
{
|
||||
label: "Finanzas",
|
||||
items: [
|
||||
{ to: "/budget", icon: Calculator, label: "Presupuesto" },
|
||||
{ to: "/salarios", icon: Landmark, label: "Salarios" },
|
||||
{ to: "/pensions", icon: PiggyBank, label: "Pensiones" },
|
||||
{ to: "/proyecciones", icon: TrendingUp, label: "Proyecciones" },
|
||||
{ to: "/analytics", icon: BarChart3, label: "Analytics" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Servicios",
|
||||
items: [
|
||||
{ to: "/servicios-municipales", icon: Droplets, label: "Municipalidad" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function SidebarNav({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const isActive = (to: string) =>
|
||||
pathname === to || pathname.startsWith(`${to}/`);
|
||||
|
||||
return (
|
||||
<nav className="flex flex-col gap-0.5 px-3">
|
||||
{navSections.map((section) => (
|
||||
<div key={section.label}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3 pt-4 pb-1">
|
||||
{section.label}
|
||||
</p>
|
||||
{section.items.map(({ to, icon: Icon, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
isActive(to)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export default function Layout() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { privacyMode, togglePrivacy } = usePrivacy();
|
||||
const { logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
const { pathname } = useLocation();
|
||||
const title = titleFor(pathname);
|
||||
// Asistente hosts its own scroll container (the chat), so its route caps
|
||||
// the shell at the viewport; every other page keeps normal body scroll.
|
||||
const fullHeight = pathname === "/asistente";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<header className="border-b border-border backdrop-blur-sm sticky top-0 z-50 bg-background/90">
|
||||
<div className="px-4 sm:px-6 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset className={fullHeight ? "h-svh overflow-hidden" : undefined}>
|
||||
<header className="sticky top-0 z-40 flex h-14 shrink-0 items-center gap-2 border-b border-border bg-background/90 px-4 backdrop-blur-sm">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
{pathname === "/" ? (
|
||||
<BreadcrumbPage>Inicio</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink render={<Link to="/" />}>
|
||||
Inicio
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
{pathname !== "/" && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<CommandMenu />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
title="Open menu"
|
||||
aria-label="Open menu"
|
||||
className="md:hidden"
|
||||
onClick={togglePrivacy}
|
||||
title="Toggle privacy mode"
|
||||
aria-label="Toggle privacy mode"
|
||||
aria-pressed={privacyMode}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span className="text-lg font-bold tracking-tight hidden sm:inline" style={{ fontFamily: "var(--font-heading)" }}>
|
||||
Wealthy<span className="text-primary">Smart</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={togglePrivacy} title="Toggle privacy mode" aria-label="Toggle privacy mode">
|
||||
{privacyMode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={toggleTheme} title="Toggle theme" aria-label="Toggle theme">
|
||||
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
{privacyMode ? (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
className="hidden md:inline-flex"
|
||||
onClick={toggleTheme}
|
||||
title="Toggle theme"
|
||||
aria-label="Toggle theme"
|
||||
aria-pressed={theme === "dark"}
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{theme === "dark" ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex">
|
||||
<aside className="hidden md:flex md:flex-col md:w-56 md:flex-shrink-0 border-r border-border sticky top-[57px] h-[calc(100vh-57px)] overflow-y-auto bg-background">
|
||||
<div className="flex-1">
|
||||
<SidebarNav />
|
||||
</div>
|
||||
<div className="px-3 pb-4">
|
||||
<Separator className="mb-2" />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="p-0 w-64">
|
||||
<SheetHeader className="p-4">
|
||||
<SheetTitle className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
|
||||
<Wallet className="w-4 h-4 text-primary-foreground" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-heading)" }}>
|
||||
Wealthy<span className="text-primary">Smart</span>
|
||||
</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
<div className="flex flex-col h-[calc(100%-65px)]">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<SidebarNav onNavigate={() => setMobileOpen(false)} />
|
||||
</div>
|
||||
<div className="px-3 pb-4">
|
||||
<Separator className="mb-2" />
|
||||
<SheetClose render={<span />}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileOpen(false);
|
||||
void handleLogout();
|
||||
}}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted w-full cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<main className="flex-1 min-w-0 px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
</header>
|
||||
<main className="flex min-h-0 flex-1 flex-col px-4 py-6 sm:px-6 lg:px-8">
|
||||
<div className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
87
frontend/src/components/MeterLabelsDialog.tsx
Normal file
87
frontend/src/components/MeterLabelsDialog.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import api, { type UserSettingsData } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface Props {
|
||||
meterIds: string[];
|
||||
current: Record<string, string>;
|
||||
settingsData: UserSettingsData;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/** Human names for water meters, persisted in UserSettings (review UX-24). */
|
||||
export default function MeterLabelsDialog({
|
||||
meterIds,
|
||||
current,
|
||||
settingsData,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [labels, setLabels] = useState<Record<string, string>>(() => ({
|
||||
...current,
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(labels).filter(([, v]) => v.trim() !== ''),
|
||||
);
|
||||
await api.patch('/settings/', {
|
||||
data: { ...settingsData, meter_labels: cleaned },
|
||||
});
|
||||
toast.success('Etiquetas guardadas');
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('No se pudieron guardar las etiquetas');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Etiquetas de medidores</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
{meterIds.map((id) => (
|
||||
<div key={id} className="space-y-1">
|
||||
<Label className="text-xs font-mono">Medidor {id}</Label>
|
||||
<Input
|
||||
value={labels[id] ?? ''}
|
||||
placeholder="p. ej. Casa, Cochera…"
|
||||
onChange={(e) =>
|
||||
setLabels((prev) => ({ ...prev, [id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { ClipboardPaste, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import api, { type ImportResult } from '@/lib/api';
|
||||
import { formatAmount } from '@/lib/format';
|
||||
import { formatShortDate } from '@/lib/dates';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -39,8 +43,40 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
||||
const { data } = await api.post<ImportResult>('/import/paste', { text, bank, source });
|
||||
setResult(data);
|
||||
if (data.imported > 0) onImported();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} catch {
|
||||
toast.error('No se pudo importar - revisa el formato');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Re-import ONLY the skipped lines with the dedup check disabled, so rows
|
||||
// that imported on the first pass are never duplicated.
|
||||
const handleImportDuplicates = async () => {
|
||||
if (!result || result.skipped.length === 0) return;
|
||||
const lines = text.trim().split('\n');
|
||||
const dupText = result.skipped
|
||||
.map((s) => lines[s.line - 1])
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
setImporting(true);
|
||||
try {
|
||||
const { data } = await api.post<ImportResult>('/import/paste', {
|
||||
text: dupText,
|
||||
bank,
|
||||
source,
|
||||
allow_duplicates: true,
|
||||
});
|
||||
toast.success(`${data.imported} duplicado(s) importados`);
|
||||
setResult({
|
||||
...result,
|
||||
imported: result.imported + data.imported,
|
||||
skipped: [],
|
||||
duplicates: 0,
|
||||
});
|
||||
onImported();
|
||||
} catch {
|
||||
toast.error('No se pudieron importar los duplicados');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
@@ -61,7 +97,7 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<Select value={bank} onValueChange={setBank}>
|
||||
<Select value={bank} onValueChange={(v) => v && setBank(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -74,7 +110,7 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Source</Label>
|
||||
<Select value={source} onValueChange={setSource}>
|
||||
<Select value={source} onValueChange={(v) => v && setSource(v)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -134,6 +170,39 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result.skipped.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-3 space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{result.skipped.length} duplicado(s) omitido(s)
|
||||
</p>
|
||||
<ul className="text-xs max-h-40 overflow-y-auto space-y-1.5">
|
||||
{result.skipped.map((s) => (
|
||||
<li key={`${s.line}-${s.existing_id}`} className="space-y-0.5">
|
||||
<span className="font-mono">
|
||||
{s.merchant} · {formatShortDate(s.date)} ·{' '}
|
||||
{formatAmount(s.amount, s.currency)}
|
||||
</span>
|
||||
<span className="block text-muted-foreground">
|
||||
coincide con #{s.existing_id}: {s.existing_merchant} del{' '}
|
||||
{formatShortDate(s.existing_date)} ({s.existing_source})
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleImportDuplicates}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing
|
||||
? 'Importando...'
|
||||
: 'Importar duplicados de todos modos'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={onClose} className="w-full">
|
||||
Done
|
||||
</Button>
|
||||
|
||||
139
frontend/src/components/PensionAssumptionsDialog.tsx
Normal file
139
frontend/src/components/PensionAssumptionsDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import api, { type UserSettingsData } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export interface FundAssumption {
|
||||
monthlyContribution: number;
|
||||
annualRate: number;
|
||||
}
|
||||
|
||||
export type PensionAssumptions = Partial<
|
||||
Record<'FCL' | 'ROP' | 'VOL', FundAssumption>
|
||||
>;
|
||||
|
||||
interface Props {
|
||||
current: Record<'FCL' | 'ROP' | 'VOL', FundAssumption>;
|
||||
settingsData: UserSettingsData;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const FUND_LABELS: Record<'FCL' | 'ROP' | 'VOL', string> = {
|
||||
FCL: 'FCL — Capitalización Laboral',
|
||||
ROP: 'ROP — Régimen Obligatorio',
|
||||
VOL: 'VOL — Fondo Voluntario',
|
||||
};
|
||||
|
||||
/** Per-fund projection assumptions, persisted server-side in UserSettings so
|
||||
* they survive devices and reloads (review UX-12). */
|
||||
export default function PensionAssumptionsDialog({
|
||||
current,
|
||||
settingsData,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [form, setForm] = useState(() => ({
|
||||
FCL: { ...current.FCL },
|
||||
ROP: { ...current.ROP },
|
||||
VOL: { ...current.VOL },
|
||||
}));
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const setField = (
|
||||
fund: 'FCL' | 'ROP' | 'VOL',
|
||||
field: keyof FundAssumption,
|
||||
value: string,
|
||||
) => {
|
||||
const num = parseFloat(value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
[fund]: { ...prev[fund], [field]: isNaN(num) ? 0 : num },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// The settings PATCH replaces the whole blob — merge over what exists.
|
||||
await api.patch('/settings/', {
|
||||
data: { ...settingsData, pension_assumptions: form },
|
||||
});
|
||||
toast.success('Supuestos de pensión guardados');
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
onClose();
|
||||
} catch {
|
||||
toast.error('No se pudieron guardar los supuestos');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Supuestos de proyección</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground -mt-2">
|
||||
Usados para proyectar el crecimiento de cada fondo. El saldo inicial
|
||||
siempre viene del último estado de cuenta.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{(Object.keys(FUND_LABELS) as Array<'FCL' | 'ROP' | 'VOL'>).map(
|
||||
(fund) => (
|
||||
<div key={fund} className="space-y-2">
|
||||
<p className="text-sm font-medium">{FUND_LABELS[fund]}</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Aporte mensual (₡)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1000"
|
||||
value={form[fund].monthlyContribution}
|
||||
onChange={(e) =>
|
||||
setField(fund, 'monthlyContribution', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Rendimiento anual (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="30"
|
||||
step="0.1"
|
||||
value={form[fund].annualRate}
|
||||
onChange={(e) =>
|
||||
setField(fund, 'annualRate', e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Guardando...' : 'Guardar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user