Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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,
)
130 changes: 130 additions & 0 deletions alembic/versions/d5b8f1c07e42_add_transference_category_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""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:
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