Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The reference deployment is live at https://finitum.app (Lightsail VPS, deployed
- **Backend**: FastAPI (`src/api/server.py`), PostgreSQL + Alembic (`src/db/`, `alembic/`), Redis (dedupe + Gmail-confirmation capture).
- **Ingestion**: `src/email_service/ingest.py` -- resolves user by `ingest_token` from the `u-<token>@<INGEST_DOMAIN>` recipient, HMAC-verifies `X-Finitum-Signature` (`INGEST_WEBHOOK_SECRET`), dedupes on `Message-ID`, auto-captures Gmail forwarding-confirmation links/codes into Redis for one-click setup. Worker lives in `infra/email-worker/`.
- **Parsers**: fully data-driven. All bank logic lives in `src/parsers/regex.json` (per-bank: `remitents` sender allowlist, `subject` classification patterns, `body` extraction regexes). Engine: `src/parsers/parser.py` (`EmailParser`, `BankPatterns.from_json`). The bank is a per-user setting (`User.bank`); there is no content-based bank auto-detection. `GET /banks` derives the bank list from `regex.json` keys.
- **Categories**: keyword matching in `src/parsers/base.py` from root `categories.json`; slugs/labels registry in `src/category_catalog.py`; Spanish overrides in `category_labels.es.json`; per-user custom categories via `POST /categories` + `src/db/categories.py`.
- **Categories**: keyword matching in `src/parsers/base.py` from root `categories.json`; slugs/labels registry in `src/category_catalog.py`; Spanish overrides in `category_labels.es.json`. `categories.json` only seeds the shared catalog -- at runtime everything (including transference categorization) resolves through `src/db/categories.py` against the `categories` / `category_patterns` / `category_overrides` tables. Builtin categories are global rows, so a user editing one gets a private `CategoryOverride` (rename) plus forked `CategoryPattern` rows; `POST /categories/{id}/reset` drops both. `POST /categories/recategorize` re-applies keywords to stored expenses and transferences, leaving unmatched rows on their current category. UI lives at `web/app/routes/categories.tsx`.
- **Frontend**: React Router v7 + TypeScript + Tailwind + Bun in `web/` (file routes under `web/app/routes/`). `profile.tsx` holds the up-to-date forwarding-setup UX; `home.tsx` and `guide.tsx` still carry stale OAuth-era messaging.

## Adding a bank (the core contributor flow)
Expand Down
111 changes: 111 additions & 0 deletions alembic/versions/c4d7e1b9a250_add_user_category_overrides.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""add per-user category overrides and pattern ownership

Builtin categories are shared rows, so a user cannot edit them in place. This
migration adds:

* ``category_patterns.user_id`` -- NULL for the shared catalog rows, set when
the keyword belongs to a user (their own category, or their fork of a
builtin one).
* ``category_overrides`` -- a user's rename of a builtin category and the flag
marking that they took over its keyword set.

Revision ID: c4d7e1b9a250
Revises: b3f1c2a4d5e6
Create Date: 2026-07-25

"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "c4d7e1b9a250"
down_revision: str | Sequence[str] | None = "b3f1c2a4d5e6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

LEGACY_PATTERN_INDEX = "ix_category_patterns_category_id_pattern"
PATTERN_INDEX = "ix_category_patterns_category_id_user_id_pattern"


