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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions backend/alembic/versions/0001_initial_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Revises: 0003
Create Date: 2026-04-10
"""

import sqlalchemy as sa

from alembic import op
Expand Down
141 changes: 120 additions & 21 deletions backend/alembic/versions/0007_spendhound_mvp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"])
Expand All @@ -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"])
Expand All @@ -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),
Expand All @@ -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"])
Expand All @@ -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),
Expand All @@ -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"])
Expand All @@ -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"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
14 changes: 12 additions & 2 deletions backend/alembic/versions/0009_add_transaction_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
Loading
Loading