diff --git a/README.md b/README.md index 9321c68..1c9a335 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,24 @@ Licensed under the [GNU Affero General Public License v3.0](LICENSE). --- +## Screenshots + +> Or, try it live [here](https://spendhound.dodecahedrons.com). + +| Dashboard | Expense list | +|---|---| +| ![Monthly analytics overview](docs/screenshots/dashboard.png) | ![Expense list with filters](docs/screenshots/expenses.png) | + +| Receipt extraction | AI chat | +|---|---| +| ![AI receipt parsing preview](docs/screenshots/receipt-extraction.png) | ![Financial chat grounded on 90 days of expenses](docs/screenshots/chat.png) | + +| Budgets | Categories and rules | +|---|---| +| ![Budget vs actual tracking](docs/screenshots/budgets.png) | ![Category and merchant rule management](docs/screenshots/categories.png) | + +--- + ## Features - Google OAuth with admin approval gate diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py index 4f57ea2..d3ffdcd 100644 --- a/backend/alembic/versions/0001_initial_schema.py +++ b/backend/alembic/versions/0001_initial_schema.py @@ -168,9 +168,7 @@ def upgrade() -> None: "ALTER TABLE job_description_embeddings " "ALTER COLUMN embedding TYPE vector(1536) USING embedding::vector" ) - op.create_index( - "ix_jde_application_id", "job_description_embeddings", ["application_id"] - ) + op.create_index("ix_jde_application_id", "job_description_embeddings", ["application_id"]) # IVFFlat index for approximate nearest-neighbor search op.execute( "CREATE INDEX ix_jde_embedding_ivfflat " diff --git a/backend/alembic/versions/0004_add_rejection_reason_and_user_cv.py b/backend/alembic/versions/0004_add_rejection_reason_and_user_cv.py index f19e300..ac9f3c3 100644 --- a/backend/alembic/versions/0004_add_rejection_reason_and_user_cv.py +++ b/backend/alembic/versions/0004_add_rejection_reason_and_user_cv.py @@ -4,6 +4,7 @@ Revises: 0003 Create Date: 2026-04-10 """ + import sqlalchemy as sa from alembic import op diff --git a/backend/alembic/versions/0007_spendhound_mvp.py b/backend/alembic/versions/0007_spendhound_mvp.py index 90201c3..82b7255 100644 --- a/backend/alembic/versions/0007_spendhound_mvp.py +++ b/backend/alembic/versions/0007_spendhound_mvp.py @@ -44,7 +44,9 @@ def upgrade() -> None: chat_message_columns = {column["name"] for column in inspector.get_columns("chat_messages")} if "session_id" in chat_message_columns: - chat_message_indexes = {index["name"] for index in inspector.get_indexes("chat_messages")} + chat_message_indexes = { + index["name"] for index in inspector.get_indexes("chat_messages") + } if "ix_chat_messages_session_id" in chat_message_indexes: logger.info("Dropping legacy index ix_chat_messages_session_id") op.drop_index("ix_chat_messages_session_id", table_name="chat_messages") @@ -76,14 +78,29 @@ def upgrade() -> None: op.create_table( "categories", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("name", sa.String(80), nullable=False), sa.Column("color", sa.String(20), nullable=False, server_default="#60a5fa"), sa.Column("icon", sa.String(32), nullable=True), sa.Column("description", sa.Text(), nullable=True), sa.Column("is_system", sa.Boolean(), nullable=False, server_default="false"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.UniqueConstraint("user_id", "name", name="uq_categories_user_name"), ) op.create_index("ix_categories_user_id", "categories", ["user_id"]) @@ -92,15 +109,35 @@ def upgrade() -> None: op.create_table( "merchant_rules", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "category_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("categories.id", ondelete="SET NULL"), + nullable=True, + ), sa.Column("merchant_pattern", sa.String(255), nullable=False), sa.Column("pattern_type", sa.String(20), nullable=False, server_default="contains"), sa.Column("priority", sa.Integer(), nullable=False, server_default="100"), sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), sa.Column("notes", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), ) op.create_index("ix_merchant_rules_user_id", "merchant_rules", ["user_id"]) op.create_index("ix_merchant_rules_category_id", "merchant_rules", ["category_id"]) @@ -109,7 +146,12 @@ def upgrade() -> None: op.create_table( "receipts", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("original_filename", sa.String(255), nullable=False), sa.Column("stored_filename", sa.String(255), nullable=False), sa.Column("content_type", sa.String(120), nullable=True), @@ -118,11 +160,23 @@ def upgrade() -> None: sa.Column("ocr_text", sa.Text(), nullable=True), sa.Column("preview_data", postgresql.JSONB(astext_type=sa.Text()), nullable=True), sa.Column("extraction_confidence", sa.Float(), nullable=True), - sa.Column("extraction_status", sa.String(30), nullable=False, server_default="uploaded"), + sa.Column( + "extraction_status", sa.String(30), nullable=False, server_default="uploaded" + ), sa.Column("needs_review", sa.Boolean(), nullable=False, server_default="true"), sa.Column("review_notes", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), sa.Column("finalized_at", sa.DateTime(timezone=True), nullable=True), ) op.create_index("ix_receipts_user_id", "receipts", ["user_id"]) @@ -131,9 +185,24 @@ def upgrade() -> None: op.create_table( "expenses", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), - sa.Column("receipt_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("receipts.id", ondelete="SET NULL"), nullable=True), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "category_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("categories.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "receipt_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("receipts.id", ondelete="SET NULL"), + nullable=True, + ), sa.Column("merchant", sa.String(255), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("amount", sa.Numeric(12, 2), nullable=False), @@ -145,8 +214,18 @@ def upgrade() -> None: sa.Column("notes", sa.Text(), nullable=True), sa.Column("recurring_group", sa.String(255), nullable=True), sa.Column("is_recurring", sa.Boolean(), nullable=False, server_default="false"), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), ) op.create_index("ix_expenses_user_id", "expenses", ["user_id"]) op.create_index("ix_expenses_category_id", "expenses", ["category_id"]) @@ -159,16 +238,36 @@ def upgrade() -> None: op.create_table( "budgets", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("category_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("categories.id", ondelete="SET NULL"), nullable=True), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "category_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("categories.id", ondelete="SET NULL"), + nullable=True, + ), sa.Column("name", sa.String(120), nullable=False), sa.Column("amount", sa.Numeric(12, 2), nullable=False), sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"), sa.Column("period", sa.String(20), nullable=False, server_default="monthly"), sa.Column("month_start", sa.Date(), nullable=False), sa.Column("notes", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), ) op.create_index("ix_budgets_user_id", "budgets", ["user_id"]) op.create_index("ix_budgets_category_id", "budgets", ["category_id"]) diff --git a/backend/alembic/versions/0008_add_expense_items_and_statement_imports.py b/backend/alembic/versions/0008_add_expense_items_and_statement_imports.py index 2f8b8ff..a9c3d93 100644 --- a/backend/alembic/versions/0008_add_expense_items_and_statement_imports.py +++ b/backend/alembic/versions/0008_add_expense_items_and_statement_imports.py @@ -22,21 +22,41 @@ def upgrade() -> None: receipt_columns = {column["name"] for column in inspector.get_columns("receipts")} if "document_kind" not in receipt_columns: - op.add_column("receipts", sa.Column("document_kind", sa.String(length=20), nullable=False, server_default="receipt")) + op.add_column( + "receipts", + sa.Column( + "document_kind", sa.String(length=20), nullable=False, server_default="receipt" + ), + ) if not inspector.has_table("expense_items"): op.create_table( "expense_items", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("expense_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "expense_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("expenses.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("description", sa.String(length=300), nullable=False), sa.Column("quantity", sa.Float(), nullable=True), sa.Column("unit_price", sa.Numeric(12, 2), nullable=True), sa.Column("total_price", sa.Numeric(12, 2), nullable=True), sa.Column("subcategory", sa.String(length=120), nullable=True), sa.Column("subcategory_confidence", sa.Float(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), ) op.create_index("ix_expense_items_expense_id", "expense_items", ["expense_id"]) op.create_index("ix_expense_items_subcategory", "expense_items", ["subcategory"]) diff --git a/backend/alembic/versions/0009_add_transaction_types.py b/backend/alembic/versions/0009_add_transaction_types.py index 7446ba7..8451afa 100644 --- a/backend/alembic/versions/0009_add_transaction_types.py +++ b/backend/alembic/versions/0009_add_transaction_types.py @@ -22,14 +22,24 @@ def upgrade() -> None: category_columns = {column["name"] for column in inspector.get_columns("categories")} category_indexes = {index["name"] for index in inspector.get_indexes("categories")} if "transaction_type" not in category_columns: - op.add_column("categories", sa.Column("transaction_type", sa.String(length=20), nullable=False, server_default="debit")) + op.add_column( + "categories", + sa.Column( + "transaction_type", sa.String(length=20), nullable=False, server_default="debit" + ), + ) if "ix_categories_transaction_type" not in category_indexes: op.create_index("ix_categories_transaction_type", "categories", ["transaction_type"]) expense_columns = {column["name"] for column in inspector.get_columns("expenses")} expense_indexes = {index["name"] for index in inspector.get_indexes("expenses")} if "transaction_type" not in expense_columns: - op.add_column("expenses", sa.Column("transaction_type", sa.String(length=20), nullable=False, server_default="debit")) + op.add_column( + "expenses", + sa.Column( + "transaction_type", sa.String(length=20), nullable=False, server_default="debit" + ), + ) if "ix_expenses_transaction_type" not in expense_indexes: op.create_index("ix_expenses_transaction_type", "expenses", ["transaction_type"]) diff --git a/backend/alembic/versions/0010_add_expense_cadence_and_major_purchase.py b/backend/alembic/versions/0010_add_expense_cadence_and_major_purchase.py index c2865bc..5e286fc 100644 --- a/backend/alembic/versions/0010_add_expense_cadence_and_major_purchase.py +++ b/backend/alembic/versions/0010_add_expense_cadence_and_major_purchase.py @@ -22,11 +22,19 @@ def upgrade() -> None: expense_indexes = {index["name"] for index in inspector.get_indexes("expenses")} if "cadence" not in expense_columns: - op.add_column("expenses", sa.Column("cadence", sa.String(length=20), nullable=False, server_default="one_time")) + op.add_column( + "expenses", + sa.Column("cadence", sa.String(length=20), nullable=False, server_default="one_time"), + ) if "cadence_override" not in expense_columns: - op.add_column("expenses", sa.Column("cadence_override", sa.String(length=20), nullable=True)) + op.add_column( + "expenses", sa.Column("cadence_override", sa.String(length=20), nullable=True) + ) if "is_major_purchase" not in expense_columns: - op.add_column("expenses", sa.Column("is_major_purchase", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column( + "expenses", + sa.Column("is_major_purchase", sa.Boolean(), nullable=False, server_default=sa.false()), + ) if "ix_expenses_cadence" not in expense_indexes: op.create_index("ix_expenses_cadence", "expenses", ["cadence"]) diff --git a/backend/alembic/versions/0011_add_expense_chat.py b/backend/alembic/versions/0011_add_expense_chat.py index 88a2c58..0a559ed 100644 --- a/backend/alembic/versions/0011_add_expense_chat.py +++ b/backend/alembic/versions/0011_add_expense_chat.py @@ -46,14 +46,29 @@ def upgrade() -> None: op.create_table( "chat_sessions", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("title", sa.String(length=255), nullable=False, server_default="New Chat"), sa.Column("summary", sa.Text(), nullable=True), sa.Column("token_count", sa.Integer(), nullable=False, server_default="0"), sa.Column("max_tokens", sa.Integer(), nullable=False, server_default="4096"), sa.Column("last_message_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), ) op.create_index("ix_chat_sessions_user_id", "chat_sessions", ["user_id"]) op.create_index("ix_chat_sessions_last_message_at", "chat_sessions", ["last_message_at"]) @@ -61,8 +76,18 @@ def upgrade() -> None: op.create_table( "chat_messages", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("session_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "session_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("chat_sessions.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("role", sa.String(length=20), nullable=False), sa.Column("content", sa.Text(), nullable=False), sa.Column("client_id", sa.String(length=120), nullable=False), @@ -71,8 +96,18 @@ def upgrade() -> None: sa.Column("model", sa.String(length=255), nullable=True), sa.Column("token_count", sa.Integer(), nullable=True), sa.Column("metadata", sa.JSON(), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), ) op.create_index("ix_chat_messages_user_id", "chat_messages", ["user_id"]) op.create_index("ix_chat_messages_session_id", "chat_messages", ["session_id"]) @@ -84,8 +119,16 @@ def upgrade() -> None: def downgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) - message_indexes = {index["name"] for index in inspector.get_indexes("chat_messages")} if inspector.has_table("chat_messages") else set() - session_indexes = {index["name"] for index in inspector.get_indexes("chat_sessions")} if inspector.has_table("chat_sessions") else set() + message_indexes = ( + {index["name"] for index in inspector.get_indexes("chat_messages")} + if inspector.has_table("chat_messages") + else set() + ) + session_indexes = ( + {index["name"] for index in inspector.get_indexes("chat_sessions")} + if inspector.has_table("chat_sessions") + else set() + ) for index_name in [ "ix_chat_messages_parent_client_id", diff --git a/backend/alembic/versions/0012_add_monthly_report_deliveries.py b/backend/alembic/versions/0012_add_monthly_report_deliveries.py index cd107a0..200199f 100644 --- a/backend/alembic/versions/0012_add_monthly_report_deliveries.py +++ b/backend/alembic/versions/0012_add_monthly_report_deliveries.py @@ -20,7 +20,12 @@ def upgrade() -> None: op.create_table( "monthly_report_deliveries", sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), - sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "user_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("report_month", sa.Date(), nullable=False), sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"), sa.Column("attempted_at", sa.DateTime(timezone=True), nullable=True), @@ -28,17 +33,35 @@ def upgrade() -> None: sa.Column("resend_email_id", sa.String(length=255), nullable=True), sa.Column("pdf_source_url", sa.String(length=1000), nullable=True), sa.Column("error_message", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")), - sa.UniqueConstraint("user_id", "report_month", name="uq_monthly_report_deliveries_user_month"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.UniqueConstraint( + "user_id", "report_month", name="uq_monthly_report_deliveries_user_month" + ), + ) + op.create_index( + "ix_monthly_report_deliveries_user_id", "monthly_report_deliveries", ["user_id"] + ) + op.create_index( + "ix_monthly_report_deliveries_report_month", "monthly_report_deliveries", ["report_month"] ) - op.create_index("ix_monthly_report_deliveries_user_id", "monthly_report_deliveries", ["user_id"]) - op.create_index("ix_monthly_report_deliveries_report_month", "monthly_report_deliveries", ["report_month"]) op.create_index("ix_monthly_report_deliveries_status", "monthly_report_deliveries", ["status"]) def downgrade() -> None: op.drop_index("ix_monthly_report_deliveries_status", table_name="monthly_report_deliveries") - op.drop_index("ix_monthly_report_deliveries_report_month", table_name="monthly_report_deliveries") + op.drop_index( + "ix_monthly_report_deliveries_report_month", table_name="monthly_report_deliveries" + ) op.drop_index("ix_monthly_report_deliveries_user_id", table_name="monthly_report_deliveries") op.drop_table("monthly_report_deliveries") diff --git a/backend/alembic/versions/0013_add_user_automatic_monthly_reports.py b/backend/alembic/versions/0013_add_user_automatic_monthly_reports.py index c2d2919..9cb5a78 100644 --- a/backend/alembic/versions/0013_add_user_automatic_monthly_reports.py +++ b/backend/alembic/versions/0013_add_user_automatic_monthly_reports.py @@ -18,7 +18,9 @@ def upgrade() -> None: op.add_column( "users", - sa.Column("automatic_monthly_reports", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column( + "automatic_monthly_reports", sa.Boolean(), nullable=False, server_default=sa.true() + ), ) diff --git a/backend/alembic/versions/0014_add_recurring_generation_fields.py b/backend/alembic/versions/0014_add_recurring_generation_fields.py index 5eafe86..47b67ed 100644 --- a/backend/alembic/versions/0014_add_recurring_generation_fields.py +++ b/backend/alembic/versions/0014_add_recurring_generation_fields.py @@ -36,8 +36,15 @@ def upgrade() -> None: "expenses", sa.Column("generated_for_month", sa.Date(), nullable=True), ) - op.create_index(op.f("ix_expenses_generated_for_month"), "expenses", ["generated_for_month"], unique=False) - op.create_index(op.f("ix_expenses_recurring_source_expense_id"), "expenses", ["recurring_source_expense_id"], unique=False) + op.create_index( + op.f("ix_expenses_generated_for_month"), "expenses", ["generated_for_month"], unique=False + ) + op.create_index( + op.f("ix_expenses_recurring_source_expense_id"), + "expenses", + ["recurring_source_expense_id"], + unique=False, + ) op.create_foreign_key( "fk_expenses_recurring_source_expense_id_expenses", "expenses", @@ -49,7 +56,9 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_constraint("fk_expenses_recurring_source_expense_id_expenses", "expenses", type_="foreignkey") + op.drop_constraint( + "fk_expenses_recurring_source_expense_id_expenses", "expenses", type_="foreignkey" + ) op.drop_index(op.f("ix_expenses_recurring_source_expense_id"), table_name="expenses") op.drop_index(op.f("ix_expenses_generated_for_month"), table_name="expenses") op.drop_column("expenses", "generated_for_month") diff --git a/backend/alembic/versions/0015_add_item_keyword_rules.py b/backend/alembic/versions/0015_add_item_keyword_rules.py index 3a2e232..ee0d1fd 100644 --- a/backend/alembic/versions/0015_add_item_keyword_rules.py +++ b/backend/alembic/versions/0015_add_item_keyword_rules.py @@ -26,12 +26,18 @@ def upgrade() -> None: sa.Column("priority", sa.Integer(), nullable=False, server_default="100"), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("notes", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column( + "created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), ) - op.create_index(op.f("ix_item_keyword_rules_user_id"), "item_keyword_rules", ["user_id"], unique=False) + op.create_index( + op.f("ix_item_keyword_rules_user_id"), "item_keyword_rules", ["user_id"], unique=False + ) def downgrade() -> None: diff --git a/backend/alembic/versions/0021_add_partners_and_ledgers.py b/backend/alembic/versions/0021_add_partners_and_ledgers.py index 642f1f0..4665c5e 100644 --- a/backend/alembic/versions/0021_add_partners_and_ledgers.py +++ b/backend/alembic/versions/0021_add_partners_and_ledgers.py @@ -24,37 +24,80 @@ def upgrade() -> None: sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True), sa.Column("name", sa.String(255), nullable=False), sa.Column("type", sa.String(20), nullable=False, server_default="personal"), - sa.Column("created_by", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "created_by", + sa.Uuid(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), ) op.create_index("ix_ledgers_created_by", "ledgers", ["created_by"]) op.create_table( "ledger_memberships", sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True), - sa.Column("ledger_id", sa.Uuid(as_uuid=True), sa.ForeignKey("ledgers.id", ondelete="CASCADE"), nullable=False), - sa.Column("user_id", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "ledger_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("ledgers.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "user_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("role", sa.String(20), nullable=False, server_default="owner"), - sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), sa.UniqueConstraint("ledger_id", "user_id", name="uq_ledger_membership"), ) op.create_index("ix_ledger_memberships_ledger_id", "ledger_memberships", ["ledger_id"]) op.create_index("ix_ledger_memberships_user_id", "ledger_memberships", ["user_id"]) - op.add_column("expenses", sa.Column("ledger_id", sa.Uuid(as_uuid=True), sa.ForeignKey("ledgers.id", ondelete="SET NULL"), nullable=True)) + op.add_column( + "expenses", + sa.Column( + "ledger_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("ledgers.id", ondelete="SET NULL"), + nullable=True, + ), + ) op.create_index("ix_expenses_ledger_id", "expenses", ["ledger_id"]) op.create_table( "partner_requests", sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True), - sa.Column("requester_id", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column( + "requester_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ), sa.Column("recipient_email", sa.String(255), nullable=False), - sa.Column("recipient_id", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column( + "recipient_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), sa.Column("status", sa.String(20), nullable=False, server_default="pending"), sa.Column("token", sa.Text, nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), ) op.create_index("ix_partner_requests_requester_id", "partner_requests", ["requester_id"]) op.create_index("ix_partner_requests_recipient_email", "partner_requests", ["recipient_email"]) @@ -63,12 +106,29 @@ def upgrade() -> None: op.create_table( "ledger_audit_logs", sa.Column("id", sa.Uuid(as_uuid=True), primary_key=True), - sa.Column("ledger_id", sa.Uuid(as_uuid=True), sa.ForeignKey("ledgers.id", ondelete="CASCADE"), nullable=False), - sa.Column("expense_id", sa.Uuid(as_uuid=True), sa.ForeignKey("expenses.id", ondelete="SET NULL"), nullable=True), - sa.Column("user_id", sa.Uuid(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column( + "ledger_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("ledgers.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "expense_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("expenses.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column( + "user_id", + sa.Uuid(as_uuid=True), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), sa.Column("action", sa.String(50), nullable=False), sa.Column("changes", sa.Text, nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), ) op.create_index("ix_ledger_audit_logs_ledger_id", "ledger_audit_logs", ["ledger_id"]) op.create_index("ix_ledger_audit_logs_expense_id", "ledger_audit_logs", ["expense_id"]) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 8c787cc..916f48a 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -37,7 +37,11 @@ async def get_admin_user(current_user: User = Depends(get_current_user)) -> User def create_action_token(user_id: uuid.UUID, action: str) -> str: expire = datetime.now(UTC) + timedelta(hours=_ACTION_TOKEN_EXPIRY_HOURS) - return jwt.encode({"sub": str(user_id), "action": action, "exp": expire}, settings.jwt_secret, algorithm=settings.jwt_algorithm) + return jwt.encode( + {"sub": str(user_id), "action": action, "exp": expire}, + settings.jwt_secret, + algorithm=settings.jwt_algorithm, + ) def _decode_action_token(token: str, expected_action: str) -> str: @@ -85,10 +89,16 @@ async def approve_user(token: str = Query(...), db: AsyncSession = Depends(get_d if user is None: return _html_page("User Not Found", "No matching user found.", "#ef4444") if user.status == "approved": - return _html_page("Already Approved", f"{user.email} already has access to SpendHound.", "#22c55e") + return _html_page( + "Already Approved", f"{user.email} already has access to SpendHound.", "#22c55e" + ) user.status = "approved" await db.commit() - return _html_page("Access Granted", f"{user.email} has been approved and can now sign in to SpendHound.", "#22c55e") + return _html_page( + "Access Granted", + f"{user.email} has been approved and can now sign in to SpendHound.", + "#22c55e", + ) @router.get("/reject", response_class=HTMLResponse, include_in_schema=False) @@ -125,16 +135,45 @@ class UpdateStatusRequest(BaseModel): @router.get("/panel/users", response_model=list[AdminUserResponse]) -async def list_users(_admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)) -> list[AdminUserResponse]: - counts_q = select(Expense.user_id, func.count(Expense.id).label("expense_count")).group_by(Expense.user_id).subquery() - result = await db.execute(select(User, func.coalesce(counts_q.c.expense_count, 0).label("expense_count")).outerjoin(counts_q, User.id == counts_q.c.user_id).order_by(User.created_at.desc())) - return [AdminUserResponse(id=str(user.id), email=user.email, name=user.name, avatar_url=user.avatar_url, status=user.status, is_admin=is_admin_email(user.email), expense_count=int(count), created_at=user.created_at.isoformat()) for user, count in result.all()] +async def list_users( + _admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db) +) -> list[AdminUserResponse]: + counts_q = ( + select(Expense.user_id, func.count(Expense.id).label("expense_count")) + .group_by(Expense.user_id) + .subquery() + ) + result = await db.execute( + select(User, func.coalesce(counts_q.c.expense_count, 0).label("expense_count")) + .outerjoin(counts_q, User.id == counts_q.c.user_id) + .order_by(User.created_at.desc()) + ) + return [ + AdminUserResponse( + id=str(user.id), + email=user.email, + name=user.name, + avatar_url=user.avatar_url, + status=user.status, + is_admin=is_admin_email(user.email), + expense_count=int(count), + created_at=user.created_at.isoformat(), + ) + for user, count in result.all() + ] @router.patch("/panel/users/{user_id}/status") -async def update_user_status(user_id: uuid.UUID, body: UpdateStatusRequest, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)) -> dict: +async def update_user_status( + user_id: uuid.UUID, + body: UpdateStatusRequest, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> dict: if body.status not in ("approved", "rejected"): - raise HTTPException(status_code=400, detail="Invalid status value. Expected 'approved' or 'rejected'.") + raise HTTPException( + status_code=400, detail="Invalid status value. Expected 'approved' or 'rejected'." + ) result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: @@ -143,18 +182,24 @@ async def update_user_status(user_id: uuid.UUID, body: UpdateStatusRequest, admi raise HTTPException(status_code=400, detail="Cannot change admin's own status") user.status = body.status await db.commit() - logger.info("User status updated by admin", target=user.email, status=body.status, admin=admin.email) + logger.info( + "User status updated by admin", target=user.email, status=body.status, admin=admin.email + ) return {"id": str(user.id), "status": user.status} @router.delete("/panel/users/{user_id}", status_code=204) -async def delete_user(user_id: uuid.UUID, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)) -> None: +async def delete_user( + user_id: uuid.UUID, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db) +) -> None: result = await db.execute(select(User).where(User.id == user_id)) user = result.scalar_one_or_none() if user is None: raise HTTPException(status_code=404, detail="User not found") if is_admin_email(user.email): raise HTTPException(status_code=400, detail="Cannot delete the admin account") - logger.info("Deleting user and all related SpendHound data", target=user.email, admin=admin.email) + logger.info( + "Deleting user and all related SpendHound data", target=user.email, admin=admin.email + ) await db.delete(user) await db.commit() diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a0b312c..7d87f07 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -68,16 +68,27 @@ def serialize_user_profile(user: User) -> UserResponse: @router.post("/google", response_model=AuthResponse) @limiter.limit(f"{settings.rate_limit_auth_per_minute}/minute") -async def google_auth(request: Request, body: GoogleTokenRequest, db: AsyncSession = Depends(get_db), _bot_check: None = Depends(block_bots)) -> AuthResponse: +async def google_auth( + request: Request, + body: GoogleTokenRequest, + db: AsyncSession = Depends(get_db), + _bot_check: None = Depends(block_bots), +) -> AuthResponse: try: - id_info = id_token.verify_oauth2_token(body.id_token, google_requests.Request(), settings.google_client_id) # type: ignore[no-untyped-call] + id_info = id_token.verify_oauth2_token( + body.id_token, google_requests.Request(), settings.google_client_id + ) # type: ignore[no-untyped-call] except ValueError as error: logger.warning("Invalid Google ID token", error=str(error)) - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid Google token: {error}") from error + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid Google token: {error}" + ) from error email = id_info.get("email") if not email: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google token missing email claim") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="Google token missing email claim" + ) normalized_email = email.strip().lower() result = await db.execute(select(User).where(func.lower(User.email) == normalized_email)) @@ -85,8 +96,17 @@ async def google_auth(request: Request, body: GoogleTokenRequest, db: AsyncSessi is_new_user = user is None if user is None: - initial_status = "approved" if (is_admin_email(normalized_email) or not (settings.admin_email or "").strip()) else "pending" - user = User(email=normalized_email, name=id_info.get("name"), avatar_url=id_info.get("picture"), status=initial_status) + initial_status = ( + "approved" + if (is_admin_email(normalized_email) or not (settings.admin_email or "").strip()) + else "pending" + ) + user = User( + email=normalized_email, + name=id_info.get("name"), + avatar_url=id_info.get("picture"), + status=initial_status, + ) db.add(user) await db.flush() logger.info("New user created", email=email, status=initial_status) @@ -106,7 +126,12 @@ async def google_auth(request: Request, body: GoogleTokenRequest, db: AsyncSessi reject_token = create_action_token(user.id, "reject") approve_url = f"{settings.app_url}/backend/api/admin/approve?token={approve_token}" reject_url = f"{settings.app_url}/backend/api/admin/reject?token={reject_token}" - await send_approval_request_email(user_email=user.email, user_name=user.name, approve_url=approve_url, reject_url=reject_url) + await send_approval_request_email( + user_email=user.email, + user_name=user.name, + approve_url=approve_url, + reject_url=reject_url, + ) token = create_access_token(user.id, user.email) is_admin = is_admin_email(normalized_email) @@ -125,6 +150,7 @@ async def google_auth(request: Request, body: GoogleTokenRequest, db: AsyncSessi @router.post("/demo-login", response_model=AuthResponse) +@limiter.limit(f"{settings.rate_limit_auth_per_minute}/minute") async def demo_login(request: Request, db: AsyncSession = Depends(get_db)) -> AuthResponse: """Issue a backend JWT for the Bruce Wayne demo account — no Google auth required. @@ -153,6 +179,7 @@ async def demo_login(request: Request, db: AsyncSession = Depends(get_db)) -> Au @router.post("/demo-login-peter", response_model=AuthResponse) +@limiter.limit(f"{settings.rate_limit_auth_per_minute}/minute") async def demo_login_peter(request: Request, db: AsyncSession = Depends(get_db)) -> AuthResponse: """Issue a backend JWT for the Peter Parker demo account — no Google auth required.""" from app.services.demo_seed import seed_peter_data @@ -230,6 +257,7 @@ async def update_llm_settings( await db.commit() await db.refresh(current_user) from app.services.cache import invalidate_llm_models_cache + await invalidate_llm_models_cache(current_user.id) return serialize_user_profile(current_user) @@ -291,7 +319,11 @@ async def get_me_stats( """Return current user's join date, expense count, and needs-review count.""" expense_count_result, needs_review_result = await asyncio.gather( db.execute(select(func.count(Expense.id)).where(Expense.user_id == current_user.id)), - db.execute(select(func.count(Expense.id)).where(Expense.user_id == current_user.id, Expense.needs_review.is_(True))), + db.execute( + select(func.count(Expense.id)).where( + Expense.user_id == current_user.id, Expense.needs_review.is_(True) + ) + ), ) return { "created_at": current_user.created_at.isoformat(), @@ -311,18 +343,24 @@ async def search_users( if len(q) < 3: return [] from sqlalchemy import or_ + result = await db.execute( - select(User).where( + select(User) + .where( User.status == "approved", User.id != current_user.id, or_( func.lower(User.email).contains(q), func.lower(User.name).contains(q), ), - ).limit(10) + ) + .limit(10) ) users = result.scalars().all() - return [{"id": str(u.id), "name": u.name, "email": u.email, "avatar_url": u.avatar_url} for u in users] + return [ + {"id": str(u.id), "name": u.name, "email": u.email, "avatar_url": u.avatar_url} + for u in users + ] @router.delete("/me/data") @@ -380,6 +418,11 @@ async def delete_my_account( - Uploaded receipt/statement files on disk - Redis cache entries for this user (best-effort) """ + from app.services.llm.factory import _is_demo_user + + if _is_demo_user(current_user): + raise HTTPException(status_code=403, detail="Demo accounts cannot be deleted.") + import shutil from pathlib import Path @@ -397,6 +440,7 @@ async def delete_my_account( # Best-effort cache eviction — non-fatal if Redis is unreachable try: from app.services.cache import invalidate_analytics_cache + await invalidate_analytics_cache(user_id) except Exception: pass diff --git a/backend/app/api/budgets.py b/backend/app/api/budgets.py index d11e21d..7eae2fb 100644 --- a/backend/app/api/budgets.py +++ b/backend/app/api/budgets.py @@ -46,7 +46,11 @@ class BudgetUpdate(BaseModel): @router.get("") -async def list_budgets(month: str | None = Query(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: +async def list_budgets( + month: str | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict]: month_value = month_start_from_string(month) month_end = next_month(month_value) result = await db.execute( @@ -74,11 +78,22 @@ async def list_budgets(month: str | None = Query(default=None), current_user: Us overall += amount actuals[category_name] = actuals.get(category_name, 0.0) + amount - return [serialize_budget(budget, category_name=category_name, actual=overall if category_name is None else actuals.get(category_name, 0.0)) for budget, category_name in budget_rows] + return [ + serialize_budget( + budget, + category_name=category_name, + actual=overall if category_name is None else actuals.get(category_name, 0.0), + ) + for budget, category_name in budget_rows + ] @router.post("") -async def create_budget(body: BudgetCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def create_budget( + body: BudgetCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: budget = Budget( user_id=current_user.id, name=body.name.strip(), @@ -92,14 +107,25 @@ async def create_budget(body: BudgetCreate, current_user: User = Depends(get_cur await db.flush() category_name = None if budget.category_id: - result = await db.execute(select(Category.name).where(Category.id == budget.category_id, Category.user_id == current_user.id)) + result = await db.execute( + select(Category.name).where( + Category.id == budget.category_id, Category.user_id == current_user.id + ) + ) category_name = result.scalar_one_or_none() return serialize_budget(budget, category_name=category_name) @router.patch("/{budget_id}") -async def update_budget(budget_id: uuid.UUID, body: BudgetUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - result = await db.execute(select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id)) +async def update_budget( + budget_id: uuid.UUID, + body: BudgetUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id) + ) budget = result.scalar_one_or_none() if budget is None: raise HTTPException(status_code=404, detail="Budget not found") @@ -111,14 +137,24 @@ async def update_budget(budget_id: uuid.UUID, body: BudgetUpdate, current_user: await db.flush() category_name = None if budget.category_id: - category_result = await db.execute(select(Category.name).where(Category.id == budget.category_id, Category.user_id == current_user.id)) + category_result = await db.execute( + select(Category.name).where( + Category.id == budget.category_id, Category.user_id == current_user.id + ) + ) category_name = category_result.scalar_one_or_none() return serialize_budget(budget, category_name=category_name) @router.delete("/{budget_id}", status_code=204) -async def delete_budget(budget_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> None: - result = await db.execute(select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id)) +async def delete_budget( + budget_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + result = await db.execute( + select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id) + ) budget = result.scalar_one_or_none() if budget is None: raise HTTPException(status_code=404, detail="Budget not found") diff --git a/backend/app/api/categories.py b/backend/app/api/categories.py index 3bacad4..d80336c 100644 --- a/backend/app/api/categories.py +++ b/backend/app/api/categories.py @@ -90,16 +90,28 @@ class ItemKeywordRuleUpdate(BaseModel): @router.get("") -async def list_categories(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: +async def list_categories( + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) +) -> list[dict]: await ensure_default_categories(db, current_user.id) - result = await db.execute(select(Category).where(Category.user_id == current_user.id).order_by(Category.name.asc())) + result = await db.execute( + select(Category).where(Category.user_id == current_user.id).order_by(Category.name.asc()) + ) return [serialize_category(category) for category in result.scalars().all()] @router.post("") -async def create_category(body: CategoryCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def create_category( + body: CategoryCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: await ensure_default_categories(db, current_user.id) - existing = await db.execute(select(Category).where(Category.user_id == current_user.id, Category.name.ilike(body.name.strip()))) + existing = await db.execute( + select(Category).where( + Category.user_id == current_user.id, Category.name.ilike(body.name.strip()) + ) + ) if existing.scalar_one_or_none() is not None: raise HTTPException(status_code=400, detail="Category already exists") is_admin = is_admin_email(current_user.email) @@ -119,8 +131,15 @@ async def create_category(body: CategoryCreate, current_user: User = Depends(get @router.patch("/{category_id}") -async def update_category(category_id: uuid.UUID, body: CategoryUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - result = await db.execute(select(Category).where(Category.id == category_id, Category.user_id == current_user.id)) +async def update_category( + category_id: uuid.UUID, + body: CategoryUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(Category).where(Category.id == category_id, Category.user_id == current_user.id) + ) category = result.scalar_one_or_none() if category is None: raise HTTPException(status_code=404, detail="Category not found") @@ -133,8 +152,14 @@ async def update_category(category_id: uuid.UUID, body: CategoryUpdate, current_ @router.delete("/{category_id}", status_code=204) -async def delete_category(category_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> None: - result = await db.execute(select(Category).where(Category.id == category_id, Category.user_id == current_user.id)) +async def delete_category( + category_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + result = await db.execute( + select(Category).where(Category.id == category_id, Category.user_id == current_user.id) + ) category = result.scalar_one_or_none() if category is None: raise HTTPException(status_code=404, detail="Category not found") @@ -142,7 +167,9 @@ async def delete_category(category_id: uuid.UUID, current_user: User = Depends(g @router.get("/rules") -async def list_rules(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: +async def list_rules( + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) +) -> list[dict]: result = await db.execute( select(MerchantRule, Category.name) .outerjoin(Category, Category.id == MerchantRule.category_id) @@ -152,13 +179,19 @@ async def list_rules(current_user: User = Depends(get_current_user), db: AsyncSe MerchantRule.is_global.is_(True), ) ) - .order_by(MerchantRule.is_global.asc(), MerchantRule.priority.asc(), MerchantRule.created_at.asc()) + .order_by( + MerchantRule.is_global.asc(), MerchantRule.priority.asc(), MerchantRule.created_at.asc() + ) ) return [serialize_rule(rule, category_name) for rule, category_name in result.all()] @router.post("/rules") -async def create_rule(body: MerchantRuleCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def create_rule( + body: MerchantRuleCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: is_admin = is_admin_email(current_user.email) rule = MerchantRule( user_id=current_user.id, @@ -174,14 +207,27 @@ async def create_rule(body: MerchantRuleCreate, current_user: User = Depends(get await db.flush() category_name = None if rule.category_id: - category_result = await db.execute(select(Category.name).where(Category.id == rule.category_id, Category.user_id == current_user.id)) + category_result = await db.execute( + select(Category.name).where( + Category.id == rule.category_id, Category.user_id == current_user.id + ) + ) category_name = category_result.scalar_one_or_none() return serialize_rule(rule, category_name) @router.patch("/rules/{rule_id}") -async def update_rule(rule_id: uuid.UUID, body: MerchantRuleUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - result = await db.execute(select(MerchantRule).where(MerchantRule.id == rule_id, MerchantRule.user_id == current_user.id)) +async def update_rule( + rule_id: uuid.UUID, + body: MerchantRuleUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(MerchantRule).where( + MerchantRule.id == rule_id, MerchantRule.user_id == current_user.id + ) + ) rule = result.scalar_one_or_none() if rule is None: raise HTTPException(status_code=404, detail="Rule not found") @@ -195,14 +241,26 @@ async def update_rule(rule_id: uuid.UUID, body: MerchantRuleUpdate, current_user await db.flush() category_name = None if rule.category_id: - category_result = await db.execute(select(Category.name).where(Category.id == rule.category_id, Category.user_id == current_user.id)) + category_result = await db.execute( + select(Category.name).where( + Category.id == rule.category_id, Category.user_id == current_user.id + ) + ) category_name = category_result.scalar_one_or_none() return serialize_rule(rule, category_name) @router.delete("/rules/{rule_id}", status_code=204) -async def delete_rule(rule_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> None: - result = await db.execute(select(MerchantRule).where(MerchantRule.id == rule_id, MerchantRule.user_id == current_user.id)) +async def delete_rule( + rule_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + result = await db.execute( + select(MerchantRule).where( + MerchantRule.id == rule_id, MerchantRule.user_id == current_user.id + ) + ) rule = result.scalar_one_or_none() if rule is None: raise HTTPException(status_code=404, detail="Rule not found") @@ -211,8 +269,11 @@ async def delete_rule(rule_id: uuid.UUID, current_user: User = Depends(get_curre # ── Item keyword rules ─────────────────────────────────────────────────────── + @router.get("/item-rules") -async def list_item_rules(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: +async def list_item_rules( + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) +) -> list[dict]: """Return user's own rules plus all global rules.""" result = await db.execute( select(ItemKeywordRule) @@ -222,15 +283,26 @@ async def list_item_rules(current_user: User = Depends(get_current_user), db: As ItemKeywordRule.is_global.is_(True), ) ) - .order_by(ItemKeywordRule.is_global.asc(), ItemKeywordRule.priority.asc(), ItemKeywordRule.created_at.asc()) + .order_by( + ItemKeywordRule.is_global.asc(), + ItemKeywordRule.priority.asc(), + ItemKeywordRule.created_at.asc(), + ) ) return [serialize_item_rule(rule) for rule in result.scalars().all()] @router.post("/item-rules") -async def create_item_rule(body: ItemKeywordRuleCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def create_item_rule( + body: ItemKeywordRuleCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: if body.pattern_type not in _VALID_PATTERN_TYPES: - raise HTTPException(status_code=400, detail=f"Invalid pattern_type. Choose from: {', '.join(sorted(_VALID_PATTERN_TYPES))}") + raise HTTPException( + status_code=400, + detail=f"Invalid pattern_type. Choose from: {', '.join(sorted(_VALID_PATTERN_TYPES))}", + ) # Only admin can create global rules is_admin = is_admin_email(current_user.email) is_global = body.is_global and is_admin @@ -250,15 +322,27 @@ async def create_item_rule(body: ItemKeywordRuleCreate, current_user: User = Dep @router.patch("/item-rules/{rule_id}") -async def update_item_rule(rule_id: uuid.UUID, body: ItemKeywordRuleUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - result = await db.execute(select(ItemKeywordRule).where(ItemKeywordRule.id == rule_id, ItemKeywordRule.user_id == current_user.id)) +async def update_item_rule( + rule_id: uuid.UUID, + body: ItemKeywordRuleUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(ItemKeywordRule).where( + ItemKeywordRule.id == rule_id, ItemKeywordRule.user_id == current_user.id + ) + ) rule = result.scalar_one_or_none() if rule is None: raise HTTPException(status_code=404, detail="Item rule not found") is_admin = is_admin_email(current_user.email) data = body.model_dump(exclude_unset=True) if "pattern_type" in data and data["pattern_type"] not in _VALID_PATTERN_TYPES: - raise HTTPException(status_code=400, detail=f"Invalid pattern_type. Choose from: {', '.join(sorted(_VALID_PATTERN_TYPES))}") + raise HTTPException( + status_code=400, + detail=f"Invalid pattern_type. Choose from: {', '.join(sorted(_VALID_PATTERN_TYPES))}", + ) for field, value in data.items(): if field == "is_global": # Only admin can promote/demote global flag @@ -271,8 +355,16 @@ async def update_item_rule(rule_id: uuid.UUID, body: ItemKeywordRuleUpdate, curr @router.delete("/item-rules/{rule_id}", status_code=204) -async def delete_item_rule(rule_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> None: - result = await db.execute(select(ItemKeywordRule).where(ItemKeywordRule.id == rule_id, ItemKeywordRule.user_id == current_user.id)) +async def delete_item_rule( + rule_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + result = await db.execute( + select(ItemKeywordRule).where( + ItemKeywordRule.id == rule_id, ItemKeywordRule.user_id == current_user.id + ) + ) rule = result.scalar_one_or_none() if rule is None: raise HTTPException(status_code=404, detail="Item rule not found") @@ -281,6 +373,7 @@ async def delete_item_rule(rule_id: uuid.UUID, current_user: User = Depends(get_ # ── Knowledge base (RAG embeddings) ───────────────────────────────────────── + def _serialize_kb_entry(entry: ItemEmbedding) -> dict: return { "id": str(entry.id), @@ -335,7 +428,9 @@ async def upload_knowledge_base( """ is_admin = is_admin_email(current_user.email) if is_global and not is_admin: - raise HTTPException(status_code=403, detail="Only admin can upload global knowledge-base entries") + raise HTTPException( + status_code=403, detail="Only admin can upload global knowledge-base entries" + ) content_bytes = await file.read() try: @@ -359,7 +454,10 @@ async def upload_knowledge_base( entries.append((desc, subcat)) if not entries: - raise HTTPException(status_code=400, detail="No valid entries found. Expected CSV with columns: description,subcategory") + raise HTTPException( + status_code=400, + detail="No valid entries found. Expected CSV with columns: description,subcategory", + ) inserted = await bulk_upsert_embeddings( db, diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 0de7056..e6088c8 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -100,7 +100,9 @@ async def stream_chat( raise HTTPException(status_code=400, detail="Message is required") service = ExpenseChatService(db, current_user) return StreamingResponse( - service.stream_chat(current_user.id, session_id=session_id, request=body, http_request=request), + service.stream_chat( + current_user.id, session_id=session_id, request=body, http_request=request + ), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", diff --git a/backend/app/api/expenses.py b/backend/app/api/expenses.py index 0a475e0..8ff7e36 100644 --- a/backend/app/api/expenses.py +++ b/backend/app/api/expenses.py @@ -71,7 +71,9 @@ def _resolve_cadence_fields( if resolved_cadence == CADENCE_CUSTOM: interval = cadence_interval or 3 if interval < 2: - raise HTTPException(status_code=422, detail="cadence_interval must be at least 2 for custom cadence") + raise HTTPException( + status_code=422, detail="cadence_interval must be at least 2 for custom cadence" + ) return interval, None, None if resolved_cadence == CADENCE_PREPAID: months = prepaid_months or 12 @@ -88,11 +90,15 @@ def _normalize_major_purchase(value: bool | None, *, transaction_type: str) -> b return bool(value) and normalize_transaction_type(transaction_type) == TRANSACTION_TYPE_DEBIT -async def _get_existing_receipt_expense(db: AsyncSession, *, user_id: uuid.UUID, receipt_id: uuid.UUID, source: str = "receipt") -> tuple[Expense, str | None] | None: +async def _get_existing_receipt_expense( + db: AsyncSession, *, user_id: uuid.UUID, receipt_id: uuid.UUID, source: str = "receipt" +) -> tuple[Expense, str | None] | None: result = await db.execute( select(Expense, Category.name) .outerjoin(Category, Category.id == Expense.category_id) - .where(Expense.user_id == user_id, Expense.receipt_id == receipt_id, Expense.source == source) + .where( + Expense.user_id == user_id, Expense.receipt_id == receipt_id, Expense.source == source + ) .order_by(Expense.created_at.asc()) .limit(1) ) @@ -100,7 +106,9 @@ async def _get_existing_receipt_expense(db: AsyncSession, *, user_id: uuid.UUID, return (row[0], row[1]) if row is not None else None -async def _get_expense_with_category_name(db: AsyncSession, *, user_id: uuid.UUID, expense_id: uuid.UUID) -> tuple[Expense, str | None] | None: +async def _get_expense_with_category_name( + db: AsyncSession, *, user_id: uuid.UUID, expense_id: uuid.UUID +) -> tuple[Expense, str | None] | None: result = await db.execute( select(Expense, Category.name) .outerjoin(Category, Category.id == Expense.category_id) @@ -123,9 +131,9 @@ class ExpenseCreate(BaseModel): notes: str | None = None items: list[dict] | None = None cadence: str | None = None - cadence_interval: int | None = None # for cadence="custom", must be >= 2 - prepaid_months: int | None = None # for cadence="prepaid" - prepaid_start_date: str | None = None # ISO date; defaults to expense_date for prepaid + cadence_interval: int | None = None # for cadence="custom", must be >= 2 + prepaid_months: int | None = None # for cadence="prepaid" + prepaid_start_date: str | None = None # ISO date; defaults to expense_date for prepaid recurring_variable: bool = False recurring_auto_add: bool = False is_major_purchase: bool = False @@ -194,7 +202,12 @@ async def list_expenses( try: lid = uuid.UUID(p) # Verify membership - m = await db.execute(select(LedgerMembership).where(LedgerMembership.ledger_id == lid, LedgerMembership.user_id == current_user.id)) + m = await db.execute( + select(LedgerMembership).where( + LedgerMembership.ledger_id == lid, + LedgerMembership.user_id == current_user.id, + ) + ) if m.scalar_one_or_none(): parsed_ledger_ids.append(lid) except ValueError: @@ -221,12 +234,22 @@ async def list_expenses( conditions.append(sql_and(Expense.user_id == current_user.id, Expense.ledger_id.is_(None))) base = base.where(sql_or(*conditions)) - base = apply_expense_filters(base, user_id=None, month=month, category_id=category_id, transaction_type=transaction_type, cadence=cadence, review_only=review_only, search=search).order_by(Expense.expense_date.desc(), Expense.created_at.desc()) + base = apply_expense_filters( + base, + user_id=None, + month=month, + category_id=category_id, + transaction_type=transaction_type, + cadence=cadence, + review_only=review_only, + search=search, + ).order_by(Expense.expense_date.desc(), Expense.created_at.desc()) result = await db.execute(base) rows = result.all() if show_duplicates: from collections import defaultdict + key_map: dict[tuple, list] = defaultdict(list) for row in rows: exp = row[0] @@ -246,20 +269,45 @@ async def list_expenses( added_by = None if expense.user_id != current_user.id and expense.user_id in shared_user_map: u = shared_user_map[expense.user_id] - added_by = {"id": str(u.id), "name": u.name, "email": u.email, "avatar_url": u.avatar_url} - items.append(serialize_expense(expense, category_name=category_name, receipt_filename=receipt_filename, ledger_name=ledger_name, added_by=added_by)) + added_by = { + "id": str(u.id), + "name": u.name, + "email": u.email, + "avatar_url": u.avatar_url, + } + items.append( + serialize_expense( + expense, + category_name=category_name, + receipt_filename=receipt_filename, + ledger_name=ledger_name, + added_by=added_by, + ) + ) return {"items": items, "total": len(items)} @router.get("/review-queue") -async def review_queue(current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - receipt_result = await db.execute(select(Receipt).where(Receipt.user_id == current_user.id, (Receipt.needs_review.is_(True)) | (Receipt.extraction_status != "finalized")).order_by(Receipt.created_at.desc())) +async def review_queue( + current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) +) -> dict: + receipt_result = await db.execute( + select(Receipt) + .where( + Receipt.user_id == current_user.id, + (Receipt.needs_review.is_(True)) | (Receipt.extraction_status != "finalized"), + ) + .order_by(Receipt.created_at.desc()) + ) expense_result = await db.execute( select(Expense, Category.name, Receipt.original_filename) .outerjoin(Category, Category.id == Expense.category_id) .outerjoin(Receipt, Receipt.id == Expense.receipt_id) - .where(Expense.user_id == current_user.id, (Expense.needs_review.is_(True)) | (Expense.category_id.is_(None))) + .where( + Expense.user_id == current_user.id, + (Expense.needs_review.is_(True)) | (Expense.category_id.is_(None)), + ) .order_by(Expense.expense_date.desc(), Expense.created_at.desc()) ) return { @@ -275,28 +323,64 @@ async def review_queue(current_user: User = Depends(get_current_user), db: Async } for receipt in receipt_result.scalars().all() ], - "expenses": [serialize_expense(expense, category_name=category_name, receipt_filename=receipt_filename) for expense, category_name, receipt_filename in expense_result.all()], + "expenses": [ + serialize_expense( + expense, category_name=category_name, receipt_filename=receipt_filename + ) + for expense, category_name, receipt_filename in expense_result.all() + ], } @router.get("/export") -async def export_expenses(export_format: str = Query(default="json"), month: str | None = Query(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> Response: +async def export_expenses( + export_format: str = Query(default="json"), + month: str | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> Response: export_payload = await build_expense_export_payload(db, user_id=current_user.id, month=month) items = export_payload["items"] if export_format == "csv": output = io.StringIO() - fieldnames = ["id", "expense_date", "merchant", "description", "amount", "signed_amount", "transaction_type", "currency", "cadence", "cadence_override", "is_major_purchase", "category_name", "source", "needs_review", "is_recurring", "receipt_filename", "notes"] + fieldnames = [ + "id", + "expense_date", + "merchant", + "description", + "amount", + "signed_amount", + "transaction_type", + "currency", + "cadence", + "cadence_override", + "is_major_purchase", + "category_name", + "source", + "needs_review", + "is_recurring", + "receipt_filename", + "notes", + ] writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows({key: item.get(key) for key in fieldnames} for item in items) - return StreamingResponse(iter([output.getvalue()]), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=spendhound-expenses.csv"}) + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=spendhound-expenses.csv"}, + ) return JSONResponse(export_payload) @router.post("") -async def create_expense(body: ExpenseCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def create_expense( + body: ExpenseCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: await ensure_default_categories(db, current_user.id) transaction_type = normalize_transaction_type(body.transaction_type) cadence_override = _parse_cadence_override(body.cadence) @@ -312,20 +396,27 @@ async def create_expense(body: ExpenseCreate, current_user: User = Depends(get_c recurring_variable=body.recurring_variable, recurring_auto_add=body.recurring_auto_add, ) - category = await resolve_category(db, current_user.id, category_id=body.category_id, category_name=body.category_name, merchant=body.merchant, transaction_type=transaction_type) + category = await resolve_category( + db, + current_user.id, + category_id=body.category_id, + category_name=body.category_name, + merchant=body.merchant, + transaction_type=transaction_type, + ) # Validate ledger membership if a ledger is specified ledger_id = body.ledger_id ledger_name: str | None = None if ledger_id: - ledger_result = await db.execute( - select(Ledger).where(Ledger.id == ledger_id) - ) + ledger_result = await db.execute(select(Ledger).where(Ledger.id == ledger_id)) ledger = ledger_result.scalar_one_or_none() if not ledger: raise HTTPException(status_code=404, detail="Ledger not found") membership = await db.execute( - select(LedgerMembership).where(LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == current_user.id) + select(LedgerMembership).where( + LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == current_user.id + ) ) if not membership.scalar_one_or_none(): raise HTTPException(status_code=403, detail="You are not a member of this ledger") @@ -351,30 +442,53 @@ async def create_expense(body: ExpenseCreate, current_user: User = Depends(get_c prepaid_start_date=resolved_prepaid_start, recurring_variable=recurring_variable, recurring_auto_add=recurring_auto_add, - is_major_purchase=_normalize_major_purchase(body.is_major_purchase, transaction_type=transaction_type), + is_major_purchase=_normalize_major_purchase( + body.is_major_purchase, transaction_type=transaction_type + ), ledger_id=ledger_id, ) db.add(expense) await db.flush() - await replace_expense_items(db, expense, body.items, category_name=category.name if category else body.category_name) + await replace_expense_items( + db, expense, body.items, category_name=category.name if category else body.category_name + ) if ledger_id: - db.add(LedgerAuditLog(ledger_id=ledger_id, expense_id=expense.id, user_id=current_user.id, action="created")) + db.add( + LedgerAuditLog( + ledger_id=ledger_id, + expense_id=expense.id, + user_id=current_user.id, + action="created", + ) + ) await recompute_recurring_expenses(db, current_user.id) await db.refresh(expense) await invalidate_analytics_cache(current_user.id) - return serialize_expense(expense, category_name=category.name if category else None, ledger_name=ledger_name) + return serialize_expense( + expense, category_name=category.name if category else None, ledger_name=ledger_name + ) @router.post("/from-receipt") -async def create_expense_from_receipt(body: ReceiptExpenseCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - receipt_result = await db.execute(select(Receipt).where(Receipt.id == body.receipt_id, Receipt.user_id == current_user.id).with_for_update()) +async def create_expense_from_receipt( + body: ReceiptExpenseCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + receipt_result = await db.execute( + select(Receipt) + .where(Receipt.id == body.receipt_id, Receipt.user_id == current_user.id) + .with_for_update() + ) receipt = receipt_result.scalar_one_or_none() if receipt is None: raise HTTPException(status_code=404, detail="Receipt not found") if receipt.document_kind != "receipt": raise HTTPException(status_code=400, detail="This document is not a receipt upload") - existing_expense_row = await _get_existing_receipt_expense(db, user_id=current_user.id, receipt_id=receipt.id) + existing_expense_row = await _get_existing_receipt_expense( + db, user_id=current_user.id, receipt_id=receipt.id + ) if existing_expense_row is not None: existing_expense, existing_category_name = existing_expense_row if receipt.extraction_status != "finalized": @@ -382,7 +496,11 @@ async def create_expense_from_receipt(body: ReceiptExpenseCreate, current_user: receipt.needs_review = existing_expense.needs_review receipt.finalized_at = receipt.finalized_at or datetime.now(UTC) await db.flush() - return serialize_expense(existing_expense, category_name=existing_category_name, receipt_filename=receipt.original_filename) + return serialize_expense( + existing_expense, + category_name=existing_category_name, + receipt_filename=receipt.original_filename, + ) if receipt.extraction_status == "finalized": raise HTTPException(status_code=409, detail="Receipt has already been finalized") @@ -401,8 +519,17 @@ async def create_expense_from_receipt(body: ReceiptExpenseCreate, current_user: recurring_variable=body.recurring_variable, recurring_auto_add=body.recurring_auto_add, ) - category = await resolve_category(db, current_user.id, category_id=body.category_id, category_name=body.category_name, merchant=body.merchant, transaction_type=transaction_type) - confidence = body.confidence if body.confidence is not None else receipt.extraction_confidence or 0.5 + category = await resolve_category( + db, + current_user.id, + category_id=body.category_id, + category_name=body.category_name, + merchant=body.merchant, + transaction_type=transaction_type, + ) + confidence = ( + body.confidence if body.confidence is not None else receipt.extraction_confidence or 0.5 + ) expense = Expense( user_id=current_user.id, merchant=body.merchant.strip(), @@ -424,7 +551,9 @@ async def create_expense_from_receipt(body: ReceiptExpenseCreate, current_user: prepaid_start_date=resolved_prepaid_start, recurring_variable=recurring_variable, recurring_auto_add=recurring_auto_add, - is_major_purchase=_normalize_major_purchase(body.is_major_purchase, transaction_type=transaction_type), + is_major_purchase=_normalize_major_purchase( + body.is_major_purchase, transaction_type=transaction_type + ), ) db.add(expense) receipt.preview_data = { @@ -439,24 +568,45 @@ async def create_expense_from_receipt(body: ReceiptExpenseCreate, current_user: "cadence": cadence_override or CADENCE_ONE_TIME, "recurring_variable": recurring_variable, "recurring_auto_add": recurring_auto_add, - "is_major_purchase": _normalize_major_purchase(body.is_major_purchase, transaction_type=transaction_type), - "items": body.items if body.items is not None else (receipt.preview_data or {}).get("items", []), + "is_major_purchase": _normalize_major_purchase( + body.is_major_purchase, transaction_type=transaction_type + ), + "items": body.items + if body.items is not None + else (receipt.preview_data or {}).get("items", []), "confidence": confidence, } receipt.needs_review = expense.needs_review receipt.extraction_status = "finalized" receipt.finalized_at = datetime.now(UTC) await db.flush() - await replace_expense_items(db, expense, body.items if body.items is not None else (receipt.preview_data or {}).get("items", []), category_name=category.name if category else body.category_name) + await replace_expense_items( + db, + expense, + body.items if body.items is not None else (receipt.preview_data or {}).get("items", []), + category_name=category.name if category else body.category_name, + ) await recompute_recurring_expenses(db, current_user.id) await db.refresh(expense) await invalidate_analytics_cache(current_user.id) - return serialize_expense(expense, category_name=category.name if category else None, receipt_filename=receipt.original_filename) + return serialize_expense( + expense, + category_name=category.name if category else None, + receipt_filename=receipt.original_filename, + ) @router.post("/from-statement-entry") -async def create_expense_from_statement_entry(body: StatementExpenseCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - receipt_result = await db.execute(select(Receipt).where(Receipt.id == body.receipt_id, Receipt.user_id == current_user.id).with_for_update()) +async def create_expense_from_statement_entry( + body: StatementExpenseCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + receipt_result = await db.execute( + select(Receipt) + .where(Receipt.id == body.receipt_id, Receipt.user_id == current_user.id) + .with_for_update() + ) receipt = receipt_result.scalar_one_or_none() if receipt is None: raise HTTPException(status_code=404, detail="Statement import not found") @@ -471,11 +621,17 @@ async def create_expense_from_statement_entry(body: StatementExpenseCreate, curr if entry.get("status") == "finalized": saved_expense_id = entry.get("saved_expense_id") if saved_expense_id: - existing_expense_row = await _get_expense_with_category_name(db, user_id=current_user.id, expense_id=uuid.UUID(str(saved_expense_id))) + existing_expense_row = await _get_expense_with_category_name( + db, user_id=current_user.id, expense_id=uuid.UUID(str(saved_expense_id)) + ) if existing_expense_row is not None: existing_expense, existing_category_name = existing_expense_row return { - "expense": serialize_expense(existing_expense, category_name=existing_category_name, receipt_filename=receipt.original_filename), + "expense": serialize_expense( + existing_expense, + category_name=existing_category_name, + receipt_filename=receipt.original_filename, + ), "statement": serialize_receipt(receipt), } raise HTTPException(status_code=409, detail="Statement entry has already been finalized") @@ -494,8 +650,19 @@ async def create_expense_from_statement_entry(body: StatementExpenseCreate, curr recurring_variable=body.recurring_variable, recurring_auto_add=body.recurring_auto_add, ) - category = await resolve_category(db, current_user.id, category_id=body.category_id, category_name=body.category_name, merchant=body.merchant, transaction_type=transaction_type) - confidence = body.confidence if body.confidence is not None else float(entry.get("confidence") or receipt.extraction_confidence or 0.5) + category = await resolve_category( + db, + current_user.id, + category_id=body.category_id, + category_name=body.category_name, + merchant=body.merchant, + transaction_type=transaction_type, + ) + confidence = ( + body.confidence + if body.confidence is not None + else float(entry.get("confidence") or receipt.extraction_confidence or 0.5) + ) expense = Expense( user_id=current_user.id, merchant=body.merchant.strip(), @@ -517,7 +684,9 @@ async def create_expense_from_statement_entry(body: StatementExpenseCreate, curr prepaid_start_date=resolved_prepaid_start, recurring_variable=recurring_variable, recurring_auto_add=recurring_auto_add, - is_major_purchase=_normalize_major_purchase(body.is_major_purchase, transaction_type=transaction_type), + is_major_purchase=_normalize_major_purchase( + body.is_major_purchase, transaction_type=transaction_type + ), ) db.add(expense) await db.flush() @@ -535,7 +704,9 @@ async def create_expense_from_statement_entry(body: StatementExpenseCreate, curr "cadence": cadence_override or CADENCE_ONE_TIME, "recurring_variable": recurring_variable, "recurring_auto_add": recurring_auto_add, - "is_major_purchase": _normalize_major_purchase(body.is_major_purchase, transaction_type=transaction_type), + "is_major_purchase": _normalize_major_purchase( + body.is_major_purchase, transaction_type=transaction_type + ), "confidence": confidence, "status": "finalized", "saved_expense_id": str(expense.id), @@ -554,13 +725,21 @@ async def create_expense_from_statement_entry(body: StatementExpenseCreate, curr await db.refresh(expense) await invalidate_analytics_cache(current_user.id) return { - "expense": serialize_expense(expense, category_name=category.name if category else None, receipt_filename=receipt.original_filename), + "expense": serialize_expense( + expense, + category_name=category.name if category else None, + receipt_filename=receipt.original_filename, + ), "statement": serialize_receipt(receipt), } @router.get("/{expense_id}") -async def get_expense(expense_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def get_expense( + expense_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: result = await db.execute( select(Expense) .options(selectinload(Expense.items), selectinload(Expense.receipt)) @@ -571,7 +750,11 @@ async def get_expense(expense_id: uuid.UUID, current_user: User = Depends(get_cu raise HTTPException(status_code=404, detail="Expense not found") category_name = None if expense.category_id: - category_result = await db.execute(select(Category.name).where(Category.id == expense.category_id, Category.user_id == current_user.id)) + category_result = await db.execute( + select(Category.name).where( + Category.id == expense.category_id, Category.user_id == current_user.id + ) + ) category_name = category_result.scalar_one_or_none() receipt = expense.receipt return serialize_expense( @@ -586,15 +769,28 @@ async def get_expense(expense_id: uuid.UUID, current_user: User = Depends(get_cu @router.patch("/{expense_id}") -async def update_expense(expense_id: uuid.UUID, body: ExpenseUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - result = await db.execute(select(Expense).where(Expense.id == expense_id, Expense.user_id == current_user.id)) +async def update_expense( + expense_id: uuid.UUID, + body: ExpenseUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(Expense).where(Expense.id == expense_id, Expense.user_id == current_user.id) + ) expense = result.scalar_one_or_none() if expense is None: raise HTTPException(status_code=404, detail="Expense not found") data = body.model_dump(exclude_unset=True) - next_transaction_type = normalize_transaction_type(data.get("transaction_type"), default=expense.transaction_type) - next_cadence = _parse_cadence_override(data.get("cadence")) if "cadence" in data else (expense.cadence_override or expense.cadence) + next_transaction_type = normalize_transaction_type( + data.get("transaction_type"), default=expense.transaction_type + ) + next_cadence = ( + _parse_cadence_override(data.get("cadence")) + if "cadence" in data + else (expense.cadence_override or expense.cadence) + ) recurring_variable, recurring_auto_add = normalize_recurring_settings( next_cadence, recurring_variable=data.get("recurring_variable", expense.recurring_variable), @@ -611,7 +807,9 @@ async def update_expense(expense_id: uuid.UUID, body: ExpenseUpdate, current_use if "cadence_interval" in data and next_cadence == CADENCE_CUSTOM: interval = data["cadence_interval"] or 3 if interval < 2: - raise HTTPException(status_code=422, detail="cadence_interval must be at least 2 for custom cadence") + raise HTTPException( + status_code=422, detail="cadence_interval must be at least 2 for custom cadence" + ) expense.cadence_interval = interval if "prepaid_months" in data and next_cadence == CADENCE_PREPAID: expense.prepaid_months = data["prepaid_months"] @@ -625,9 +823,22 @@ async def update_expense(expense_id: uuid.UUID, body: ExpenseUpdate, current_use category_name = data.get("category_name") if "category_name" in data else None merchant = data.get("merchant", expense.merchant) if "category_id" in data or "category_name" in data: - category = await resolve_category(db, current_user.id, category_id=category_id, category_name=category_name, merchant=merchant, transaction_type=next_transaction_type) + category = await resolve_category( + db, + current_user.id, + category_id=category_id, + category_name=category_name, + merchant=merchant, + transaction_type=next_transaction_type, + ) else: - category = await resolve_category(db, current_user.id, category_id=expense.category_id, merchant=merchant, transaction_type=next_transaction_type) + category = await resolve_category( + db, + current_user.id, + category_id=expense.category_id, + merchant=merchant, + transaction_type=next_transaction_type, + ) expense.category_id = category.id if category else None for field, value in data.items(): if field == "amount" and value is not None: @@ -637,33 +848,61 @@ async def update_expense(expense_id: uuid.UUID, body: ExpenseUpdate, current_use elif field == "expense_date" and value is not None: expense.expense_date = datetime.fromisoformat(value).date() elif field == "is_major_purchase": - expense.is_major_purchase = _normalize_major_purchase(value, transaction_type=next_transaction_type) + expense.is_major_purchase = _normalize_major_purchase( + value, transaction_type=next_transaction_type + ) elif field == "cadence": continue - elif field in {"recurring_variable", "recurring_auto_add", "cadence_interval", "prepaid_months", "prepaid_start_date"}: + elif field in { + "recurring_variable", + "recurring_auto_add", + "cadence_interval", + "prepaid_months", + "prepaid_start_date", + }: continue elif field not in {"category_id", "category_name"}: setattr(expense, field, value) current_category = None if expense.category_id: - category_result = await db.execute(select(Category).where(Category.id == expense.category_id, Category.user_id == current_user.id)) + category_result = await db.execute( + select(Category).where( + Category.id == expense.category_id, Category.user_id == current_user.id + ) + ) current_category = category_result.scalar_one_or_none() - expense.needs_review = body.needs_review if body.needs_review is not None else expense_requires_review(current_category, expense.confidence, expense.source) + expense.needs_review = ( + body.needs_review + if body.needs_review is not None + else expense_requires_review(current_category, expense.confidence, expense.source) + ) await db.flush() await recompute_recurring_expenses(db, current_user.id) await db.refresh(expense) receipt_filename = None if expense.receipt_id: - receipt_result = await db.execute(select(Receipt.original_filename).where(Receipt.id == expense.receipt_id)) + receipt_result = await db.execute( + select(Receipt.original_filename).where(Receipt.id == expense.receipt_id) + ) receipt_filename = receipt_result.scalar_one_or_none() await invalidate_analytics_cache(current_user.id) - return serialize_expense(expense, category_name=current_category.name if current_category else None, receipt_filename=receipt_filename) + return serialize_expense( + expense, + category_name=current_category.name if current_category else None, + receipt_filename=receipt_filename, + ) @router.delete("/{expense_id}", status_code=204) -async def delete_expense(expense_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> None: - result = await db.execute(select(Expense).where(Expense.id == expense_id, Expense.user_id == current_user.id)) +async def delete_expense( + expense_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + result = await db.execute( + select(Expense).where(Expense.id == expense_id, Expense.user_id == current_user.id) + ) expense = result.scalar_one_or_none() if expense is None: raise HTTPException(status_code=404, detail="Expense not found") diff --git a/backend/app/api/ledgers.py b/backend/app/api/ledgers.py index 9e97b7b..e8eb38e 100644 --- a/backend/app/api/ledgers.py +++ b/backend/app/api/ledgers.py @@ -24,21 +24,33 @@ logger = structlog.get_logger(__name__) -async def _get_accessible_ledger(db: AsyncSession, *, ledger_id: uuid.UUID, user_id: uuid.UUID) -> Ledger: +async def _get_accessible_ledger( + db: AsyncSession, *, ledger_id: uuid.UUID, user_id: uuid.UUID +) -> Ledger: """Return ledger if user is a member; raise 404/403 otherwise.""" result = await db.execute(select(Ledger).where(Ledger.id == ledger_id)) ledger = result.scalar_one_or_none() if not ledger: raise HTTPException(status_code=404, detail="Ledger not found") membership = await db.execute( - select(LedgerMembership).where(LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == user_id) + select(LedgerMembership).where( + LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == user_id + ) ) if not membership.scalar_one_or_none(): raise HTTPException(status_code=403, detail="You are not a member of this ledger") return ledger -async def _log_audit(db: AsyncSession, *, ledger_id: uuid.UUID, user_id: uuid.UUID, expense_id: uuid.UUID | None, action: str, changes: dict | None = None) -> None: +async def _log_audit( + db: AsyncSession, + *, + ledger_id: uuid.UUID, + user_id: uuid.UUID, + expense_id: uuid.UUID | None, + action: str, + changes: dict | None = None, +) -> None: log = LedgerAuditLog( ledger_id=ledger_id, expense_id=expense_id, @@ -126,7 +138,9 @@ async def list_ledgers( memberships_by_ledger.setdefault(m.ledger_id, []).append(m) return { - "ledgers": [_serialize_ledger(ldg, memberships_by_ledger.get(ldg.id, [])) for ldg in ledgers] + "ledgers": [ + _serialize_ledger(ldg, memberships_by_ledger.get(ldg.id, [])) for ldg in ledgers + ] } @@ -147,14 +161,18 @@ async def create_ledger( partner_check = await db.execute( select(PartnerRequest).where( or_( - (PartnerRequest.requester_id == current_user.id) & (PartnerRequest.recipient_id == uid), - (PartnerRequest.requester_id == uid) & (PartnerRequest.recipient_id == current_user.id), + (PartnerRequest.requester_id == current_user.id) + & (PartnerRequest.recipient_id == uid), + (PartnerRequest.requester_id == uid) + & (PartnerRequest.recipient_id == current_user.id), ), PartnerRequest.status == PARTNER_STATUS_ACCEPTED, ) ) if not partner_check.scalar_one_or_none(): - raise HTTPException(status_code=400, detail=f"User {uid} is not your expense partner") + raise HTTPException( + status_code=400, detail=f"User {uid} is not your expense partner" + ) partner_ids.add(uid) ledger = Ledger(name=body.name.strip(), type=body.type, created_by=current_user.id) @@ -171,7 +189,9 @@ async def create_ledger( await db.commit() await db.refresh(ledger) - membership_result = await db.execute(select(LedgerMembership).where(LedgerMembership.ledger_id == ledger.id)) + membership_result = await db.execute( + select(LedgerMembership).where(LedgerMembership.ledger_id == ledger.id) + ) memberships = membership_result.scalars().all() user_ids = [m.user_id for m in memberships] user_result = await db.execute(select(User).where(User.id.in_(user_ids))) @@ -233,8 +253,10 @@ async def add_ledger_members( partner_check = await db.execute( select(PartnerRequest).where( or_( - (PartnerRequest.requester_id == current_user.id) & (PartnerRequest.recipient_id == uid), - (PartnerRequest.requester_id == uid) & (PartnerRequest.recipient_id == current_user.id), + (PartnerRequest.requester_id == current_user.id) + & (PartnerRequest.recipient_id == uid), + (PartnerRequest.requester_id == uid) + & (PartnerRequest.recipient_id == current_user.id), ), PartnerRequest.status == PARTNER_STATUS_ACCEPTED, ) @@ -242,14 +264,18 @@ async def add_ledger_members( if not partner_check.scalar_one_or_none(): raise HTTPException(status_code=400, detail=f"User {uid} is not your expense partner") existing = await db.execute( - select(LedgerMembership).where(LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == uid) + select(LedgerMembership).where( + LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == uid + ) ) if not existing.scalar_one_or_none(): db.add(LedgerMembership(ledger_id=ledger_id, user_id=uid, role="member")) await db.commit() - membership_result = await db.execute(select(LedgerMembership).where(LedgerMembership.ledger_id == ledger_id)) + membership_result = await db.execute( + select(LedgerMembership).where(LedgerMembership.ledger_id == ledger_id) + ) memberships = membership_result.scalars().all() user_ids = [m.user_id for m in memberships] user_result = await db.execute(select(User).where(User.id.in_(user_ids))) @@ -268,9 +294,13 @@ async def leave_ledger( """Leave a shared ledger. The owner must delete instead.""" ledger = await _get_accessible_ledger(db, ledger_id=ledger_id, user_id=current_user.id) if ledger.created_by == current_user.id: - raise HTTPException(status_code=400, detail="You are the owner — delete the ledger instead of leaving") + raise HTTPException( + status_code=400, detail="You are the owner — delete the ledger instead of leaving" + ) membership = await db.execute( - select(LedgerMembership).where(LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == current_user.id) + select(LedgerMembership).where( + LedgerMembership.ledger_id == ledger_id, LedgerMembership.user_id == current_user.id + ) ) m = membership.scalar_one_or_none() if m: @@ -311,8 +341,12 @@ async def get_audit_log( "id": str(log.user_id), "name": user_map[log.user_id].name if log.user_id in user_map else None, "email": user_map[log.user_id].email if log.user_id in user_map else None, - "avatar_url": user_map[log.user_id].avatar_url if log.user_id in user_map else None, - } if log.user_id else None, + "avatar_url": user_map[log.user_id].avatar_url + if log.user_id in user_map + else None, + } + if log.user_id + else None, "changes": json.loads(log.changes) if log.changes else None, "created_at": log.created_at.isoformat(), } @@ -333,9 +367,7 @@ async def move_expenses( moved = 0 for eid in body.expense_ids: - result = await db.execute( - select(Expense).where(Expense.id == eid) - ) + result = await db.execute(select(Expense).where(Expense.id == eid)) expense = result.scalar_one_or_none() if not expense: continue @@ -344,7 +376,10 @@ async def move_expenses( has_access = expense.user_id == current_user.id if not has_access and expense.ledger_id: m = await db.execute( - select(LedgerMembership).where(LedgerMembership.ledger_id == expense.ledger_id, LedgerMembership.user_id == current_user.id) + select(LedgerMembership).where( + LedgerMembership.ledger_id == expense.ledger_id, + LedgerMembership.user_id == current_user.id, + ) ) has_access = m.scalar_one_or_none() is not None @@ -354,9 +389,25 @@ async def move_expenses( old_ledger_id = expense.ledger_id expense.ledger_id = body.target_ledger_id if body.target_ledger_id: - await _log_audit(db, ledger_id=body.target_ledger_id, user_id=current_user.id, expense_id=eid, action="moved_in", changes={"from_ledger_id": str(old_ledger_id) if old_ledger_id else None}) + await _log_audit( + db, + ledger_id=body.target_ledger_id, + user_id=current_user.id, + expense_id=eid, + action="moved_in", + changes={"from_ledger_id": str(old_ledger_id) if old_ledger_id else None}, + ) if old_ledger_id: - await _log_audit(db, ledger_id=old_ledger_id, user_id=current_user.id, expense_id=eid, action="moved_out", changes={"to_ledger_id": str(body.target_ledger_id) if body.target_ledger_id else None}) + await _log_audit( + db, + ledger_id=old_ledger_id, + user_id=current_user.id, + expense_id=eid, + action="moved_out", + changes={ + "to_ledger_id": str(body.target_ledger_id) if body.target_ledger_id else None + }, + ) moved += 1 await db.commit() @@ -376,9 +427,7 @@ async def copy_expenses( copied = 0 for eid in body.expense_ids: - result = await db.execute( - select(Expense).where(Expense.id == eid) - ) + result = await db.execute(select(Expense).where(Expense.id == eid)) src = result.scalar_one_or_none() if not src: continue @@ -413,17 +462,26 @@ async def copy_expenses( await db.flush() for item in src_items: - db.add(ExpenseItem( - expense_id=new_expense.id, - description=item.description, - quantity=item.quantity, - unit_price=item.unit_price, - total_price=item.total_price, - subcategory=item.subcategory, - )) + db.add( + ExpenseItem( + expense_id=new_expense.id, + description=item.description, + quantity=item.quantity, + unit_price=item.unit_price, + total_price=item.total_price, + subcategory=item.subcategory, + ) + ) if target_id: - await _log_audit(db, ledger_id=target_id, user_id=current_user.id, expense_id=new_expense.id, action="copied_in", changes={"source_expense_id": str(eid)}) + await _log_audit( + db, + ledger_id=target_id, + user_id=current_user.id, + expense_id=new_expense.id, + action="copied_in", + changes={"source_expense_id": str(eid)}, + ) copied += 1 await db.commit() diff --git a/backend/app/api/llm_models.py b/backend/app/api/llm_models.py index e57a26a..9ee8ca0 100644 --- a/backend/app/api/llm_models.py +++ b/backend/app/api/llm_models.py @@ -31,10 +31,28 @@ # Helpers # --------------------------------------------------------------------------- -OPENAI_INCLUDE_PREFIXES = ("gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4", "gpt-3.5-turbo", "o1", "o3", "o4") +OPENAI_INCLUDE_PREFIXES = ( + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-4", + "gpt-3.5-turbo", + "o1", + "o3", + "o4", +) OPENAI_EXCLUDE_SUBSTRINGS = ( - "dall-e", "whisper", "tts", "embedding", "moderation", "audio", - "babbage", "davinci", "search", "realtime", "transcribe", + "dall-e", + "whisper", + "tts", + "embedding", + "moderation", + "audio", + "babbage", + "davinci", + "search", + "realtime", + "transcribe", ) OPENAI_VISION_SUBSTRINGS = ("gpt-4o", "gpt-4-turbo", "o1", "o3", "o4") @@ -54,6 +72,7 @@ def _get_effective_api_key( if user.llm_api_key: try: from app.services.llm.encryption import decrypt_api_key + return decrypt_api_key(user.llm_api_key) except Exception: pass @@ -78,6 +97,7 @@ def _get_effective_api_key( # Provider helpers # --------------------------------------------------------------------------- + async def _list_openai(api_key: str) -> list[LLMModelInfo]: """Fetch and filter models from the OpenAI API.""" try: @@ -140,7 +160,12 @@ async def _list_anthropic(api_key: str) -> list[LLMModelInfo]: display_name: str = m.get("display_name", model_id) if not model_id: continue - vision = "claude-3" in model_id or "claude-opus" in model_id or "claude-sonnet" in model_id or "claude-haiku" in model_id + vision = ( + "claude-3" in model_id + or "claude-opus" in model_id + or "claude-sonnet" in model_id + or "claude-haiku" in model_id + ) models.append(LLMModelInfo(id=model_id, name=display_name, supports_vision=vision)) return sorted(models, key=lambda m: m.id)[:_MAX_MODELS] @@ -335,11 +360,9 @@ async def _list_mistral(api_key: str) -> list[LLMModelInfo]: async def _list_nebius(api_key: str, base_url: str | None) -> list[LLMModelInfo]: """Fetch models from the Nebius (OpenAI-compatible) API.""" - nebius_base = ( - base_url - or settings.nebius_base_url - or "https://api.studio.nebius.ai" - ).rstrip("/") + nebius_base = (base_url or settings.nebius_base_url or "https://api.studio.nebius.ai").rstrip( + "/" + ) try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: @@ -398,10 +421,13 @@ async def _list_ollama(base_url: str | None) -> list[LLMModelInfo]: # Route # --------------------------------------------------------------------------- + @router.get("/models", response_model=list[LLMModelInfo]) async def list_llm_models( provider: str = Query(..., description="LLM provider name"), - api_key: str | None = Query(default=None, description="Plaintext API key for this one-time fetch (never stored)"), + api_key: str | None = Query( + default=None, description="Plaintext API key for this one-time fetch (never stored)" + ), current_user: User = Depends(get_current_user), ) -> list[LLMModelInfo]: """ @@ -437,7 +463,11 @@ async def list_llm_models( elif provider_lower == "mistral": result = await _list_mistral(effective_key) if effective_key else [] elif provider_lower == "nebius": - result = await _list_nebius(effective_key, current_user.llm_base_url) if effective_key else [] + result = ( + await _list_nebius(effective_key, current_user.llm_base_url) + if effective_key + else [] + ) elif provider_lower == "ollama": result = await _list_ollama(current_user.llm_base_url) else: @@ -445,6 +475,8 @@ async def list_llm_models( result = [] if use_cache and result: - await set_cached_llm_models(provider_lower, current_user.id, [m.model_dump() for m in result]) + await set_cached_llm_models( + provider_lower, current_user.id, [m.model_dump() for m in result] + ) return result diff --git a/backend/app/api/monthly_reports.py b/backend/app/api/monthly_reports.py index 1d35080..1815ff1 100644 --- a/backend/app/api/monthly_reports.py +++ b/backend/app/api/monthly_reports.py @@ -33,7 +33,9 @@ def _parse_report_month(month: str) -> date: try: return month_start_from_string(month) except ValueError as error: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="month must be a valid YYYY-MM value") from error + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, detail="month must be a valid YYYY-MM value" + ) from error @router.post("/send", response_model=MonthlyReportSendResponse) diff --git a/backend/app/api/ollama.py b/backend/app/api/ollama.py index 1fcb35e..267ee39 100644 --- a/backend/app/api/ollama.py +++ b/backend/app/api/ollama.py @@ -1,6 +1,5 @@ """Ollama utility endpoints.""" - import httpx from fastapi import APIRouter, Depends @@ -29,7 +28,9 @@ async def list_ollama_models( Filters out known embedding models. Returns empty list if Ollama is not reachable. """ - ollama_base = (current_user.llm_base_url or settings.ollama_url or "http://localhost:11434").rstrip("/") + ollama_base = ( + current_user.llm_base_url or settings.ollama_url or "http://localhost:11434" + ).rstrip("/") try: async with httpx.AsyncClient(timeout=5.0) as client: diff --git a/backend/app/api/partners.py b/backend/app/api/partners.py index b7dad91..070c6fe 100644 --- a/backend/app/api/partners.py +++ b/backend/app/api/partners.py @@ -33,7 +33,9 @@ def _serialize_request(req: PartnerRequest, *, viewer_id: uuid.UUID) -> dict: "id": str(req.id), "direction": "sent" if is_sender else "received", "status": req.status, - "email": req.recipient_email if is_sender else (req.requester.email if req.requester else ""), + "email": req.recipient_email + if is_sender + else (req.requester.email if req.requester else ""), "name": None if is_sender else (req.requester.name if req.requester else None), "avatar_url": None if is_sender else (req.requester.avatar_url if req.requester else None), "created_at": req.created_at.isoformat(), @@ -62,7 +64,10 @@ async def list_partners( result = await db.execute( select(PartnerRequest) .where( - or_(PartnerRequest.requester_id == current_user.id, PartnerRequest.recipient_id == current_user.id) + or_( + PartnerRequest.requester_id == current_user.id, + PartnerRequest.recipient_id == current_user.id, + ) ) .order_by(PartnerRequest.created_at.desc()) ) @@ -80,7 +85,11 @@ async def list_partners( partner_result = await db.execute(select(User).where(User.id.in_(accepted_partner_ids))) partners = [_serialize_partner(u) for u in partner_result.scalars().all()] - pending = [_serialize_request(r, viewer_id=current_user.id) for r in requests if r.status == PARTNER_STATUS_PENDING] + pending = [ + _serialize_request(r, viewer_id=current_user.id) + for r in requests + if r.status == PARTNER_STATUS_PENDING + ] return {"partners": partners, "pending_requests": pending} @@ -103,20 +112,27 @@ async def send_partner_request( ) ) if existing.scalar_one_or_none(): - raise HTTPException(status_code=409, detail="A pending request to this email already exists") + raise HTTPException( + status_code=409, detail="A pending request to this email already exists" + ) recipient_result = await db.execute(select(User).where(func.lower(User.email) == target_email)) recipient = recipient_result.scalar_one_or_none() if not recipient: - raise HTTPException(status_code=404, detail="No SpendHound account found with that email. Only existing users can be added as partners.") + raise HTTPException( + status_code=404, + detail="No SpendHound account found with that email. Only existing users can be added as partners.", + ) if recipient: already_partners = await db.execute( select(PartnerRequest).where( or_( - (PartnerRequest.requester_id == current_user.id) & (PartnerRequest.recipient_id == recipient.id), - (PartnerRequest.requester_id == recipient.id) & (PartnerRequest.recipient_id == current_user.id), + (PartnerRequest.requester_id == current_user.id) + & (PartnerRequest.recipient_id == recipient.id), + (PartnerRequest.requester_id == recipient.id) + & (PartnerRequest.recipient_id == current_user.id), ), PartnerRequest.status == PARTNER_STATUS_ACCEPTED, ) @@ -164,7 +180,10 @@ async def accept_partner_request( req = result.scalar_one_or_none() if not req: raise HTTPException(status_code=404, detail="Request not found") - if req.recipient_id != current_user.id and func.lower(req.recipient_email) != current_user.email.lower(): + if ( + req.recipient_id != current_user.id + and func.lower(req.recipient_email) != current_user.email.lower() + ): raise HTTPException(status_code=403, detail="Not your request to accept") if req.status != PARTNER_STATUS_PENDING: raise HTTPException(status_code=409, detail=f"Request is already {req.status}") @@ -185,7 +204,10 @@ async def reject_partner_request( req = result.scalar_one_or_none() if not req: raise HTTPException(status_code=404, detail="Request not found") - if req.recipient_id != current_user.id and req.recipient_email.lower() != current_user.email.lower(): + if ( + req.recipient_id != current_user.id + and req.recipient_email.lower() != current_user.email.lower() + ): raise HTTPException(status_code=403, detail="Not your request to reject") if req.status != PARTNER_STATUS_PENDING: raise HTTPException(status_code=409, detail=f"Request is already {req.status}") diff --git a/backend/app/api/receipts.py b/backend/app/api/receipts.py index 8980f05..005d637 100644 --- a/backend/app/api/receipts.py +++ b/backend/app/api/receipts.py @@ -28,7 +28,11 @@ @router.get("") -async def list_receipts(needs_review: bool | None = Query(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> list[dict]: +async def list_receipts( + needs_review: bool | None = Query(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> list[dict]: statement = select(Receipt).where(Receipt.user_id == current_user.id) if needs_review is not None: statement = statement.where(Receipt.needs_review.is_(needs_review)) @@ -38,12 +42,24 @@ async def list_receipts(needs_review: bool | None = Query(default=None), current @router.post("/upload") @limiter.limit(f"{settings.rate_limit_upload_per_minute}/minute") -async def upload_receipt(request: Request, file: UploadFile = File(...), provider: str | None = Form(default=None), model: str | None = Form(default=None), api_key: str | None = Form(default=None), base_url: str | None = Form(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), _bot_check: None = Depends(block_bots)) -> dict: +async def upload_receipt( + request: Request, + file: UploadFile = File(...), + provider: str | None = Form(default=None), + model: str | None = Form(default=None), + api_key: str | None = Form(default=None), + base_url: str | None = Form(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + _bot_check: None = Depends(block_bots), +) -> dict: await validate_llm_base_url(base_url) await ensure_default_categories(db, current_user.id) stored = await store_upload(current_user.id, file) filename = file.filename or stored.stored_filename - llm_config = create_llm_config(provider=provider, model=model, api_key=api_key, base_url=base_url) + llm_config = create_llm_config( + provider=provider, model=model, api_key=api_key, base_url=base_url + ) receipt = Receipt( user_id=current_user.id, original_filename=filename, @@ -89,15 +105,27 @@ async def upload_receipt(request: Request, file: UploadFile = File(...), provide @router.post("/upload-statement") -async def upload_statement(file: UploadFile = File(...), provider: str | None = Form(default=None), model: str | None = Form(default=None), api_key: str | None = Form(default=None), base_url: str | None = Form(default=None), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: +async def upload_statement( + file: UploadFile = File(...), + provider: str | None = Form(default=None), + model: str | None = Form(default=None), + api_key: str | None = Form(default=None), + base_url: str | None = Form(default=None), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: await validate_llm_base_url(base_url) filename = file.filename or "statement.pdf" is_pdf = (file.content_type or "") == "application/pdf" or filename.lower().endswith(".pdf") if not is_pdf: - raise HTTPException(status_code=400, detail="Bank statement import currently requires a PDF upload") + raise HTTPException( + status_code=400, detail="Bank statement import currently requires a PDF upload" + ) stored = await store_upload(current_user.id, file) - llm_config = create_llm_config(provider=provider, model=model, api_key=api_key, base_url=base_url) + llm_config = create_llm_config( + provider=provider, model=model, api_key=api_key, base_url=base_url + ) # Create receipt immediately with pending status — extraction runs in Celery worker. receipt = Receipt( @@ -132,8 +160,14 @@ async def upload_statement(file: UploadFile = File(...), provider: str | None = @router.get("/{receipt_id}") -async def get_receipt(receipt_id: uuid.UUID, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)) -> dict: - result = await db.execute(select(Receipt).where(Receipt.id == receipt_id, Receipt.user_id == current_user.id)) +async def get_receipt( + receipt_id: uuid.UUID, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(Receipt).where(Receipt.id == receipt_id, Receipt.user_id == current_user.id) + ) receipt = result.scalar_one_or_none() if receipt is None: raise HTTPException(status_code=404, detail="Receipt not found") diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index 34eb366..a2748bc 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -24,7 +24,12 @@ "spendhound", broker=settings.redis_url, backend=None, # results discarded — status lives in Postgres - include=["app.tasks.receipt_tasks", "app.tasks.statement_tasks", "app.tasks.report_tasks", "app.tasks.demo_tasks"], + include=[ + "app.tasks.receipt_tasks", + "app.tasks.statement_tasks", + "app.tasks.report_tasks", + "app.tasks.demo_tasks", + ], ) celery_app.conf.update( diff --git a/backend/app/config.py b/backend/app/config.py index 8f07734..86b03d1 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -29,12 +29,16 @@ class Settings(BaseSettings): default="ollama", description="LLM provider: ollama | openai | anthropic | nebius", ) - ollama_url: str = Field(default="http://host.docker.internal:11434", description="Ollama base URL") + ollama_url: str = Field( + default="http://host.docker.internal:11434", description="Ollama base URL" + ) ollama_model: str = Field(default="gemma4:e4b", description="Ollama model name") openai_api_key: str = Field(default="", description="OpenAI API key") openai_model: str = Field(default="gpt-4o-mini", description="OpenAI model name") anthropic_api_key: str = Field(default="", description="Anthropic API key") - anthropic_model: str = Field(default="claude-sonnet-4-20250514", description="Anthropic model name") + anthropic_model: str = Field( + default="claude-sonnet-4-20250514", description="Anthropic model name" + ) nebius_api_key: str = Field(default="", description="Nebius API key") nebius_model: str = Field(default="", description="Nebius model name") nebius_base_url: str = Field(default="", description="Nebius base URL") @@ -46,26 +50,51 @@ class Settings(BaseSettings): app_url: str = Field(default="http://localhost:3000", description="Public frontend URL") resend_api_key: str = Field(default="", description="Resend API key") resend_from_email: str = Field(default="", description="Approval email sender") - monthly_reports_enabled: bool = Field(default=False, description="Enable monthly report delivery job") - monthly_reports_timezone: str = Field(default="UTC", description="IANA timezone used to compute the reporting month") - monthly_reports_frontend_pdf_url: str = Field(default="", description="Internal frontend endpoint used to render monthly report PDFs") - monthly_reports_frontend_token: str = Field(default="", description="Shared secret token sent to the internal frontend PDF endpoint") - monthly_reports_frontend_token_header: str = Field(default="X-SpendHound-Internal-Token", description="Header name used for monthly report frontend authentication") - monthly_reports_frontend_timeout_seconds: int = Field(default=60, description="Timeout for internal frontend monthly report PDF requests") - recurring_generation_enabled: bool = Field(default=False, description="Enable automatic generation of recurring expenses") - recurring_generation_timezone: str = Field(default="UTC", description="IANA timezone used to compute recurring expense generation months") + monthly_reports_enabled: bool = Field( + default=False, description="Enable monthly report delivery job" + ) + monthly_reports_timezone: str = Field( + default="UTC", description="IANA timezone used to compute the reporting month" + ) + monthly_reports_frontend_pdf_url: str = Field( + default="", description="Internal frontend endpoint used to render monthly report PDFs" + ) + monthly_reports_frontend_token: str = Field( + default="", description="Shared secret token sent to the internal frontend PDF endpoint" + ) + monthly_reports_frontend_token_header: str = Field( + default="X-SpendHound-Internal-Token", + description="Header name used for monthly report frontend authentication", + ) + monthly_reports_frontend_timeout_seconds: int = Field( + default=60, description="Timeout for internal frontend monthly report PDF requests" + ) + recurring_generation_enabled: bool = Field( + default=False, description="Enable automatic generation of recurring expenses" + ) + recurring_generation_timezone: str = Field( + default="UTC", + description="IANA timezone used to compute recurring expense generation months", + ) llm_key_encryption_secret: str = Field( default="", description=( "Fernet encryption key for user LLM API keys stored in the database. " - "Generate with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + 'Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' ), ) - ollama_embedding_model: str = Field(default="embeddinggemma:latest", description="Ollama model for item embeddings (RAG)") - embedding_dimensions: int = Field(default=768, description="Vector dimensions produced by the embedding model") - rag_similarity_threshold: float = Field(default=0.22, description="Cosine distance threshold for RAG match (lower = stricter; 0.0=identical, 2.0=opposite)") + ollama_embedding_model: str = Field( + default="embeddinggemma:latest", description="Ollama model for item embeddings (RAG)" + ) + embedding_dimensions: int = Field( + default=768, description="Vector dimensions produced by the embedding model" + ) + rag_similarity_threshold: float = Field( + default=0.22, + description="Cosine distance threshold for RAG match (lower = stricter; 0.0=identical, 2.0=opposite)", + ) # ── LLM concurrency ────────────────────────────────────────────────────────── ollama_max_concurrent: int = Field( @@ -102,13 +131,21 @@ class Settings(BaseSettings): ) # ── Rate limiting ───────────────────────────────────────────────────────── - rate_limit_chat_per_minute: int = Field(default=20, description="Chat stream requests per user per minute") - rate_limit_upload_per_minute: int = Field(default=3, description="Receipt uploads per user per minute") - rate_limit_auth_per_minute: int = Field(default=10, description="Auth requests per IP per minute") + rate_limit_chat_per_minute: int = Field( + default=20, description="Chat stream requests per user per minute" + ) + rate_limit_upload_per_minute: int = Field( + default=3, description="Receipt uploads per user per minute" + ) + rate_limit_auth_per_minute: int = Field( + default=10, description="Auth requests per IP per minute" + ) # ── Database connection pool ────────────────────────────────────────────── db_pool_size: int = Field(default=20, description="SQLAlchemy async connection pool base size") - db_max_overflow: int = Field(default=40, description="Max extra connections beyond db_pool_size") + db_max_overflow: int = Field( + default=40, description="Max extra connections beyond db_pool_size" + ) # ── Observability ───────────────────────────────────────────────────────── metrics_token: str = Field( diff --git a/backend/app/database.py b/backend/app/database.py index 9351665..d306e9a 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -4,6 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.pool import NullPool from app.config import settings @@ -15,13 +16,13 @@ if not settings.database_url.startswith("sqlite"): engine_kwargs["pool_size"] = settings.db_pool_size engine_kwargs["max_overflow"] = settings.db_max_overflow - engine_kwargs["pool_timeout"] = 30 # fail fast if pool exhausted instead of blocking - engine_kwargs["pool_recycle"] = 1800 # recycle stale connections every 30 min + engine_kwargs["pool_timeout"] = 30 # fail fast if pool exhausted instead of blocking + engine_kwargs["pool_recycle"] = 1800 # recycle stale connections every 30 min -# Create async engine with connection pooling where supported. +# FastAPI engine - connection pool shared across the process lifetime. engine = create_async_engine(settings.database_url, **engine_kwargs) -# Session factory +# Session factory for FastAPI request handlers. AsyncSessionLocal = async_sessionmaker( engine, class_=AsyncSession, @@ -30,6 +31,26 @@ autoflush=False, ) +# Celery tasks call asyncio.run() for each task, creating a new event loop every +# time. The pooled engine above keeps asyncpg connections alive across calls; +# those connections hold asyncio Futures bound to the previous (now closed) loop, +# causing "Future attached to a different loop" on the second task run. +# NullPool disables connection reuse entirely: each checkout opens a fresh +# connection and each checkin closes it, so there is no cross-loop state. +_celery_engine = create_async_engine( + settings.database_url, + poolclass=NullPool, + echo=settings.debug, +) + +CelerySessionLocal = async_sessionmaker( + _celery_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) + class Base(DeclarativeBase): """Base class for all SQLAlchemy ORM models.""" diff --git a/backend/app/jobs/monthly_reports.py b/backend/app/jobs/monthly_reports.py index ea47539..7c430f5 100644 --- a/backend/app/jobs/monthly_reports.py +++ b/backend/app/jobs/monthly_reports.py @@ -41,7 +41,11 @@ class MonthlyReportSendResult: def previous_calendar_month_start(reference_datetime: datetime | None = None) -> date: timezone_name = ZoneInfo(settings.monthly_reports_timezone) - local_now = reference_datetime.astimezone(timezone_name) if reference_datetime else datetime.now(timezone_name) + local_now = ( + reference_datetime.astimezone(timezone_name) + if reference_datetime + else datetime.now(timezone_name) + ) if local_now.month == 1: return date(local_now.year - 1, 12, 1) return date(local_now.year, local_now.month - 1, 1) @@ -53,7 +57,9 @@ async def fetch_monthly_report_pdf(user: User, report_month: date) -> bytes: headers = {"Accept": "application/pdf"} if settings.monthly_reports_frontend_token: - headers[settings.monthly_reports_frontend_token_header] = settings.monthly_reports_frontend_token + headers[settings.monthly_reports_frontend_token_header] = ( + settings.monthly_reports_frontend_token + ) payload = { "user_id": str(user.id), @@ -62,8 +68,12 @@ async def fetch_monthly_report_pdf(user: User, report_month: date) -> bytes: "report_month": report_month.strftime("%Y-%m"), } - async with httpx.AsyncClient(timeout=settings.monthly_reports_frontend_timeout_seconds) as client: - response = await client.post(settings.monthly_reports_frontend_pdf_url, headers=headers, json=payload) + async with httpx.AsyncClient( + timeout=settings.monthly_reports_frontend_timeout_seconds + ) as client: + response = await client.post( + settings.monthly_reports_frontend_pdf_url, headers=headers, json=payload + ) response.raise_for_status() if not response.content: @@ -94,7 +104,9 @@ async def get_or_create_monthly_report_delivery( ) delivery = delivery_result.scalar_one_or_none() if delivery is None: - delivery = MonthlyReportDelivery(user_id=user_id, report_month=report_month, status="pending") + delivery = MonthlyReportDelivery( + user_id=user_id, report_month=report_month, status="pending" + ) db.add(delivery) await db.flush() return delivery @@ -108,7 +120,9 @@ async def send_monthly_report_for_user( force: bool = False, ) -> MonthlyReportSendResult: report_month_key = report_month.strftime("%Y-%m") - delivery = await get_or_create_monthly_report_delivery(db, user_id=user.id, report_month=report_month) + delivery = await get_or_create_monthly_report_delivery( + db, user_id=user.id, report_month=report_month + ) if not force and delivery.status == "sent": logger.info( @@ -118,7 +132,9 @@ async def send_monthly_report_for_user( report_month=report_month_key, reason="already_sent", ) - return MonthlyReportSendResult(report_month=report_month_key, outcome="skipped", delivery=delivery) + return MonthlyReportSendResult( + report_month=report_month_key, outcome="skipped", delivery=delivery + ) try: attempted_at = datetime.now(UTC) @@ -129,7 +145,9 @@ async def send_monthly_report_for_user( delivery.resend_email_id = None delivery.pdf_source_url = settings.monthly_reports_frontend_pdf_url or None - expense_json_bytes = await build_expense_export_json_bytes(db, user_id=user.id, month=report_month_key) + expense_json_bytes = await build_expense_export_json_bytes( + db, user_id=user.id, month=report_month_key + ) dashboard_pdf_bytes = await fetch_monthly_report_pdf(user, report_month) resend_email_id = await send_monthly_report_email( user.email, @@ -154,7 +172,9 @@ async def send_monthly_report_for_user( resend_email_id=resend_email_id, forced=force, ) - return MonthlyReportSendResult(report_month=report_month_key, outcome="sent", delivery=delivery) + return MonthlyReportSendResult( + report_month=report_month_key, outcome="sent", delivery=delivery + ) except Exception as error: delivery.status = "failed" delivery.error_message = str(error) @@ -169,13 +189,19 @@ async def send_monthly_report_for_user( error=str(error), forced=force, ) - return MonthlyReportSendResult(report_month=report_month_key, outcome="failed", delivery=delivery) + return MonthlyReportSendResult( + report_month=report_month_key, outcome="failed", delivery=delivery + ) -async def send_monthly_reports_for_month(db: AsyncSession, report_month: date) -> MonthlyReportJobSummary: +async def send_monthly_reports_for_month( + db: AsyncSession, report_month: date +) -> MonthlyReportJobSummary: report_month_key = report_month.strftime("%Y-%m") summary = MonthlyReportJobSummary(report_month=report_month_key) - users_result = await db.execute(select(User).where(User.status == "approved").order_by(User.created_at.asc())) + users_result = await db.execute( + select(User).where(User.status == "approved").order_by(User.created_at.asc()) + ) for user in users_result.scalars().all(): summary.processed_users += 1 diff --git a/backend/app/jobs/recurring_expenses.py b/backend/app/jobs/recurring_expenses.py index a2228d6..88c11be 100644 --- a/backend/app/jobs/recurring_expenses.py +++ b/backend/app/jobs/recurring_expenses.py @@ -29,7 +29,11 @@ class RecurringExpenseGenerationSummary: def current_calendar_month_start(reference_datetime: datetime | None = None) -> date: timezone_name = ZoneInfo(settings.recurring_generation_timezone) - local_now = reference_datetime.astimezone(timezone_name) if reference_datetime else datetime.now(timezone_name) + local_now = ( + reference_datetime.astimezone(timezone_name) + if reference_datetime + else datetime.now(timezone_name) + ) return date(local_now.year, local_now.month, 1) @@ -38,7 +42,9 @@ async def generate_recurring_expenses_for_all_users( target_month: date, ) -> RecurringExpenseGenerationSummary: summary = RecurringExpenseGenerationSummary(target_month=target_month.strftime("%Y-%m")) - users_result = await db.execute(select(User).where(User.status == "approved").order_by(User.created_at.asc())) + users_result = await db.execute( + select(User).where(User.status == "approved").order_by(User.created_at.asc()) + ) for user in users_result.scalars().all(): summary.processed_users += 1 diff --git a/backend/app/main.py b/backend/app/main.py index 8ee4fd2..984f96b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -142,7 +142,9 @@ def _safe_get_route_name(scope, routes, route_name=None): # type: ignore[no-unt app.include_router(receipts.router, prefix="/api/receipts", tags=["receipts"]) app.include_router(chat.router, prefix="/api/chat", tags=["chat"]) app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"]) - app.include_router(monthly_reports.router, prefix="/api/monthly-reports", tags=["monthly-reports"]) + app.include_router( + monthly_reports.router, prefix="/api/monthly-reports", tags=["monthly-reports"] + ) app.include_router(ollama.router) app.include_router(llm_models.router) @@ -154,9 +156,7 @@ async def health_check() -> dict[str, str]: async def metrics_endpoint(authorization: str = Header(default="")) -> Response: """Prometheus scrape endpoint — bearer token required.""" token = settings.metrics_token - if not token or not hmac.compare_digest( - authorization.encode(), f"Bearer {token}".encode() - ): + if not token or not hmac.compare_digest(authorization.encode(), f"Bearer {token}".encode()): raise HTTPException(status_code=403, detail="Forbidden") depth = await get_celery_queue_depth() RECEIPT_QUEUE_DEPTH.set(depth) diff --git a/backend/app/middleware/rate_limit.py b/backend/app/middleware/rate_limit.py index b2d7dec..574b20b 100644 --- a/backend/app/middleware/rate_limit.py +++ b/backend/app/middleware/rate_limit.py @@ -34,4 +34,6 @@ def _rate_limit_key(request: Request) -> str: return f"ip:{get_remote_address(request)}" -limiter = Limiter(key_func=_rate_limit_key, storage_uri=settings.redis_url, strategy="moving-window") +limiter = Limiter( + key_func=_rate_limit_key, storage_uri=settings.redis_url, strategy="moving-window" +) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 44f31dc..ce400ba 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -13,4 +13,21 @@ from app.models.receipt import Receipt from app.models.user import User -__all__ = ["Budget", "Category", "ChatMessage", "ChatSession", "Expense", "ExpenseItem", "ItemEmbedding", "ItemKeywordRule", "Ledger", "LedgerAuditLog", "LedgerMembership", "MerchantRule", "MonthlyReportDelivery", "PartnerRequest", "Receipt", "User"] +__all__ = [ + "Budget", + "Category", + "ChatMessage", + "ChatSession", + "Expense", + "ExpenseItem", + "ItemEmbedding", + "ItemKeywordRule", + "Ledger", + "LedgerAuditLog", + "LedgerMembership", + "MerchantRule", + "MonthlyReportDelivery", + "PartnerRequest", + "Receipt", + "User", +] diff --git a/backend/app/models/budget.py b/backend/app/models/budget.py index dcd6a9f..015042d 100644 --- a/backend/app/models/budget.py +++ b/backend/app/models/budget.py @@ -23,16 +23,27 @@ class Budget(Base): __tablename__ = "budgets" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("categories.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) name: Mapped[str] = mapped_column(String(120), nullable=False) amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default="EUR") period: Mapped[str] = mapped_column(String(20), nullable=False, server_default="monthly") month_start: Mapped[date] = mapped_column(Date, nullable=False, index=True) notes: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) user: Mapped[User] = relationship("User", back_populates="budgets") category: Mapped[Category | None] = relationship("Category", back_populates="budgets") diff --git a/backend/app/models/category.py b/backend/app/models/category.py index aaacee6..e0c30ad 100644 --- a/backend/app/models/category.py +++ b/backend/app/models/category.py @@ -34,20 +34,30 @@ class Category(Base): __table_args__ = (UniqueConstraint("user_id", "name", name="uq_categories_user_name"),) id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) name: Mapped[str] = mapped_column(String(80), nullable=False) color: Mapped[str] = mapped_column(String(20), nullable=False, server_default="#60a5fa") icon: Mapped[str | None] = mapped_column(String(32), nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) - transaction_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default="debit", index=True) + transaction_type: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="debit", index=True + ) is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) user: Mapped[User] = relationship("User", back_populates="categories") expenses: Mapped[list[Expense]] = relationship("Expense", back_populates="category") budgets: Mapped[list[Budget]] = relationship("Budget", back_populates="category") - merchant_rules: Mapped[list[MerchantRule]] = relationship("MerchantRule", back_populates="category") + merchant_rules: Mapped[list[MerchantRule]] = relationship( + "MerchantRule", back_populates="category" + ) class MerchantRule(Base): @@ -56,16 +66,29 @@ class MerchantRule(Base): __tablename__ = "merchant_rules" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("categories.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) merchant_pattern: Mapped[str] = mapped_column(String(255), nullable=False) pattern_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default="contains") priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="100") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") - is_global: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", index=True) + is_global: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false", index=True + ) notes: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) user: Mapped[User] = relationship("User", back_populates="merchant_rules") category: Mapped[Category | None] = relationship("Category", back_populates="merchant_rules") @@ -81,15 +104,23 @@ class ItemKeywordRule(Base): __tablename__ = "item_keyword_rules" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - is_global: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + is_global: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false", index=True + ) keyword: Mapped[str] = mapped_column(String(255), nullable=False) subcategory_label: Mapped[str] = mapped_column(String(120), nullable=False) pattern_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default="fuzzy") priority: Mapped[int] = mapped_column(Integer, nullable=False, server_default="100") is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") notes: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) user: Mapped[User] = relationship("User", back_populates="item_keyword_rules") diff --git a/backend/app/models/chat_message.py b/backend/app/models/chat_message.py index 7e8c497..f9d8250 100644 --- a/backend/app/models/chat_message.py +++ b/backend/app/models/chat_message.py @@ -22,8 +22,15 @@ class ChatMessage(Base): __tablename__ = "chat_messages" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - session_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + session_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("chat_sessions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) role: Mapped[str] = mapped_column(String(20), nullable=False, index=True) content: Mapped[str] = mapped_column(Text, nullable=False) client_id: Mapped[str] = mapped_column(String(120), nullable=False, index=True) @@ -32,7 +39,9 @@ class ChatMessage(Base): model: Mapped[str | None] = mapped_column(String(255), nullable=True) token_count: Mapped[int | None] = mapped_column(Integer, nullable=True) message_metadata: Mapped[dict] = mapped_column("metadata", JSON, nullable=False, default=dict) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) diff --git a/backend/app/models/chat_session.py b/backend/app/models/chat_session.py index aaff08a..8bacfb1 100644 --- a/backend/app/models/chat_session.py +++ b/backend/app/models/chat_session.py @@ -22,13 +22,19 @@ class ChatSession(Base): __tablename__ = "chat_sessions" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False, server_default="New Chat") summary: Mapped[str | None] = mapped_column(Text, nullable=True) token_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") max_tokens: Mapped[int] = mapped_column(Integer, nullable=False, server_default="4096") - last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + last_message_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) diff --git a/backend/app/models/expense.py b/backend/app/models/expense.py index c0a7fdf..6671349 100644 --- a/backend/app/models/expense.py +++ b/backend/app/models/expense.py @@ -38,13 +38,27 @@ class Expense(Base): __tablename__ = "expenses" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("categories.id", ondelete="SET NULL"), nullable=True, index=True) - receipt_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("receipts.id", ondelete="SET NULL"), nullable=True, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("categories.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + receipt_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("receipts.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) merchant: Mapped[str] = mapped_column(String(255), nullable=False, index=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) - transaction_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default="debit", index=True) + transaction_type: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="debit", index=True + ) currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default="EUR") expense_date: Mapped[date] = mapped_column(Date, nullable=False, index=True) source: Mapped[str] = mapped_column(String(30), nullable=False, server_default="manual") @@ -53,24 +67,45 @@ class Expense(Base): notes: Mapped[str | None] = mapped_column(Text, nullable=True) recurring_group: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) is_recurring: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") - cadence: Mapped[str] = mapped_column(String(20), nullable=False, server_default="one_time", index=True) + cadence: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="one_time", index=True + ) cadence_override: Mapped[str | None] = mapped_column(String(20), nullable=True) cadence_interval: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) prepaid_months: Mapped[int | None] = mapped_column(Integer, nullable=True) prepaid_start_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) - recurring_variable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - recurring_auto_add: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - recurring_source_expense_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("expenses.id", ondelete="SET NULL"), nullable=True, index=True) - auto_generated: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + recurring_variable: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + recurring_auto_add: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + recurring_source_expense_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("expenses.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + auto_generated: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) generated_for_month: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) is_major_purchase: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) - ledger_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("ledgers.id", ondelete="SET NULL"), nullable=True, index=True) + ledger_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), ForeignKey("ledgers.id", ondelete="SET NULL"), nullable=True, index=True + ) user: Mapped[User] = relationship("User", back_populates="expenses") category: Mapped[Category | None] = relationship("Category", back_populates="expenses") receipt: Mapped[Receipt | None] = relationship("Receipt", back_populates="expenses") - items: Mapped[list[ExpenseItem]] = relationship("ExpenseItem", back_populates="expense", cascade="all, delete-orphan") + items: Mapped[list[ExpenseItem]] = relationship( + "ExpenseItem", back_populates="expense", cascade="all, delete-orphan" + ) ledger: Mapped[Ledger | None] = relationship("Ledger", back_populates="expenses") diff --git a/backend/app/models/expense_item.py b/backend/app/models/expense_item.py index 6c2c00d..07d65e7 100644 --- a/backend/app/models/expense_item.py +++ b/backend/app/models/expense_item.py @@ -22,14 +22,23 @@ class ExpenseItem(Base): __tablename__ = "expense_items" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - expense_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("expenses.id", ondelete="CASCADE"), nullable=False, index=True) + expense_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("expenses.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) description: Mapped[str] = mapped_column(String(300), nullable=False) quantity: Mapped[float | None] = mapped_column(Float, nullable=True) unit_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) total_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) subcategory: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) subcategory_confidence: Mapped[float | None] = mapped_column(Float, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) expense: Mapped[Expense] = relationship("Expense", back_populates="items") diff --git a/backend/app/models/item_embedding.py b/backend/app/models/item_embedding.py index fb6e1f1..9302a63 100644 --- a/backend/app/models/item_embedding.py +++ b/backend/app/models/item_embedding.py @@ -30,15 +30,21 @@ class ItemEmbedding(Base): user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True ) - is_global: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false", index=True) + is_global: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="false", index=True + ) description_text: Mapped[str] = mapped_column(String(300), nullable=False) # Vector dimension must match settings.embedding_dimensions (default 768). # If you change the dimension, create a new migration to alter the column. - embedding: Mapped[list[float]] = mapped_column(Vector(settings.embedding_dimensions), nullable=False) + embedding: Mapped[list[float]] = mapped_column( + Vector(settings.embedding_dimensions), nullable=False + ) subcategory_label: Mapped[str] = mapped_column(String(120), nullable=False) # source: "document" (admin upload) | "correction" (user-confirmed fix) source: Mapped[str] = mapped_column(String(50), nullable=False, server_default="document") notes: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) user: Mapped[User | None] = relationship("User", back_populates="item_embeddings") diff --git a/backend/app/models/ledger.py b/backend/app/models/ledger.py index d7bbfa7..5bdd19e 100644 --- a/backend/app/models/ledger.py +++ b/backend/app/models/ledger.py @@ -22,14 +22,26 @@ class Ledger(Base): id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(255), nullable=False) type: Mapped[str] = mapped_column(String(20), nullable=False, server_default="personal") - created_by: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - creator: Mapped[User] = relationship("User", foreign_keys=[created_by], back_populates="created_ledgers") - memberships: Mapped[list[LedgerMembership]] = relationship("LedgerMembership", back_populates="ledger", cascade="all, delete-orphan") + created_by: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + creator: Mapped[User] = relationship( + "User", foreign_keys=[created_by], back_populates="created_ledgers" + ) + memberships: Mapped[list[LedgerMembership]] = relationship( + "LedgerMembership", back_populates="ledger", cascade="all, delete-orphan" + ) expenses: Mapped[list[Expense]] = relationship("Expense", back_populates="ledger") - audit_logs: Mapped[list[LedgerAuditLog]] = relationship("LedgerAuditLog", back_populates="ledger", cascade="all, delete-orphan") + audit_logs: Mapped[list[LedgerAuditLog]] = relationship( + "LedgerAuditLog", back_populates="ledger", cascade="all, delete-orphan" + ) class LedgerMembership(Base): @@ -37,10 +49,16 @@ class LedgerMembership(Base): __table_args__ = (UniqueConstraint("ledger_id", "user_id", name="uq_ledger_membership"),) id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - ledger_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("ledgers.id", ondelete="CASCADE"), nullable=False, index=True) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + ledger_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("ledgers.id", ondelete="CASCADE"), nullable=False, index=True + ) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) role: Mapped[str] = mapped_column(String(20), nullable=False, server_default="owner") - joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + joined_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) ledger: Mapped[Ledger] = relationship("Ledger", back_populates="memberships") user: Mapped[User] = relationship("User", back_populates="ledger_memberships") @@ -50,12 +68,23 @@ class LedgerAuditLog(Base): __tablename__ = "ledger_audit_logs" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - ledger_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("ledgers.id", ondelete="CASCADE"), nullable=False, index=True) - expense_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("expenses.id", ondelete="SET NULL"), nullable=True, index=True) - user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + ledger_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("ledgers.id", ondelete="CASCADE"), nullable=False, index=True + ) + expense_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), + ForeignKey("expenses.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + user_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) action: Mapped[str] = mapped_column(String(50), nullable=False) changes: Mapped[str | None] = mapped_column(String, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) ledger: Mapped[Ledger] = relationship("Ledger", back_populates="audit_logs") user: Mapped[User] = relationship("User") diff --git a/backend/app/models/monthly_report_delivery.py b/backend/app/models/monthly_report_delivery.py index ba68878..0b28d3b 100644 --- a/backend/app/models/monthly_report_delivery.py +++ b/backend/app/models/monthly_report_delivery.py @@ -24,15 +24,21 @@ class MonthlyReportDelivery(Base): ) id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) report_month: Mapped[date] = mapped_column(Date, nullable=False, index=True) - status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending", index=True) + status: Mapped[str] = mapped_column( + String(20), nullable=False, server_default="pending", index=True + ) attempted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) resend_email_id: Mapped[str | None] = mapped_column(String(255), nullable=True) pdf_source_url: Mapped[str | None] = mapped_column(String(1000), nullable=True) error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/backend/app/models/partner.py b/backend/app/models/partner.py index 29f7422..037b387 100644 --- a/backend/app/models/partner.py +++ b/backend/app/models/partner.py @@ -23,13 +23,27 @@ class PartnerRequest(Base): __tablename__ = "partner_requests" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - requester_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + requester_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) recipient_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) - recipient_id: Mapped[uuid.UUID | None] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=PARTNER_STATUS_PENDING) + recipient_id: Mapped[uuid.UUID | None] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, server_default=PARTNER_STATUS_PENDING + ) token: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) - - requester: Mapped[User] = relationship("User", foreign_keys=[requester_id], back_populates="sent_partner_requests") - recipient: Mapped[User | None] = relationship("User", foreign_keys=[recipient_id], back_populates="received_partner_requests") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) + + requester: Mapped[User] = relationship( + "User", foreign_keys=[requester_id], back_populates="sent_partner_requests" + ) + recipient: Mapped[User | None] = relationship( + "User", foreign_keys=[recipient_id], back_populates="received_partner_requests" + ) diff --git a/backend/app/models/receipt.py b/backend/app/models/receipt.py index 5424369..93dc3ba 100644 --- a/backend/app/models/receipt.py +++ b/backend/app/models/receipt.py @@ -22,7 +22,9 @@ class Receipt(Base): __tablename__ = "receipts" id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[uuid.UUID] = mapped_column( + Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) original_filename: Mapped[str] = mapped_column(String(255), nullable=False) stored_filename: Mapped[str] = mapped_column(String(255), nullable=False) content_type: Mapped[str | None] = mapped_column(String(120), nullable=True) @@ -32,11 +34,17 @@ class Receipt(Base): preview_data: Mapped[dict | None] = mapped_column(JSON, nullable=True) extraction_confidence: Mapped[float | None] = mapped_column(Float, nullable=True) document_kind: Mapped[str] = mapped_column(String(20), nullable=False, server_default="receipt") - extraction_status: Mapped[str] = mapped_column(String(30), nullable=False, server_default="uploaded") + extraction_status: Mapped[str] = mapped_column( + String(30), nullable=False, server_default="uploaded" + ) needs_review: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true") review_notes: Mapped[str | None] = mapped_column(Text, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False + ) finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) user: Mapped[User] = relationship("User", back_populates="receipts") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index a84c28f..f6dfdea 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -34,31 +34,69 @@ class User(Base): name: Mapped[str | None] = mapped_column(String(255), nullable=True) avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="pending") - automatic_monthly_reports: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=true()) + automatic_monthly_reports: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=true() + ) receipt_prompt_override: Mapped[str | None] = mapped_column(Text, nullable=True) llm_provider: Mapped[str | None] = mapped_column(String(50), nullable=True) llm_model: Mapped[str | None] = mapped_column(String(255), nullable=True) llm_api_key: Mapped[str | None] = mapped_column(Text, nullable=True) llm_base_url: Mapped[str | None] = mapped_column(String(500), nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False ) - created_ledgers: Mapped[list["Ledger"]] = relationship("Ledger", foreign_keys="Ledger.created_by", back_populates="creator", cascade="all, delete-orphan") - ledger_memberships: Mapped[list["LedgerMembership"]] = relationship("LedgerMembership", back_populates="user", cascade="all, delete-orphan") - sent_partner_requests: Mapped[list["PartnerRequest"]] = relationship("PartnerRequest", foreign_keys="PartnerRequest.requester_id", back_populates="requester", cascade="all, delete-orphan") - received_partner_requests: Mapped[list["PartnerRequest"]] = relationship("PartnerRequest", foreign_keys="PartnerRequest.recipient_id", back_populates="recipient") - categories: Mapped[list["Category"]] = relationship("Category", back_populates="user", cascade="all, delete-orphan") - merchant_rules: Mapped[list["MerchantRule"]] = relationship("MerchantRule", back_populates="user", cascade="all, delete-orphan") - item_keyword_rules: Mapped[list["ItemKeywordRule"]] = relationship("ItemKeywordRule", back_populates="user", cascade="all, delete-orphan") - item_embeddings: Mapped[list["ItemEmbedding"]] = relationship("ItemEmbedding", back_populates="user", cascade="all, delete-orphan") - budgets: Mapped[list["Budget"]] = relationship("Budget", back_populates="user", cascade="all, delete-orphan") - receipts: Mapped[list["Receipt"]] = relationship("Receipt", back_populates="user", cascade="all, delete-orphan") - expenses: Mapped[list["Expense"]] = relationship("Expense", back_populates="user", cascade="all, delete-orphan") - monthly_report_deliveries: Mapped[list["MonthlyReportDelivery"]] = relationship("MonthlyReportDelivery", back_populates="user", cascade="all, delete-orphan") - chat_sessions: Mapped[list["ChatSession"]] = relationship("ChatSession", back_populates="user", cascade="all, delete-orphan") - chat_messages: Mapped[list["ChatMessage"]] = relationship("ChatMessage", back_populates="user", cascade="all, delete-orphan") + created_ledgers: Mapped[list["Ledger"]] = relationship( + "Ledger", + foreign_keys="Ledger.created_by", + back_populates="creator", + cascade="all, delete-orphan", + ) + ledger_memberships: Mapped[list["LedgerMembership"]] = relationship( + "LedgerMembership", back_populates="user", cascade="all, delete-orphan" + ) + sent_partner_requests: Mapped[list["PartnerRequest"]] = relationship( + "PartnerRequest", + foreign_keys="PartnerRequest.requester_id", + back_populates="requester", + cascade="all, delete-orphan", + ) + received_partner_requests: Mapped[list["PartnerRequest"]] = relationship( + "PartnerRequest", foreign_keys="PartnerRequest.recipient_id", back_populates="recipient" + ) + categories: Mapped[list["Category"]] = relationship( + "Category", back_populates="user", cascade="all, delete-orphan" + ) + merchant_rules: Mapped[list["MerchantRule"]] = relationship( + "MerchantRule", back_populates="user", cascade="all, delete-orphan" + ) + item_keyword_rules: Mapped[list["ItemKeywordRule"]] = relationship( + "ItemKeywordRule", back_populates="user", cascade="all, delete-orphan" + ) + item_embeddings: Mapped[list["ItemEmbedding"]] = relationship( + "ItemEmbedding", back_populates="user", cascade="all, delete-orphan" + ) + budgets: Mapped[list["Budget"]] = relationship( + "Budget", back_populates="user", cascade="all, delete-orphan" + ) + receipts: Mapped[list["Receipt"]] = relationship( + "Receipt", back_populates="user", cascade="all, delete-orphan" + ) + expenses: Mapped[list["Expense"]] = relationship( + "Expense", back_populates="user", cascade="all, delete-orphan" + ) + monthly_report_deliveries: Mapped[list["MonthlyReportDelivery"]] = relationship( + "MonthlyReportDelivery", back_populates="user", cascade="all, delete-orphan" + ) + chat_sessions: Mapped[list["ChatSession"]] = relationship( + "ChatSession", back_populates="user", cascade="all, delete-orphan" + ) + chat_messages: Mapped[list["ChatMessage"]] = relationship( + "ChatMessage", back_populates="user", cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"" diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f451c77..ac4cb32 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -26,7 +26,9 @@ class UserResponse(BaseModel): llm_provider: str | None = None llm_model: str | None = None llm_base_url: str | None = None - has_llm_api_key: bool = False # True if a stored encrypted key exists — never return the raw key + has_llm_api_key: bool = ( + False # True if a stored encrypted key exists — never return the raw key + ) created_at: datetime @@ -45,9 +47,9 @@ class UserLLMSettingsUpdateRequest(BaseModel): llm_provider: str | None = None llm_model: str | None = None - llm_api_key: str | None = None # Plaintext; backend encrypts before storing + llm_api_key: str | None = None # Plaintext; backend encrypts before storing llm_base_url: str | None = None - clear_api_key: bool = False # If True, delete the stored key + clear_api_key: bool = False # If True, delete the stored key class LLMTestRequest(BaseModel): @@ -55,7 +57,7 @@ class LLMTestRequest(BaseModel): provider: str | None = None model: str | None = None - api_key: str | None = None # Plaintext, NOT saved to DB + api_key: str | None = None # Plaintext, NOT saved to DB base_url: str | None = None @@ -63,23 +65,23 @@ class LLMTestResponse(BaseModel): """Response schema for the LLM test endpoint.""" success: bool - response: str | None = None # LLM reply on success - error: str | None = None # Error message on failure + response: str | None = None # LLM reply on success + error: str | None = None # Error message on failure class LLMModelPricing(BaseModel): """Per-provider token pricing when the API exposes it.""" - input_per_1m: float | None = None # USD per 1M input tokens + input_per_1m: float | None = None # USD per 1M input tokens output_per_1m: float | None = None # USD per 1M output tokens class LLMModelInfo(BaseModel): """Metadata for a single chat/vision model returned by the listing endpoint.""" - id: str # model identifier (used in API calls) - name: str # display name + id: str # model identifier (used in API calls) + name: str # display name description: str | None = None context_length: int | None = None pricing: LLMModelPricing | None = None # None = no pricing info available - supports_vision: bool = False # true if image input supported + supports_vision: bool = False # true if image input supported diff --git a/backend/app/services/analytics.py b/backend/app/services/analytics.py index 135eaf7..5090f0d 100644 --- a/backend/app/services/analytics.py +++ b/backend/app/services/analytics.py @@ -30,7 +30,9 @@ logger = structlog.get_logger(__name__) -async def _build_grocery_insights(db: AsyncSession, user_id: uuid.UUID, selected_month: date, month_end: date) -> dict: +async def _build_grocery_insights( + db: AsyncSession, user_id: uuid.UUID, selected_month: date, month_end: date +) -> dict: result = await db.execute( select(ExpenseItem, Expense.amount) .join(Expense, Expense.id == ExpenseItem.expense_id) @@ -62,7 +64,11 @@ async def _build_grocery_insights(db: AsyncSession, user_id: uuid.UUID, selected items_by_expense: dict[uuid.UUID, list[tuple[ExpenseItem, float]]] = defaultdict(list) for item, expense_amount in grocery_item_rows: - raw_amount = float(item.total_price) if item.total_price is not None else float(item.unit_price or 0) * float(item.quantity or 0) + raw_amount = ( + float(item.total_price) + if item.total_price is not None + else float(item.unit_price or 0) * float(item.quantity or 0) + ) approved_totals_by_expense[item.expense_id] = float(expense_amount) items_by_expense[item.expense_id].append((item, raw_amount)) @@ -117,20 +123,40 @@ async def _build_grocery_insights(db: AsyncSession, user_id: uuid.UUID, selected } -async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, month: str | None) -> dict: +async def build_dashboard_analytics( + db: AsyncSession, user_id: uuid.UUID, *, month: str | None +) -> dict: selected_month = month_start_from_string(month) month_end = next_month(selected_month) expenses_result = await db.execute( select(Expense, Category.name) .outerjoin(Category, Category.id == Expense.category_id) - .where(Expense.user_id == user_id, Expense.expense_date >= selected_month, Expense.expense_date < month_end) + .where( + Expense.user_id == user_id, + Expense.expense_date >= selected_month, + Expense.expense_date < month_end, + ) .order_by(Expense.expense_date.desc(), Expense.created_at.desc()) ) expense_rows = expenses_result.all() - money_out = round(sum(float(expense.amount) for expense, _ in expense_rows if expense.transaction_type == TRANSACTION_TYPE_DEBIT), 2) - money_in = round(sum(float(expense.amount) for expense, _ in expense_rows if expense.transaction_type == TRANSACTION_TYPE_CREDIT), 2) + money_out = round( + sum( + float(expense.amount) + for expense, _ in expense_rows + if expense.transaction_type == TRANSACTION_TYPE_DEBIT + ), + 2, + ) + money_in = round( + sum( + float(expense.amount) + for expense, _ in expense_rows + if expense.transaction_type == TRANSACTION_TYPE_CREDIT + ), + 2, + ) monthly_total = money_out net_total = round(money_in - money_out, 2) @@ -143,10 +169,17 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon else: money_in_by_currency[cur] += float(expense.amount) all_currencies = set(list(money_out_by_currency.keys()) + list(money_in_by_currency.keys())) - net_by_currency = {cur: round(money_in_by_currency.get(cur, 0.0) - money_out_by_currency.get(cur, 0.0), 2) for cur in all_currencies} + net_by_currency = { + cur: round(money_in_by_currency.get(cur, 0.0) - money_out_by_currency.get(cur, 0.0), 2) + for cur in all_currencies + } transaction_count = len(expense_rows) - debit_count = sum(1 for expense, _ in expense_rows if expense.transaction_type == TRANSACTION_TYPE_DEBIT) - credit_count = sum(1 for expense, _ in expense_rows if expense.transaction_type == TRANSACTION_TYPE_CREDIT) + debit_count = sum( + 1 for expense, _ in expense_rows if expense.transaction_type == TRANSACTION_TYPE_DEBIT + ) + credit_count = sum( + 1 for expense, _ in expense_rows if expense.transaction_type == TRANSACTION_TYPE_CREDIT + ) average_transaction = round(monthly_total / debit_count, 2) if debit_count else 0.0 average_income = round(money_in / credit_count, 2) if credit_count else 0.0 @@ -180,7 +213,11 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon "is_major_purchase": expense.is_major_purchase, } ) - if expense.transaction_type == TRANSACTION_TYPE_DEBIT and expense.cadence == CADENCE_ONE_TIME and expense.is_major_purchase: + if ( + expense.transaction_type == TRANSACTION_TYPE_DEBIT + and expense.cadence == CADENCE_ONE_TIME + and expense.is_major_purchase + ): major_one_time_purchases.append( { "id": str(expense.id), @@ -199,11 +236,17 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon trend_start = date(selected_month.year - 1, selected_month.month, 1) trend_result = await db.execute( select(Expense) - .where(Expense.user_id == user_id, Expense.expense_date >= trend_start, Expense.expense_date < month_end) + .where( + Expense.user_id == user_id, + Expense.expense_date >= trend_start, + Expense.expense_date < month_end, + ) .order_by(Expense.expense_date.asc()) ) trend_expenses = trend_result.scalars().all() - monthly_trend_map: dict[str, dict[str, float]] = defaultdict(lambda: {"money_in": 0.0, "money_out": 0.0, "net": 0.0}) + monthly_trend_map: dict[str, dict[str, float]] = defaultdict( + lambda: {"money_in": 0.0, "money_out": 0.0, "net": 0.0} + ) for expense in trend_expenses: month_key = expense.expense_date.strftime("%Y-%m") if expense.transaction_type == TRANSACTION_TYPE_CREDIT: @@ -226,7 +269,9 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon category_actuals[category_name] += float(expense.amount) budgets = [] for budget, category_name in budget_rows: - actual = monthly_total if category_name is None else category_actuals.get(category_name, 0.0) + actual = ( + monthly_total if category_name is None else category_actuals.get(category_name, 0.0) + ) budgets.append(serialize_budget(budget, category_name=category_name, actual=actual)) grocery_insights = await _build_grocery_insights(db, user_id, selected_month, month_end) @@ -283,9 +328,21 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon "money_in": money_in, "money_out": money_out, "net": net_total, - "money_out_by_currency": {k: round(v, 2) for k, v in sorted(money_out_by_currency.items(), key=lambda item: (item[0] != "EUR", -item[1]))}, - "money_in_by_currency": {k: round(v, 2) for k, v in sorted(money_in_by_currency.items(), key=lambda item: (item[0] != "EUR", -item[1]))}, - "net_by_currency": dict(sorted(net_by_currency.items(), key=lambda item: item[0] != "EUR")), + "money_out_by_currency": { + k: round(v, 2) + for k, v in sorted( + money_out_by_currency.items(), key=lambda item: (item[0] != "EUR", -item[1]) + ) + }, + "money_in_by_currency": { + k: round(v, 2) + for k, v in sorted( + money_in_by_currency.items(), key=lambda item: (item[0] != "EUR", -item[1]) + ) + }, + "net_by_currency": dict( + sorted(net_by_currency.items(), key=lambda item: item[0] != "EUR") + ), "transaction_count": transaction_count, "average_transaction": average_transaction, "average_outflow": average_transaction, @@ -294,19 +351,27 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon }, "spend_by_category": [ {"name": name, "amount": round(amount, 2)} - for name, amount in sorted(by_category_map.items(), key=lambda item: item[1], reverse=True) + for name, amount in sorted( + by_category_map.items(), key=lambda item: item[1], reverse=True + ) ], "income_by_category": [ {"name": name, "amount": round(amount, 2)} - for name, amount in sorted(income_by_category_map.items(), key=lambda item: item[1], reverse=True) + for name, amount in sorted( + income_by_category_map.items(), key=lambda item: item[1], reverse=True + ) ], "top_merchants": [ {"merchant": name, "amount": round(amount, 2)} - for name, amount in sorted(by_merchant_map.items(), key=lambda item: item[1], reverse=True)[:8] + for name, amount in sorted( + by_merchant_map.items(), key=lambda item: item[1], reverse=True + )[:8] ], "top_income_sources": [ {"merchant": name, "amount": round(amount, 2)} - for name, amount in sorted(income_by_merchant_map.items(), key=lambda item: item[1], reverse=True)[:8] + for name, amount in sorted( + income_by_merchant_map.items(), key=lambda item: item[1], reverse=True + )[:8] ], "monthly_trend": [ { @@ -320,7 +385,9 @@ async def build_dashboard_analytics(db: AsyncSession, user_id: uuid.UUID, *, mon ], "recurring_transactions": recurring_items, "recurring_expenses": recurring_items, - "major_one_time_purchases": sorted(major_one_time_purchases, key=lambda item: item["amount"], reverse=True)[:6], + "major_one_time_purchases": sorted( + major_one_time_purchases, key=lambda item: item["amount"], reverse=True + )[:6], "prepaid_subscriptions": prepaid_subscriptions, "budgets": budgets, "grocery_insights": grocery_insights, diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py index d50bad9..5f078d9 100644 --- a/backend/app/services/cache.py +++ b/backend/app/services/cache.py @@ -85,9 +85,7 @@ async def get_cached_analytics(user_id: uuid.UUID, month: str | None) -> dict | return None -async def set_cached_analytics( - user_id: uuid.UUID, month: str | None, data: dict -) -> None: +async def set_cached_analytics(user_id: uuid.UUID, month: str | None, data: dict) -> None: """Write dashboard analytics to cache. Silently ignores Redis errors.""" if _redis_client is None: return @@ -153,9 +151,7 @@ async def get_cached_llm_models(provider: str, user_id: uuid.UUID) -> list | Non return None -async def set_cached_llm_models( - provider: str, user_id: uuid.UUID, data: list -) -> None: +async def set_cached_llm_models(provider: str, user_id: uuid.UUID, data: list) -> None: """Write model list to cache. Silently ignores Redis errors.""" if _redis_client is None: return diff --git a/backend/app/services/demo_seed.py b/backend/app/services/demo_seed.py index 57a1f9b..b01b43b 100644 --- a/backend/app/services/demo_seed.py +++ b/backend/app/services/demo_seed.py @@ -37,6 +37,7 @@ # Avatar helper — inline SVG data URI, no external dependency, CSP-safe # --------------------------------------------------------------------------- + def _avatar_svg(initials: str, bg: str, fg: str) -> str: """Return a data:image/svg+xml;base64 URI for an initials avatar. @@ -49,7 +50,7 @@ def _avatar_svg(initials: str, bg: str, fg: str) -> str: f'{initials}' - f'' + f"" ) return "data:image/svg+xml;base64," + base64.b64encode(svg.encode()).decode() @@ -136,8 +137,18 @@ def _day(months_ago: int, day: int) -> date: # Expense item helpers # --------------------------------------------------------------------------- -def _item(desc: str, qty: float | None, unit: float | None, total: float, subcat: str | None = None) -> dict: - return {"description": desc, "quantity": qty, "unit_price": unit, "total": total, "subcategory": subcat, "subcategory_confidence": 0.92 if subcat else None} + +def _item( + desc: str, qty: float | None, unit: float | None, total: float, subcat: str | None = None +) -> dict: + return { + "description": desc, + "quantity": qty, + "unit_price": unit, + "total": total, + "subcategory": subcat, + "subcategory_confidence": 0.92 if subcat else None, + } # Grocery items for each month's Alfred run @@ -190,7 +201,13 @@ def _item(desc: str, qty: float | None, unit: float | None, total: float, subcat _item("Organic dark chocolate 85% x8 bars", 8, 4.50, 36.00, "Snacks"), _item("Fresh pappardelle pasta x3", 3, 3.50, 10.50, "Pantry"), _item("Buffalo mozzarella x3", 3, 4.90, 14.70, "Dairy & Eggs"), - _item("Fresh summer truffle 30g (Alfred's 'non-negotiable' splurge)", 1, 54.00, 54.00, "Condiments & Spices"), + _item( + "Fresh summer truffle 30g (Alfred's 'non-negotiable' splurge)", + 1, + 54.00, + 54.00, + "Condiments & Spices", + ), _item("Cherry tomatoes 500g x3", 3, 2.80, 8.40, "Vegetables"), _item("Fresh basil bundle x2", 2, 1.80, 3.60, "Condiments & Spices"), _item("Prosecco DOC x2", 2, 9.90, 19.80, "Beverages"), @@ -217,7 +234,13 @@ def _item(desc: str, qty: float | None, unit: float | None, total: float, subcat _item("Smucker's strawberry jam", 1, 3.29, 3.29, "Condiments & Spices"), _item("Whole milk 1 gallon", 1, 3.80, 3.80, "Dairy & Eggs"), _item("Large eggs x12", 1, 2.89, 2.89, "Dairy & Eggs"), - _item("Gatorade Blue Bolt x6 (electrolyte replenishment — not labeled as such)", 6, 1.29, 7.74, "Beverages"), + _item( + "Gatorade Blue Bolt x6 (electrolyte replenishment — not labeled as such)", + 6, + 1.29, + 7.74, + "Beverages", + ), _item("Clif Bar assorted x8 (field rations, again not labeled)", 8, 1.25, 10.00, "Snacks"), _item("Apples x5 (Aunt May made me)", 5, 0.50, 2.50, "Vegetables"), _item("Instant oatmeal packets x8", 8, 0.45, 3.60, "Breakfast & Cereal"), @@ -302,277 +325,666 @@ def _e( } -def _build_expenses(ledger_manor: uuid.UUID, ledger_fox: uuid.UUID, ledger_jl: uuid.UUID) -> list[dict]: +def _build_expenses( + ledger_manor: uuid.UUID, ledger_fox: uuid.UUID, ledger_jl: uuid.UUID +) -> list[dict]: return [ - # ── Month -2 (two months ago) ──────────────────────────────────────────── - # Big Q1 income - _e("inc-m2-01", "Wayne Enterprises", "280000.00", "Salary", _day(2, 1), tx="credit", - description="Q1 executive dividend — board-approved"), - - _e("m2-01", "Wayne Manor Property Management", "3200.00", "Housing", _day(2, 1), - description="Q2 property insurance & estate levy"), - - _e("m2-02", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(2, 2), - cadence="monthly", rg=_RG["therapy"], - description="Session 1: recurring nightmares, social isolation, strong aversion to clowns"), - - _e("m2-03", "Wayne Manor Estate Grid", "1840.00", "Bills", _day(2, 3), - cadence="monthly", rg=_RG["manor_elec"], - description="Wayne Manor electricity — billing cycle"), - - _e("m2-04", "Gotham Industrial Power Corp", "4100.00", "Bills", _day(2, 4), - cadence="monthly", rg=_RG["cave_elec"], - description="Sub-basement power draw (cave ventilation & server farm)"), - - _e("m2-05", "WayneTech Advanced Materials", "3600.00", "Shopping", _day(2, 5), - is_major=True, - description="Tactical-grade Kevlar jacket, ballistic prototype x4 — field test batch"), - - _e("m2-06", "ComfortCave Industries Ltd.", "144.00", "Shopping", _day(2, 6), - description="Custom bat-motif briefs — 48-pack (comfort is non-negotiable)"), - - _e("m2-07", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(2, 9), - cadence="monthly", rg=_RG["therapy"], - description="Session 2: trust issues, childhood trauma, unhealthy obsession with justice"), - - _e("m2-08", "Metropolis Geological Survey", "42000.00", "WayneTech R&D", _day(2, 10), - is_major=True, - description="Kryptonite sample Class-A 200g — research & containment (strictly precautionary)"), - - _e("m2-09", "The Blue Orchid, Nanda Parbat", "1240.00", "Dining", _day(2, 12), - description="Business dinner with R. al Ghul — 'League networking'. Ordered the tasting menu."), - - _e("m2-10", "Gotham Opera House", "1800.00", "Entertainment", _day(2, 14), - description="La Traviata — Box A (10 seats). Arrived via grappling hook, left via limousine."), - - _e("m2-11", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(2, 16), - cadence="monthly", rg=_RG["therapy"], - description="Session 3: boundary-setting, identity concealment & its psychological cost"), - - _e("m2-12", "Two Sides Brasserie", "380.00", "Dining", _day(2, 18), - description="Lunch with Harvey Dent — pre-acid incident. Split the bill 50/50."), - - _e("m2-13", "Anthropic (Claude Code)", "180.00", "Shopping", _day(2, 20), - cadence="monthly", rg=_RG["claude"], - description="Monthly AI assistant subscription — indispensable for cave ops planning"), - - _e("m2-14", "Gotham City Asylum Foundation", "50000.00", "Wayne Foundation", _day(2, 21), - is_major=True, tx="debit", - description="Annual charitable endowment — rehabilitation programmes fund"), - - _e("m2-15", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(2, 23), - cadence="monthly", rg=_RG["therapy"], - description="Session 4: parental loss, long-term coping strategies (ongoing since age 8)"), - - _e("m2-16", "GothamAir Private Charter", "18500.00", "Travel", _day(2, 24), - is_major=True, - description="Gotham ↔ Nanda Parbat return — cargo hold includes 'sports equipment'"), - - _e("m2-17", "Billa Supermercato", "345.00", "Groceries", _day(2, 26), - description="Alfred's weekly provisions — he insists on checking every label", - items=_M2_GROCERY_ITEMS), - - _e("m2-18", "Wayne Foundation Events", "8800.00", "Entertainment", _day(2, 28), - is_major=True, - description="Annual Gala — 22 VIP tickets. Wore the tuxedo, not the other suit."), - - _e("m2-19", "WayneTech Manufacturing", "1600.00", "Crime Fighting", _day(2, 30), - description="Batarang replenishment pack 200 units — monthly supply gone in 3 weeks (Gotham is busy)"), - + _e( + "inc-m2-01", + "Wayne Enterprises", + "280000.00", + "Salary", + _day(2, 1), + tx="credit", + description="Q1 executive dividend — board-approved", + ), + _e( + "m2-01", + "Wayne Manor Property Management", + "3200.00", + "Housing", + _day(2, 1), + description="Q2 property insurance & estate levy", + ), + _e( + "m2-02", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(2, 2), + cadence="monthly", + rg=_RG["therapy"], + description="Session 1: recurring nightmares, social isolation, strong aversion to clowns", + ), + _e( + "m2-03", + "Wayne Manor Estate Grid", + "1840.00", + "Bills", + _day(2, 3), + cadence="monthly", + rg=_RG["manor_elec"], + description="Wayne Manor electricity — billing cycle", + ), + _e( + "m2-04", + "Gotham Industrial Power Corp", + "4100.00", + "Bills", + _day(2, 4), + cadence="monthly", + rg=_RG["cave_elec"], + description="Sub-basement power draw (cave ventilation & server farm)", + ), + _e( + "m2-05", + "WayneTech Advanced Materials", + "3600.00", + "Shopping", + _day(2, 5), + is_major=True, + description="Tactical-grade Kevlar jacket, ballistic prototype x4 — field test batch", + ), + _e( + "m2-06", + "ComfortCave Industries Ltd.", + "144.00", + "Shopping", + _day(2, 6), + description="Custom bat-motif briefs — 48-pack (comfort is non-negotiable)", + ), + _e( + "m2-07", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(2, 9), + cadence="monthly", + rg=_RG["therapy"], + description="Session 2: trust issues, childhood trauma, unhealthy obsession with justice", + ), + _e( + "m2-08", + "Metropolis Geological Survey", + "42000.00", + "WayneTech R&D", + _day(2, 10), + is_major=True, + description="Kryptonite sample Class-A 200g — research & containment (strictly precautionary)", + ), + _e( + "m2-09", + "The Blue Orchid, Nanda Parbat", + "1240.00", + "Dining", + _day(2, 12), + description="Business dinner with R. al Ghul — 'League networking'. Ordered the tasting menu.", + ), + _e( + "m2-10", + "Gotham Opera House", + "1800.00", + "Entertainment", + _day(2, 14), + description="La Traviata — Box A (10 seats). Arrived via grappling hook, left via limousine.", + ), + _e( + "m2-11", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(2, 16), + cadence="monthly", + rg=_RG["therapy"], + description="Session 3: boundary-setting, identity concealment & its psychological cost", + ), + _e( + "m2-12", + "Two Sides Brasserie", + "380.00", + "Dining", + _day(2, 18), + description="Lunch with Harvey Dent — pre-acid incident. Split the bill 50/50.", + ), + _e( + "m2-13", + "Anthropic (Claude Code)", + "180.00", + "Shopping", + _day(2, 20), + cadence="monthly", + rg=_RG["claude"], + description="Monthly AI assistant subscription — indispensable for cave ops planning", + ), + _e( + "m2-14", + "Gotham City Asylum Foundation", + "50000.00", + "Wayne Foundation", + _day(2, 21), + is_major=True, + tx="debit", + description="Annual charitable endowment — rehabilitation programmes fund", + ), + _e( + "m2-15", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(2, 23), + cadence="monthly", + rg=_RG["therapy"], + description="Session 4: parental loss, long-term coping strategies (ongoing since age 8)", + ), + _e( + "m2-16", + "GothamAir Private Charter", + "18500.00", + "Travel", + _day(2, 24), + is_major=True, + description="Gotham ↔ Nanda Parbat return — cargo hold includes 'sports equipment'", + ), + _e( + "m2-17", + "Billa Supermercato", + "345.00", + "Groceries", + _day(2, 26), + description="Alfred's weekly provisions — he insists on checking every label", + items=_M2_GROCERY_ITEMS, + ), + _e( + "m2-18", + "Wayne Foundation Events", + "8800.00", + "Entertainment", + _day(2, 28), + is_major=True, + description="Annual Gala — 22 VIP tickets. Wore the tuxedo, not the other suit.", + ), + _e( + "m2-19", + "WayneTech Manufacturing", + "1600.00", + "Crime Fighting", + _day(2, 30), + description="Batarang replenishment pack 200 units — monthly supply gone in 3 weeks (Gotham is busy)", + ), # Wayne Manor Ops ledger — month -2 - _e("l-manor-m2-01", "Whole Foods Market Gotham", "1800.00", "Groceries", _day(2, 8), - ledger_id=ledger_manor, description="Wayne Manor monthly provisions — Alfred's bulk order"), - _e("l-manor-m2-02", "Gotham Green Grounds Ltd.", "850.00", "Other", _day(2, 15), - ledger_id=ledger_manor, description="Manor grounds bi-monthly maintenance & topiary"), - + _e( + "l-manor-m2-01", + "Whole Foods Market Gotham", + "1800.00", + "Groceries", + _day(2, 8), + ledger_id=ledger_manor, + description="Wayne Manor monthly provisions — Alfred's bulk order", + ), + _e( + "l-manor-m2-02", + "Gotham Green Grounds Ltd.", + "850.00", + "Other", + _day(2, 15), + ledger_id=ledger_manor, + description="Manor grounds bi-monthly maintenance & topiary", + ), # Fox-Wayne Labs ledger — month -2 - _e("l-fox-m2-01", "Gotham Advanced Composites Inc.", "12400.00", "WayneTech R&D", _day(2, 22), - ledger_id=ledger_fox, is_major=True, - description="Fox-Wayne Labs Q2 raw materials — carbon fiber, titanium alloy sheets"), - + _e( + "l-fox-m2-01", + "Gotham Advanced Composites Inc.", + "12400.00", + "WayneTech R&D", + _day(2, 22), + ledger_id=ledger_fox, + is_major=True, + description="Fox-Wayne Labs Q2 raw materials — carbon fiber, titanium alloy sheets", + ), # JL Petty Cash — month -2 - _e("l-jl-m2-01", "Gotham Roast Coffee", "145.00", "Dining", _day(2, 29), - ledger_id=ledger_jl, description="JL HQ monthly coffee & snack fund. Aquaman drinks an unreasonable amount."), - + _e( + "l-jl-m2-01", + "Gotham Roast Coffee", + "145.00", + "Dining", + _day(2, 29), + ledger_id=ledger_jl, + description="JL HQ monthly coffee & snack fund. Aquaman drinks an unreasonable amount.", + ), # ── Month -1 (last month) ──────────────────────────────────────────────── - - _e("inc-m1-01", "Wayne Enterprises", "95000.00", "Salary", _day(1, 1), tx="credit", - description="Monthly executive compensation — board-approved"), - - _e("m1-01", "Wayne Manor Estate Grid", "1840.00", "Bills", _day(1, 1), - cadence="monthly", rg=_RG["manor_elec"], - description="Wayne Manor electricity — previous billing cycle"), - - _e("m1-02", "Gotham Industrial Power Corp", "4100.00", "Bills", _day(1, 1), - cadence="monthly", rg=_RG["cave_elec"], - description="Sub-basement power draw — previous billing (the servers never sleep)"), - - _e("m1-03", "WayneTech BioLab", "8900.00", "Crime Fighting", _day(1, 3), - is_major=True, - description="Anti-Joker toxin research Phase II — synthesis & antidote stockpile"), - - _e("m1-04", "WayneTech Engineering", "2800.00", "Shopping", _day(1, 5), - description="Grappling hook upgrade v4.0 — titanium core, 300m rated, silent deployment"), - - _e("m1-05", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(1, 6), - cadence="monthly", rg=_RG["therapy"], - description="Session 5: processing the Ra's al Ghul dinner (again). Dr. Q seems concerned."), - - _e("m1-06", "Gotham Sports Medicine Clinic", "480.00", "Health", _day(1, 8), - description="Sports physio — knee (recurring injury, cause of injury: classified)"), - - _e("m1-07", "WayneTech Special Projects", "29500.00", "WayneTech R&D", _day(1, 10), - is_major=True, - description="Kryptonite containment unit — Lucius Fox custom-spec, lead-lined with biometric lock"), - - _e("m1-08", "GothamAir Private Charter", "2400.00", "Travel", _day(1, 11), - description="Gotham → Metropolis business class — JL Emergency Summit (not my idea)"), - - _e("m1-09", "The Diamond Restaurant", "940.00", "Dining", _day(1, 13), - description="Dinner with Diana Prince — she ordered the entire seafood section. Respect."), - - _e("m1-10", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(1, 14), - cadence="monthly", rg=_RG["therapy"], - description="Session 6: dinner with Diana (complicated), ongoing Ra's situation (very complicated)"), - - _e("m1-11", "Himalayan Botanicals Ltd.", "11200.00", "Shopping", _day(1, 15), - is_major=True, - description="Blue lotus flower seeds x500 — rare Nanda Parbat cultivar (research purposes ONLY)"), - - _e("m1-12", "Gotham Knights Sports Group", "3200.00", "Entertainment", _day(1, 17), - description="VIP courtside box (16 seats) — Knights vs. Metropolis United. Clark was there. Awkward."), - - _e("m1-13", "ComfortCave Industries Ltd.", "72.00", "Shopping", _day(1, 18), - description="Custom bat-motif briefs — 24-pack (half-order, trying to cut back)"), - - _e("m1-14", "Anthropic (Claude Code)", "180.00", "Shopping", _day(1, 19), - cadence="monthly", rg=_RG["claude"], - description="Monthly AI assistant — saved 3 hours of cave analysis this week"), - - _e("m1-15", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(1, 21), - cadence="monthly", rg=_RG["therapy"], - description="Session 7: she asked why I keep attending Ra's dinners. I said networking."), - - _e("m1-16", "Gotham Children's Hospital", "75000.00", "Wayne Foundation", _day(1, 23), - is_major=True, - description="New cardiac unit sponsorship — anonymous donor (it's not anonymous)"), - - _e("m1-17", "Billa Supermercato", "412.00", "Groceries", _day(1, 25), - description="Alfred's weekly provisions — he added salmon this month", - items=_M1_GROCERY_ITEMS), - - _e("m1-18", "WayneTech Ordnance", "2100.00", "Crime Fighting", _day(1, 25), - description="Explosive gel refill cartridges — batch order (Gotham especially busy)"), - - _e("m1-19", "WayneTech Materials Supply", "7200.00", "Shopping", _day(1, 27), - is_major=True, - description="Armored motorcycle jacket bulk order x12 — for the team (just Bruce)"), - - _e("m1-20", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(1, 28), - cadence="monthly", rg=_RG["therapy"], - description="Session 8: blue flower seeds purchase flagged as 'concerning pattern'. Agreed to discuss."), - - _e("m1-21", "WayneTech Drone Division", "44000.00", "Crime Fighting", _day(1, 30), - is_major=True, - description="Gotham surveillance drone fleet x8 — silent rotors, 48h battery, Lucius-certified"), - + _e( + "inc-m1-01", + "Wayne Enterprises", + "95000.00", + "Salary", + _day(1, 1), + tx="credit", + description="Monthly executive compensation — board-approved", + ), + _e( + "m1-01", + "Wayne Manor Estate Grid", + "1840.00", + "Bills", + _day(1, 1), + cadence="monthly", + rg=_RG["manor_elec"], + description="Wayne Manor electricity — previous billing cycle", + ), + _e( + "m1-02", + "Gotham Industrial Power Corp", + "4100.00", + "Bills", + _day(1, 1), + cadence="monthly", + rg=_RG["cave_elec"], + description="Sub-basement power draw — previous billing (the servers never sleep)", + ), + _e( + "m1-03", + "WayneTech BioLab", + "8900.00", + "Crime Fighting", + _day(1, 3), + is_major=True, + description="Anti-Joker toxin research Phase II — synthesis & antidote stockpile", + ), + _e( + "m1-04", + "WayneTech Engineering", + "2800.00", + "Shopping", + _day(1, 5), + description="Grappling hook upgrade v4.0 — titanium core, 300m rated, silent deployment", + ), + _e( + "m1-05", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(1, 6), + cadence="monthly", + rg=_RG["therapy"], + description="Session 5: processing the Ra's al Ghul dinner (again). Dr. Q seems concerned.", + ), + _e( + "m1-06", + "Gotham Sports Medicine Clinic", + "480.00", + "Health", + _day(1, 8), + description="Sports physio — knee (recurring injury, cause of injury: classified)", + ), + _e( + "m1-07", + "WayneTech Special Projects", + "29500.00", + "WayneTech R&D", + _day(1, 10), + is_major=True, + description="Kryptonite containment unit — Lucius Fox custom-spec, lead-lined with biometric lock", + ), + _e( + "m1-08", + "GothamAir Private Charter", + "2400.00", + "Travel", + _day(1, 11), + description="Gotham → Metropolis business class — JL Emergency Summit (not my idea)", + ), + _e( + "m1-09", + "The Diamond Restaurant", + "940.00", + "Dining", + _day(1, 13), + description="Dinner with Diana Prince — she ordered the entire seafood section. Respect.", + ), + _e( + "m1-10", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(1, 14), + cadence="monthly", + rg=_RG["therapy"], + description="Session 6: dinner with Diana (complicated), ongoing Ra's situation (very complicated)", + ), + _e( + "m1-11", + "Himalayan Botanicals Ltd.", + "11200.00", + "Shopping", + _day(1, 15), + is_major=True, + description="Blue lotus flower seeds x500 — rare Nanda Parbat cultivar (research purposes ONLY)", + ), + _e( + "m1-12", + "Gotham Knights Sports Group", + "3200.00", + "Entertainment", + _day(1, 17), + description="VIP courtside box (16 seats) — Knights vs. Metropolis United. Clark was there. Awkward.", + ), + _e( + "m1-13", + "ComfortCave Industries Ltd.", + "72.00", + "Shopping", + _day(1, 18), + description="Custom bat-motif briefs — 24-pack (half-order, trying to cut back)", + ), + _e( + "m1-14", + "Anthropic (Claude Code)", + "180.00", + "Shopping", + _day(1, 19), + cadence="monthly", + rg=_RG["claude"], + description="Monthly AI assistant — saved 3 hours of cave analysis this week", + ), + _e( + "m1-15", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(1, 21), + cadence="monthly", + rg=_RG["therapy"], + description="Session 7: she asked why I keep attending Ra's dinners. I said networking.", + ), + _e( + "m1-16", + "Gotham Children's Hospital", + "75000.00", + "Wayne Foundation", + _day(1, 23), + is_major=True, + description="New cardiac unit sponsorship — anonymous donor (it's not anonymous)", + ), + _e( + "m1-17", + "Billa Supermercato", + "412.00", + "Groceries", + _day(1, 25), + description="Alfred's weekly provisions — he added salmon this month", + items=_M1_GROCERY_ITEMS, + ), + _e( + "m1-18", + "WayneTech Ordnance", + "2100.00", + "Crime Fighting", + _day(1, 25), + description="Explosive gel refill cartridges — batch order (Gotham especially busy)", + ), + _e( + "m1-19", + "WayneTech Materials Supply", + "7200.00", + "Shopping", + _day(1, 27), + is_major=True, + description="Armored motorcycle jacket bulk order x12 — for the team (just Bruce)", + ), + _e( + "m1-20", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(1, 28), + cadence="monthly", + rg=_RG["therapy"], + description="Session 8: blue flower seeds purchase flagged as 'concerning pattern'. Agreed to discuss.", + ), + _e( + "m1-21", + "WayneTech Drone Division", + "44000.00", + "Crime Fighting", + _day(1, 30), + is_major=True, + description="Gotham surveillance drone fleet x8 — silent rotors, 48h battery, Lucius-certified", + ), # Wayne Manor Ops ledger — month -1 - _e("l-manor-m1-01", "Whole Foods Market Gotham", "1920.00", "Groceries", _day(1, 6), - ledger_id=ledger_manor, description="Wayne Manor monthly provisions"), - _e("l-manor-m1-02", "CaveClean Industrial Services", "440.00", "Groceries", _day(1, 28), - ledger_id=ledger_manor, description="Batcave deep-clean supplies — industrial-grade, bat-safe certified"), - + _e( + "l-manor-m1-01", + "Whole Foods Market Gotham", + "1920.00", + "Groceries", + _day(1, 6), + ledger_id=ledger_manor, + description="Wayne Manor monthly provisions", + ), + _e( + "l-manor-m1-02", + "CaveClean Industrial Services", + "440.00", + "Groceries", + _day(1, 28), + ledger_id=ledger_manor, + description="Batcave deep-clean supplies — industrial-grade, bat-safe certified", + ), # Fox-Wayne Labs ledger — month -1 - _e("l-fox-m1-01", "CognitionTech Computing", "8900.00", "WayneTech R&D", _day(1, 16), - ledger_id=ledger_fox, is_major=True, - description="Batcomputer RAM expansion — Lucius Fox spec, 2PB upgrade"), - + _e( + "l-fox-m1-01", + "CognitionTech Computing", + "8900.00", + "WayneTech R&D", + _day(1, 16), + ledger_id=ledger_fox, + is_major=True, + description="Batcomputer RAM expansion — Lucius Fox spec, 2PB upgrade", + ), # JL Petty Cash — month -1 - _e("l-jl-m1-01", "Gotham Pizza Palace", "280.00", "Dining", _day(1, 20), - ledger_id=ledger_jl, - description="JL meeting dinner — 4 large pizzas. Aquaman ate 6 slices. Arthur pays next time."), - + _e( + "l-jl-m1-01", + "Gotham Pizza Palace", + "280.00", + "Dining", + _day(1, 20), + ledger_id=ledger_jl, + description="JL meeting dinner — 4 large pizzas. Aquaman ate 6 slices. Arthur pays next time.", + ), # ── Current month ──────────────────────────────────────────────────────── - - _e("inc-m0-01", "Wayne Enterprises", "95000.00", "Salary", _day(0, 1), tx="credit", - description="Monthly executive compensation — board-approved"), - - _e("m0-01", "Wayne Manor Estate Grid", "1840.00", "Bills", _day(0, 1), - cadence="monthly", rg=_RG["manor_elec"], - description="Wayne Manor electricity — previous billing cycle"), - - _e("m0-02", "Gotham Industrial Power Corp", "4100.00", "Bills", _day(0, 1), - cadence="monthly", rg=_RG["cave_elec"], - description="Sub-basement power draw — previous billing"), - - _e("m0-03", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(0, 2), - cadence="monthly", rg=_RG["therapy"], - description="Session 9: the blue flower situation. She brought in a specialist colleague."), - - _e("m0-04", "WayneTech Engineering", "4800.00", "Shopping", _day(0, 3), - description="Tactical utility belt prototype v7 — grappling hook, gel dispenser, 12 compartments"), - - _e("m0-05", "Caffè Romano, Gotham CBD", "280.00", "Dining", _day(0, 5), - description="Business lunch with Clark Kent (Daily Planet) — he asked a lot of questions. Nice tie."), - - _e("m0-06", "Metropolis Advanced Materials", "68000.00", "WayneTech R&D", _day(0, 6), - is_major=True, - description="Kryptonite acquisition: 3 premium-grade specimens — green, red, gold variants (precautionary)"), - - _e("m0-07", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(0, 9), - cadence="monthly", rg=_RG["therapy"], - description="Session 10: she asked why I now own Kryptonite in three colours. Still classified."), - - _e("m0-08", "al-Ghul Estate, Nanda Parbat", "880.00", "Dining", _day(0, 10), - description="League of Shadows annual networking dinner — third year attending. Ra's is a good host."), - - _e("m0-09", "WayneTech Fuels Lab", "1240.00", "Crime Fighting", _day(0, 12), - description="Batmobile premium synthetic fuel blend — 180L, high-octane, low thermal signature"), - - _e("m0-grocery", "Billa Supermercato", "296.00", "Groceries", _day(0, 12), - description="Alfred's current-month provisions — the truffle was 'non-negotiable', apparently", - items=_M0_GROCERY_ITEMS), - - _e("m0-10", "ComfortCave Industries Ltd.", "144.00", "Shopping", _day(0, 13), - description="Custom bat-motif briefs — BACK TO 48-PACK. The half-order experiment failed."), - - _e("m0-11", "The Grand Ballroom, Gotham Plaza", "18500.00", "Entertainment", _day(0, 15), - is_major=True, - description="Wayne Enterprises Q2 Staff Summer Party — 340 attendees, open bar (Alfred supervised)"), - - _e("m0-12", "Dr. Harleen Quinzel, Psy.D.", "320.00", "Health", _day(0, 16), - cadence="monthly", rg=_RG["therapy"], - description="Session 11: staff party incident (joker-themed decoration — someone's fired). Bilateral trauma."), - - _e("m0-13", "Gotham Addiction Recovery Center", "100000.00", "Wayne Foundation", _day(0, 17), - is_major=True, - description="New wing naming rights + construction sponsorship — 'The Thomas & Martha Wayne Wing'"), - - _e("m0-14", "Anthropic (Claude Code)", "180.00", "Shopping", _day(0, 18), - cadence="monthly", rg=_RG["claude"], - description="Monthly AI assistant — used it to analyse Kryptonite acquisition ROI. Helpful."), - - _e("m0-15", "Gotham Sports Medicine Clinic", "750.00", "Health", _day(0, 19), - description="Sports physio — knee (same knee). 'You really need to stop doing whatever you're doing.'"), - - _e("m0-16", "WayneTech Optics Division", "1800.00", "Crime Fighting", _day(0, 20), - description="Night vision replacement unit x2, long-range — the gargoyle landing was a mistake"), - + _e( + "inc-m0-01", + "Wayne Enterprises", + "95000.00", + "Salary", + _day(0, 1), + tx="credit", + description="Monthly executive compensation — board-approved", + ), + _e( + "m0-01", + "Wayne Manor Estate Grid", + "1840.00", + "Bills", + _day(0, 1), + cadence="monthly", + rg=_RG["manor_elec"], + description="Wayne Manor electricity — previous billing cycle", + ), + _e( + "m0-02", + "Gotham Industrial Power Corp", + "4100.00", + "Bills", + _day(0, 1), + cadence="monthly", + rg=_RG["cave_elec"], + description="Sub-basement power draw — previous billing", + ), + _e( + "m0-03", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(0, 2), + cadence="monthly", + rg=_RG["therapy"], + description="Session 9: the blue flower situation. She brought in a specialist colleague.", + ), + _e( + "m0-04", + "WayneTech Engineering", + "4800.00", + "Shopping", + _day(0, 3), + description="Tactical utility belt prototype v7 — grappling hook, gel dispenser, 12 compartments", + ), + _e( + "m0-05", + "Caffè Romano, Gotham CBD", + "280.00", + "Dining", + _day(0, 5), + description="Business lunch with Clark Kent (Daily Planet) — he asked a lot of questions. Nice tie.", + ), + _e( + "m0-06", + "Metropolis Advanced Materials", + "68000.00", + "WayneTech R&D", + _day(0, 6), + is_major=True, + description="Kryptonite acquisition: 3 premium-grade specimens — green, red, gold variants (precautionary)", + ), + _e( + "m0-07", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(0, 9), + cadence="monthly", + rg=_RG["therapy"], + description="Session 10: she asked why I now own Kryptonite in three colours. Still classified.", + ), + _e( + "m0-08", + "al-Ghul Estate, Nanda Parbat", + "880.00", + "Dining", + _day(0, 10), + description="League of Shadows annual networking dinner — third year attending. Ra's is a good host.", + ), + _e( + "m0-09", + "WayneTech Fuels Lab", + "1240.00", + "Crime Fighting", + _day(0, 12), + description="Batmobile premium synthetic fuel blend — 180L, high-octane, low thermal signature", + ), + _e( + "m0-grocery", + "Billa Supermercato", + "296.00", + "Groceries", + _day(0, 12), + description="Alfred's current-month provisions — the truffle was 'non-negotiable', apparently", + items=_M0_GROCERY_ITEMS, + ), + _e( + "m0-10", + "ComfortCave Industries Ltd.", + "144.00", + "Shopping", + _day(0, 13), + description="Custom bat-motif briefs — BACK TO 48-PACK. The half-order experiment failed.", + ), + _e( + "m0-11", + "The Grand Ballroom, Gotham Plaza", + "18500.00", + "Entertainment", + _day(0, 15), + is_major=True, + description="Wayne Enterprises Q2 Staff Summer Party — 340 attendees, open bar (Alfred supervised)", + ), + _e( + "m0-12", + "Dr. Harleen Quinzel, Psy.D.", + "320.00", + "Health", + _day(0, 16), + cadence="monthly", + rg=_RG["therapy"], + description="Session 11: staff party incident (joker-themed decoration — someone's fired). Bilateral trauma.", + ), + _e( + "m0-13", + "Gotham Addiction Recovery Center", + "100000.00", + "Wayne Foundation", + _day(0, 17), + is_major=True, + description="New wing naming rights + construction sponsorship — 'The Thomas & Martha Wayne Wing'", + ), + _e( + "m0-14", + "Anthropic (Claude Code)", + "180.00", + "Shopping", + _day(0, 18), + cadence="monthly", + rg=_RG["claude"], + description="Monthly AI assistant — used it to analyse Kryptonite acquisition ROI. Helpful.", + ), + _e( + "m0-15", + "Gotham Sports Medicine Clinic", + "750.00", + "Health", + _day(0, 19), + description="Sports physio — knee (same knee). 'You really need to stop doing whatever you're doing.'", + ), + _e( + "m0-16", + "WayneTech Optics Division", + "1800.00", + "Crime Fighting", + _day(0, 20), + description="Night vision replacement unit x2, long-range — the gargoyle landing was a mistake", + ), # Wayne Manor Ops ledger — current month - _e("l-manor-m0-01", "Whole Foods Market Gotham", "2100.00", "Groceries", _day(0, 14), - ledger_id=ledger_manor, description="Wayne Manor monthly provisions — Alfred splurged on truffles"), - + _e( + "l-manor-m0-01", + "Whole Foods Market Gotham", + "2100.00", + "Groceries", + _day(0, 14), + ledger_id=ledger_manor, + description="Wayne Manor monthly provisions — Alfred splurged on truffles", + ), # Fox-Wayne Labs ledger — current month - _e("l-fox-m0-01", "SentinelTech Systems", "19500.00", "WayneTech R&D", _day(0, 8), - ledger_id=ledger_fox, is_major=True, - description="Fox-Wayne Labs — long-range thermal imaging prototype system v2"), - + _e( + "l-fox-m0-01", + "SentinelTech Systems", + "19500.00", + "WayneTech R&D", + _day(0, 8), + ledger_id=ledger_fox, + is_major=True, + description="Fox-Wayne Labs — long-range thermal imaging prototype system v2", + ), # JL Petty Cash — current month - _e("l-jl-m0-01", "Themyscira Premium Caterers", "1200.00", "Dining", _day(0, 11), - ledger_id=ledger_jl, - description="JL Summit catering — Diana's family catering company. Excellent, but NO seafood for Arthur."), + _e( + "l-jl-m0-01", + "Themyscira Premium Caterers", + "1200.00", + "Dining", + _day(0, 11), + ledger_id=ledger_jl, + description="JL Summit catering — Diana's family catering company. Excellent, but NO seafood for Arthur.", + ), ] @@ -580,6 +992,7 @@ def _build_expenses(ledger_manor: uuid.UUID, ledger_fox: uuid.UUID, ledger_jl: u # Budget seed # --------------------------------------------------------------------------- + def _build_budgets(cat_by_name: dict[str, uuid.UUID]) -> list[dict]: month = _month_start(0) return [ @@ -648,255 +1061,601 @@ def _build_budgets(cat_by_name: dict[str, uuid.UUID]) -> list[dict]: } -def _build_peter_expenses(ledger_lab: uuid.UUID, ledger_avengers: uuid.UUID, ledger_may: uuid.UUID) -> list[dict]: +def _build_peter_expenses( + ledger_lab: uuid.UUID, ledger_avengers: uuid.UUID, ledger_may: uuid.UUID +) -> list[dict]: return [ - # ── Month -2 ───────────────────────────────────────────────────────────── - - _e("p-inc-m2-01", "Daily Bugle Photography", "340.00", "Salary", _day(2, 1), tx="credit", - description="J. Jonah Jameson's freelance check — docked €160 for 'Spider-Man photos not menacing enough'"), - - _e("p-inc-m2-02", "Empire State University", "280.00", "Salary", _day(2, 5), tx="credit", - description="Chemistry TA stipend — grading papers on polymers I invented two years ago"), - - _e("p-m2-rent", "Queens Landlord LLC", "850.00", "Housing", _day(2, 2), - cadence="monthly", rg=_PETER_RG["rent"], - description="Monthly rent — Mrs Chen gave me an extension. She said 'you're a good boy, Peter'. I said nothing."), - - _e("p-m2-webfluid", "Oscorp Industrial Chemicals", "180.00", "Web Fluid R&D", _day(2, 3), - cadence="monthly", rg=_PETER_RG["web_fluid"], - description="Web fluid base polymer — monthly supply. Lab notebook reason: 'advanced thesis research on adhesives'"), - - _e("p-m2-therapy-01", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(2, 4), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 1: we started with Uncle Ben. We always start with Uncle Ben."), - - _e("p-m2-medical-01", "Queens Medical Center", "420.00", "Health", _day(2, 6), - description="Rib fractures x2, dislocated shoulder — cause of injury entered as 'bicycle accident'. 4th visit this quarter."), - - _e("p-m2-suit-01", "Chinatown Fabric District", "34.00", "Crime Fighting", _day(2, 7), - description="Red/blue spandex 3m + UV-resistant dye — told the cashier it was for a 'theatre project'"), - - _e("p-m2-ramen", "H-Mart Queens", "8.50", "Dining", _day(2, 8), - description="Emergency ramen run — 3rd time this week. Ned says I should learn to cook. Ned is right."), - - _e("p-m2-camera", "B&H Photo Video NYC", "280.00", "Shopping", _day(2, 10), - is_major=True, - description="Nikon zoom lens — previous one destroyed in the Vulture incident. Expensed to Bugle as 'equipment wear'"), - - _e("p-m2-therapy-02", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(2, 12), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 2: still Uncle Ben. Briefly touched on Gwen. Dr. Madison suggested journaling. Will not journal."), - - _e("p-m2-cartridge", "Oscorp Advanced Materials", "320.00", "Web Fluid R&D", _day(2, 14), - is_major=True, - description="High-tensile web cartridges x12 — monthly supply. Told Ned they're '3D printing filament'. He knows."), - - _e("p-m2-chem-01", "Staten Island Chemical Co.", "95.00", "Web Fluid R&D", _day(2, 15), - description="Tensile modifier compound — web strength upgrade. MJ noticed the chemicals. I said 'for school'."), - - _e("p-m2-backpack-01", "Target Queens", "28.00", "Shopping", _day(2, 16), - description="Backpack #4 this year — previous ones: left on rooftop, caught fire, dissolved in acid (don't ask)"), - - _e("p-m2-therapy-03", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(2, 18), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 3: lighter session! We discussed compartmentalization. Very relevant to my situation."), - - _e("p-m2-book", "ESU Campus Bookstore", "74.00", "Shopping", _day(2, 19), - description="Advanced Organic Chemistry vol. 4 — for class AND for other reasons I will not specify here"), - - _e("p-m2-mj-01", "Delmar's Deli-Grocery", "22.00", "Dining", _day(2, 20), - description="Dinner with MJ — she had the salmon; I had the #3 combo. She tried to pay. I declined. Error."), - - _e("p-m2-grocery", "Walmart Supercenter Queens", "52.00", "Groceries", _day(2, 22), - description="Monthly provisions — ramen x24, PB&J supplies. Aunt May would not approve of this diet.", - items=_M2_PETER_GROCERY_ITEMS), - - _e("p-m2-therapy-04", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(2, 25), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 4: discussed masks — metaphor and literal. She doesn't know it's both."), - - _e("p-m2-metro", "MTA New York City Transit", "33.00", "Transport", _day(2, 26), - cadence="monthly", rg=_PETER_RG["metro"], - description="Monthly MetroCard — backup transport for when the suit is being repaired by Ned"), - - _e("p-m2-medical-02", "Queens Medical Center", "140.00", "Health", _day(2, 28), - description="Rib follow-up. Doctor notes I'm healing 'unusually fast'. I said: good metabolism. He: noted it."), - + _e( + "p-inc-m2-01", + "Daily Bugle Photography", + "340.00", + "Salary", + _day(2, 1), + tx="credit", + description="J. Jonah Jameson's freelance check — docked €160 for 'Spider-Man photos not menacing enough'", + ), + _e( + "p-inc-m2-02", + "Empire State University", + "280.00", + "Salary", + _day(2, 5), + tx="credit", + description="Chemistry TA stipend — grading papers on polymers I invented two years ago", + ), + _e( + "p-m2-rent", + "Queens Landlord LLC", + "850.00", + "Housing", + _day(2, 2), + cadence="monthly", + rg=_PETER_RG["rent"], + description="Monthly rent — Mrs Chen gave me an extension. She said 'you're a good boy, Peter'. I said nothing.", + ), + _e( + "p-m2-webfluid", + "Oscorp Industrial Chemicals", + "180.00", + "Web Fluid R&D", + _day(2, 3), + cadence="monthly", + rg=_PETER_RG["web_fluid"], + description="Web fluid base polymer — monthly supply. Lab notebook reason: 'advanced thesis research on adhesives'", + ), + _e( + "p-m2-therapy-01", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(2, 4), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 1: we started with Uncle Ben. We always start with Uncle Ben.", + ), + _e( + "p-m2-medical-01", + "Queens Medical Center", + "420.00", + "Health", + _day(2, 6), + description="Rib fractures x2, dislocated shoulder — cause of injury entered as 'bicycle accident'. 4th visit this quarter.", + ), + _e( + "p-m2-suit-01", + "Chinatown Fabric District", + "34.00", + "Crime Fighting", + _day(2, 7), + description="Red/blue spandex 3m + UV-resistant dye — told the cashier it was for a 'theatre project'", + ), + _e( + "p-m2-ramen", + "H-Mart Queens", + "8.50", + "Dining", + _day(2, 8), + description="Emergency ramen run — 3rd time this week. Ned says I should learn to cook. Ned is right.", + ), + _e( + "p-m2-camera", + "B&H Photo Video NYC", + "280.00", + "Shopping", + _day(2, 10), + is_major=True, + description="Nikon zoom lens — previous one destroyed in the Vulture incident. Expensed to Bugle as 'equipment wear'", + ), + _e( + "p-m2-therapy-02", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(2, 12), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 2: still Uncle Ben. Briefly touched on Gwen. Dr. Madison suggested journaling. Will not journal.", + ), + _e( + "p-m2-cartridge", + "Oscorp Advanced Materials", + "320.00", + "Web Fluid R&D", + _day(2, 14), + is_major=True, + description="High-tensile web cartridges x12 — monthly supply. Told Ned they're '3D printing filament'. He knows.", + ), + _e( + "p-m2-chem-01", + "Staten Island Chemical Co.", + "95.00", + "Web Fluid R&D", + _day(2, 15), + description="Tensile modifier compound — web strength upgrade. MJ noticed the chemicals. I said 'for school'.", + ), + _e( + "p-m2-backpack-01", + "Target Queens", + "28.00", + "Shopping", + _day(2, 16), + description="Backpack #4 this year — previous ones: left on rooftop, caught fire, dissolved in acid (don't ask)", + ), + _e( + "p-m2-therapy-03", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(2, 18), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 3: lighter session! We discussed compartmentalization. Very relevant to my situation.", + ), + _e( + "p-m2-book", + "ESU Campus Bookstore", + "74.00", + "Shopping", + _day(2, 19), + description="Advanced Organic Chemistry vol. 4 — for class AND for other reasons I will not specify here", + ), + _e( + "p-m2-mj-01", + "Delmar's Deli-Grocery", + "22.00", + "Dining", + _day(2, 20), + description="Dinner with MJ — she had the salmon; I had the #3 combo. She tried to pay. I declined. Error.", + ), + _e( + "p-m2-grocery", + "Walmart Supercenter Queens", + "52.00", + "Groceries", + _day(2, 22), + description="Monthly provisions — ramen x24, PB&J supplies. Aunt May would not approve of this diet.", + items=_M2_PETER_GROCERY_ITEMS, + ), + _e( + "p-m2-therapy-04", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(2, 25), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 4: discussed masks — metaphor and literal. She doesn't know it's both.", + ), + _e( + "p-m2-metro", + "MTA New York City Transit", + "33.00", + "Transport", + _day(2, 26), + cadence="monthly", + rg=_PETER_RG["metro"], + description="Monthly MetroCard — backup transport for when the suit is being repaired by Ned", + ), + _e( + "p-m2-medical-02", + "Queens Medical Center", + "140.00", + "Health", + _day(2, 28), + description="Rib follow-up. Doctor notes I'm healing 'unusually fast'. I said: good metabolism. He: noted it.", + ), # Parker Lab ledger — month -2 - _e("p-l-lab-m2-01", "Carolina Biological Supply", "145.00", "Web Fluid R&D", _day(2, 11), - ledger_id=ledger_lab, - description="Lab supplies (Ned's order) — listed as 'ESU adhesion research project'. That's not what we call it."), - + _e( + "p-l-lab-m2-01", + "Carolina Biological Supply", + "145.00", + "Web Fluid R&D", + _day(2, 11), + ledger_id=ledger_lab, + description="Lab supplies (Ned's order) — listed as 'ESU adhesion research project'. That's not what we call it.", + ), # Avengers — month -2 - _e("p-l-avg-m2-01", "Avengers Compound Cafeteria", "8.50", "Dining", _day(2, 19), - ledger_id=ledger_avengers, - description="Lunch at the compound — I was invited. This is not a drill. Turkey sandwich. Very good."), - + _e( + "p-l-avg-m2-01", + "Avengers Compound Cafeteria", + "8.50", + "Dining", + _day(2, 19), + ledger_id=ledger_avengers, + description="Lunch at the compound — I was invited. This is not a drill. Turkey sandwich. Very good.", + ), # Aunt May ledger — month -2 - _e("p-l-may-m2-01", "Walgreens Queens", "34.00", "Health", _day(2, 17), - ledger_id=ledger_may, - description="Aunt May's prescriptions + vitamins — she doesn't ask where I find the money. I appreciate that."), - + _e( + "p-l-may-m2-01", + "Walgreens Queens", + "34.00", + "Health", + _day(2, 17), + ledger_id=ledger_may, + description="Aunt May's prescriptions + vitamins — she doesn't ask where I find the money. I appreciate that.", + ), # ── Month -1 ───────────────────────────────────────────────────────────── - - _e("p-inc-m1-01", "Daily Bugle Photography", "480.00", "Salary", _day(1, 1), tx="credit", - description="Best Bugle check in months — Jameson said 'barely adequate'. Framed it on the wall."), - - _e("p-inc-m1-02", "Stark Industries", "500.00", "Salary", _day(1, 3), tx="credit", - description="Consulting fee from Happy Hogan — Tony called it 'educational expenses'. I'll take it."), - - _e("p-m1-rent", "Queens Landlord LLC", "850.00", "Housing", _day(1, 2), - cadence="monthly", rg=_PETER_RG["rent"], - description="Monthly rent — paid ON TIME for once. Mrs Chen nearly fainted."), - - _e("p-m1-webfluid", "Oscorp Industrial Chemicals", "180.00", "Web Fluid R&D", _day(1, 3), - cadence="monthly", rg=_PETER_RG["web_fluid"], - description="Web fluid monthly order — formula still the best I've designed. Ned wanted to add glitter. No."), - - _e("p-m1-therapy-01", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(1, 5), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 5: we made it 15 full minutes before Uncle Ben came up. Personal record."), - - _e("p-m1-medical-01", "Bellevue Hospital Center", "650.00", "Health", _day(1, 7), - is_major=True, - description="Concussion + fractured wrist — 'bicycle accident'. Doctor said 'see you next month'. Accurate prediction."), - - _e("p-m1-suit-01", "AliExpress", "18.00", "Crime Fighting", _day(1, 8), - description="Replacement eye lenses for mask — €18 + 3 weeks shipping. Ned suggested Prime. He's right."), - - _e("p-m1-cartridge", "Oscorp Advanced Materials", "320.00", "Web Fluid R&D", _day(1, 10), - is_major=True, - description="Web cartridges x12 — went through them fast. Rhino was involved. Long story, much webbing."), - - _e("p-m1-camera", "B&H Photo Video NYC", "190.00", "Shopping", _day(1, 12), - description="Camera mount + remote trigger — for 'self-portraits'. Bugle pays better for action shots."), - - _e("p-m1-therapy-02", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(1, 14), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 6: discussed whether wearing a mask all day is authentic self-expression. It is not."), - - _e("p-m1-grocery", "Walmart Supercenter Queens", "57.00", "Groceries", _day(1, 16), - description="Monthly grocery run — splurged on NAME-BRAND peanut butter. Stark stipend month. No regrets.", - items=_M1_PETER_GROCERY_ITEMS), - - _e("p-m1-chem-01", "Fisher Scientific", "145.00", "Web Fluid R&D", _day(1, 18), - description="Micro-filament spooler + precision nozzle tips — 'for the lab'. For THE lab."), - - _e("p-m1-therapy-03", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(1, 20), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 7: discussed power and responsibility. I changed the subject. She noticed immediately."), - - _e("p-m1-mj-01", "Joe Coffee Midtown", "14.00", "Dining", _day(1, 21), - description="Coffee with MJ — she's writing on vigilantes. I pretended to find it purely hypothetical."), - - _e("p-m1-backpack-01", "Target Queens", "28.00", "Shopping", _day(1, 22), - description="Backpack #2 this month — last one lasted 11 days before the Electro incident"), - - _e("p-m1-therapy-04", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(1, 24), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 8: she asked if the 'mystery friend' I mentioned has my back. Yes. Sort of. It's complicated."), - - _e("p-m1-metro", "MTA New York City Transit", "33.00", "Transport", _day(1, 25), - cadence="monthly", rg=_PETER_RG["metro"], - description="Monthly MetroCard — Happy asked why I still need this. 'Dates,' I said. Partially true."), - - _e("p-m1-suit-repair", "Chinatown Fabric District", "45.00", "Crime Fighting", _day(1, 27), - description="Suit patching + Kevlar reinforcement — Rhino tears. Ned is doing the stitching now. He's better at it."), - - _e("p-m1-tuition", "Empire State University", "380.00", "Shopping", _day(1, 28), - is_major=True, - description="Monthly tuition installment — scholarship covers 70%. The other 30% covers the anxiety too."), - + _e( + "p-inc-m1-01", + "Daily Bugle Photography", + "480.00", + "Salary", + _day(1, 1), + tx="credit", + description="Best Bugle check in months — Jameson said 'barely adequate'. Framed it on the wall.", + ), + _e( + "p-inc-m1-02", + "Stark Industries", + "500.00", + "Salary", + _day(1, 3), + tx="credit", + description="Consulting fee from Happy Hogan — Tony called it 'educational expenses'. I'll take it.", + ), + _e( + "p-m1-rent", + "Queens Landlord LLC", + "850.00", + "Housing", + _day(1, 2), + cadence="monthly", + rg=_PETER_RG["rent"], + description="Monthly rent — paid ON TIME for once. Mrs Chen nearly fainted.", + ), + _e( + "p-m1-webfluid", + "Oscorp Industrial Chemicals", + "180.00", + "Web Fluid R&D", + _day(1, 3), + cadence="monthly", + rg=_PETER_RG["web_fluid"], + description="Web fluid monthly order — formula still the best I've designed. Ned wanted to add glitter. No.", + ), + _e( + "p-m1-therapy-01", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(1, 5), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 5: we made it 15 full minutes before Uncle Ben came up. Personal record.", + ), + _e( + "p-m1-medical-01", + "Bellevue Hospital Center", + "650.00", + "Health", + _day(1, 7), + is_major=True, + description="Concussion + fractured wrist — 'bicycle accident'. Doctor said 'see you next month'. Accurate prediction.", + ), + _e( + "p-m1-suit-01", + "AliExpress", + "18.00", + "Crime Fighting", + _day(1, 8), + description="Replacement eye lenses for mask — €18 + 3 weeks shipping. Ned suggested Prime. He's right.", + ), + _e( + "p-m1-cartridge", + "Oscorp Advanced Materials", + "320.00", + "Web Fluid R&D", + _day(1, 10), + is_major=True, + description="Web cartridges x12 — went through them fast. Rhino was involved. Long story, much webbing.", + ), + _e( + "p-m1-camera", + "B&H Photo Video NYC", + "190.00", + "Shopping", + _day(1, 12), + description="Camera mount + remote trigger — for 'self-portraits'. Bugle pays better for action shots.", + ), + _e( + "p-m1-therapy-02", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(1, 14), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 6: discussed whether wearing a mask all day is authentic self-expression. It is not.", + ), + _e( + "p-m1-grocery", + "Walmart Supercenter Queens", + "57.00", + "Groceries", + _day(1, 16), + description="Monthly grocery run — splurged on NAME-BRAND peanut butter. Stark stipend month. No regrets.", + items=_M1_PETER_GROCERY_ITEMS, + ), + _e( + "p-m1-chem-01", + "Fisher Scientific", + "145.00", + "Web Fluid R&D", + _day(1, 18), + description="Micro-filament spooler + precision nozzle tips — 'for the lab'. For THE lab.", + ), + _e( + "p-m1-therapy-03", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(1, 20), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 7: discussed power and responsibility. I changed the subject. She noticed immediately.", + ), + _e( + "p-m1-mj-01", + "Joe Coffee Midtown", + "14.00", + "Dining", + _day(1, 21), + description="Coffee with MJ — she's writing on vigilantes. I pretended to find it purely hypothetical.", + ), + _e( + "p-m1-backpack-01", + "Target Queens", + "28.00", + "Shopping", + _day(1, 22), + description="Backpack #2 this month — last one lasted 11 days before the Electro incident", + ), + _e( + "p-m1-therapy-04", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(1, 24), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 8: she asked if the 'mystery friend' I mentioned has my back. Yes. Sort of. It's complicated.", + ), + _e( + "p-m1-metro", + "MTA New York City Transit", + "33.00", + "Transport", + _day(1, 25), + cadence="monthly", + rg=_PETER_RG["metro"], + description="Monthly MetroCard — Happy asked why I still need this. 'Dates,' I said. Partially true.", + ), + _e( + "p-m1-suit-repair", + "Chinatown Fabric District", + "45.00", + "Crime Fighting", + _day(1, 27), + description="Suit patching + Kevlar reinforcement — Rhino tears. Ned is doing the stitching now. He's better at it.", + ), + _e( + "p-m1-tuition", + "Empire State University", + "380.00", + "Shopping", + _day(1, 28), + is_major=True, + description="Monthly tuition installment — scholarship covers 70%. The other 30% covers the anxiety too.", + ), # Parker Lab ledger — month -1 - _e("p-l-lab-m1-01", "Sigma-Aldrich", "220.00", "Web Fluid R&D", _day(1, 13), - ledger_id=ledger_lab, is_major=True, - description="Polymer synthesis kit — Ned found a deal. The deal was: experimental and possibly not legal in 3 states."), - + _e( + "p-l-lab-m1-01", + "Sigma-Aldrich", + "220.00", + "Web Fluid R&D", + _day(1, 13), + ledger_id=ledger_lab, + is_major=True, + description="Polymer synthesis kit — Ned found a deal. The deal was: experimental and possibly not legal in 3 states.", + ), # Avengers — month -1 - _e("p-l-avg-m1-01", "Avengers Compound Gift Shop", "24.00", "Shopping", _day(1, 15), - ledger_id=ledger_avengers, - description="Bought Aunt May a 'Stark Industries' mug from the gift shop. Did not tell her where I was."), - + _e( + "p-l-avg-m1-01", + "Avengers Compound Gift Shop", + "24.00", + "Shopping", + _day(1, 15), + ledger_id=ledger_avengers, + description="Bought Aunt May a 'Stark Industries' mug from the gift shop. Did not tell her where I was.", + ), # Aunt May ledger — month -1 - _e("p-l-may-m1-01", "Key Food Supermarkets", "78.00", "Groceries", _day(1, 20), - ledger_id=ledger_may, - description="May's weekly shop — she cooks for me every Sunday. Roast chicken. This is the least I can do."), - + _e( + "p-l-may-m1-01", + "Key Food Supermarkets", + "78.00", + "Groceries", + _day(1, 20), + ledger_id=ledger_may, + description="May's weekly shop — she cooks for me every Sunday. Roast chicken. This is the least I can do.", + ), # ── Current month ───────────────────────────────────────────────────────── - - _e("p-inc-m0-01", "Daily Bugle Photography", "410.00", "Salary", _day(0, 1), tx="credit", - description="This month's Bugle check — Jameson said my Spider-Man photos 'show his criminal nature'. Framing #2."), - - _e("p-m0-rent", "Queens Landlord LLC", "850.00", "Housing", _day(0, 2), - cadence="monthly", rg=_PETER_RG["rent"], - description="Monthly rent — autopay set up. Small victory in an otherwise chaotic life."), - - _e("p-m0-webfluid", "Oscorp Industrial Chemicals", "180.00", "Web Fluid R&D", _day(0, 3), - cadence="monthly", rg=_PETER_RG["web_fluid"], - description="Web fluid supply — reformulated adhesion coefficient. Ned: 'impressive'. MJ: 'why is there webbing in the sink'."), - - _e("p-m0-therapy-01", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(0, 4), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 9: 40 full minutes, no Uncle Ben. Dr. Madison cried. (So did I, later, in the stairwell.)"), - - _e("p-m0-cartridge", "Oscorp Advanced Materials", "320.00", "Web Fluid R&D", _day(0, 6), - is_major=True, - description="Web cartridges x12 — ran out in 3 weeks. Sandman is very, very wasteful of webbing."), - - _e("p-m0-chem-01", "Staten Island Chemical Co.", "115.00", "Web Fluid R&D", _day(0, 8), - description="Polymer catalyst batch — new synthesis. Lab note: DO NOT TOUCH THE BLUE ONE (lab incident, don't ask)"), - - _e("p-m0-therapy-02", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(0, 9), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 10: relapse — talked about Uncle Ben for 55 of 60 minutes. Dr. Madison: visibly tired."), - - _e("p-m0-medical", "Queens Medical Center", "290.00", "Health", _day(0, 11), - description="Sprained ankle, bruised sternum — 'same bicycle, same exact accident'. They have a dedicated file now."), - - _e("p-m0-grocery", "Walmart Supercenter Queens", "44.00", "Groceries", _day(0, 12), - description="Monthly grocery — ramen x20, bread, PB, Gatorade x6. Aunt May would not approve. She'd be right.", - items=_M0_PETER_GROCERY_ITEMS), - - _e("p-m0-mj-01", "Patsy's Pizzeria Queens", "28.00", "Dining", _day(0, 13), - description="Pizza with MJ — her idea, my bill. She had 4 slices. I respect that. I had 2. Shame."), - - _e("p-m0-suit-01", "Amazon.com", "52.00", "Crime Fighting", _day(0, 14), - description="Carbon fibre reinforcement fabric + heat-resistant thread — suit v3.4 upgrade. Ned: 'This one will hold?' Maybe."), - - _e("p-m0-therapy-03", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(0, 16), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 11: breakthrough — 25 minutes no Uncle Ben. Then I mentioned the radioactive spider. Back to zero."), - - _e("p-m0-metro", "MTA New York City Transit", "33.00", "Transport", _day(0, 17), - cadence="monthly", rg=_PETER_RG["metro"], - description="Monthly MetroCard — Ned asked why I still buy this. 'For dates,' I said. Partially true."), - - _e("p-m0-backpack-01", "Duane Reade", "19.00", "Shopping", _day(0, 18), - description="Backpack from the clearance bin — €19. Mr Stark would be embarrassed. He never said that but I know."), - - _e("p-m0-therapy-04", "Dr. Marla Madison, LCSW", "80.00", "Health", _day(0, 19), - cadence="monthly", rg=_PETER_RG["therapy"], - description="Session 12: discussed responsibility. She still doesn't know. I think that's the whole point."), - - _e("p-m0-tuition", "Empire State University", "380.00", "Shopping", _day(0, 20), - is_major=True, - description="Tuition installment — Professor Connors wrote a glowing recommendation. He then became a lizard."), - + _e( + "p-inc-m0-01", + "Daily Bugle Photography", + "410.00", + "Salary", + _day(0, 1), + tx="credit", + description="This month's Bugle check — Jameson said my Spider-Man photos 'show his criminal nature'. Framing #2.", + ), + _e( + "p-m0-rent", + "Queens Landlord LLC", + "850.00", + "Housing", + _day(0, 2), + cadence="monthly", + rg=_PETER_RG["rent"], + description="Monthly rent — autopay set up. Small victory in an otherwise chaotic life.", + ), + _e( + "p-m0-webfluid", + "Oscorp Industrial Chemicals", + "180.00", + "Web Fluid R&D", + _day(0, 3), + cadence="monthly", + rg=_PETER_RG["web_fluid"], + description="Web fluid supply — reformulated adhesion coefficient. Ned: 'impressive'. MJ: 'why is there webbing in the sink'.", + ), + _e( + "p-m0-therapy-01", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(0, 4), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 9: 40 full minutes, no Uncle Ben. Dr. Madison cried. (So did I, later, in the stairwell.)", + ), + _e( + "p-m0-cartridge", + "Oscorp Advanced Materials", + "320.00", + "Web Fluid R&D", + _day(0, 6), + is_major=True, + description="Web cartridges x12 — ran out in 3 weeks. Sandman is very, very wasteful of webbing.", + ), + _e( + "p-m0-chem-01", + "Staten Island Chemical Co.", + "115.00", + "Web Fluid R&D", + _day(0, 8), + description="Polymer catalyst batch — new synthesis. Lab note: DO NOT TOUCH THE BLUE ONE (lab incident, don't ask)", + ), + _e( + "p-m0-therapy-02", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(0, 9), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 10: relapse — talked about Uncle Ben for 55 of 60 minutes. Dr. Madison: visibly tired.", + ), + _e( + "p-m0-medical", + "Queens Medical Center", + "290.00", + "Health", + _day(0, 11), + description="Sprained ankle, bruised sternum — 'same bicycle, same exact accident'. They have a dedicated file now.", + ), + _e( + "p-m0-grocery", + "Walmart Supercenter Queens", + "44.00", + "Groceries", + _day(0, 12), + description="Monthly grocery — ramen x20, bread, PB, Gatorade x6. Aunt May would not approve. She'd be right.", + items=_M0_PETER_GROCERY_ITEMS, + ), + _e( + "p-m0-mj-01", + "Patsy's Pizzeria Queens", + "28.00", + "Dining", + _day(0, 13), + description="Pizza with MJ — her idea, my bill. She had 4 slices. I respect that. I had 2. Shame.", + ), + _e( + "p-m0-suit-01", + "Amazon.com", + "52.00", + "Crime Fighting", + _day(0, 14), + description="Carbon fibre reinforcement fabric + heat-resistant thread — suit v3.4 upgrade. Ned: 'This one will hold?' Maybe.", + ), + _e( + "p-m0-therapy-03", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(0, 16), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 11: breakthrough — 25 minutes no Uncle Ben. Then I mentioned the radioactive spider. Back to zero.", + ), + _e( + "p-m0-metro", + "MTA New York City Transit", + "33.00", + "Transport", + _day(0, 17), + cadence="monthly", + rg=_PETER_RG["metro"], + description="Monthly MetroCard — Ned asked why I still buy this. 'For dates,' I said. Partially true.", + ), + _e( + "p-m0-backpack-01", + "Duane Reade", + "19.00", + "Shopping", + _day(0, 18), + description="Backpack from the clearance bin — €19. Mr Stark would be embarrassed. He never said that but I know.", + ), + _e( + "p-m0-therapy-04", + "Dr. Marla Madison, LCSW", + "80.00", + "Health", + _day(0, 19), + cadence="monthly", + rg=_PETER_RG["therapy"], + description="Session 12: discussed responsibility. She still doesn't know. I think that's the whole point.", + ), + _e( + "p-m0-tuition", + "Empire State University", + "380.00", + "Shopping", + _day(0, 20), + is_major=True, + description="Tuition installment — Professor Connors wrote a glowing recommendation. He then became a lizard.", + ), # Parker Lab ledger — current month - _e("p-l-lab-m0-01", "LabSupply Direct", "95.00", "Web Fluid R&D", _day(0, 7), - ledger_id=ledger_lab, - description="Micro-dispensing nozzles x20 — web cartridge refill components. Ned: 'this is genuinely so cool'"), - + _e( + "p-l-lab-m0-01", + "LabSupply Direct", + "95.00", + "Web Fluid R&D", + _day(0, 7), + ledger_id=ledger_lab, + description="Micro-dispensing nozzles x20 — web cartridge refill components. Ned: 'this is genuinely so cool'", + ), # Avengers — current month - _e("p-l-avg-m0-01", "Shake Shack Midtown", "12.50", "Dining", _day(0, 9), - ledger_id=ledger_avengers, - description="Lunch near the compound — Happy invited. I paid my own shake out of pride. Happy didn't notice."), - + _e( + "p-l-avg-m0-01", + "Shake Shack Midtown", + "12.50", + "Dining", + _day(0, 9), + ledger_id=ledger_avengers, + description="Lunch near the compound — Happy invited. I paid my own shake out of pride. Happy didn't notice.", + ), # Aunt May ledger — current month - _e("p-l-may-m0-01", "Walgreens Queens", "34.00", "Health", _day(0, 10), - ledger_id=ledger_may, - description="May's prescriptions — she asked why I look bruised. I said 'gym'. She said 'sure'. She knows."), + _e( + "p-l-may-m0-01", + "Walgreens Queens", + "34.00", + "Health", + _day(0, 10), + ledger_id=ledger_may, + description="May's prescriptions — she asked why I look bruised. I said 'gym'. She said 'sure'. She knows.", + ), ] @@ -960,6 +1719,7 @@ def _build_peter_budgets(cat_by_name: dict[str, uuid.UUID]) -> list[dict]: # Core helpers # --------------------------------------------------------------------------- + async def _get_or_create_user( db: AsyncSession, user_id: uuid.UUID, @@ -993,7 +1753,16 @@ async def _ensure_custom_categories( ) -> None: for name, color, icon, tx_type in custom_cats: if name.lower() not in {k.lower() for k in cat_by_name}: - db.add(Category(user_id=user_id, name=name, color=color, icon=icon, transaction_type=tx_type, is_system=False)) + db.add( + Category( + user_id=user_id, + name=name, + color=color, + icon=icon, + transaction_type=tx_type, + is_system=False, + ) + ) await db.flush() @@ -1010,13 +1779,15 @@ async def _ensure_partner_requests( ) ) if result.scalar_one_or_none() is None: - db.add(PartnerRequest( - id=_did(f"partner:{bruce_id}:{partner_id}"), - requester_id=bruce_id, - recipient_email=partner_email, - recipient_id=partner_id, - status="accepted", - )) + db.add( + PartnerRequest( + id=_did(f"partner:{bruce_id}:{partner_id}"), + requester_id=bruce_id, + recipient_email=partner_email, + recipient_id=partner_id, + status="accepted", + ) + ) await db.flush() @@ -1073,29 +1844,33 @@ async def _seed_expenses_and_budgets( await db.flush() for item in row["items"]: - db.add(ExpenseItem( - expense_id=expense.id, - description=item["description"], - quantity=item.get("quantity"), - unit_price=Decimal(str(item["unit_price"])) if item.get("unit_price") else None, - total_price=Decimal(str(item["total"])), - subcategory=item.get("subcategory"), - subcategory_confidence=item.get("subcategory_confidence"), - )) + db.add( + ExpenseItem( + expense_id=expense.id, + description=item["description"], + quantity=item.get("quantity"), + unit_price=Decimal(str(item["unit_price"])) if item.get("unit_price") else None, + total_price=Decimal(str(item["total"])), + subcategory=item.get("subcategory"), + subcategory_confidence=item.get("subcategory_confidence"), + ) + ) budgets = _build_budgets(cat_by_name) for b in budgets: - db.add(Budget( - id=b["id"], - user_id=user_id, - name=b["name"], - category_id=b["category_id"], - amount=b["amount"], - currency=b["currency"], - period=b["period"], - month_start=b["month_start"], - notes=b["notes"], - )) + db.add( + Budget( + id=b["id"], + user_id=user_id, + name=b["name"], + category_id=b["category_id"], + amount=b["amount"], + currency=b["currency"], + period=b["period"], + month_start=b["month_start"], + notes=b["notes"], + ) + ) await db.flush() @@ -1103,22 +1878,41 @@ async def _seed_expenses_and_budgets( # Public API # --------------------------------------------------------------------------- + async def seed_demo_data(db: AsyncSession) -> User: """Create Bruce Wayne, partner stubs, categories, and all seed data. Idempotent: safe to call multiple times — will not duplicate data. """ # Stub partner accounts - await _get_or_create_user(db, ALFRED_ID, "alfred.pennyworth@waynemanor.com", "Alfred Pennyworth", - _avatar_svg("AP", "#1a1a2e", "#d4af37")) - await _get_or_create_user(db, LUCIUS_ID, "lucius.fox@wayneenterprises.com", "Lucius Fox", - _avatar_svg("LF", "#1e3a5f", "#60a5fa")) - await _get_or_create_user(db, CLARK_ID, "clark.kent@dailyplanet.com", "Clark Kent", - _avatar_svg("CK", "#1e293b", "#ef4444")) + await _get_or_create_user( + db, + ALFRED_ID, + "alfred.pennyworth@waynemanor.com", + "Alfred Pennyworth", + _avatar_svg("AP", "#1a1a2e", "#d4af37"), + ) + await _get_or_create_user( + db, + LUCIUS_ID, + "lucius.fox@wayneenterprises.com", + "Lucius Fox", + _avatar_svg("LF", "#1e3a5f", "#60a5fa"), + ) + await _get_or_create_user( + db, + CLARK_ID, + "clark.kent@dailyplanet.com", + "Clark Kent", + _avatar_svg("CK", "#1e293b", "#ef4444"), + ) # Bruce Wayne bruce = await _get_or_create_user( - db, BRUCE_WAYNE_ID, DEMO_USER_EMAIL, "Bruce Wayne", + db, + BRUCE_WAYNE_ID, + DEMO_USER_EMAIL, + "Bruce Wayne", _avatar_svg("BW", "#0d1117", "#fbbf24"), ) @@ -1130,16 +1924,22 @@ async def seed_demo_data(db: AsyncSession) -> User: cat_by_name = await _get_categories(db, bruce.id) # Partner links (shown on account page) - await _ensure_partner_requests(db, bruce.id, [ - (ALFRED_ID, "alfred.pennyworth@waynemanor.com"), - (LUCIUS_ID, "lucius.fox@wayneenterprises.com"), - (CLARK_ID, "clark.kent@dailyplanet.com"), - ]) + await _ensure_partner_requests( + db, + bruce.id, + [ + (ALFRED_ID, "alfred.pennyworth@waynemanor.com"), + (LUCIUS_ID, "lucius.fox@wayneenterprises.com"), + (CLARK_ID, "clark.kent@dailyplanet.com"), + ], + ) # Seed if empty, or if the current month's grocery (a reliable canary) is missing — # which happens when new seed code is deployed but the beat reset hasn't fired yet. needs_seed = False - count_result = await db.execute(select(func.count(Expense.id)).where(Expense.user_id == bruce.id)) + count_result = await db.execute( + select(func.count(Expense.id)).where(Expense.user_id == bruce.id) + ) if (count_result.scalar() or 0) == 0: needs_seed = True else: @@ -1159,7 +1959,11 @@ async def seed_demo_data(db: AsyncSession) -> User: return bruce -async def reset_demo_data(db: AsyncSession, user_id: uuid.UUID | None = None, cat_by_name: dict[str, uuid.UUID] | None = None) -> None: +async def reset_demo_data( + db: AsyncSession, + user_id: uuid.UUID | None = None, + cat_by_name: dict[str, uuid.UUID] | None = None, +) -> None: """Wipe and re-seed all ephemeral data for Bruce Wayne. Called by the Celery beat task every 30 minutes and on first login when @@ -1186,7 +1990,9 @@ async def reset_demo_data(db: AsyncSession, user_id: uuid.UUID | None = None, ca ledger_fox = await _create_ledger(db, _LEDGER_FOX_ID, "Fox-Wayne Labs", uid, [LUCIUS_ID]) ledger_jl = await _create_ledger(db, _LEDGER_JL_ID, "JL Petty Cash", uid, [CLARK_ID]) - await _seed_expenses_and_budgets(db, uid, cat_by_name, ledger_manor.id, ledger_fox.id, ledger_jl.id) + await _seed_expenses_and_budgets( + db, uid, cat_by_name, ledger_manor.id, ledger_fox.id, ledger_jl.id + ) await db.commit() await invalidate_analytics_cache(uid) logger.info("demo.reset_complete", user_id=str(uid)) @@ -1197,17 +2003,40 @@ async def seed_peter_data(db: AsyncSession) -> User: Idempotent: safe to call multiple times — will not duplicate data. """ - await _get_or_create_user(db, MJ_ID, "mj.watson@empire-state.edu", "Mary Jane Watson", - _avatar_svg("MJ", "#7f1d1d", "#fca5a5")) - await _get_or_create_user(db, NED_ID, "ned.leeds@empire-state.edu", "Ned Leeds", - _avatar_svg("NL", "#1e3a5f", "#93c5fd")) - await _get_or_create_user(db, AUNT_MAY_ID, "may.parker@queens.ny.us", "May Parker", - _avatar_svg("MP", "#3b1f5c", "#c084fc")) - await _get_or_create_user(db, HAPPY_ID, "happy.hogan@starkindustries.com", "Happy Hogan", - _avatar_svg("HH", "#1c1917", "#fb923c")) + await _get_or_create_user( + db, + MJ_ID, + "mj.watson@empire-state.edu", + "Mary Jane Watson", + _avatar_svg("MJ", "#7f1d1d", "#fca5a5"), + ) + await _get_or_create_user( + db, + NED_ID, + "ned.leeds@empire-state.edu", + "Ned Leeds", + _avatar_svg("NL", "#1e3a5f", "#93c5fd"), + ) + await _get_or_create_user( + db, + AUNT_MAY_ID, + "may.parker@queens.ny.us", + "May Parker", + _avatar_svg("MP", "#3b1f5c", "#c084fc"), + ) + await _get_or_create_user( + db, + HAPPY_ID, + "happy.hogan@starkindustries.com", + "Happy Hogan", + _avatar_svg("HH", "#1c1917", "#fb923c"), + ) peter = await _get_or_create_user( - db, PETER_PARKER_ID, PETER_PARKER_EMAIL, "Peter Parker", + db, + PETER_PARKER_ID, + PETER_PARKER_EMAIL, + "Peter Parker", _avatar_svg("PP", "#1e1b4b", "#f87171"), ) @@ -1217,15 +2046,21 @@ async def seed_peter_data(db: AsyncSession) -> User: await db.commit() cat_by_name = await _get_categories(db, peter.id) - await _ensure_partner_requests(db, peter.id, [ - (MJ_ID, "mj.watson@empire-state.edu"), - (NED_ID, "ned.leeds@empire-state.edu"), - (AUNT_MAY_ID, "may.parker@queens.ny.us"), - (HAPPY_ID, "happy.hogan@starkindustries.com"), - ]) + await _ensure_partner_requests( + db, + peter.id, + [ + (MJ_ID, "mj.watson@empire-state.edu"), + (NED_ID, "ned.leeds@empire-state.edu"), + (AUNT_MAY_ID, "may.parker@queens.ny.us"), + (HAPPY_ID, "happy.hogan@starkindustries.com"), + ], + ) needs_seed = False - count_result = await db.execute(select(func.count(Expense.id)).where(Expense.user_id == peter.id)) + count_result = await db.execute( + select(func.count(Expense.id)).where(Expense.user_id == peter.id) + ) if (count_result.scalar() or 0) == 0: needs_seed = True else: @@ -1245,7 +2080,11 @@ async def seed_peter_data(db: AsyncSession) -> User: return peter -async def reset_peter_data(db: AsyncSession, user_id: uuid.UUID | None = None, cat_by_name: dict[str, uuid.UUID] | None = None) -> None: +async def reset_peter_data( + db: AsyncSession, + user_id: uuid.UUID | None = None, + cat_by_name: dict[str, uuid.UUID] | None = None, +) -> None: """Wipe and re-seed all ephemeral data for Peter Parker.""" uid = user_id or PETER_PARKER_ID @@ -1259,8 +2098,12 @@ async def reset_peter_data(db: AsyncSession, user_id: uuid.UUID | None = None, c cat_by_name = await _get_categories(db, uid) ledger_lab = await _create_ledger(db, _LEDGER_PARKER_LAB_ID, "Parker's Lab", uid, [NED_ID]) - ledger_avengers = await _create_ledger(db, _LEDGER_AVENGERS_ID, "Avengers Auxiliary", uid, [HAPPY_ID]) - ledger_may = await _create_ledger(db, _LEDGER_MAY_ID, "Aunt May's Household", uid, [AUNT_MAY_ID]) + ledger_avengers = await _create_ledger( + db, _LEDGER_AVENGERS_ID, "Avengers Auxiliary", uid, [HAPPY_ID] + ) + ledger_may = await _create_ledger( + db, _LEDGER_MAY_ID, "Aunt May's Household", uid, [AUNT_MAY_ID] + ) rows = _build_peter_expenses(ledger_lab.id, ledger_avengers.id, ledger_may.id) for row in rows: @@ -1289,29 +2132,33 @@ async def reset_peter_data(db: AsyncSession, user_id: uuid.UUID | None = None, c db.add(expense) await db.flush() for item in row["items"]: - db.add(ExpenseItem( - expense_id=expense.id, - description=item["description"], - quantity=item.get("quantity"), - unit_price=Decimal(str(item["unit_price"])) if item.get("unit_price") else None, - total_price=Decimal(str(item["total"])), - subcategory=item.get("subcategory"), - subcategory_confidence=item.get("subcategory_confidence"), - )) + db.add( + ExpenseItem( + expense_id=expense.id, + description=item["description"], + quantity=item.get("quantity"), + unit_price=Decimal(str(item["unit_price"])) if item.get("unit_price") else None, + total_price=Decimal(str(item["total"])), + subcategory=item.get("subcategory"), + subcategory_confidence=item.get("subcategory_confidence"), + ) + ) budgets = _build_peter_budgets(cat_by_name) for b in budgets: - db.add(Budget( - id=b["id"], - user_id=uid, - name=b["name"], - category_id=b["category_id"], - amount=b["amount"], - currency=b["currency"], - period=b["period"], - month_start=b["month_start"], - notes=b["notes"], - )) + db.add( + Budget( + id=b["id"], + user_id=uid, + name=b["name"], + category_id=b["category_id"], + amount=b["amount"], + currency=b["currency"], + period=b["period"], + month_start=b["month_start"], + notes=b["notes"], + ) + ) await db.flush() await db.commit() await invalidate_analytics_cache(uid) diff --git a/backend/app/services/email.py b/backend/app/services/email.py index 9bafde3..0ff6a93 100644 --- a/backend/app/services/email.py +++ b/backend/app/services/email.py @@ -63,7 +63,9 @@ async def send_email( return response.json() -async def send_approval_request_email(user_email: str, user_name: str | None, approve_url: str, reject_url: str) -> None: +async def send_approval_request_email( + user_email: str, user_name: str | None, approve_url: str, reject_url: str +) -> None: if not settings.admin_email: logger.warning("ADMIN_EMAIL not set — skipping approval request email") return @@ -74,7 +76,11 @@ async def send_approval_request_email(user_email: str, user_name: str | None, ap html=_approval_email_html(user_email, user_name, approve_url, reject_url), ) if response is not None: - logger.info("Approval request email sent", to=settings.admin_email, resend_email_id=response.get("id")) + logger.info( + "Approval request email sent", + to=settings.admin_email, + resend_email_id=response.get("id"), + ) except Exception as error: logger.error("Failed to send approval request email", error=str(error)) @@ -106,7 +112,12 @@ async def send_monthly_report_email( ) if response is None: return None - logger.info("Monthly report email sent", to=user_email, report_month=report_month_key, resend_email_id=response.get("id")) + logger.info( + "Monthly report email sent", + to=user_email, + report_month=report_month_key, + resend_email_id=response.get("id"), + ) return response.get("id") @@ -122,15 +133,21 @@ async def send_partner_request_email( response = await send_email( to=[recipient_email], subject=f"[SpendHound] {requester_name or requester_email} wants to add you as an expense partner", - html=_partner_request_email_html(requester_name, requester_email, accept_url, reject_url), + html=_partner_request_email_html( + requester_name, requester_email, accept_url, reject_url + ), ) if response is not None: - logger.info("Partner request email sent", to=recipient_email, resend_email_id=response.get("id")) + logger.info( + "Partner request email sent", to=recipient_email, resend_email_id=response.get("id") + ) except Exception as error: logger.error("Failed to send partner request email", error=str(error)) -def _partner_request_email_html(requester_name: str | None, requester_email: str, accept_url: str, reject_url: str) -> str: +def _partner_request_email_html( + requester_name: str | None, requester_email: str, accept_url: str, reject_url: str +) -> str: name = requester_name or requester_email return f""" @@ -147,7 +164,9 @@ def _partner_request_email_html(requester_name: str | None, requester_email: str """ -def _approval_email_html(user_email: str, user_name: str | None, approve_url: str, reject_url: str) -> str: +def _approval_email_html( + user_email: str, user_name: str | None, approve_url: str, reject_url: str +) -> str: name = user_name or user_email return f""" @@ -167,7 +186,9 @@ def _approval_email_html(user_email: str, user_name: str | None, approve_url: st """ -def _monthly_report_email_html(user_email: str, user_name: str | None, report_month_label: str) -> str: +def _monthly_report_email_html( + user_email: str, user_name: str | None, report_month_label: str +) -> str: name = user_name or user_email return f""" diff --git a/backend/app/services/expense_chat.py b/backend/app/services/expense_chat.py index 9188b56..6c540a7 100644 --- a/backend/app/services/expense_chat.py +++ b/backend/app/services/expense_chat.py @@ -74,7 +74,9 @@ async def list_sessions(self, user_id: uuid.UUID) -> list[ChatSessionResponse]: ) return [self._session_response(session) for session in result.scalars().all()] - async def create_session(self, user_id: uuid.UUID, *, title: str | None = None) -> ChatSessionResponse: + async def create_session( + self, user_id: uuid.UUID, *, title: str | None = None + ) -> ChatSessionResponse: clean_title = (title or "New Chat").strip() or "New Chat" session = ChatSession( user_id=user_id, @@ -105,12 +107,16 @@ async def delete_session(self, user_id: uuid.UUID, *, session_id: uuid.UUID) -> await self.db.delete(session) await self.db.flush() - async def get_history(self, user_id: uuid.UUID, *, session_id: uuid.UUID) -> ChatHistoryResponse: + async def get_history( + self, user_id: uuid.UUID, *, session_id: uuid.UUID + ) -> ChatHistoryResponse: session = await self._get_session(user_id, session_id, load_messages=True) messages = [self._message_response(message) for message in session.messages] return ChatHistoryResponse(session=self._session_response(session), messages=messages) - async def clear_history(self, user_id: uuid.UUID, *, session_id: uuid.UUID) -> ChatHistoryResponse: + async def clear_history( + self, user_id: uuid.UUID, *, session_id: uuid.UUID + ) -> ChatHistoryResponse: session = await self._get_session(user_id, session_id) await self.db.execute(delete(ChatMessage).where(ChatMessage.session_id == session.id)) session.summary = None @@ -146,7 +152,9 @@ async def stream_chat( self.db.add(user_message) await self.db.flush() - history_messages = [message for message in session.messages if message.id != user_message.id] + history_messages = [ + message for message in session.messages if message.id != user_message.id + ] prompt_messages = await self._build_chat_prompt_messages( user_id=user_id, session=session, @@ -208,7 +216,9 @@ async def stream_chat( self.db.add(assistant_message) session.last_message_at = datetime.now(UTC) session.max_tokens = request.max_tokens - session.token_count += (user_message.token_count or 0) + (assistant_message.token_count or 0) + session.token_count += (user_message.token_count or 0) + ( + assistant_message.token_count or 0 + ) if session.title == TITLE_FALLBACK: session.title = await self._generate_title(user_message.content, llm_config) await self.db.flush() @@ -246,7 +256,10 @@ async def stream_summary( session=session, history_messages=history_messages, ) - messages = [Message(role="system", content=SYSTEM_PROMPT), Message(role="user", content=summary_prompt)] + messages = [ + Message(role="system", content=SYSTEM_PROMPT), + Message(role="user", content=summary_prompt), + ] yield self._sse_event( "meta", @@ -281,7 +294,9 @@ async def stream_summary( async def _get_session( self, user_id: uuid.UUID, session_id: uuid.UUID, *, load_messages: bool = False ) -> ChatSession: - statement = select(ChatSession).where(ChatSession.id == session_id, ChatSession.user_id == user_id) + statement = select(ChatSession).where( + ChatSession.id == session_id, ChatSession.user_id == user_id + ) if load_messages: statement = statement.options(selectinload(ChatSession.messages)) result = await self.db.execute(statement) @@ -299,7 +314,11 @@ async def _build_chat_prompt_messages( user_prompt: str, ) -> list[Message]: finance_context = await self._build_finance_context(user_id) - contextual_system = SYSTEM_PROMPT + "\n\n" + self._build_context_block(session=session, finance_context=finance_context) + contextual_system = ( + SYSTEM_PROMPT + + "\n\n" + + self._build_context_block(session=session, finance_context=finance_context) + ) prompt_messages: list[Message] = [Message(role="system", content=contextual_system)] for message in history_messages[-16:]: if message.role not in {"user", "assistant"}: @@ -311,7 +330,11 @@ async def _build_chat_prompt_messages( async def _build_finance_context(self, user_id: uuid.UUID) -> str: today = date.today() current_month = month_start_from_string(today.strftime("%Y-%m")) - next_month = date(current_month.year + (1 if current_month.month == 12 else 0), 1 if current_month.month == 12 else current_month.month + 1, 1) + next_month = date( + current_month.year + (1 if current_month.month == 12 else 0), + 1 if current_month.month == 12 else current_month.month + 1, + 1, + ) lookback_date = today - timedelta(days=90) recent_expenses_result = await self.db.execute( @@ -324,7 +347,9 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: recent_expenses = recent_expenses_result.all() category_totals_result = await self.db.execute( - select(Category.name, Expense.transaction_type, Expense.currency, func.sum(Expense.amount)) + select( + Category.name, Expense.transaction_type, Expense.currency, func.sum(Expense.amount) + ) .select_from(Expense) .outerjoin(Category, Category.id == Expense.category_id) .where(Expense.user_id == user_id, Expense.expense_date >= lookback_date) @@ -334,7 +359,9 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: ) merchant_totals_result = await self.db.execute( - select(Expense.merchant, Expense.currency, func.sum(Expense.amount), func.count(Expense.id)) + select( + Expense.merchant, Expense.currency, func.sum(Expense.amount), func.count(Expense.id) + ) .where( Expense.user_id == user_id, Expense.expense_date >= lookback_date, @@ -346,7 +373,13 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: ) recurring_result = await self.db.execute( - select(Expense.merchant, Expense.cadence, Expense.currency, func.avg(Expense.amount), func.count(Expense.id)) + select( + Expense.merchant, + Expense.cadence, + Expense.currency, + func.avg(Expense.amount), + func.count(Expense.id), + ) .where( Expense.user_id == user_id, (Expense.is_recurring.is_(True)) | (Expense.cadence != "one_time"), @@ -374,11 +407,12 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: ) .group_by(Expense.category_id) ) - monthly_spend_by_category = {category_id: float(total or 0) for category_id, total in monthly_spend_result.all()} + monthly_spend_by_category = { + category_id: float(total or 0) for category_id, total in monthly_spend_result.all() + } month_total_result = await self.db.execute( - select(func.sum(Expense.amount)) - .where( + select(func.sum(Expense.amount)).where( Expense.user_id == user_id, Expense.transaction_type == "debit", Expense.expense_date >= current_month, @@ -388,17 +422,29 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: month_total_spend = float(month_total_result.scalar() or 0) receipt_items_result = await self.db.execute( - select(ExpenseItem.description, func.sum(ExpenseItem.total_price), func.count(ExpenseItem.id)) + select( + ExpenseItem.description, + func.sum(ExpenseItem.total_price), + func.count(ExpenseItem.id), + ) .select_from(ExpenseItem) .join(Expense, Expense.id == ExpenseItem.expense_id) .where(Expense.user_id == user_id, Expense.expense_date >= lookback_date) .group_by(ExpenseItem.description) - .order_by(func.sum(ExpenseItem.total_price).desc().nullslast(), func.count(ExpenseItem.id).desc()) + .order_by( + func.sum(ExpenseItem.total_price).desc().nullslast(), + func.count(ExpenseItem.id).desc(), + ) .limit(10) ) receipt_result = await self.db.execute( - select(Receipt.original_filename, Receipt.document_kind, Receipt.extraction_status, Receipt.created_at) + select( + Receipt.original_filename, + Receipt.document_kind, + Receipt.extraction_status, + Receipt.created_at, + ) .where(Receipt.user_id == user_id) .order_by(Receipt.created_at.desc()) .limit(5) @@ -407,8 +453,12 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: sections: list[str] = [] sections.append("=== DATE CONTEXT ===") sections.append(f"Today's date: {today.isoformat()} ({today.strftime('%A, %B %d, %Y')})") - sections.append(f"Current month: {today.strftime('%B %Y')} — starts {current_month.isoformat()}, ends {(next_month - timedelta(days=1)).isoformat()}") - sections.append(f"Expense data range: {lookback_date.isoformat()} to {today.isoformat()} (last 90 days)") + sections.append( + f"Current month: {today.strftime('%B %Y')} — starts {current_month.isoformat()}, ends {(next_month - timedelta(days=1)).isoformat()}" + ) + sections.append( + f"Expense data range: {lookback_date.isoformat()} to {today.isoformat()} (last 90 days)" + ) sections.append("====================") sections.append("") sections.append("Recent expenses:") @@ -433,7 +483,11 @@ async def _build_finance_context(self, user_id: uuid.UUID) -> str: sections.append("Budgets for current month:") if budgets: for budget, category_name in budgets: - actual = month_total_spend if budget.category_id is None else monthly_spend_by_category.get(budget.category_id, 0.0) + actual = ( + month_total_spend + if budget.category_id is None + else monthly_spend_by_category.get(budget.category_id, 0.0) + ) sections.append( f"- {budget.name}: budget {float(budget.amount):.2f} {budget.currency}, spent {actual:.2f} {budget.currency}, remaining {float(budget.amount) - actual:.2f} {budget.currency} | category {category_name or 'All spending'}" ) @@ -500,7 +554,9 @@ def _build_summary_prompt( session: ChatSession | None, history_messages: list[ChatMessage], ) -> str: - history_text = "\n".join(f"- {message.role}: {message.content}" for message in history_messages[-12:]) + history_text = "\n".join( + f"- {message.role}: {message.content}" for message in history_messages[-12:] + ) return ( "Summarize the user's finances using the structured SpendHound context below. " "Prioritize notable spend trends, category outliers, budgets at risk, recurring charges, and helpful next actions.\n\n" @@ -544,7 +600,9 @@ def _message_response(self, message: ChatMessage) -> ChatMessageResponse: updated_at=message.updated_at, ) - def _build_llm_config(self, request: ChatStreamRequest | ChatSummarizeStreamRequest) -> LLMConfig: + def _build_llm_config( + self, request: ChatStreamRequest | ChatSummarizeStreamRequest + ) -> LLMConfig: # Build a config from any explicit request params (may all be None) request_config = create_llm_config( provider=request.provider, diff --git a/backend/app/services/item_rag.py b/backend/app/services/item_rag.py index aa16ace..b52916f 100644 --- a/backend/app/services/item_rag.py +++ b/backend/app/services/item_rag.py @@ -36,11 +36,15 @@ async def get_embedding(text: str) -> list[float] | None: data = response.json() embedding = data.get("embedding") if not isinstance(embedding, list) or not embedding: - logger.warning("item_rag.embedding.empty_response", model=settings.ollama_embedding_model) + logger.warning( + "item_rag.embedding.empty_response", model=settings.ollama_embedding_model + ) return None return embedding except Exception as exc: - logger.warning("item_rag.embedding.failed", error=str(exc), model=settings.ollama_embedding_model) + logger.warning( + "item_rag.embedding.failed", error=str(exc), model=settings.ollama_embedding_model + ) return None diff --git a/backend/app/services/llm/anthropic.py b/backend/app/services/llm/anthropic.py index ab6b30b..b115f06 100644 --- a/backend/app/services/llm/anthropic.py +++ b/backend/app/services/llm/anthropic.py @@ -80,7 +80,9 @@ async def complete(self, messages: list[Message], config: LLMConfig | None = Non max_tokens = config.max_tokens if config else 4096 system_prompt, user_messages = _split_system(messages) - anthropic_messages = [{"role": m.role, "content": _build_content_blocks(m)} for m in user_messages] + anthropic_messages = [ + {"role": m.role, "content": _build_content_blocks(m)} for m in user_messages + ] kwargs: dict = { "model": model, @@ -110,7 +112,9 @@ async def stream( max_tokens = config.max_tokens if config else 4096 system_prompt, user_messages = _split_system(messages) - anthropic_messages = [{"role": m.role, "content": _build_content_blocks(m)} for m in user_messages] + anthropic_messages = [ + {"role": m.role, "content": _build_content_blocks(m)} for m in user_messages + ] kwargs: dict = { "model": model, diff --git a/backend/app/services/llm/encryption.py b/backend/app/services/llm/encryption.py index ba76c08..4ca57b4 100644 --- a/backend/app/services/llm/encryption.py +++ b/backend/app/services/llm/encryption.py @@ -10,7 +10,7 @@ def _get_fernet() -> Fernet: if not secret: raise ValueError( "LLM_KEY_ENCRYPTION_SECRET is not configured. " - "Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\"" + 'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' ) return Fernet(secret.encode() if isinstance(secret, str) else secret) @@ -25,4 +25,6 @@ def decrypt_api_key(encrypted_key: str) -> str: try: return _get_fernet().decrypt(encrypted_key.encode()).decode() except InvalidToken as e: - raise ValueError("Failed to decrypt API key — the encryption secret may have changed.") from e + raise ValueError( + "Failed to decrypt API key — the encryption secret may have changed." + ) from e diff --git a/backend/app/services/llm/factory.py b/backend/app/services/llm/factory.py index 849d2d8..2bec642 100644 --- a/backend/app/services/llm/factory.py +++ b/backend/app/services/llm/factory.py @@ -65,10 +65,12 @@ def get_llm_provider(config: LLMConfig | None = None) -> BaseLLMProvider: return MeteredLLMProvider(OllamaProvider(), "ollama") -_DEMO_USER_EMAILS = frozenset({ - "bruce.wayne@wayneenterprises.com", - "peter.parker@dailybugle.com", -}) +_DEMO_USER_EMAILS = frozenset( + { + "bruce.wayne@wayneenterprises.com", + "peter.parker@dailybugle.com", + } +) def _is_demo_user(user: User) -> bool: @@ -104,9 +106,8 @@ def resolve_user_llm_config( raise HTTPException( status_code=400, detail=( - "Demo mode: Ollama is disabled for the Bruce Wayne account. " - "Add your own API key in Settings → AI Provider to unlock AI features. " - "Bruce Wayne always pays his own way." + "Demo mode: the server's local AI model is not available for demo accounts. " + "Add your own API key in Settings → AI Provider to unlock AI features." ), ) @@ -114,8 +115,12 @@ def resolve_user_llm_config( if effective_provider == "ollama": return LLMConfig( provider=effective_provider, - model=(request_config.model if request_config else None) or user.llm_model or settings.ollama_model, - base_url=(request_config.base_url if request_config else None) or user.llm_base_url or settings.ollama_url, + model=(request_config.model if request_config else None) + or user.llm_model + or settings.ollama_model, + base_url=(request_config.base_url if request_config else None) + or user.llm_base_url + or settings.ollama_url, temperature=request_config.temperature if request_config else 0.1, max_tokens=request_config.max_tokens if request_config else 4096, ) diff --git a/backend/app/services/llm/nebius.py b/backend/app/services/llm/nebius.py index 3907623..046c68b 100644 --- a/backend/app/services/llm/nebius.py +++ b/backend/app/services/llm/nebius.py @@ -23,9 +23,7 @@ def _get_client(self, config: LLMConfig | None) -> Any: try: from openai import AsyncOpenAI except ImportError as exc: - raise ValueError( - "openai package is not installed. Run: pip install openai" - ) from exc + raise ValueError("openai package is not installed. Run: pip install openai") from exc api_key = (config.api_key if config else None) or settings.nebius_api_key base_url = (config.base_url if config else None) or settings.nebius_base_url diff --git a/backend/app/services/llm/ollama.py b/backend/app/services/llm/ollama.py index c8b7c5b..b5b8347 100644 --- a/backend/app/services/llm/ollama.py +++ b/backend/app/services/llm/ollama.py @@ -82,7 +82,9 @@ async def complete(self, messages: list[Message], config: LLMConfig | None = Non "ollama.complete.semaphore_timeout", max_concurrent=settings.ollama_max_concurrent, ) - raise HTTPException(status_code=503, detail="LLM is busy. Please try again in a moment.") from None + raise HTTPException( + status_code=503, detail="LLM is busy. Please try again in a moment." + ) from None try: url = f"{self._base_url(config)}/api/chat" @@ -109,7 +111,9 @@ async def complete(self, messages: list[Message], config: LLMConfig | None = Non data = response.json() content = data.get("message", {}).get("content") if content is None: - raise ValueError(f"Unexpected Ollama response payload keys: {sorted(data.keys())}") + raise ValueError( + f"Unexpected Ollama response payload keys: {sorted(data.keys())}" + ) return content except httpx.HTTPStatusError as exc: raise ValueError( diff --git a/backend/app/services/llm/openai.py b/backend/app/services/llm/openai.py index 452f61a..8eac24e 100644 --- a/backend/app/services/llm/openai.py +++ b/backend/app/services/llm/openai.py @@ -23,9 +23,7 @@ def _get_client(self, config: LLMConfig | None) -> Any: try: from openai import AsyncOpenAI except ImportError as exc: - raise ValueError( - "openai package is not installed. Run: pip install openai" - ) from exc + raise ValueError("openai package is not installed. Run: pip install openai") from exc api_key = (config.api_key if config else None) or settings.openai_api_key base_url = (config.base_url if config else None) or None diff --git a/backend/app/services/receipt_extraction.py b/backend/app/services/receipt_extraction.py index 3b4b6c9..dfa77ab 100644 --- a/backend/app/services/receipt_extraction.py +++ b/backend/app/services/receipt_extraction.py @@ -54,24 +54,34 @@ "despar": "Despar", } -_GENERIC_CATEGORY_NAMES = {"misc", "miscellaneous", "other", "other expense", "shopping", "spesa varia", "varie"} +_GENERIC_CATEGORY_NAMES = { + "misc", + "miscellaneous", + "other", + "other expense", + "shopping", + "spesa varia", + "varie", +} # ── Upload validation ───────────────────────────────────────────────────────── -_ALLOWED_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".pdf"}) +_ALLOWED_EXTENSIONS: frozenset[str] = frozenset( + {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".pdf"} +) _UPLOAD_MAX_BYTES: int = 50 * 1024 * 1024 # 50 MB hard cap before any processing # Limits to prevent resource exhaustion from crafted PDF uploads. -_MAX_PDF_PAGES = 50 # no real receipt or statement needs more than this +_MAX_PDF_PAGES = 50 # no real receipt or statement needs more than this # First-bytes signatures for each allowed extension. # WebP is handled separately (RIFF....WEBP layout). _MAGIC_BYTES: dict[str, list[bytes]] = { - ".jpg": [b"\xff\xd8\xff"], + ".jpg": [b"\xff\xd8\xff"], ".jpeg": [b"\xff\xd8\xff"], - ".png": [b"\x89PNG\r\n\x1a\n"], - ".gif": [b"GIF87a", b"GIF89a"], - ".bmp": [b"BM"], - ".pdf": [b"%PDF"], + ".png": [b"\x89PNG\r\n\x1a\n"], + ".gif": [b"GIF87a", b"GIF89a"], + ".bmp": [b"BM"], + ".pdf": [b"%PDF"], } @@ -173,15 +183,22 @@ async def store_upload(user_id: uuid.UUID, upload: UploadFile) -> StoredReceipt: content = await upload.read() if len(content) > _UPLOAD_MAX_BYTES: - raise HTTPException(status_code=413, detail=f"File too large. Maximum allowed size is {_UPLOAD_MAX_BYTES // (1024 * 1024)} MB.") + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum allowed size is {_UPLOAD_MAX_BYTES // (1024 * 1024)} MB.", + ) if suffix and not _check_magic_bytes(content, suffix): - raise HTTPException(status_code=400, detail="File content does not match its declared type.") + raise HTTPException( + status_code=400, detail="File content does not match its declared type." + ) stored_filename = f"{uuid.uuid4()}{suffix}" target_path = receipt_dir / stored_filename target_path.write_bytes(content) - return StoredReceipt(stored_filename=stored_filename, storage_path=str(target_path), file_size=len(content)) + return StoredReceipt( + stored_filename=stored_filename, storage_path=str(target_path), file_size=len(content) + ) async def extract_text_from_file(storage_path: str, content_type: str | None) -> str: @@ -203,7 +220,9 @@ async def extract_text_from_file(storage_path: str, content_type: str | None) -> return "\n\n".join(plumber_text).strip() try: reader = PdfReader(storage_path) - return "\n".join((page.extract_text() or "") for page in reader.pages[:_MAX_PDF_PAGES]).strip() + return "\n".join( + (page.extract_text() or "") for page in reader.pages[:_MAX_PDF_PAGES] + ).strip() except Exception: return "" return "" @@ -249,7 +268,12 @@ def _canonical_supermarket_name(value: str | None) -> str | None: return None compact = normalized.replace(" ", "") for candidate, label in _ITALIAN_SUPERMARKET_MERCHANTS.items(): - if candidate == normalized or candidate == compact or candidate in normalized or normalized in candidate: + if ( + candidate == normalized + or candidate == compact + or candidate in normalized + or normalized in candidate + ): return label return None @@ -258,7 +282,24 @@ def _looks_like_noise_line(line: str) -> bool: normalized = normalize_match_text(line) if not normalized: return True - if any(token in normalized for token in {"totale", "subtotal", "pagamento", "contanti", "resto", "iva", "telefono", "scontrino", "documento", "eur", "euro", "bancomat", "cassa"}): + if any( + token in normalized + for token in { + "totale", + "subtotal", + "pagamento", + "contanti", + "resto", + "iva", + "telefono", + "scontrino", + "documento", + "eur", + "euro", + "bancomat", + "cassa", + } + ): return True if re.search(r"\d{2}[/-]\d{2}[/-]\d{2,4}", line): return True @@ -291,36 +332,82 @@ def _looks_like_grocery_item(description: str | None) -> bool: if not normalized: return False grocery_terms = { - "pomodoro", "pomodori", "latte", "pane", "pasta", "riso", "banana", "mele", "mela", "insalata", "zucchine", "pollo", "uova", "mozzarella", "yogurt", "acqua", "detersivo", "sapone", "shampoo", "cien", "verdura", "frutta", + "pomodoro", + "pomodori", + "latte", + "pane", + "pasta", + "riso", + "banana", + "mele", + "mela", + "insalata", + "zucchine", + "pollo", + "uova", + "mozzarella", + "yogurt", + "acqua", + "detersivo", + "sapone", + "shampoo", + "cien", + "verdura", + "frutta", } return any(term in normalized for term in grocery_terms) def _should_force_groceries(preview: ReceiptPreviewModel, context_text: str | None = None) -> bool: - if normalize_transaction_type(preview.transaction_type, default=TRANSACTION_TYPE_DEBIT) != TRANSACTION_TYPE_DEBIT: + if ( + normalize_transaction_type(preview.transaction_type, default=TRANSACTION_TYPE_DEBIT) + != TRANSACTION_TYPE_DEBIT + ): return False if _is_supermarket_merchant(preview.merchant): return True item_descriptions = [item.description for item in (preview.items or []) if item.description] - if item_descriptions and sum(1 for description in item_descriptions if _looks_like_grocery_item(description)) >= max(1, len(item_descriptions) // 2): + if item_descriptions and sum( + 1 for description in item_descriptions if _looks_like_grocery_item(description) + ) >= max(1, len(item_descriptions) // 2): return True return _canonical_supermarket_name(context_text or "") is not None -async def _apply_category_heuristics(db: AsyncSession, user_id: uuid.UUID, preview: ReceiptPreviewModel, *, context_text: str | None = None) -> ReceiptPreviewModel: +async def _apply_category_heuristics( + db: AsyncSession, + user_id: uuid.UUID, + preview: ReceiptPreviewModel, + *, + context_text: str | None = None, +) -> ReceiptPreviewModel: if _should_force_groceries(preview, context_text=context_text): preview.category_name = "Groceries" preview.confidence = max(preview.confidence or 0.35, 0.86) normalized_category_name = normalize_match_text(preview.category_name) if preview.merchant: - matched_category = await find_matching_category(db, user_id, preview.merchant, transaction_type=preview.transaction_type or TRANSACTION_TYPE_DEBIT) - if matched_category is not None and (not normalized_category_name or normalized_category_name in _GENERIC_CATEGORY_NAMES or _is_supermarket_merchant(preview.merchant)): + matched_category = await find_matching_category( + db, + user_id, + preview.merchant, + transaction_type=preview.transaction_type or TRANSACTION_TYPE_DEBIT, + ) + if matched_category is not None and ( + not normalized_category_name + or normalized_category_name in _GENERIC_CATEGORY_NAMES + or _is_supermarket_merchant(preview.merchant) + ): preview.category_name = matched_category.name preview.confidence = max(preview.confidence or 0.35, 0.82) if preview.category_name: - existing_category = await get_category_by_name(db, user_id, preview.category_name, transaction_type=preview.transaction_type or TRANSACTION_TYPE_DEBIT) + existing_category = await get_category_by_name( + db, + user_id, + preview.category_name, + transaction_type=preview.transaction_type or TRANSACTION_TYPE_DEBIT, + ) if existing_category is not None: preview.category_name = existing_category.name return preview @@ -361,7 +448,9 @@ async def _apply_category_heuristics(db: AsyncSession, user_id: uuid.UUID, previ _DEBIT_DIRECTION_MARKERS = {"debit", "dr", "outgoing", "out"} -def _infer_transaction_type(description: str | None, amount: float | None, *, direction_hint: str | None = None) -> str: +def _infer_transaction_type( + description: str | None, amount: float | None, *, direction_hint: str | None = None +) -> str: normalized_description = re.sub(r"\s+", " ", (description or "").lower()).strip() normalized_hint = re.sub(r"[^a-z]", "", (direction_hint or "").lower()) if normalized_hint in _CREDIT_DIRECTION_MARKERS: @@ -377,7 +466,9 @@ def _infer_transaction_type(description: str | None, amount: float | None, *, di return TRANSACTION_TYPE_DEBIT -def _normalize_statement_entries(entries: list[StatementPreviewEntryModel] | None) -> list[StatementPreviewEntryModel]: +def _normalize_statement_entries( + entries: list[StatementPreviewEntryModel] | None, +) -> list[StatementPreviewEntryModel]: normalized: list[StatementPreviewEntryModel] = [] for entry in (entries or [])[:200]: cleaned = StatementPreviewEntryModel.model_validate(entry) @@ -386,10 +477,18 @@ def _normalize_statement_entries(entries: list[StatementPreviewEntryModel] | Non normalized_amount = float(Decimal(str(cleaned.amount)).quantize(Decimal("0.01"))) except (InvalidOperation, ValueError): normalized_amount = None - cleaned.transaction_type = normalize_transaction_type(cleaned.transaction_type or _infer_transaction_type(f"{cleaned.description or ''} {cleaned.notes or ''}", normalized_amount), default=TRANSACTION_TYPE_DEBIT) + cleaned.transaction_type = normalize_transaction_type( + cleaned.transaction_type + or _infer_transaction_type( + f"{cleaned.description or ''} {cleaned.notes or ''}", normalized_amount + ), + default=TRANSACTION_TYPE_DEBIT, + ) cleaned.amount = abs(normalized_amount) if normalized_amount is not None else None else: - cleaned.transaction_type = normalize_transaction_type(cleaned.transaction_type, default=TRANSACTION_TYPE_DEBIT) + cleaned.transaction_type = normalize_transaction_type( + cleaned.transaction_type, default=TRANSACTION_TYPE_DEBIT + ) cleaned.currency = _normalize_currency(cleaned.currency) cleaned.expense_date = _parse_date_candidate(cleaned.expense_date) cleaned.merchant = (cleaned.merchant or "").strip()[:255] or None @@ -397,7 +496,9 @@ def _normalize_statement_entries(entries: list[StatementPreviewEntryModel] | Non cleaned.category_name = (cleaned.category_name or "").strip()[:120] or None cleaned.notes = (cleaned.notes or "").strip()[:2000] or None cleaned.status = cleaned.status if cleaned.status in {"pending", "finalized"} else "pending" - cleaned.confidence = min(max(cleaned.confidence if cleaned.confidence is not None else 0.45, 0.0), 1.0) + cleaned.confidence = min( + max(cleaned.confidence if cleaned.confidence is not None else 0.45, 0.0), 1.0 + ) if cleaned.amount is None or not cleaned.merchant or cleaned.expense_date is None: continue normalized.append(cleaned) @@ -453,7 +554,9 @@ def _merchant_hint_from_filename(filename: str) -> str | None: return candidate.title()[:255] -def _preview_context_defaults(*, filename: str | None, context_text: str | None) -> dict[str, str | float | None]: +def _preview_context_defaults( + *, filename: str | None, context_text: str | None +) -> dict[str, str | float | None]: text = (context_text or "").strip() lines = [line.strip() for line in text.splitlines() if line.strip()] amount = None @@ -486,7 +589,9 @@ def _preview_context_defaults(*, filename: str | None, context_text: str | None) } -def _finalize_preview_model(model: ReceiptPreviewModel, *, filename: str | None = None, context_text: str | None = None) -> ReceiptPreviewModel: +def _finalize_preview_model( + model: ReceiptPreviewModel, *, filename: str | None = None, context_text: str | None = None +) -> ReceiptPreviewModel: defaults = _preview_context_defaults(filename=filename, context_text=context_text) if not model.merchant: model.merchant = defaults["merchant"] if isinstance(defaults["merchant"], str) else None @@ -498,14 +603,20 @@ def _finalize_preview_model(model: ReceiptPreviewModel, *, filename: str | None model.amount = None elif isinstance(defaults["amount"], float): model.amount = defaults["amount"] - model.transaction_type = normalize_transaction_type(model.transaction_type, default=TRANSACTION_TYPE_DEBIT) + model.transaction_type = normalize_transaction_type( + model.transaction_type, default=TRANSACTION_TYPE_DEBIT + ) model.currency = _normalize_currency(model.currency) if model.merchant: - model.merchant = (_canonical_supermarket_name(model.merchant) or model.merchant.strip())[:255] + model.merchant = (_canonical_supermarket_name(model.merchant) or model.merchant.strip())[ + :255 + ] elif isinstance(defaults["merchant"], str): model.merchant = defaults["merchant"][:255] parsed_expense_date = _parse_date_candidate(model.expense_date) - model.expense_date = parsed_expense_date or (defaults["expense_date"] if isinstance(defaults["expense_date"], str) else None) + model.expense_date = parsed_expense_date or ( + defaults["expense_date"] if isinstance(defaults["expense_date"], str) else None + ) if model.description: model.description = model.description.strip()[:300] elif isinstance(defaults["description"], str): @@ -514,12 +625,20 @@ def _finalize_preview_model(model: ReceiptPreviewModel, *, filename: str | None model.category_name = model.category_name.strip()[:120] or None if model.notes: model.notes = model.notes.strip()[:2000] or None - model.confidence = min(max(model.confidence if model.confidence is not None else 0.35, 0.0), 1.0) + model.confidence = min( + max(model.confidence if model.confidence is not None else 0.35, 0.0), 1.0 + ) model.items = _normalize_items(model.items) return model -async def _apply_receipt_category_heuristics(db: AsyncSession, user_id: uuid.UUID, preview: ReceiptPreviewModel, *, context_text: str | None = None) -> ReceiptPreviewModel: +async def _apply_receipt_category_heuristics( + db: AsyncSession, + user_id: uuid.UUID, + preview: ReceiptPreviewModel, + *, + context_text: str | None = None, +) -> ReceiptPreviewModel: if _should_force_groceries(preview, context_text=context_text): preview.category_name = "Groceries" preview.confidence = max(preview.confidence or 0.35, 0.86) @@ -552,10 +671,14 @@ async def _apply_receipt_category_heuristics(db: AsyncSession, user_id: uuid.UUI return preview -def _finalize_statement_preview(model: StatementPreviewModel, *, text: str | None = None) -> StatementPreviewModel: +def _finalize_statement_preview( + model: StatementPreviewModel, *, text: str | None = None +) -> StatementPreviewModel: model.summary = (model.summary or "").strip()[:500] or None model.notes = (model.notes or "").strip()[:2000] or None - model.confidence = min(max(model.confidence if model.confidence is not None else 0.45, 0.0), 1.0) + model.confidence = min( + max(model.confidence if model.confidence is not None else 0.45, 0.0), 1.0 + ) model.entries = _normalize_statement_entries(model.entries) if model.summary is None and model.entries: merchants = ", ".join(entry.merchant or "Unknown" for entry in model.entries[:3]) @@ -570,7 +693,9 @@ def _extract_json_object(raw_text: str) -> dict | None: if not text: return None - fenced_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL | re.IGNORECASE) + fenced_match = re.search( + r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL | re.IGNORECASE + ) if fenced_match: text = fenced_match.group(1).strip() @@ -623,7 +748,7 @@ def _response_preview(raw_text: str, *, limit: int = 400) -> str: def _effective_provider_name(llm_config: LLMConfig | None) -> str: - return (llm_config.provider if llm_config and llm_config.provider else settings.llm_provider) + return llm_config.provider if llm_config and llm_config.provider else settings.llm_provider def _effective_model_name(llm_config: LLMConfig | None) -> str: @@ -641,20 +766,33 @@ def _effective_model_name(llm_config: LLMConfig | None) -> str: def fallback_preview_from_text(text: str, filename: str) -> ReceiptPreviewModel: defaults = _preview_context_defaults(filename=filename, context_text=text) - return _finalize_preview_model(ReceiptPreviewModel( - merchant=defaults["merchant"] if isinstance(defaults["merchant"], str) else None, - amount=defaults["amount"] if isinstance(defaults["amount"], float) else None, - transaction_type=TRANSACTION_TYPE_DEBIT, - expense_date=defaults["expense_date"] if isinstance(defaults["expense_date"], str) else None, - description=defaults["description"] if isinstance(defaults["description"], str) else None, - notes="Fallback text extraction used after multimodal receipt extraction was unavailable or failed.", - confidence=0.4 if isinstance(defaults["amount"], float) else 0.25, - ), filename=filename, context_text=text) + return _finalize_preview_model( + ReceiptPreviewModel( + merchant=defaults["merchant"] if isinstance(defaults["merchant"], str) else None, + amount=defaults["amount"] if isinstance(defaults["amount"], float) else None, + transaction_type=TRANSACTION_TYPE_DEBIT, + expense_date=defaults["expense_date"] + if isinstance(defaults["expense_date"], str) + else None, + description=defaults["description"] + if isinstance(defaults["description"], str) + else None, + notes="Fallback text extraction used after multimodal receipt extraction was unavailable or failed.", + confidence=0.4 if isinstance(defaults["amount"], float) else 0.25, + ), + filename=filename, + context_text=text, + ) def _merchant_from_statement_description(description: str) -> str | None: normalized = re.sub(r"\s+", " ", description).strip(" -") - normalized = re.sub(r"\b(?:card|visa|pos|debit|purchase|payment|transaction|auth|ref)\b", "", normalized, flags=re.IGNORECASE) + normalized = re.sub( + r"\b(?:card|visa|pos|debit|purchase|payment|transaction|auth|ref)\b", + "", + normalized, + flags=re.IGNORECASE, + ) normalized = re.sub(r"\s+", " ", normalized).strip(" -") if not normalized: return None @@ -683,7 +821,14 @@ def fallback_statement_preview_from_text(text: str, filename: str) -> StatementP StatementPreviewEntryModel( merchant=merchant, amount=abs(amount), - transaction_type=normalize_transaction_type(_infer_transaction_type(f"{description} {direction_marker or ''}", amount, direction_hint=direction_marker), default=TRANSACTION_TYPE_DEBIT), + transaction_type=normalize_transaction_type( + _infer_transaction_type( + f"{description} {direction_marker or ''}", + amount, + direction_hint=direction_marker, + ), + default=TRANSACTION_TYPE_DEBIT, + ), currency=settings.default_currency, expense_date=expense_date, description=description[:300], @@ -793,7 +938,11 @@ async def llm_receipt_preview_from_image( Message( role="user", content=_receipt_prompt(filename), - images=[ImageInput(media_type=media_type, data=base64.b64encode(file_bytes).decode("utf-8"))], + images=[ + ImageInput( + media_type=media_type, data=base64.b64encode(file_bytes).decode("utf-8") + ) + ], ), ], request_config, @@ -824,7 +973,9 @@ async def llm_receipt_preview_from_image( return None -async def llm_receipt_preview(text: str, filename: str, llm_config: LLMConfig | None, system_prompt: str | None = None) -> ReceiptPreviewModel | None: +async def llm_receipt_preview( + text: str, filename: str, llm_config: LLMConfig | None, system_prompt: str | None = None +) -> ReceiptPreviewModel | None: if not text.strip(): return None try: @@ -902,7 +1053,9 @@ def _statement_prompt(filename: str, text: str) -> str: ) -async def llm_statement_preview(text: str, filename: str, llm_config: LLMConfig | None) -> StatementPreviewModel | None: +async def llm_statement_preview( + text: str, filename: str, llm_config: LLMConfig | None +) -> StatementPreviewModel | None: if not text.strip(): return None try: @@ -968,7 +1121,9 @@ async def build_receipt_preview( select(User.receipt_prompt_override).where(User.email == admin_email).limit(1) ) prompt_override = admin_prompt_result.scalar_one_or_none() - preview = await llm_receipt_preview_from_image(storage_path, filename, content_type, effective_config, prompt_override) + preview = await llm_receipt_preview_from_image( + storage_path, filename, content_type, effective_config, prompt_override + ) if preview is None: logger.info( "receipt_extraction.using_text_fallback", @@ -976,8 +1131,12 @@ async def build_receipt_preview( content_type=content_type, ) extracted_text = await extract_text_from_file(storage_path, content_type) - used_text_fallback = bool(extracted_text.strip()) or not _is_supported_image(content_type, filename) - preview = await llm_receipt_preview(extracted_text, filename, effective_config, prompt_override) + used_text_fallback = bool(extracted_text.strip()) or not _is_supported_image( + content_type, filename + ) + preview = await llm_receipt_preview( + extracted_text, filename, effective_config, prompt_override + ) if preview is None: logger.info( "receipt_extraction.using_rule_based_fallback", @@ -987,13 +1146,22 @@ async def build_receipt_preview( ) preview = fallback_preview_from_text(extracted_text, filename) if preview.category_name is None and preview.merchant: - category = await resolve_category(db, user.id, merchant=preview.merchant, transaction_type=preview.transaction_type or TRANSACTION_TYPE_DEBIT) + category = await resolve_category( + db, + user.id, + merchant=preview.merchant, + transaction_type=preview.transaction_type or TRANSACTION_TYPE_DEBIT, + ) if category is not None: preview.category_name = category.name preview.confidence = max(preview.confidence or 0.0, 0.72) - preview = await _apply_receipt_category_heuristics(db, user.id, preview, context_text=extracted_text) + preview = await _apply_receipt_category_heuristics( + db, user.id, preview, context_text=extracted_text + ) preview = _finalize_preview_model(preview, filename=filename, context_text=extracted_text) - return ReceiptExtractionResult(preview=preview, extracted_text=extracted_text, used_text_fallback=used_text_fallback) + return ReceiptExtractionResult( + preview=preview, extracted_text=extracted_text, used_text_fallback=used_text_fallback + ) async def build_statement_preview( @@ -1011,13 +1179,22 @@ async def build_statement_preview( extracted_text = await extract_text_from_file(storage_path, content_type) preview = await llm_statement_preview(extracted_text, filename, effective_config) if preview is None: - logger.info("receipt_extraction.using_statement_fallback", filename=filename, content_type=content_type) + logger.info( + "receipt_extraction.using_statement_fallback", + filename=filename, + content_type=content_type, + ) preview = fallback_statement_preview_from_text(extracted_text, filename) resolved_entries: list[dict[str, Any]] = [] for entry in preview.entries: payload = entry.model_dump() if payload.get("category_name") is None and payload.get("merchant"): - category = await resolve_category(db, user.id, merchant=payload["merchant"], transaction_type=payload.get("transaction_type") or TRANSACTION_TYPE_DEBIT) + category = await resolve_category( + db, + user.id, + merchant=payload["merchant"], + transaction_type=payload.get("transaction_type") or TRANSACTION_TYPE_DEBIT, + ) if category is not None: payload["category_name"] = category.name payload["confidence"] = max(float(payload.get("confidence") or 0.45), 0.72) @@ -1031,10 +1208,19 @@ async def build_statement_preview( ), text=extracted_text, ) - return ReceiptExtractionResult(preview=preview, extracted_text=extracted_text, used_text_fallback=True) + return ReceiptExtractionResult( + preview=preview, extracted_text=extracted_text, used_text_fallback=True + ) -def create_llm_config(*, provider: str | None, model: str | None, api_key: str | None, base_url: str | None) -> LLMConfig | None: +def create_llm_config( + *, provider: str | None, model: str | None, api_key: str | None, base_url: str | None +) -> LLMConfig | None: if not any([provider, model, api_key, base_url]): return None - return LLMConfig(provider=provider or None, model=model or None, api_key=api_key or None, base_url=base_url or None) + return LLMConfig( + provider=provider or None, + model=model or None, + api_key=api_key or None, + base_url=base_url or None, + ) diff --git a/backend/app/services/receipt_queue.py b/backend/app/services/receipt_queue.py index d41bc38..4136d50 100644 --- a/backend/app/services/receipt_queue.py +++ b/backend/app/services/receipt_queue.py @@ -113,7 +113,9 @@ async def _run_extraction_job(job: ExtractionJob) -> None: ) try: async with AsyncSessionLocal() as db: - receipt_result = await db.execute(select(Receipt).where(Receipt.id == job.receipt_id)) + receipt_result = await db.execute( + select(Receipt).where(Receipt.id == job.receipt_id) + ) receipt = receipt_result.scalar_one_or_none() if receipt: receipt.extraction_status = "error" diff --git a/backend/app/services/report_exports.py b/backend/app/services/report_exports.py index 8a387e6..0cead40 100644 --- a/backend/app/services/report_exports.py +++ b/backend/app/services/report_exports.py @@ -15,7 +15,9 @@ from app.services.spendhound import apply_expense_filters, serialize_expense -async def build_expense_export_payload(db: AsyncSession, *, user_id: uuid.UUID, month: str | None) -> dict: +async def build_expense_export_payload( + db: AsyncSession, *, user_id: uuid.UUID, month: str | None +) -> dict: statement = apply_expense_filters( select(Expense, Category.name, Receipt.original_filename) .outerjoin(Category, Category.id == Expense.category_id) @@ -36,6 +38,8 @@ async def build_expense_export_payload(db: AsyncSession, *, user_id: uuid.UUID, } -async def build_expense_export_json_bytes(db: AsyncSession, *, user_id: uuid.UUID, month: str | None) -> bytes: +async def build_expense_export_json_bytes( + db: AsyncSession, *, user_id: uuid.UUID, month: str | None +) -> bytes: payload = await build_expense_export_payload(db, user_id=user_id, month=month) return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") diff --git a/backend/app/services/spendhound.py b/backend/app/services/spendhound.py index 047065b..7cfeb27 100644 --- a/backend/app/services/spendhound.py +++ b/backend/app/services/spendhound.py @@ -32,7 +32,7 @@ CADENCE_ONE_TIME = "one_time" CADENCE_MONTHLY = "monthly" CADENCE_YEARLY = "yearly" -CADENCE_CUSTOM = "custom" # every N months, interval stored in cadence_interval +CADENCE_CUSTOM = "custom" # every N months, interval stored in cadence_interval CADENCE_PREPAID = "prepaid" # single lump-sum payment covering N months DEFAULT_CATEGORIES: list[tuple[str, str, str, str]] = [ @@ -57,159 +57,196 @@ GROCERY_SUBCATEGORY_RULES: list[tuple[re.Pattern[str], str]] = [ # Vegetables — EN + IT - (re.compile( - r"\b(lettuce|tomato|tomatoes|spinach|potato|potatoes|onion|onions|broccoli|carrot|carrots|" - r"pepper|peppers|cucumber|cucumbers|zucchini|mushroom|mushrooms|salad|vegetable|vegetables|veg|" - r"pomodoro|pomodori|pomodorino|pomodorini|spinaci|patata|patate|cipolla|cipolle|carota|carote|" - r"peperone|peperoni|cetriolo|cetrioli|zucchina|zucchine|fungo|funghi|insalata|verdura|verdure|" - r"aglio|finocchio|finocchi|melanzana|melanzane|carciofo|carciofi|cavolo|cavoli|" - r"asparago|asparagi|fagiolino|fagiolini|pisello|piselli|mais|radicchio|sedano|rucola|porro|porri|" - r"cipollotto|broccolo|cavolfiore|zucca|fagiolini)\b", - re.IGNORECASE, - ), "Vegetables"), - + ( + re.compile( + r"\b(lettuce|tomato|tomatoes|spinach|potato|potatoes|onion|onions|broccoli|carrot|carrots|" + r"pepper|peppers|cucumber|cucumbers|zucchini|mushroom|mushrooms|salad|vegetable|vegetables|veg|" + r"pomodoro|pomodori|pomodorino|pomodorini|spinaci|patata|patate|cipolla|cipolle|carota|carote|" + r"peperone|peperoni|cetriolo|cetrioli|zucchina|zucchine|fungo|funghi|insalata|verdura|verdure|" + r"aglio|finocchio|finocchi|melanzana|melanzane|carciofo|carciofi|cavolo|cavoli|" + r"asparago|asparagi|fagiolino|fagiolini|pisello|piselli|mais|radicchio|sedano|rucola|porro|porri|" + r"cipollotto|broccolo|cavolfiore|zucca|fagiolini)\b", + re.IGNORECASE, + ), + "Vegetables", + ), # Fruit — EN + IT - (re.compile( - r"\b(apple|apples|banana|bananas|orange|oranges|lemon|lemons|lime|limes|" - r"berry|berries|grape|grapes|melon|melons|pear|pears|peach|peaches|" - r"kiwi|kiwis|mango|mangoes|avocado|avocados|fruit|" - r"mela|mele|banane|arancia|arance|limone|limoni|fragola|fragole|uva|" - r"melone|meloni|pera|pere|pesca|pesche|frutta|ananas|ciliegia|ciliegie|" - r"albicocca|albicocche|prugna|prugne|fico|fichi|pompelmo|mandarino|mandarini|" - r"clementina|clementine|mora|more|lampone|lamponi|mirtillo|mirtilli)\b", - re.IGNORECASE, - ), "Fruit"), - + ( + re.compile( + r"\b(apple|apples|banana|bananas|orange|oranges|lemon|lemons|lime|limes|" + r"berry|berries|grape|grapes|melon|melons|pear|pears|peach|peaches|" + r"kiwi|kiwis|mango|mangoes|avocado|avocados|fruit|" + r"mela|mele|banane|arancia|arance|limone|limoni|fragola|fragole|uva|" + r"melone|meloni|pera|pere|pesca|pesche|frutta|ananas|ciliegia|ciliegie|" + r"albicocca|albicocche|prugna|prugne|fico|fichi|pompelmo|mandarino|mandarini|" + r"clementina|clementine|mora|more|lampone|lamponi|mirtillo|mirtilli)\b", + re.IGNORECASE, + ), + "Fruit", + ), # Meat — EN + IT - (re.compile( - r"\b(chicken|beef|pork|turkey|sausage|bacon|mince|steak|ham|meat|" - r"pollo|manzo|maiale|tacchino|salsiccia|salsicce|pancetta|bistecca|prosciutto|carne|" - r"vitello|agnello|salame|mortadella|bresaola|speck|wurstel|cotoletta|cotolette|" - r"arrosto|fettina|fettine|braciola|braciole|hamburger|polpetta|polpette|affettato|affettati)\b", - re.IGNORECASE, - ), "Meat"), - + ( + re.compile( + r"\b(chicken|beef|pork|turkey|sausage|bacon|mince|steak|ham|meat|" + r"pollo|manzo|maiale|tacchino|salsiccia|salsicce|pancetta|bistecca|prosciutto|carne|" + r"vitello|agnello|salame|mortadella|bresaola|speck|wurstel|cotoletta|cotolette|" + r"arrosto|fettina|fettine|braciola|braciole|hamburger|polpetta|polpette|affettato|affettati)\b", + re.IGNORECASE, + ), + "Meat", + ), # Fish & Seafood — EN + IT - (re.compile( - r"\b(salmon|tuna|cod|shrimp|prawn|mussel|mussels|fish|seafood|" - r"salmone|tonno|merluzzo|gambero|gamberetti|gamberi|cozza|cozze|pesce|" - r"acciuga|acciughe|sardina|sardine|trota|branzino|orata|vongola|vongole|" - r"calamaro|calamari|polpo|baccala|dentice|sgombro|rombo|spigola)\b", - re.IGNORECASE, - ), "Fish & Seafood"), - + ( + re.compile( + r"\b(salmon|tuna|cod|shrimp|prawn|mussel|mussels|fish|seafood|" + r"salmone|tonno|merluzzo|gambero|gamberetti|gamberi|cozza|cozze|pesce|" + r"acciuga|acciughe|sardina|sardine|trota|branzino|orata|vongola|vongole|" + r"calamaro|calamari|polpo|baccala|dentice|sgombro|rombo|spigola)\b", + re.IGNORECASE, + ), + "Fish & Seafood", + ), # Dairy & Eggs — EN + IT - (re.compile( - r"\b(milk|yogurt|cheese|butter|cream|mozzarella|parmesan|egg|eggs|" - r"latte|formaggio|formaggi|burro|panna|parmigiano|grana|uova|uovo|ricotta|" - r"gorgonzola|pecorino|mascarpone|scamorza|stracchino|taleggio|kefir|" - r"brie|feta|fontina|provolone|asiago|emmental|caciotta|crescenza)\b", - re.IGNORECASE, - ), "Dairy & Eggs"), - + ( + re.compile( + r"\b(milk|yogurt|cheese|butter|cream|mozzarella|parmesan|egg|eggs|" + r"latte|formaggio|formaggi|burro|panna|parmigiano|grana|uova|uovo|ricotta|" + r"gorgonzola|pecorino|mascarpone|scamorza|stracchino|taleggio|kefir|" + r"brie|feta|fontina|provolone|asiago|emmental|caciotta|crescenza)\b", + re.IGNORECASE, + ), + "Dairy & Eggs", + ), # Bakery — EN + IT - (re.compile( - r"\b(bread|bagel|croissant|bun|roll|baguette|cake|muffin|bakery|pastry|tortilla|" - r"pane|cornetto|brioche|focaccia|ciabatta|grissini|panino|panini|" - r"schiacciata|tramezzino|filone|michetta|sfilatino|biscottate)\b", - re.IGNORECASE, - ), "Bakery"), - + ( + re.compile( + r"\b(bread|bagel|croissant|bun|roll|baguette|cake|muffin|bakery|pastry|tortilla|" + r"pane|cornetto|brioche|focaccia|ciabatta|grissini|panino|panini|" + r"schiacciata|tramezzino|filone|michetta|sfilatino|biscottate)\b", + re.IGNORECASE, + ), + "Bakery", + ), # Frozen — EN + IT - (re.compile( - r"\b(frozen|ice cream|gelato|pizza|fries|fish fingers|" - r"surgelato|surgelati|ghiacciolo|gelati)\b", - re.IGNORECASE, - ), "Frozen"), - + ( + re.compile( + r"\b(frozen|ice cream|gelato|pizza|fries|fish fingers|" + r"surgelato|surgelati|ghiacciolo|gelati)\b", + re.IGNORECASE, + ), + "Frozen", + ), # Snacks — EN + IT - (re.compile( - r"\b(chips|crisps|cracker|crackers|chocolate|cookie|cookies|biscuit|biscuits|" - r"snack|snacks|candy|popcorn|nuts|trail mix|" - r"patatine|cioccolato|biscotto|biscotti|caramella|caramelle|" - r"noccioline|noci|mandorle|arachidi|pistacchi|wafer|merendina|merendine|confetti)\b", - re.IGNORECASE, - ), "Snacks"), - + ( + re.compile( + r"\b(chips|crisps|cracker|crackers|chocolate|cookie|cookies|biscuit|biscuits|" + r"snack|snacks|candy|popcorn|nuts|trail mix|" + r"patatine|cioccolato|biscotto|biscotti|caramella|caramelle|" + r"noccioline|noci|mandorle|arachidi|pistacchi|wafer|merendina|merendine|confetti)\b", + re.IGNORECASE, + ), + "Snacks", + ), # Beverages — EN + IT - (re.compile( - r"\b(water|juice|cola|soda|sparkling|coffee|tea|beer|wine|drink|beverage|" - r"acqua|succo|bibita|bibite|gassata|caffe|birra|vino|aranciata|limonata|" - r"aperitivo|spumante|prosecco|bevanda|bevande|sciroppo|infuso|tisana|smoothie)\b", - re.IGNORECASE, - ), "Beverages"), - + ( + re.compile( + r"\b(water|juice|cola|soda|sparkling|coffee|tea|beer|wine|drink|beverage|" + r"acqua|succo|bibita|bibite|gassata|caffe|birra|vino|aranciata|limonata|" + r"aperitivo|spumante|prosecco|bevanda|bevande|sciroppo|infuso|tisana|smoothie)\b", + re.IGNORECASE, + ), + "Beverages", + ), # Cleaning Products — EN + IT - (re.compile( - r"\b(detergent|dish soap|dishwasher|bleach|cleaner|disinfectant|toilet cleaner|" - r"laundry|softener|descaler|cleaning|" - r"detersivo|detersivi|ammorbidente|anticalcare|sgrassatore|candeggina|" - r"disinfettante|brillantante|pavimenti)\b", - re.IGNORECASE, - ), "Cleaning Products"), - + ( + re.compile( + r"\b(detergent|dish soap|dishwasher|bleach|cleaner|disinfectant|toilet cleaner|" + r"laundry|softener|descaler|cleaning|" + r"detersivo|detersivi|ammorbidente|anticalcare|sgrassatore|candeggina|" + r"disinfettante|brillantante|pavimenti)\b", + re.IGNORECASE, + ), + "Cleaning Products", + ), # Personal Care — EN + IT - (re.compile( - r"\b(shampoo|conditioner|soap|body wash|deodorant|toothpaste|toothbrush|" - r"razor|lotion|personal care|" - r"sapone|bagnoschiuma|dentifricio|spazzolino|rasoio|crema|balsamo|" - r"assorbente|assorbenti|deodorante|dopobarba|profumo|igiene)\b", - re.IGNORECASE, - ), "Personal Care"), - + ( + re.compile( + r"\b(shampoo|conditioner|soap|body wash|deodorant|toothpaste|toothbrush|" + r"razor|lotion|personal care|" + r"sapone|bagnoschiuma|dentifricio|spazzolino|rasoio|crema|balsamo|" + r"assorbente|assorbenti|deodorante|dopobarba|profumo|igiene)\b", + re.IGNORECASE, + ), + "Personal Care", + ), # Baby — EN + IT - (re.compile( - r"\b(diaper|diapers|wipes|formula|baby food|baby|" - r"pannolino|pannolini|salviette|omogeneizzato|omogeneizzati|neonato|biberon)\b", - re.IGNORECASE, - ), "Baby"), - + ( + re.compile( + r"\b(diaper|diapers|wipes|formula|baby food|baby|" + r"pannolino|pannolini|salviette|omogeneizzato|omogeneizzati|neonato|biberon)\b", + re.IGNORECASE, + ), + "Baby", + ), # Pet Care — EN + IT - (re.compile( - r"\b(cat food|dog food|pet food|kibble|litter|treats|pet|" - r"gatto|lettiera|mangime|crocchette)\b", - re.IGNORECASE, - ), "Pet Care"), - + ( + re.compile( + r"\b(cat food|dog food|pet food|kibble|litter|treats|pet|" + r"gatto|lettiera|mangime|crocchette)\b", + re.IGNORECASE, + ), + "Pet Care", + ), # Household — EN + IT - (re.compile( - r"\b(paper towel|paper towels|napkin|napkins|foil|cling film|garbage bag|" - r"trash bag|bin bag|batteries|light bulb|household|" - r"tovagliolo|tovaglioli|alluminio|pellicola|batterie|lampadina|" - r"sacchetti|fazzoletti|scottex)\b", - re.IGNORECASE, - ), "Household"), - + ( + re.compile( + r"\b(paper towel|paper towels|napkin|napkins|foil|cling film|garbage bag|" + r"trash bag|bin bag|batteries|light bulb|household|" + r"tovagliolo|tovaglioli|alluminio|pellicola|batterie|lampadina|" + r"sacchetti|fazzoletti|scottex)\b", + re.IGNORECASE, + ), + "Household", + ), # Breakfast & Cereal — EN + IT - (re.compile( - r"\b(cereal|granola|oats|muesli|breakfast|" - r"cereali|fiocchi|avena|colazione|cacao|nesquik)\b", - re.IGNORECASE, - ), "Breakfast & Cereal"), - + ( + re.compile( + r"\b(cereal|granola|oats|muesli|breakfast|" + r"cereali|fiocchi|avena|colazione|cacao|nesquik)\b", + re.IGNORECASE, + ), + "Breakfast & Cereal", + ), # Condiments & Spices — EN + IT - (re.compile( - r"\b(ketchup|mustard|mayo|mayonnaise|vinegar|hot sauce|soy sauce|peppercorn|" - r"spice|spices|herb|seasoning|salt|" - r"senape|maionese|aceto|pepe|spezie|erbe|condimento|sale|origano|rosmarino|" - r"basilico|prezzemolo|curry|paprika|dado|salsa)\b", - re.IGNORECASE, - ), "Condiments & Spices"), - + ( + re.compile( + r"\b(ketchup|mustard|mayo|mayonnaise|vinegar|hot sauce|soy sauce|peppercorn|" + r"spice|spices|herb|seasoning|salt|" + r"senape|maionese|aceto|pepe|spezie|erbe|condimento|sale|origano|rosmarino|" + r"basilico|prezzemolo|curry|paprika|dado|salsa)\b", + re.IGNORECASE, + ), + "Condiments & Spices", + ), # Pantry (staples) — EN + IT - (re.compile( - r"\b(pasta|rice|flour|oil|sauce|beans|lentils|canned|soup|pantry|sugar|breadcrumbs|noodles|" - r"riso|farina|olio|sugo|fagioli|lenticchie|conserva|minestra|zucchero|" - r"pangrattato|pelati|passata|legumi|ceci|polpa|brodo|dispensa)\b", - re.IGNORECASE, - ), "Pantry"), - + ( + re.compile( + r"\b(pasta|rice|flour|oil|sauce|beans|lentils|canned|soup|pantry|sugar|breadcrumbs|noodles|" + r"riso|farina|olio|sugo|fagioli|lenticchie|conserva|minestra|zucchero|" + r"pangrattato|pelati|passata|legumi|ceci|polpa|brodo|dispensa)\b", + re.IGNORECASE, + ), + "Pantry", + ), # Prepared Meals — EN + IT - (re.compile( - r"\b(ready meal|prepared|meal deal|sandwich|wrap|sushi|rotisserie|deli|take away|" - r"gastronomia|rosticceria|piatto pronto)\b", - re.IGNORECASE, - ), "Prepared Meals"), + ( + re.compile( + r"\b(ready meal|prepared|meal deal|sandwich|wrap|sushi|rotisserie|deli|take away|" + r"gastronomia|rosticceria|piatto pronto)\b", + re.IGNORECASE, + ), + "Prepared Meals", + ), ] @@ -301,7 +338,12 @@ def cadence_is_recurring(value: str | None) -> bool: return normalize_cadence(value) in {CADENCE_MONTHLY, CADENCE_YEARLY, CADENCE_CUSTOM} -def normalize_recurring_settings(cadence: str | None, *, recurring_variable: bool | None = None, recurring_auto_add: bool | None = None) -> tuple[bool, bool]: +def normalize_recurring_settings( + cadence: str | None, + *, + recurring_variable: bool | None = None, + recurring_auto_add: bool | None = None, +) -> tuple[bool, bool]: normalized_cadence = normalize_cadence(cadence) if not cadence_is_recurring(normalized_cadence): return False, False @@ -310,7 +352,12 @@ def normalize_recurring_settings(cadence: str | None, *, recurring_variable: boo def signed_amount(value: Decimal | float, transaction_type: str) -> float: amount = float(value) - return round(amount if normalize_transaction_type(transaction_type) == TRANSACTION_TYPE_CREDIT else -amount, 2) + return round( + amount + if normalize_transaction_type(transaction_type) == TRANSACTION_TYPE_CREDIT + else -amount, + 2, + ) def _expense_group_key(expense: Expense) -> tuple[str, str, str]: @@ -327,7 +374,10 @@ def _detect_recurring_cadence(group: list[Expense]) -> str | None: return None if any(abs(amount - avg_amount) / avg_amount > 0.05 for amount in amounts): return None - gaps = [(group[index].expense_date - group[index - 1].expense_date).days for index in range(1, len(group))] + gaps = [ + (group[index].expense_date - group[index - 1].expense_date).days + for index in range(1, len(group)) + ] if not gaps: return None if all(20 <= gap <= 40 for gap in gaps): @@ -361,12 +411,16 @@ def fuzzy_text_match(value: str, pattern: str) -> bool: if SequenceMatcher(None, normalized_value, normalized_pattern).ratio() >= 0.84: return True for token in pattern_tokens: - if any(SequenceMatcher(None, token, candidate).ratio() >= 0.9 for candidate in value_tokens): + if any( + SequenceMatcher(None, token, candidate).ratio() >= 0.9 for candidate in value_tokens + ): return True return False -async def expense_category_name(db: AsyncSession, expense: Expense, category_name: str | None = None) -> str | None: +async def expense_category_name( + db: AsyncSession, expense: Expense, category_name: str | None = None +) -> str | None: if category_name is not None: return category_name if expense.category is not None: @@ -382,16 +436,31 @@ async def ensure_default_categories(db: AsyncSession, user_id: uuid.UUID) -> Non existing = {name.lower() for name in result.scalars().all()} for name, color, icon, transaction_type in DEFAULT_CATEGORIES: if name.lower() not in existing: - db.add(Category(user_id=user_id, name=name, color=color, icon=icon, transaction_type=transaction_type, is_system=True)) + db.add( + Category( + user_id=user_id, + name=name, + color=color, + icon=icon, + transaction_type=transaction_type, + is_system=True, + ) + ) await db.flush() -async def get_category_by_name(db: AsyncSession, user_id: uuid.UUID, name: str | None, *, transaction_type: str | None = None) -> Category | None: +async def get_category_by_name( + db: AsyncSession, user_id: uuid.UUID, name: str | None, *, transaction_type: str | None = None +) -> Category | None: if not name: return None - statement = select(Category).where(Category.user_id == user_id, Category.name.ilike(name.strip())) + statement = select(Category).where( + Category.user_id == user_id, Category.name.ilike(name.strip()) + ) if transaction_type: - statement = statement.where(Category.transaction_type == normalize_transaction_type(transaction_type)) + statement = statement.where( + Category.transaction_type == normalize_transaction_type(transaction_type) + ) result = await db.execute(statement) return result.scalar_one_or_none() @@ -412,7 +481,12 @@ async def get_or_create_category( existing_category = await get_category_by_name(db, user_id, name) if existing_category is not None: return None - category = Category(user_id=user_id, name=name.strip(), color=color, transaction_type=normalize_transaction_type(transaction_type)) + category = Category( + user_id=user_id, + name=name.strip(), + color=color, + transaction_type=normalize_transaction_type(transaction_type), + ) db.add(category) await db.flush() return category @@ -467,11 +541,19 @@ def matches_item_keyword_rule(description: str, rule: ItemKeywordRule) -> bool: kw = normalize_match_text(rule.keyword) if len(kw) < 2: return False - return any(_is_subsequence(kw, token) for token in normalize_match_text(description).split()) + return any( + _is_subsequence(kw, token) for token in normalize_match_text(description).split() + ) return fuzzy_text_match(description, rule.keyword) -async def find_matching_category(db: AsyncSession, user_id: uuid.UUID, merchant: str | None, *, transaction_type: str | None = None) -> Category | None: +async def find_matching_category( + db: AsyncSession, + user_id: uuid.UUID, + merchant: str | None, + *, + transaction_type: str | None = None, +) -> Category | None: if not merchant: return None normalized_type = normalize_transaction_type(transaction_type) if transaction_type else None @@ -485,7 +567,9 @@ async def find_matching_category(db: AsyncSession, user_id: uuid.UUID, merchant: ), ) # user-specific rules first, then global; within each group order by priority - .order_by(MerchantRule.is_global.asc(), MerchantRule.priority.asc(), MerchantRule.created_at.asc()) + .order_by( + MerchantRule.is_global.asc(), MerchantRule.priority.asc(), MerchantRule.created_at.asc() + ) ) for rule in result.scalars().all(): if not rule.category_id: @@ -493,21 +577,32 @@ async def find_matching_category(db: AsyncSession, user_id: uuid.UUID, merchant: if matches_rule(merchant, rule): # For global rules, the category may belong to a different user — look up by id only if rule.is_global: - category_result = await db.execute(select(Category).where(Category.id == rule.category_id)) + category_result = await db.execute( + select(Category).where(Category.id == rule.category_id) + ) else: - category_result = await db.execute(select(Category).where(Category.id == rule.category_id, Category.user_id == user_id)) + category_result = await db.execute( + select(Category).where( + Category.id == rule.category_id, Category.user_id == user_id + ) + ) category = category_result.scalar_one_or_none() - if category is not None and (normalized_type is None or category.transaction_type == normalized_type): + if category is not None and ( + normalized_type is None or category.transaction_type == normalized_type + ): return category return None -async def find_matching_item_subcategory(db: AsyncSession, user_id: uuid.UUID, description: str | None) -> tuple[str, float] | None: +async def find_matching_item_subcategory( + db: AsyncSession, user_id: uuid.UUID, description: str | None +) -> tuple[str, float] | None: """Check user-specific rules first (lower priority number = higher precedence), then fall back to global rules created by admin.""" if not description: return None from sqlalchemy import or_ + result = await db.execute( select(ItemKeywordRule) .where( @@ -544,7 +639,9 @@ async def upsert_item_keyword_rule_from_correction( Picks the longest normalized token (≥4 chars, non-numeric) as the keyword with `contains` matching. Skips if an identical rule already exists. """ - tokens = [t for t in normalize_match_text(description).split() if len(t) >= 4 and not t.isdigit()] + tokens = [ + t for t in normalize_match_text(description).split() if len(t) >= 4 and not t.isdigit() + ] if not tokens: return None keyword = max(tokens, key=len) @@ -586,12 +683,16 @@ async def resolve_category( ) -> Category | None: normalized_type = normalize_transaction_type(transaction_type) if category_id: - result = await db.execute(select(Category).where(Category.id == category_id, Category.user_id == user_id)) + result = await db.execute( + select(Category).where(Category.id == category_id, Category.user_id == user_id) + ) category = result.scalar_one_or_none() if category is not None and category.transaction_type == normalized_type: return category if category_name: - category = await get_or_create_category(db, user_id, category_name, transaction_type=normalized_type) + category = await get_or_create_category( + db, user_id, category_name, transaction_type=normalized_type + ) if category is not None: return category return await find_matching_category(db, user_id, merchant, transaction_type=normalized_type) @@ -655,7 +756,9 @@ def serialize_item_rule(rule: ItemKeywordRule) -> dict: } -def serialize_budget(budget: Budget, category_name: str | None = None, actual: float | None = None) -> dict: +def serialize_budget( + budget: Budget, category_name: str | None = None, actual: float | None = None +) -> dict: amount = float(budget.amount) actual_value = actual or 0.0 return { @@ -753,12 +856,18 @@ def serialize_expense( "cadence_override": expense.cadence_override, "recurring_variable": expense.recurring_variable, "recurring_auto_add": expense.recurring_auto_add, - "recurring_source_expense_id": str(expense.recurring_source_expense_id) if expense.recurring_source_expense_id else None, + "recurring_source_expense_id": str(expense.recurring_source_expense_id) + if expense.recurring_source_expense_id + else None, "auto_generated": expense.auto_generated, - "generated_for_month": expense.generated_for_month.isoformat() if expense.generated_for_month else None, + "generated_for_month": expense.generated_for_month.isoformat() + if expense.generated_for_month + else None, "cadence_interval": expense.cadence_interval, "prepaid_months": expense.prepaid_months, - "prepaid_start_date": expense.prepaid_start_date.isoformat() if expense.prepaid_start_date else None, + "prepaid_start_date": expense.prepaid_start_date.isoformat() + if expense.prepaid_start_date + else None, "prepaid_end_date": _compute_prepaid_end_date(expense), "is_major_purchase": expense.is_major_purchase, "category_id": str(expense.category_id) if expense.category_id else None, @@ -797,7 +906,9 @@ def _item_value(item: object, field_name: str) -> Any: return getattr(item, field_name, None) -async def replace_expense_items(db: AsyncSession, expense: Expense, items: list[Any] | None, *, category_name: str | None = None) -> None: +async def replace_expense_items( + db: AsyncSession, expense: Expense, items: list[Any] | None, *, category_name: str | None = None +) -> None: resolved_category_name = await expense_category_name(db, expense, category_name) is_grocery_expense = is_grocery_category_name(resolved_category_name) normalized_items: list[ExpenseItem] = [] @@ -810,11 +921,14 @@ async def replace_expense_items(db: AsyncSession, expense: Expense, items: list[ subcategory = str(_item_value(item, "subcategory") or "").strip()[:120] or None subcategory_confidence = _coerce_optional_float(_item_value(item, "subcategory_confidence")) if is_grocery_expense and not subcategory: - matched_subcategory = await find_matching_item_subcategory(db, expense.user_id, description) + matched_subcategory = await find_matching_item_subcategory( + db, expense.user_id, description + ) if matched_subcategory is not None: subcategory, subcategory_confidence = matched_subcategory if is_grocery_expense and not subcategory: from app.services.item_rag import find_similar_subcategory + rag_result = await find_similar_subcategory(db, expense.user_id, description) if rag_result is not None: subcategory, subcategory_confidence = rag_result @@ -852,7 +966,9 @@ async def replace_expense_items(db: AsyncSession, expense: Expense, items: list[ async def recompute_recurring_expenses(db: AsyncSession, user_id: uuid.UUID) -> None: - result = await db.execute(select(Expense).where(Expense.user_id == user_id).order_by(Expense.expense_date.asc())) + result = await db.execute( + select(Expense).where(Expense.user_id == user_id).order_by(Expense.expense_date.asc()) + ) expenses = result.scalars().all() grouped: dict[tuple[str, str, str], list[Expense]] = defaultdict(list) @@ -865,7 +981,9 @@ async def recompute_recurring_expenses(db: AsyncSession, user_id: uuid.UUID) -> if cadence_is_recurring(overridden_cadence): merchant_key, currency, transaction_type = _expense_group_key(expense) expense.is_recurring = True - expense.recurring_group = f"manual:{merchant_key}:{currency}:{transaction_type}:{overridden_cadence}" + expense.recurring_group = ( + f"manual:{merchant_key}:{currency}:{transaction_type}:{overridden_cadence}" + ) continue expense.cadence = CADENCE_ONE_TIME @@ -878,14 +996,19 @@ async def recompute_recurring_expenses(db: AsyncSession, user_id: uuid.UUID) -> if detected_cadence is None: continue avg_amount = sum(float(item.amount) for item in group) / len(group) - recurring_group = f"{merchant_key}:{currency}:{transaction_type}:{detected_cadence}:{avg_amount:.2f}" + recurring_group = ( + f"{merchant_key}:{currency}:{transaction_type}:{detected_cadence}:{avg_amount:.2f}" + ) for expense in group: expense.cadence = detected_cadence expense.is_recurring = True expense.recurring_group = recurring_group for expense in expenses: - if normalize_transaction_type(expense.transaction_type) != TRANSACTION_TYPE_DEBIT or expense.cadence != CADENCE_ONE_TIME: + if ( + normalize_transaction_type(expense.transaction_type) != TRANSACTION_TYPE_DEBIT + or expense.cadence != CADENCE_ONE_TIME + ): expense.is_major_purchase = False await db.flush() @@ -901,12 +1024,16 @@ def recurring_expense_is_due_for_month(expense: Expense, target_month: date) -> return expense.expense_date.month == target_month.month if cadence == CADENCE_CUSTOM: interval = expense.cadence_interval or 1 - months_since = (target_month.year - template_month.year) * 12 + (target_month.month - template_month.month) + months_since = (target_month.year - template_month.year) * 12 + ( + target_month.month - template_month.month + ) return months_since % interval == 0 return False -async def generate_recurring_expenses_for_month(db: AsyncSession, user_id: uuid.UUID, target_month: date) -> list[Expense]: +async def generate_recurring_expenses_for_month( + db: AsyncSession, user_id: uuid.UUID, target_month: date +) -> list[Expense]: target_month = month_start_for_date(target_month) result = await db.execute( select(Expense) @@ -927,11 +1054,13 @@ async def generate_recurring_expenses_for_month(db: AsyncSession, user_id: uuid. continue existing_result = await db.execute( - select(Expense.id).where( + select(Expense.id) + .where( Expense.user_id == user_id, Expense.recurring_source_expense_id == template.id, Expense.generated_for_month == target_month, - ).limit(1) + ) + .limit(1) ) if existing_result.scalar_one_or_none() is not None: continue @@ -986,20 +1115,32 @@ def apply_expense_filters( statement = statement.where(Expense.user_id == user_id) if month and month not in {"all", "all_time"}: start = month_start_from_string(month) - statement = statement.where(Expense.expense_date >= start, Expense.expense_date < next_month(start)) + statement = statement.where( + Expense.expense_date >= start, Expense.expense_date < next_month(start) + ) if category_id: statement = statement.where(Expense.category_id == category_id) if transaction_type: - statement = statement.where(Expense.transaction_type == normalize_transaction_type(transaction_type)) + statement = statement.where( + Expense.transaction_type == normalize_transaction_type(transaction_type) + ) if cadence and cadence not in {"all", "auto"}: statement = statement.where(Expense.cadence == normalize_cadence(cadence)) if review_only: - statement = statement.where((Expense.needs_review.is_(True)) | (Expense.category_id.is_(None))) + statement = statement.where( + (Expense.needs_review.is_(True)) | (Expense.category_id.is_(None)) + ) if search: like_value = f"%{search.strip()}%" - statement = statement.where(Expense.merchant.ilike(like_value) | Expense.description.ilike(like_value)) + statement = statement.where( + Expense.merchant.ilike(like_value) | Expense.description.ilike(like_value) + ) return statement async def delete_orphaned_category_rules(db: AsyncSession, category_id: uuid.UUID) -> None: - await db.execute(delete(MerchantRule).where(MerchantRule.category_id == category_id, MerchantRule.is_active.is_(False))) + await db.execute( + delete(MerchantRule).where( + MerchantRule.category_id == category_id, MerchantRule.is_active.is_(False) + ) + ) diff --git a/backend/app/services/url_validation.py b/backend/app/services/url_validation.py index 77546e9..63b29d7 100644 --- a/backend/app/services/url_validation.py +++ b/backend/app/services/url_validation.py @@ -21,27 +21,44 @@ _PRIVATE_NETWORKS: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = [ ipaddress.ip_network("10.0.0.0/8"), - ipaddress.ip_network("172.16.0.0/12"), # includes Docker bridge (172.17-31.x) + ipaddress.ip_network("172.16.0.0/12"), # includes Docker bridge (172.17-31.x) ipaddress.ip_network("192.168.0.0/16"), - ipaddress.ip_network("127.0.0.0/8"), # loopback - ipaddress.ip_network("169.254.0.0/16"), # link-local / AWS metadata (169.254.169.254) + ipaddress.ip_network("127.0.0.0/8"), # loopback + ipaddress.ip_network("169.254.0.0/16"), # link-local / AWS metadata (169.254.169.254) ipaddress.ip_network("0.0.0.0/8"), - ipaddress.ip_network("100.64.0.0/10"), # carrier-grade NAT - ipaddress.ip_network("::1/128"), # IPv6 loopback - ipaddress.ip_network("fe80::/10"), # IPv6 link-local - ipaddress.ip_network("fc00::/7"), # IPv6 unique local + ipaddress.ip_network("100.64.0.0/10"), # carrier-grade NAT + ipaddress.ip_network("::1/128"), # IPv6 loopback + ipaddress.ip_network("fe80::/10"), # IPv6 link-local + ipaddress.ip_network("fc00::/7"), # IPv6 unique local ] # Docker Compose service names and other known-internal hostnames that resolve # inside the container network. Blocked by name before DNS, because by the time # we try to resolve them from inside Docker they point to real (internal) IPs. -_BLOCKED_HOSTNAMES: frozenset[str] = frozenset({ - "localhost", - "db", "postgres", "postgresql", "mysql", "mariadb", "mongo", "mongodb", - "redis", "rabbitmq", "kafka", "zookeeper", "elasticsearch", - "backend", "frontend", "cloudflared", "nginx", "traefik", - "metadata", "metadata.google.internal", # GCP metadata -}) +_BLOCKED_HOSTNAMES: frozenset[str] = frozenset( + { + "localhost", + "db", + "postgres", + "postgresql", + "mysql", + "mariadb", + "mongo", + "mongodb", + "redis", + "rabbitmq", + "kafka", + "zookeeper", + "elasticsearch", + "backend", + "frontend", + "cloudflared", + "nginx", + "traefik", + "metadata", + "metadata.google.internal", # GCP metadata + } +) def _is_private_ip(ip: str) -> bool: diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py index 9e23239..b94b9bd 100644 --- a/backend/tests/test_admin.py +++ b/backend/tests/test_admin.py @@ -48,13 +48,17 @@ async def test_admin_panel_access_and_review_controls(client, db_session): assert status_response.status_code == 200 assert status_response.json() == {"status": "approved", "is_admin": True} - panel_response = await client.get("/api/admin/panel/users", headers=_auth_headers(admin_user)) + panel_response = await client.get( + "/api/admin/panel/users", headers=_auth_headers(admin_user) + ) assert panel_response.status_code == 200 returned_emails = {user["email"] for user in panel_response.json()} assert settings.admin_email in returned_emails assert regular_user.email in returned_emails - forbidden_response = await client.get("/api/admin/panel/users", headers=_auth_headers(non_admin)) + forbidden_response = await client.get( + "/api/admin/panel/users", headers=_auth_headers(non_admin) + ) assert forbidden_response.status_code == 403 invalid_status_response = await client.patch( diff --git a/backend/tests/test_cache_invalidation.py b/backend/tests/test_cache_invalidation.py index 29d4d73..c2c650b 100644 --- a/backend/tests/test_cache_invalidation.py +++ b/backend/tests/test_cache_invalidation.py @@ -50,9 +50,7 @@ async def test_analytics_cache_invalidated_on_expense_create( ) -async def test_analytics_cache_all_months_invalidated( - client, auth_headers, test_user, fake_redis -): +async def test_analytics_cache_all_months_invalidated(client, auth_headers, test_user, fake_redis): """invalidate_analytics_cache uses scan+delete with a wildcard — all month variants go.""" keys = [ f"analytics:dashboard:{test_user.id}:2026-04", diff --git a/backend/tests/test_expenses_crud.py b/backend/tests/test_expenses_crud.py index 4517a98..b29085c 100644 --- a/backend/tests/test_expenses_crud.py +++ b/backend/tests/test_expenses_crud.py @@ -59,7 +59,9 @@ async def test_create_list_update_delete_expense(client: AsyncClient, auth_heade assert delete_response.status_code == 204 -async def test_create_credit_transaction_and_filter_by_type(client: AsyncClient, auth_headers: dict): +async def test_create_credit_transaction_and_filter_by_type( + client: AsyncClient, auth_headers: dict +): payload = { "merchant": "ACME Payroll", "amount": 3500.00, @@ -76,7 +78,9 @@ async def test_create_credit_transaction_and_filter_by_type(client: AsyncClient, assert created["category_name"] == "Salary" assert created["signed_amount"] == 3500.0 - credit_list_response = await client.get("/api/expenses?month=2026-04&transaction_type=credit", headers=auth_headers) + credit_list_response = await client.get( + "/api/expenses?month=2026-04&transaction_type=credit", headers=auth_headers + ) assert credit_list_response.status_code == 200 credit_listing = credit_list_response.json() assert any(item["id"] == created["id"] for item in credit_listing["items"]) @@ -113,7 +117,9 @@ async def test_all_time_listing_and_cadence_filter(client: AsyncClient, auth_hea assert all(item["id"] != created["id"] for item in current_month.json()["items"]) -async def test_recurring_settings_are_saved_and_cleared_on_update(client: AsyncClient, auth_headers: dict): +async def test_recurring_settings_are_saved_and_cleared_on_update( + client: AsyncClient, auth_headers: dict +): create_response = await client.post( "/api/expenses", json={ @@ -166,17 +172,25 @@ async def test_review_queue_and_export(client: AsyncClient, auth_headers: dict): assert len(review_data["expenses"]) >= 1 assert review_data["expenses"][0]["needs_review"] is True - export_json = await client.get("/api/expenses/export?format=json&month=2026-04", headers=auth_headers) + export_json = await client.get( + "/api/expenses/export?format=json&month=2026-04", headers=auth_headers + ) assert export_json.status_code == 200 assert export_json.json()["total"] >= 1 - export_csv = await client.get("/api/expenses/export?format=csv&month=2026-04", headers=auth_headers) + export_csv = await client.get( + "/api/expenses/export?format=csv&month=2026-04", headers=auth_headers + ) assert export_csv.status_code == 200 assert "merchant" in export_csv.text -async def test_generate_recurring_expenses_for_month_creates_constant_and_variable_entries(db_session: AsyncSession, test_user): - bills_category = Category(user_id=test_user.id, name="Bills", color="#f87171", transaction_type="debit") +async def test_generate_recurring_expenses_for_month_creates_constant_and_variable_entries( + db_session: AsyncSession, test_user +): + bills_category = Category( + user_id=test_user.id, name="Bills", color="#f87171", transaction_type="debit" + ) db_session.add(bills_category) await db_session.flush() @@ -218,7 +232,9 @@ async def test_generate_recurring_expenses_for_month_creates_constant_and_variab await db_session.flush() await recompute_recurring_expenses(db_session, test_user.id) - generated = await generate_recurring_expenses_for_month(db_session, test_user.id, date(2026, 5, 1)) + generated = await generate_recurring_expenses_for_month( + db_session, test_user.id, date(2026, 5, 1) + ) await db_session.commit() assert len(generated) == 2 @@ -238,12 +254,16 @@ async def test_generate_recurring_expenses_for_month_creates_constant_and_variab assert power.expense_date == date(2026, 5, 18) assert power.needs_review is True - second_run = await generate_recurring_expenses_for_month(db_session, test_user.id, date(2026, 5, 1)) + second_run = await generate_recurring_expenses_for_month( + db_session, test_user.id, date(2026, 5, 1) + ) await db_session.commit() assert second_run == [] -async def test_create_expense_from_receipt_is_listed_in_saved_month(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): +async def test_create_expense_from_receipt_is_listed_in_saved_month( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): receipt = Receipt( id=uuid.uuid4(), user_id=test_user.id, @@ -307,7 +327,9 @@ async def test_create_expense_from_receipt_is_listed_in_saved_month(client: Asyn assert detail["items"][0]["description"] == "Soup" -async def test_create_expense_from_statement_entry_updates_queue(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): +async def test_create_expense_from_statement_entry_updates_queue( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): statement = Receipt( id=uuid.uuid4(), user_id=test_user.id, @@ -373,7 +395,9 @@ async def test_create_expense_from_statement_entry_updates_queue(client: AsyncCl assert payload["statement"]["preview"]["entries"][1]["status"] == "pending" -async def test_create_credit_transaction_from_statement_entry(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): +async def test_create_credit_transaction_from_statement_entry( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): statement = Receipt( id=uuid.uuid4(), user_id=test_user.id, @@ -430,7 +454,9 @@ async def test_create_credit_transaction_from_statement_entry(client: AsyncClien assert payload["expense"]["category_name"] == "Salary" -async def test_replace_expense_items_replaces_rows_without_relationship_assignment(db_session: AsyncSession, test_user): +async def test_replace_expense_items_replaces_rows_without_relationship_assignment( + db_session: AsyncSession, test_user +): expense = Expense( id=uuid.uuid4(), user_id=test_user.id, @@ -464,7 +490,9 @@ async def test_replace_expense_items_replaces_rows_without_relationship_assignme stored_items = ( ( await db_session.execute( - select(ExpenseItem).where(ExpenseItem.expense_id == expense.id).order_by(ExpenseItem.created_at.asc()) + select(ExpenseItem) + .where(ExpenseItem.expense_id == expense.id) + .order_by(ExpenseItem.created_at.asc()) ) ) .scalars() @@ -477,7 +505,9 @@ async def test_replace_expense_items_replaces_rows_without_relationship_assignme assert [item.description for item in expense.items] == ["Coffee"] -async def test_replace_expense_items_assigns_deterministic_grocery_subcategories(db_session: AsyncSession, test_user): +async def test_replace_expense_items_assigns_deterministic_grocery_subcategories( + db_session: AsyncSession, test_user +): grocery_category = Category(user_id=test_user.id, name="Groceries", color="#34d399") db_session.add(grocery_category) await db_session.flush() @@ -510,18 +540,26 @@ async def test_replace_expense_items_assigns_deterministic_grocery_subcategories stored_items = ( ( await db_session.execute( - select(ExpenseItem).where(ExpenseItem.expense_id == expense.id).order_by(ExpenseItem.created_at.asc()) + select(ExpenseItem) + .where(ExpenseItem.expense_id == expense.id) + .order_by(ExpenseItem.created_at.asc()) ) ) .scalars() .all() ) - assert [item.subcategory for item in stored_items] == ["Vegetables", "Meat", "Cleaning Products"] + assert [item.subcategory for item in stored_items] == [ + "Vegetables", + "Meat", + "Cleaning Products", + ] assert all(item.subcategory_confidence is not None for item in stored_items) -async def test_dashboard_analytics_derives_grocery_subcategories_for_existing_blank_items(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): +async def test_dashboard_analytics_derives_grocery_subcategories_for_existing_blank_items( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): grocery_category = Category(user_id=test_user.id, name="Groceries", color="#34d399") db_session.add(grocery_category) await db_session.flush() @@ -542,9 +580,27 @@ async def test_dashboard_analytics_derives_grocery_subcategories_for_existing_bl await db_session.flush() db_session.add_all( [ - ExpenseItem(expense_id=expense.id, description="Bananas", quantity=1, unit_price=1.8, total_price=normalize_money(1.8)), - ExpenseItem(expense_id=expense.id, description="Laundry detergent", quantity=1, unit_price=5.4, total_price=normalize_money(5.4)), - ExpenseItem(expense_id=expense.id, description="Pasta", quantity=2, unit_price=1.5, total_price=normalize_money(3.0)), + ExpenseItem( + expense_id=expense.id, + description="Bananas", + quantity=1, + unit_price=1.8, + total_price=normalize_money(1.8), + ), + ExpenseItem( + expense_id=expense.id, + description="Laundry detergent", + quantity=1, + unit_price=5.4, + total_price=normalize_money(5.4), + ), + ExpenseItem( + expense_id=expense.id, + description="Pasta", + quantity=2, + unit_price=1.5, + total_price=normalize_money(3.0), + ), ] ) await db_session.commit() @@ -560,8 +616,12 @@ async def test_dashboard_analytics_derives_grocery_subcategories_for_existing_bl assert payload["grocery_insights"]["uncategorized_count"] == 0 -async def test_dashboard_grocery_insights_scale_item_totals_to_approved_expense_amount(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): - grocery_category = Category(user_id=test_user.id, name="Groceries", color="#34d399", transaction_type="debit") +async def test_dashboard_grocery_insights_scale_item_totals_to_approved_expense_amount( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): + grocery_category = Category( + user_id=test_user.id, name="Groceries", color="#34d399", transaction_type="debit" + ) db_session.add(grocery_category) await db_session.flush() @@ -582,8 +642,20 @@ async def test_dashboard_grocery_insights_scale_item_totals_to_approved_expense_ await db_session.flush() db_session.add_all( [ - ExpenseItem(expense_id=expense.id, description="Bananas", quantity=1, unit_price=4.0, total_price=normalize_money(4.0)), - ExpenseItem(expense_id=expense.id, description="Pasta", quantity=1, unit_price=8.0, total_price=normalize_money(8.0)), + ExpenseItem( + expense_id=expense.id, + description="Bananas", + quantity=1, + unit_price=4.0, + total_price=normalize_money(4.0), + ), + ExpenseItem( + expense_id=expense.id, + description="Pasta", + quantity=1, + unit_price=8.0, + total_price=normalize_money(8.0), + ), ] ) await db_session.commit() @@ -593,7 +665,9 @@ async def test_dashboard_grocery_insights_scale_item_totals_to_approved_expense_ assert response.status_code == 200 payload = response.json() grocery_insights = payload["grocery_insights"] - top_subcategories = {item["name"]: item["amount"] for item in grocery_insights["top_subcategories"]} + top_subcategories = { + item["name"]: item["amount"] for item in grocery_insights["top_subcategories"] + } assert payload["summary"]["money_out"] == 10.0 assert grocery_insights["total_itemized_spend"] == 10.0 @@ -601,9 +675,15 @@ async def test_dashboard_grocery_insights_scale_item_totals_to_approved_expense_ assert top_subcategories["Fruit"] == 3.33 -async def test_dashboard_analytics_separates_money_in_and_out(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): - grocery_category = Category(user_id=test_user.id, name="Groceries", color="#34d399", transaction_type="debit") - salary_category = Category(user_id=test_user.id, name="Salary", color="#10b981", transaction_type="credit") +async def test_dashboard_analytics_separates_money_in_and_out( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): + grocery_category = Category( + user_id=test_user.id, name="Groceries", color="#34d399", transaction_type="debit" + ) + salary_category = Category( + user_id=test_user.id, name="Salary", color="#10b981", transaction_type="credit" + ) db_session.add_all([grocery_category, salary_category]) await db_session.flush() @@ -650,9 +730,15 @@ async def test_dashboard_analytics_separates_money_in_and_out(client: AsyncClien assert payload["income_by_category"][0]["name"] == "Salary" -async def test_dashboard_analytics_surfaces_yearly_and_major_one_time_transactions(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): - bills_category = Category(user_id=test_user.id, name="Bills", color="#f87171", transaction_type="debit") - shopping_category = Category(user_id=test_user.id, name="Shopping", color="#22c55e", transaction_type="debit") +async def test_dashboard_analytics_surfaces_yearly_and_major_one_time_transactions( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): + bills_category = Category( + user_id=test_user.id, name="Bills", color="#f87171", transaction_type="debit" + ) + shopping_category = Category( + user_id=test_user.id, name="Shopping", color="#22c55e", transaction_type="debit" + ) db_session.add_all([bills_category, shopping_category]) await db_session.flush() @@ -704,7 +790,9 @@ async def test_dashboard_analytics_surfaces_yearly_and_major_one_time_transactio assert major_purchases["Tech Store"]["is_major_purchase"] is True -async def test_create_expense_from_receipt_is_idempotent_for_repeated_submission(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): +async def test_create_expense_from_receipt_is_idempotent_for_repeated_submission( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): receipt = Receipt( id=uuid.uuid4(), user_id=test_user.id, @@ -738,14 +826,20 @@ async def test_create_expense_from_receipt_is_idempotent_for_repeated_submission "category_name": "Dining", "confidence": 0.92, } - first_response = await client.post("/api/expenses/from-receipt", json=payload, headers=auth_headers) - second_response = await client.post("/api/expenses/from-receipt", json=payload, headers=auth_headers) + first_response = await client.post( + "/api/expenses/from-receipt", json=payload, headers=auth_headers + ) + second_response = await client.post( + "/api/expenses/from-receipt", json=payload, headers=auth_headers + ) assert first_response.status_code == 200 assert second_response.status_code == 200 assert first_response.json()["id"] == second_response.json()["id"] - expense_count = await db_session.scalar(select(func.count(Expense.id)).where(Expense.receipt_id == receipt.id)) + expense_count = await db_session.scalar( + select(func.count(Expense.id)).where(Expense.receipt_id == receipt.id) + ) assert expense_count == 1 await db_session.refresh(receipt) @@ -753,7 +847,9 @@ async def test_create_expense_from_receipt_is_idempotent_for_repeated_submission assert receipt.finalized_at is not None -async def test_create_expense_from_statement_entry_is_idempotent_for_repeated_submission(client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user): +async def test_create_expense_from_statement_entry_is_idempotent_for_repeated_submission( + client: AsyncClient, auth_headers: dict, db_session: AsyncSession, test_user +): statement = Receipt( id=uuid.uuid4(), user_id=test_user.id, @@ -797,16 +893,27 @@ async def test_create_expense_from_statement_entry_is_idempotent_for_repeated_su "category_name": "Transport", "confidence": 0.86, } - first_response = await client.post("/api/expenses/from-statement-entry", json=payload, headers=auth_headers) - second_response = await client.post("/api/expenses/from-statement-entry", json=payload, headers=auth_headers) + first_response = await client.post( + "/api/expenses/from-statement-entry", json=payload, headers=auth_headers + ) + second_response = await client.post( + "/api/expenses/from-statement-entry", json=payload, headers=auth_headers + ) assert first_response.status_code == 200 assert second_response.status_code == 200 assert first_response.json()["expense"]["id"] == second_response.json()["expense"]["id"] - expense_count = await db_session.scalar(select(func.count(Expense.id)).where(Expense.receipt_id == statement.id, Expense.source == "statement")) + expense_count = await db_session.scalar( + select(func.count(Expense.id)).where( + Expense.receipt_id == statement.id, Expense.source == "statement" + ) + ) assert expense_count == 1 await db_session.refresh(statement) assert statement.preview_data["entries"][0]["status"] == "finalized" - assert statement.preview_data["entries"][0]["saved_expense_id"] == first_response.json()["expense"]["id"] + assert ( + statement.preview_data["entries"][0]["saved_expense_id"] + == first_response.json()["expense"]["id"] + ) diff --git a/backend/tests/test_monthly_reports.py b/backend/tests/test_monthly_reports.py index f9fc8ba..6871327 100644 --- a/backend/tests/test_monthly_reports.py +++ b/backend/tests/test_monthly_reports.py @@ -16,7 +16,9 @@ pytestmark = pytest.mark.asyncio -async def test_send_monthly_reports_for_month_records_success(monkeypatch, db_session: AsyncSession, test_user): +async def test_send_monthly_reports_for_month_records_success( + monkeypatch, db_session: AsyncSession, test_user +): expense = Expense( user_id=test_user.id, merchant="Metro Grocery", @@ -34,23 +36,31 @@ async def test_send_monthly_reports_for_month_records_success(monkeypatch, db_se db_session.add(expense) await db_session.commit() - monkeypatch.setattr(monthly_reports.settings, "monthly_reports_frontend_pdf_url", "http://frontend:3000/api/internal/reports/monthly-pdf") + monkeypatch.setattr( + monthly_reports.settings, + "monthly_reports_frontend_pdf_url", + "http://frontend:3000/api/internal/reports/monthly-pdf", + ) async def fake_fetch_monthly_report_pdf(user, report_month): assert user.id == test_user.id assert report_month == date(2026, 3, 1) return b"%PDF-1.4 fake pdf bytes" - async def fake_send_monthly_report_email(user_email, user_name, report_month, *, expense_json_bytes, dashboard_pdf_bytes): + async def fake_send_monthly_report_email( + user_email, user_name, report_month, *, expense_json_bytes, dashboard_pdf_bytes + ): assert user_email == test_user.email assert user_name == test_user.name assert report_month == date(2026, 3, 1) - assert b'Metro Grocery' in expense_json_bytes + assert b"Metro Grocery" in expense_json_bytes assert dashboard_pdf_bytes.startswith(b"%PDF-1.4") return "re_test_123" monkeypatch.setattr(monthly_reports, "fetch_monthly_report_pdf", fake_fetch_monthly_report_pdf) - monkeypatch.setattr(monthly_reports, "send_monthly_report_email", fake_send_monthly_report_email) + monkeypatch.setattr( + monthly_reports, "send_monthly_report_email", fake_send_monthly_report_email + ) summary = await monthly_reports.send_monthly_reports_for_month(db_session, date(2026, 3, 1)) @@ -73,7 +83,9 @@ async def fake_send_monthly_report_email(user_email, user_name, report_month, *, assert delivery.error_message is None -async def test_send_monthly_reports_for_month_skips_existing_sent_delivery(monkeypatch, db_session: AsyncSession, test_user): +async def test_send_monthly_reports_for_month_skips_existing_sent_delivery( + monkeypatch, db_session: AsyncSession, test_user +): db_session.add( MonthlyReportDelivery( user_id=test_user.id, @@ -97,7 +109,9 @@ async def should_not_run(*args, **kwargs): assert summary.failed_users == 0 -async def test_send_monthly_reports_for_month_skips_users_with_auto_send_disabled(monkeypatch, db_session: AsyncSession, test_user): +async def test_send_monthly_reports_for_month_skips_users_with_auto_send_disabled( + monkeypatch, db_session: AsyncSession, test_user +): test_user.automatic_monthly_reports = False await db_session.commit() @@ -115,7 +129,9 @@ async def should_not_run(*args, **kwargs): assert summary.failed_users == 0 -async def test_manual_send_endpoint_resends_and_reuses_existing_delivery(monkeypatch, client, db_session: AsyncSession, test_user, auth_headers): +async def test_manual_send_endpoint_resends_and_reuses_existing_delivery( + monkeypatch, client, db_session: AsyncSession, test_user, auth_headers +): existing_delivery = MonthlyReportDelivery( user_id=test_user.id, report_month=date(2026, 3, 1), @@ -130,7 +146,9 @@ async def fake_fetch_monthly_report_pdf(user, report_month): assert report_month == date(2026, 3, 1) return b"%PDF-1.4 manual pdf bytes" - async def fake_send_monthly_report_email(user_email, user_name, report_month, *, expense_json_bytes, dashboard_pdf_bytes): + async def fake_send_monthly_report_email( + user_email, user_name, report_month, *, expense_json_bytes, dashboard_pdf_bytes + ): assert user_email == test_user.email assert user_name == test_user.name assert report_month == date(2026, 3, 1) @@ -138,7 +156,9 @@ async def fake_send_monthly_report_email(user_email, user_name, report_month, *, return "re_manual_456" monkeypatch.setattr(monthly_reports, "fetch_monthly_report_pdf", fake_fetch_monthly_report_pdf) - monkeypatch.setattr(monthly_reports, "send_monthly_report_email", fake_send_monthly_report_email) + monkeypatch.setattr( + monthly_reports, "send_monthly_report_email", fake_send_monthly_report_email + ) response = await client.post( "/api/monthly-reports/send", @@ -153,13 +173,17 @@ async def fake_send_monthly_report_email(user_email, user_name, report_month, *, assert payload["resend_email_id"] == "re_manual_456" deliveries = ( - await db_session.execute( - select(MonthlyReportDelivery).where( - MonthlyReportDelivery.user_id == test_user.id, - MonthlyReportDelivery.report_month == date(2026, 3, 1), + ( + await db_session.execute( + select(MonthlyReportDelivery).where( + MonthlyReportDelivery.user_id == test_user.id, + MonthlyReportDelivery.report_month == date(2026, 3, 1), + ) ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(deliveries) == 1 assert deliveries[0].id == existing_delivery.id assert deliveries[0].status == "sent" diff --git a/backend/tests/test_parser.py b/backend/tests/test_parser.py index 99d1d3c..c386368 100644 --- a/backend/tests/test_parser.py +++ b/backend/tests/test_parser.py @@ -25,13 +25,17 @@ def test_extract_json_object_embedded_payload(): - payload = _extract_json_object('hello {"merchant": "Cafe Roma", "amount": 8.50, "confidence": 0.8} world') + payload = _extract_json_object( + 'hello {"merchant": "Cafe Roma", "amount": 8.50, "confidence": 0.8} world' + ) assert payload is not None assert payload["merchant"] == "Cafe Roma" def test_extract_json_object_from_markdown_fence(): - payload = _extract_json_object('```json\n{"merchant":"Cafe Roma","amount":8.5,"confidence":0.8}\n```') + payload = _extract_json_object( + '```json\n{"merchant":"Cafe Roma","amount":8.5,"confidence":0.8}\n```' + ) assert payload is not None assert payload["amount"] == 8.5 @@ -50,7 +54,9 @@ def test_fallback_preview_from_text_uses_filename_for_missing_merchant(): def test_merchant_hint_from_receipt_text_prefers_known_supermarket_brand(): - merchant = _merchant_hint_from_receipt_text("PUNTI FIDELITY\nESSELUNGA ROMA\nVia Example 12\nTotale 24,90") + merchant = _merchant_hint_from_receipt_text( + "PUNTI FIDELITY\nESSELUNGA ROMA\nVia Example 12\nTotale 24,90" + ) assert merchant == "Esselunga" @@ -62,7 +68,10 @@ def test_should_force_groceries_for_supermarket_receipts(): preview = ReceiptPreviewModel( merchant="Carrefour Market", transaction_type="debit", - items=[ReceiptPreviewItemModel(description="Pomodori datterini"), ReceiptPreviewItemModel(description="Cien shampoo")], + items=[ + ReceiptPreviewItemModel(description="Pomodori datterini"), + ReceiptPreviewItemModel(description="Cien shampoo"), + ], ) assert _should_force_groceries(preview) is True @@ -112,9 +121,13 @@ async def test_extract_text_from_pdf_prefers_pdfplumber(tmp_path): @pytest.mark.asyncio async def test_llm_receipt_preview_validates_json_response(): provider = MagicMock() - provider.complete = AsyncMock(return_value='{"merchant":"Trainline","amount":27.4,"currency":"EUR","expense_date":"2026-04-03","description":"Rail ticket","category_name":"Transport","notes":"","items":[{"description":"Rail ticket","total":27.4}],"confidence":0.91}') + provider.complete = AsyncMock( + return_value='{"merchant":"Trainline","amount":27.4,"currency":"EUR","expense_date":"2026-04-03","description":"Rail ticket","category_name":"Transport","notes":"","items":[{"description":"Rail ticket","total":27.4}],"confidence":0.91}' + ) with patch("app.services.receipt_extraction.get_llm_provider", return_value=provider): - preview = await llm_receipt_preview("Trainline receipt", "ticket.txt", LLMConfig(provider="ollama", model="test")) + preview = await llm_receipt_preview( + "Trainline receipt", "ticket.txt", LLMConfig(provider="ollama", model="test") + ) assert preview is not None assert preview.merchant == "Trainline" assert preview.category_name == "Transport" @@ -125,12 +138,16 @@ async def test_llm_receipt_preview_validates_json_response(): @pytest.mark.asyncio async def test_llm_receipt_preview_from_image_uses_multimodal_message(tmp_path): provider = MagicMock() - provider.complete = AsyncMock(return_value='{"merchant":"Cafe Roma","amount":8.5,"currency":"EUR","expense_date":"2026-04-11","description":"Coffee and pastry","category_name":"Dining","notes":"","items":[{"description":"Coffee","quantity":1,"unit_price":3.5,"total":3.5},{"description":"Pastry","quantity":1,"unit_price":5.0,"total":5.0}],"confidence":0.89}') + provider.complete = AsyncMock( + return_value='{"merchant":"Cafe Roma","amount":8.5,"currency":"EUR","expense_date":"2026-04-11","description":"Coffee and pastry","category_name":"Dining","notes":"","items":[{"description":"Coffee","quantity":1,"unit_price":3.5,"total":3.5},{"description":"Pastry","quantity":1,"unit_price":5.0,"total":5.0}],"confidence":0.89}' + ) image_path = tmp_path / "receipt.png" image_path.write_bytes(b"fake-image-bytes") with patch("app.services.receipt_extraction.get_llm_provider", return_value=provider): - preview = await llm_receipt_preview_from_image(str(image_path), "receipt.png", "image/png", LLMConfig(provider="ollama", model="test")) + preview = await llm_receipt_preview_from_image( + str(image_path), "receipt.png", "image/png", LLMConfig(provider="ollama", model="test") + ) assert preview is not None assert preview.merchant == "Cafe Roma" @@ -147,12 +164,19 @@ async def test_llm_receipt_preview_from_image_uses_multimodal_message(tmp_path): @pytest.mark.asyncio async def test_llm_receipt_preview_from_image_accepts_partial_json_with_null_fields(tmp_path): provider = MagicMock() - provider.complete = AsyncMock(return_value='{"merchant":null,"amount":8.5,"currency":null,"expense_date":null,"description":null,"category_name":null,"notes":"","items":null,"confidence":0.74}') + provider.complete = AsyncMock( + return_value='{"merchant":null,"amount":8.5,"currency":null,"expense_date":null,"description":null,"category_name":null,"notes":"","items":null,"confidence":0.74}' + ) image_path = tmp_path / "cafe-roma_2026-04-11.png" image_path.write_bytes(b"fake-image-bytes") with patch("app.services.receipt_extraction.get_llm_provider", return_value=provider): - preview = await llm_receipt_preview_from_image(str(image_path), "cafe-roma_2026-04-11.png", "image/png", LLMConfig(provider="ollama", model="test")) + preview = await llm_receipt_preview_from_image( + str(image_path), + "cafe-roma_2026-04-11.png", + "image/png", + LLMConfig(provider="ollama", model="test"), + ) assert preview is not None assert preview.merchant == "Cafe Roma" diff --git a/backend/tests/test_report_retry.py b/backend/tests/test_report_retry.py index 93b3bae..fc6265f 100644 --- a/backend/tests/test_report_retry.py +++ b/backend/tests/test_report_retry.py @@ -53,9 +53,9 @@ def test_backoff_arithmetic(): @pytest.mark.parametrize( "attempt, expected_countdown", [ - (0, 2), # 2 ** (0 + 1) = 2 s - (1, 4), # 2 ** (1 + 1) = 4 s - (2, 8), # 2 ** (2 + 1) = 8 s + (0, 2), # 2 ** (0 + 1) = 2 s + (1, 4), # 2 ** (1 + 1) = 4 s + (2, 8), # 2 ** (2 + 1) = 8 s ], ids=["attempt-1", "attempt-2", "attempt-3"], ) diff --git a/backend/tests/test_task_internals.py b/backend/tests/test_task_internals.py index 59fcc51..0268ebe 100644 --- a/backend/tests/test_task_internals.py +++ b/backend/tests/test_task_internals.py @@ -60,11 +60,19 @@ def test_reset_called_before_each_task(): # Compare line numbers so docstring mentions of 'asyncio.run()' don't confuse us. lines = inspect.getsource(receipt_tasks).splitlines() reset_line = next( - (i for i, line in enumerate(lines) if "_reset_ollama_semaphore()" in line and not line.strip().startswith("#")), + ( + i + for i, line in enumerate(lines) + if "_reset_ollama_semaphore()" in line and not line.strip().startswith("#") + ), None, ) run_line = next( - (i for i, line in enumerate(lines) if "asyncio.run(" in line and not line.strip().startswith(("\"\"\"", "#", "``"))), + ( + i + for i, line in enumerate(lines) + if "asyncio.run(" in line and not line.strip().startswith(('"""', "#", "``")) + ), None, ) assert reset_line is not None, "_reset_ollama_semaphore() call not found in receipt_tasks" @@ -85,6 +93,7 @@ def test_semaphore_lazily_recreated_after_reset(): async def _check(): from app.services.llm.ollama import _get_semaphore + sema = _get_semaphore() assert sema is not None assert isinstance(sema, asyncio.Semaphore) diff --git a/docs/screenshots/.gitkeep b/docs/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/screenshots/budgets.png b/docs/screenshots/budgets.png new file mode 100644 index 0000000..354d316 Binary files /dev/null and b/docs/screenshots/budgets.png differ diff --git a/docs/screenshots/categories.png b/docs/screenshots/categories.png new file mode 100644 index 0000000..b888444 Binary files /dev/null and b/docs/screenshots/categories.png differ diff --git a/docs/screenshots/chat.png b/docs/screenshots/chat.png new file mode 100644 index 0000000..dbb6a8b Binary files /dev/null and b/docs/screenshots/chat.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png new file mode 100644 index 0000000..7966df0 Binary files /dev/null and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/expenses.png b/docs/screenshots/expenses.png new file mode 100644 index 0000000..6dc3c29 Binary files /dev/null and b/docs/screenshots/expenses.png differ diff --git a/docs/screenshots/receipt-extraction.png b/docs/screenshots/receipt-extraction.png new file mode 100644 index 0000000..1a667ef Binary files /dev/null and b/docs/screenshots/receipt-extraction.png differ diff --git a/frontend/src/app/(app)/chat/page.tsx b/frontend/src/app/(app)/chat/page.tsx index c8edc87..c31f5c6 100644 --- a/frontend/src/app/(app)/chat/page.tsx +++ b/frontend/src/app/(app)/chat/page.tsx @@ -459,8 +459,10 @@ export default function ChatPage() {
⚠️ - You haven't configured an LLM provider API key yet — AI chat will use the server defaults.{" "} - Set up your API key in Settings. + {session?.isDemo + ? <>The demo account can't use the server's local AI model. Add your own API key to use chat.{" "}Set up your key in Settings. + : <>No AI provider key configured — chat will fall back to the server's default model.{" "}Add your key in Settings. + }
)} diff --git a/frontend/src/app/(app)/expenses/new/page.tsx b/frontend/src/app/(app)/expenses/new/page.tsx index a7d3f77..f00d1bb 100644 --- a/frontend/src/app/(app)/expenses/new/page.tsx +++ b/frontend/src/app/(app)/expenses/new/page.tsx @@ -900,11 +900,11 @@ export default function NewExpensePage() { setUploading(false); // Poll until the background extraction finishes - if (receipt.extraction_status === "pending") { + if (receipt.extraction_status === "pending" || receipt.extraction_status === "processing") { setExtracting(true); let polled = receipt; try { - while (polled.extraction_status === "pending" && !pollingCancelledRef.current) { + while ((polled.extraction_status === "pending" || polled.extraction_status === "processing") && !pollingCancelledRef.current) { await new Promise((resolve) => setTimeout(resolve, 2000)); if (pollingCancelledRef.current) break; polled = await getReceipt(receipt.id); @@ -1046,8 +1046,10 @@ export default function NewExpensePage() {
⚠️ - You haven't configured an LLM provider API key yet — AI-powered receipt extraction will use the server defaults.{" "} - Set up your API key in Settings. + {session?.isDemo + ? <>The demo account can't use the server's local AI model. Add your own API key to try receipt extraction.{" "}Set up your key in Settings. + : <>No AI provider key configured — receipt extraction will fall back to the server's default model.{" "}Add your key in Settings. + }
)} @@ -1414,7 +1416,7 @@ export default function NewExpensePage() { let receipt = await uploadStatement(file); setSelectedStatement(receipt); // Statement extraction runs in a Celery worker — poll until it finishes. - while (receipt.extraction_status === "pending") { + while (receipt.extraction_status === "pending" || receipt.extraction_status === "processing") { await new Promise((resolve) => setTimeout(resolve, 2000)); receipt = await getReceipt(receipt.id); setSelectedStatement(receipt);