Compare commits

...

7 Commits

Author SHA1 Message Date
Carlos Escalante
881d879ed1 Gate deploys on backend tests and frontend typecheck; record Phase 1
All checks were successful
Deploy to VPS / test (push) Successful in 1m51s
Deploy to VPS / deploy (push) Successful in 1m4s
The new test job runs the 55-test pytest suite and pnpm typecheck
before the deploy job (needs: test) — a red suite now blocks prod.

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

Unblocks gating CI on pnpm typecheck.

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

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

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

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

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

View File

@@ -5,8 +5,29 @@ on:
branches: [main] branches: [main]
jobs: 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
/tmp/test-venv/bin/python -m pytest tests/ -q
- name: Frontend typecheck
run: |
cd frontend
corepack enable
pnpm install --frozen-lockfile
pnpm typecheck
deploy: deploy:
runs-on: self-hosted runs-on: self-hosted
needs: test
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ tech_docs/
# Claude Code local state # Claude Code local state
.claude/ .claude/
.venv/

View File

@@ -17,6 +17,27 @@ The backend refuses to boot without `SECRET_KEY`, `ADMIN_USERNAME`, and
`ADMIN_PASSWORD` (no insecure defaults). Copy `.env.example` to `.env` and fill `ADMIN_PASSWORD` (no insecure defaults). Copy `.env.example` to `.env` and fill
it in; the dev docker-compose reads it. 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 ## Local Docker
```bash ```bash

149
backend/alembic.ini Normal file
View File

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

1
backend/alembic/README Normal file
View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ from sqlmodel import Session, select
from app.auth import get_current_user from app.auth import get_current_user
from app.db import get_session from app.db import get_session
from app.timeutil import utcnow
from app.models.models import ( from app.models.models import (
BalanceOverride, BalanceOverride,
BalanceOverrideCreate, BalanceOverrideCreate,
@@ -259,7 +260,7 @@ def upsert_balance_override(
if existing: if existing:
existing.override_balance = data.override_balance existing.override_balance = data.override_balance
existing.updated_at = datetime.utcnow() existing.updated_at = utcnow()
session.add(existing) session.add(existing)
session.commit() session.commit()
session.refresh(existing) session.refresh(existing)

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ from jose import JWTError, jwt
from sqlmodel import Session, select from sqlmodel import Session, select
from app.config import settings from app.config import settings
from app.timeutil import utcnow
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
@@ -70,7 +71,7 @@ def verify_admin_credentials(username: str, password: str) -> None:
def create_access_token(subject: str) -> str: 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) return jwt.encode({"sub": subject, "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
@@ -102,7 +103,7 @@ def _validate_token(token: str) -> str:
) )
).first() ).first()
if api_token: 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
return f"api:{api_token.name}" return f"api:{api_token.name}"

View File

@@ -1,5 +1,5 @@
from pydantic import model_validator from pydantic import model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -39,8 +39,7 @@ class Settings(BaseSettings):
raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'") raise ValueError("ADMIN_PASSWORD must be set and must not be 'admin'")
return self return self
class Config: model_config = SettingsConfigDict(env_file=".env")
env_file = ".env"
settings = Settings() settings = Settings()

View File

