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>
This commit is contained in:
Carlos Escalante
2026-06-09 20:31:55 -06:00
parent c725c1487d
commit 9a25c4baab
10 changed files with 519 additions and 73 deletions

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ tech_docs/
# Claude Code local state
.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
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

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

@@ -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():

View File

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

View File

@@ -1,12 +1,17 @@
import enum
from datetime import date, datetime
from app.timeutil import utcnow
from typing import Optional
from sqlalchemy import JSON, Column, 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")
class RecurringItemType(str, enum.Enum):
INCOME = "INCOME"
@@ -144,7 +149,9 @@ class TransactionBase(SQLModel):
bank: Bank = Bank.BAC
notes: Optional[str] = None
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
deferred_to_next_cycle: bool = Field(default=False)
deferred_to_next_cycle: bool = Field(
default=False, sa_column_kwargs={"server_default": "false"}
)
class Transaction(TransactionBase, table=True):
@@ -227,7 +234,7 @@ 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=utcnow)