def _index_names(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
return {index["name"] for index in inspector.get_indexes(table_name)}


def upgrade() -> None:
"""Add pattern ownership and the per-user override table."""
op.add_column("category_patterns", sa.Column("user_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_category_patterns_user_id_users",
"category_patterns",
"users",
["user_id"],
["id"],
)
op.create_index("ix_category_patterns_user_id", "category_patterns", ["user_id"], unique=False)

# Keywords of a user-owned category belong to that user.
op.execute(
sa.text(
"""
UPDATE category_patterns
SET user_id = (
SELECT categories.user_id
FROM categories
WHERE categories.id = category_patterns.category_id
)
"""
)
)

# A forked keyword set repeats builtin keywords for the same category, so
# uniqueness has to include the owner.
if LEGACY_PATTERN_INDEX in _index_names("category_patterns"):
op.drop_index(LEGACY_PATTERN_INDEX, table_name="category_patterns")
op.create_index(
PATTERN_INDEX,
"category_patterns",
["category_id", "user_id", "pattern"],
unique=True,
)

op.create_table(
"category_overrides",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("category_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=True),
sa.Column("patterns_overridden", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
sa.ForeignKeyConstraint(["category_id"], ["categories.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "category_id", name="uq_category_overrides_user_category"),
)
op.create_index("ix_category_overrides_user_id", "category_overrides", ["user_id"], unique=False)
op.create_index("ix_category_overrides_category_id", "category_overrides", ["category_id"], unique=False)


def downgrade() -> None:
"""Drop overrides and pattern ownership, keeping only the shared catalog."""
op.drop_index("ix_category_overrides_category_id", table_name="category_overrides")
op.drop_index("ix_category_overrides_user_id", table_name="category_overrides")
op.drop_table("category_overrides")

# Forked keywords have no home in the old schema.
op.execute(sa.text("DELETE FROM category_patterns WHERE user_id IS NOT NULL"))

if PATTERN_INDEX in _index_names("category_patterns"):
op.drop_index(PATTERN_INDEX, table_name="category_patterns")
op.drop_index("ix_category_patterns_user_id", table_name="category_patterns")
op.drop_constraint("fk_category_patterns_user_id_users", "category_patterns", type_="foreignkey")
op.drop_column("category_patterns", "user_id")
op.create_index(
LEGACY_PATTERN_INDEX,
"category_patterns",
["category_id", "pattern"],
unique=True,
)
132 changes: 132 additions & 0 deletions alembic/versions/d5b8f1c07e42_add_transference_category_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""point transferences at the categories catalog

Transferences stored their category as a builtin enum slug, so a user's own
category could never apply to them. This mirrors what expenses already do:
a ``category_id`` FK, backfilled from the legacy enum column.

Revision ID: d5b8f1c07e42
Revises: c4d7e1b9a250
Create Date: 2026-07-25

"""

from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql


# revision identifiers, used by Alembic.
revision: str = "d5b8f1c07e42"
down_revision: str | Sequence[str] | None = "c4d7e1b9a250"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

GENERAL_CATEGORY_SLUG = "general"


def _legacy_category_type() -> sa.types.TypeEngine:
"""The pre-existing ``expensecategory`` enum, which is never dropped."""
if op.get_bind().dialect.name == "postgresql":
return postgresql.ENUM(name="expensecategory", create_type=False)
return sa.String()


def _column_names(table_name: str) -> set[str]:
inspector = sa.inspect(op.get_bind())
return {column["name"] for column in inspector.get_columns(table_name)}


def _global_categories_by_slug() -> dict[str, int]:
connection = op.get_bind()
metadata = sa.MetaData()
categories = sa.Table("categories", metadata, autoload_with=connection)
return {
row.slug: row.id
for row in connection.execute(
sa.select(categories.c.id, categories.c.slug).where(categories.c.user_id.is_(None))
)
}


def _normalize_legacy_category(value: object | None) -> str:
if value is None:
return GENERAL_CATEGORY_SLUG
normalized = str(value).split(".")[-1].strip().lower()
return normalized or GENERAL_CATEGORY_SLUG


def _backfill_transference_categories() -> None:
connection = op.get_bind()
metadata = sa.MetaData()
transferences = sa.Table("transferences", metadata, autoload_with=connection)

categories_by_slug = _global_categories_by_slug()
default_category_id = categories_by_slug[GENERAL_CATEGORY_SLUG]
has_legacy_column = "category" in _column_names("transferences")

if has_legacy_column:
rows = connection.execute(
sa.select(transferences.c.id, transferences.c.category, transferences.c.category_id)
).all()
else:
rows = connection.execute(sa.select(transferences.c.id, transferences.c.category_id)).all()

for row in rows:
if row.category_id is not None:
continue
slug = _normalize_legacy_category(getattr(row, "category", None))
category_id = categories_by_slug.get(slug, default_category_id)
connection.execute(
transferences.update().where(transferences.c.id == row.id).values(category_id=category_id)
)


def upgrade() -> None:
"""Add transferences.category_id, backfilled from the legacy enum."""
if "category_id" not in _column_names("transferences"):
op.add_column("transferences", sa.Column("category_id", sa.Integer(), nullable=True))

_backfill_transference_categories()

with op.batch_alter_table("transferences") as batch_op:
batch_op.create_foreign_key(
"fk_transferences_category_id_categories", "categories", ["category_id"], ["id"]
)
batch_op.create_index("ix_transferences_category_id", ["category_id"], unique=False)
batch_op.alter_column("category_id", existing_type=sa.Integer(), nullable=False)
if "category" in _column_names("transferences"):
batch_op.drop_column("category")


def downgrade() -> None:
"""Restore the legacy enum column from the linked category slug."""
if "category" not in _column_names("transferences"):
op.add_column("transferences", sa.Column("category", _legacy_category_type(), nullable=True))
Comment thread
richardhapb marked this conversation as resolved.

connection = op.get_bind()
metadata = sa.MetaData()
transferences = sa.Table("transferences", metadata, autoload_with=connection)
categories = sa.Table("categories", metadata, autoload_with=connection)

slugs_by_id = {row.id: row.slug for row in connection.execute(sa.select(categories.c.id, categories.c.slug))}
global_slugs = {
row.slug
for row in connection.execute(sa.select(categories.c.slug).where(categories.c.user_id.is_(None)))
}
for row in connection.execute(sa.select(transferences.c.id, transferences.c.category_id)):
# Custom categories have no enum member, so they fall back to general.
slug = slugs_by_id.get(row.category_id, GENERAL_CATEGORY_SLUG)
if slug not in global_slugs:
slug = GENERAL_CATEGORY_SLUG
connection.execute(
transferences.update().where(transferences.c.id == row.id).values(category=slug.upper())
)

with op.batch_alter_table("transferences") as batch_op:
# Every row was just backfilled, so the original NOT NULL can come back.
batch_op.alter_column("category", existing_type=_legacy_category_type(), nullable=False)
batch_op.drop_index("ix_transferences_category_id")
batch_op.drop_constraint("fk_transferences_category_id_categories", type_="foreignkey")
batch_op.drop_column("category_id")
Loading
Loading