@@ -1,79 +1,48 @@
from sqlalchemy import text from pathlib import Path
from sqlmodel import SQLModel, Session, create_engine
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 from app.config import settings
engine = create_engine(settings.DATABASE_URL) engine = create_engine(settings.DATABASE_URL)
_MIGRATION_LOCK_KEY = 727274 # app-wide advisory lock id, arbitrary constant
def init_db():
SQLModel.metadata.create_all(engine)
def run_migrations(): def _alembic_config() -> Config:
"""Run idempotent schema migrations for columns added after initial create.""" 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: with engine.connect() as conn:
if is_postgres:
conn.execute(text(f"SELECT pg_advisory_lock({_MIGRATION_LOCK_KEY})"))
try: try:
insp = inspect(conn)
if not insp.has_table("alembic_version") and insp.has_table("transaction"):
command.stamp(cfg, "head")
command.upgrade(cfg, "head")
finally:
if is_postgres:
conn.execute( conn.execute(
text( text(f"SELECT pg_advisory_unlock({_MIGRATION_LOCK_KEY})")
"ALTER TABLE transaction ADD COLUMN IF NOT EXISTS deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT false"
) )
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(text("ALTER TYPE currency ADD VALUE IF NOT EXISTS 'EUR'"))
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text("ALTER TYPE transactiontype ADD VALUE IF NOT EXISTS 'SALARY'")
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
CREATE TABLE IF NOT EXISTS savingsaccrual (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL,
month INTEGER NOT NULL,
memp_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
mpat_amount DOUBLE PRECISION NOT NULL DEFAULT 200000,
trigger_transaction_id INTEGER,
applied_at TIMESTAMP NOT NULL DEFAULT NOW(),
notes TEXT,
CONSTRAINT savingsaccrual_year_month_key UNIQUE (year, month)
)
"""
)
)
conn.commit()
except Exception:
conn.rollback()
try:
conn.execute(
text(
"""
INSERT INTO savingsaccrual (year, month, memp_amount, mpat_amount, notes)
VALUES
(2026, 2, 200000, 200000, 'Seeded: historical baseline'),
(2026, 3, 200000, 200000, 'Seeded: historical baseline')
ON CONFLICT (year, month) DO NOTHING
"""
)
)
conn.commit()
except Exception:
conn.rollback()
def get_session(): def get_session():

View File

@@ -20,7 +20,7 @@ from app.auth import (
verify_admin_credentials, verify_admin_credentials,
) )
from app.config import settings 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.seed import seed_db
from app.services.exchange_rate import refresh_rates_periodically from app.services.exchange_rate import refresh_rates_periodically
@@ -65,8 +65,7 @@ def _pair_orphan_tool_calls(messages: list) -> list:
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() run_alembic_upgrade()
run_migrations()
seed_db() seed_db()
rate_refresh_task = asyncio.create_task(refresh_rates_periodically()) rate_refresh_task = asyncio.create_task(refresh_rates_periodically())
try: try:

View File

@@ -3,8 +3,15 @@ from datetime import date, datetime
from typing import Optional from typing import Optional
from sqlalchemy import JSON, Column, UniqueConstraint from sqlalchemy import JSON, Column, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlmodel import Field, Relationship, SQLModel 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")
class RecurringItemType(str, enum.Enum): class RecurringItemType(str, enum.Enum):
INCOME = "INCOME" INCOME = "INCOME"
@@ -106,7 +113,7 @@ class AccountBase(SQLModel):
class Account(AccountBase, table=True): class Account(AccountBase, table=True):
id: Optional[int] = Field(default=None, primary_key=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): class AccountCreate(AccountBase):
@@ -142,12 +149,14 @@ class TransactionBase(SQLModel):
bank: Bank = Bank.BAC bank: Bank = Bank.BAC
notes: Optional[str] = None notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id") category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False) deferred_to_next_cycle: bool = Field(
default=False, sa_column_kwargs={"server_default": "false"}
)
class Transaction(TransactionBase, table=True): class Transaction(TransactionBase, table=True):
id: Optional[int] = Field(default=None, primary_key=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") category: Optional[Category] = Relationship(back_populates="transactions")
@@ -182,7 +191,7 @@ class ExchangeRate(SQLModel, table=True):
date: datetime date: datetime
buy_rate: float buy_rate: float
sell_rate: float sell_rate: float
fetched_at: datetime = Field(default_factory=datetime.utcnow) fetched_at: datetime = Field(default_factory=utcnow)
class ExchangeRateRead(SQLModel): class ExchangeRateRead(SQLModel):
@@ -199,7 +208,7 @@ class APIToken(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str name: str
token_hash: str = Field(index=True) 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 expires_at: Optional[datetime] = None
is_active: bool = True is_active: bool = True
@@ -225,9 +234,9 @@ class UserSettings(SQLModel, table=True):
key: str = Field(index=True, unique=True, default="default") key: str = Field(index=True, unique=True, default="default")
data: dict = Field( data: dict = Field(
default_factory=dict, 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): class UserSettingsRead(SQLModel):
@@ -262,7 +271,7 @@ class RecurringItemBase(SQLModel):
class RecurringItem(RecurringItemBase, table=True): class RecurringItem(RecurringItemBase, table=True):
id: Optional[int] = Field(default=None, primary_key=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() category: Optional[Category] = Relationship()
@@ -298,7 +307,7 @@ class PushSubscription(SQLModel, table=True):
endpoint: str = Field(unique=True) endpoint: str = Field(unique=True)
p256dh: str p256dh: str
auth: str auth: str
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
class PushSubscriptionCreate(SQLModel): class PushSubscriptionCreate(SQLModel):
@@ -331,7 +340,7 @@ class PensionSnapshot(PensionSnapshotBase, table=True):
UniqueConstraint("fund", "period_start", "period_end"), UniqueConstraint("fund", "period_start", "period_end"),
) )
id: Optional[int] = Field(default=None, primary_key=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)
class PensionSnapshotRead(PensionSnapshotBase): class PensionSnapshotRead(PensionSnapshotBase):
@@ -348,8 +357,8 @@ class BalanceOverride(SQLModel, table=True):
year: int year: int
month: int month: int
override_balance: float override_balance: float
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=utcnow)
class BalanceOverrideCreate(SQLModel): class BalanceOverrideCreate(SQLModel):
@@ -379,7 +388,7 @@ class SavingsAccrualBase(SQLModel):
class SavingsAccrual(SavingsAccrualBase, table=True): class SavingsAccrual(SavingsAccrualBase, table=True):
__table_args__ = (UniqueConstraint("year", "month"),) __table_args__ = (UniqueConstraint("year", "month"),)
id: Optional[int] = Field(default=None, primary_key=True) 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): class SavingsAccrualCreate(SavingsAccrualBase):
@@ -423,7 +432,7 @@ class MunicipalReceiptBase(SQLModel):
class MunicipalReceipt(MunicipalReceiptBase, table=True): class MunicipalReceipt(MunicipalReceiptBase, table=True):
__table_args__ = (UniqueConstraint("account", "period"),) __table_args__ = (UniqueConstraint("account", "period"),)
id: Optional[int] = Field(default=None, primary_key=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)
water_readings: list["WaterMeterReading"] = Relationship( water_readings: list["WaterMeterReading"] = Relationship(
back_populates="receipt", back_populates="receipt",
) )
@@ -458,7 +467,7 @@ class WaterMeterReadingBase(SQLModel):
class WaterMeterReading(WaterMeterReadingBase, table=True): class WaterMeterReading(WaterMeterReadingBase, table=True):
__table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),) __table_args__ = (UniqueConstraint("meter_id", "period", "is_historical"),)
id: Optional[int] = Field(default=None, primary_key=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)
receipt: Optional[MunicipalReceipt] = Relationship( receipt: Optional[MunicipalReceipt] = Relationship(
back_populates="water_readings", back_populates="water_readings",
) )

View File

@@ -65,8 +65,14 @@ def get_effective_amount(item: RecurringItem, month: int, year: int) -> float |
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]: def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
"""Return (start, end) for a calendar month.""" """Return (start, end) for a calendar month."""
_validate_month(month)
start = datetime(year, month, 1) start = datetime(year, month, 1)
if month == 12: if month == 12:
end = datetime(year + 1, 1, 1) end = datetime(year + 1, 1, 1)
@@ -76,7 +82,15 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
def get_cycle_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) start = datetime(year, month, 18)
if month == 12: if month == 12:
end = datetime(year + 1, 1, 18) end = datetime(year + 1, 1, 18)

View File

@@ -8,6 +8,7 @@ from sqlalchemy import case
from sqlmodel import Session, col, select from sqlmodel import Session, col, select
from app.config import settings from app.config import settings
from app.timeutil import utcnow
from app.db import engine from app.db import engine
from app.models.models import ExchangeRate from app.models.models import ExchangeRate
@@ -136,7 +137,7 @@ def _fetch_rate_from_apis() -> tuple[float, float] | None:
def _remember(rate: ExchangeRate) -> ExchangeRate: def _remember(rate: ExchangeRate) -> ExchangeRate:
"""Store rate in both TTL cache and permanent last-known holder.""" """Store rate in both TTL cache and permanent last-known holder."""
global _last_known global _last_known
_cache["current"] = (rate, datetime.utcnow()) _cache["current"] = (rate, utcnow())
_last_known = rate _last_known = rate
return rate return rate
@@ -147,11 +148,11 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
# 1. Fresh memory cache (< 1 hour) # 1. Fresh memory cache (< 1 hour)
cached = _cache.get("current") cached = _cache.get("current")
if cached and datetime.utcnow() - cached[1] < CACHE_TTL: if cached and utcnow() - cached[1] < CACHE_TTL:
return cached[0] return cached[0]
# 2. Fresh DB rate (< 1 hour) # 2. Fresh DB rate (< 1 hour)
one_hour_ago = datetime.utcnow() - CACHE_TTL one_hour_ago = utcnow() - CACHE_TTL
db_rate = session.exec( db_rate = session.exec(
select(ExchangeRate) select(ExchangeRate)
.where(ExchangeRate.fetched_at > one_hour_ago) .where(ExchangeRate.fetched_at > one_hour_ago)
@@ -164,7 +165,7 @@ def get_current_rate(session: Session) -> ExchangeRate | None:
result = _fetch_rate_from_apis() result = _fetch_rate_from_apis()
if result is not None: if result is not None:
buy, sell = result 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.add(rate)
session.commit() session.commit()
session.refresh(rate) session.refresh(rate)
@@ -230,7 +231,7 @@ def get_crc_rate(code: str) -> float | None:
return 1.0 return 1.0
cached = _xcrc_cache.get(code) 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] return cached[0]
if code in _COINGECKO_IDS: if code in _COINGECKO_IDS:
@@ -239,7 +240,7 @@ def get_crc_rate(code: str) -> float | None:
rate = _fetch_fiat_crc_mid(code) rate = _fetch_fiat_crc_mid(code)
if rate is not None: if rate is not None:
_xcrc_cache[code] = (rate, datetime.utcnow()) _xcrc_cache[code] = (rate, utcnow())
_last_known_xcrc[code] = rate _last_known_xcrc[code] = rate
return rate return rate
@@ -293,7 +294,7 @@ def _refresh_usd_rate() -> bool:
return False return False
buy, sell = fetched buy, sell = fetched
with Session(engine) as session: 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.add(rate)
session.commit() session.commit()
session.refresh(rate) session.refresh(rate)
@@ -309,7 +310,7 @@ def _refresh_other_rate(code: str) -> bool:
rate = _fetch_fiat_crc_mid(code) rate = _fetch_fiat_crc_mid(code)
if rate is None: if rate is None:
return False return False
_xcrc_cache[code] = (rate, datetime.utcnow()) _xcrc_cache[code] = (rate, utcnow())
_last_known_xcrc[code] = rate _last_known_xcrc[code] = rate
return True return True
@@ -368,7 +369,7 @@ async def refresh_rates_periodically(
def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]: def get_rate_history(session: Session, days: int = 30) -> list[ExchangeRate]:
"""Get historical exchange rates.""" """Get historical exchange rates."""
cutoff = datetime.utcnow() - timedelta(days=days) cutoff = utcnow() - timedelta(days=days)
return list( return list(
session.exec( session.exec(
select(ExchangeRate) select(ExchangeRate)

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

@@ -0,0 +1,14 @@
from datetime import datetime, timezone
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)

View File

@@ -0,0 +1 @@
pytest

View File

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

@@ -0,0 +1,48 @@
"""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://")
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},
)

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
# 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 ⏳ NOT STARTED
## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED
## Phase 4 — UX, Trust & Features ⏳ NOT STARTED

View File

@@ -26,7 +26,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
const [cycles, setCycles] = useState<CycleOption[]>([]); const [cycles, setCycles] = useState<CycleOption[]>([]);
useEffect(() => { 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'; const selectedKey = value ? `${value.year}-${value.month}` : 'all';
@@ -37,7 +37,7 @@ export default function BillingCycleSelector({ value, onChange }: Props) {
<Select <Select
value={selectedKey} value={selectedKey}
onValueChange={(val) => { onValueChange={(val) => {
if (val === 'all') { if (!val || val === 'all') {
onChange(null); onChange(null);
} else { } else {
const [y, m] = val.split('-').map(Number); const [y, m] = val.split('-').map(Number);

View File

@@ -61,7 +61,7 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Bank</Label> <Label>Bank</Label>
<Select value={bank} onValueChange={setBank}> <Select value={bank} onValueChange={(v) => v && setBank(v)}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -74,7 +74,7 @@ export default function PasteImportModal({ onClose, onImported }: Props) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Source</Label> <Label>Source</Label>
<Select value={source} onValueChange={setSource}> <Select value={source} onValueChange={(v) => v && setSource(v)}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -48,8 +48,8 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
api.get('/categories/').then((r) => { api.get<Category[]>('/categories/').then((r) => {
const sorted = [...r.data].sort((a: Category, b: Category) => a.name.localeCompare(b.name)); const sorted = [...r.data].sort((a, b) => a.name.localeCompare(b.name));
setCategories(sorted); setCategories(sorted);
}); });
}, []); }, []);
@@ -144,7 +144,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Currency</Label> <Label>Currency</Label>
<Select value={form.currency} onValueChange={(v) => setForm({ ...form, currency: v })}> <Select value={form.currency} onValueChange={(v) => v && setForm({ ...form, currency: v })}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -166,7 +166,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Select value={form.transaction_type} onValueChange={(v) => setForm({ ...form, transaction_type: v })}> <Select value={form.transaction_type} onValueChange={(v) => v && setForm({ ...form, transaction_type: v })}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -180,7 +180,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
<Label>Category</Label> <Label>Category</Label>
<Select <Select
value={form.category_id ? String(form.category_id) : 'auto'} value={form.category_id ? String(form.category_id) : 'auto'}
onValueChange={(v) => setForm({ ...form, category_id: v === 'auto' ? '' : v })} onValueChange={(v) => setForm({ ...form, category_id: v && v !== 'auto' ? v : '' })}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue> <SelectValue>
@@ -201,7 +201,7 @@ export default function TransactionModal({ transaction, source, onClose, onSaved
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Bank</Label> <Label>Bank</Label>
<Select value={form.bank} onValueChange={(v) => setForm({ ...form, bank: v })}> <Select value={form.bank} onValueChange={(v) => v && setForm({ ...form, bank: v })}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>

View File

@@ -104,6 +104,17 @@ ${colorConfig
const ChartTooltip = RechartsPrimitive.Tooltip const ChartTooltip = RechartsPrimitive.Tooltip
// recharts 3 no longer exposes the tooltip/legend content props on its public
// component types; declare the shape we actually consume.
type ChartPayloadItem = {
dataKey?: string | number
name?: string | number
value?: number | string
color?: string
type?: string
payload?: Record<string, unknown> & { fill?: string }
}
function ChartTooltipContent({ function ChartTooltipContent({
active, active,
payload, payload,
@@ -118,8 +129,23 @@ function ChartTooltipContent({
color, color,
nameKey, nameKey,
labelKey, labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> & }: React.ComponentProps<"div"> & {
React.ComponentProps<"div"> & { active?: boolean
payload?: ChartPayloadItem[]
label?: string | number
labelFormatter?: (
value: string | number,
payload: ChartPayloadItem[]
) => React.ReactNode
labelClassName?: string
formatter?: (
value: number | string,
name: string | number,
item: ChartPayloadItem,
index: number,
payload: unknown
) => React.ReactNode
color?: string
hideLabel?: boolean hideLabel?: boolean
hideIndicator?: boolean hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed" indicator?: "line" | "dot" | "dashed"
@@ -144,7 +170,7 @@ function ChartTooltipContent({
if (labelFormatter) { if (labelFormatter) {
return ( return (
<div className={cn("font-medium", labelClassName)}> <div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)} {labelFormatter(value as string | number, payload)}
</div> </div>
) )
} }
@@ -184,7 +210,7 @@ function ChartTooltipContent({
.map((item, index) => { .map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}` const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key) const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color const indicatorColor = color || item.payload?.fill || item.color
return ( return (
<div <div
@@ -258,8 +284,9 @@ function ChartLegendContent({
payload, payload,
verticalAlign = "bottom", verticalAlign = "bottom",
nameKey, nameKey,
}: React.ComponentProps<"div"> & }: React.ComponentProps<"div"> & {
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & { payload?: ChartPayloadItem[]
verticalAlign?: "top" | "middle" | "bottom"
hideIcon?: boolean hideIcon?: boolean
nameKey?: string nameKey?: string
}) { }) {

View File

@@ -1,10 +1,11 @@
import api from './api'; import api from './api';
function urlBase64ToUint8Array(base64String: string): Uint8Array { function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64); const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length); // Explicit ArrayBuffer backing so the result satisfies BufferSource.
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length));
for (let i = 0; i < rawData.length; ++i) { for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i); outputArray[i] = rawData.charCodeAt(i);
} }