Skip to content
Open
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ flowchart LR
## PostgreSQL Schema (DDL)
See `backend/app/db/schema.sql`. Key tables:
- users, categories, expenses, bills, reminders
- **accounts** (multi-account support: name, type, balance, currency)
- ad_impressions, subscription_plans, user_subscriptions
- refresh_tokens (optional if rotating), audit_logs

Expand All @@ -66,6 +67,8 @@ OpenAPI: `backend/app/openapi.yaml`
- Bills: CRUD `/bills`, pay/mark `/bills/{id}/pay`
- Reminders: CRUD `/reminders`, trigger `/reminders/run`
- Insights: `/insights/monthly`, `/insights/budget-suggestion`
- **Accounts**: CRUD `/accounts` (multi-account management)
- **Dashboard**: `/dashboard/summary`, `/dashboard/overview` (multi-account financial overview)

## MVP UI/UX Plan
- Auth screens: register/login.
Expand Down Expand Up @@ -183,6 +186,37 @@ finmind/
- Primary: schedule via APScheduler in-process with persistence in Postgres (job table) and a simple daily trigger. Alternatively, use Railway/Render cron to hit `/reminders/run`.
- Twilio WhatsApp free trial supports sandbox; email via SMTP (e.g., SendGrid free tier).

## Multi-Account Dashboard

FinMind supports tracking finances across multiple accounts (checking, savings, credit, investment, cash, etc.).

### Account Types
- `CHECKING` - Primary bank account for daily transactions
- `SAVINGS` - Savings accounts
- `CREDIT` - Credit card accounts (can have negative balance)
- `INVESTMENT` - Investment/portfolio accounts
- `CASH` - Physical cash tracking
- `OTHER` - Custom account types

### Dashboard Overview Endpoint
`GET /dashboard/overview` returns:
- `net_worth.total_balance` - Sum of all account balances
- `net_worth.by_currency` - Balance breakdown by currency
- `accounts` - List of all active accounts with balances
- `recent_transactions` - Last 10 transactions with account associations
- `summary` - Monthly income/expenses and account counts

### Account Management
- `GET /accounts/` - List all accounts (optionally include inactive)
- `POST /accounts/` - Create new account
- `GET /accounts/{id}` - Get specific account
- `PUT /accounts/{id}` - Update account (name, type, balance)
- `DELETE /accounts/{id}` - Soft delete (deactivate)
- `DELETE /accounts/{id}/hard` - Permanent deletion

### Linking Expenses to Accounts
Expenses can optionally be linked to accounts via `account_id` field for detailed tracking per account.

## Security & Scalability
- JWT access/refresh, secure cookies OR Authorization header.
- RBAC-ready via roles on `users.role`.
Expand Down
69 changes: 69 additions & 0 deletions packages/backend/app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@ CREATE TABLE IF NOT EXISTS users (
ALTER TABLE users
ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(10) NOT NULL DEFAULT 'INR';

-- Multi-account support
DO $$ BEGIN
CREATE TYPE account_type AS ENUM ('CHECKING','SAVINGS','CREDIT','INVESTMENT','CASH','OTHER');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
account_type account_type NOT NULL DEFAULT 'CHECKING',
balance NUMERIC(12,2) NOT NULL DEFAULT 0.00,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_accounts_user ON accounts(user_id);

CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
Expand All @@ -22,6 +42,7 @@ CREATE TABLE IF NOT EXISTS expenses (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id INT REFERENCES categories(id) ON DELETE SET NULL,
account_id INT REFERENCES accounts(id) ON DELETE SET NULL,
amount NUMERIC(12,2) NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'INR',
expense_type VARCHAR(20) NOT NULL DEFAULT 'EXPENSE',
Expand All @@ -30,10 +51,14 @@ CREATE TABLE IF NOT EXISTS expenses (
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_expenses_user_spent_at ON expenses(user_id, spent_at DESC);
CREATE INDEX IF NOT EXISTS idx_expenses_account ON expenses(account_id);

ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS expense_type VARCHAR(20) NOT NULL DEFAULT 'EXPENSE';

ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS account_id INT REFERENCES accounts(id) ON DELETE SET NULL;

DO $$ BEGIN
CREATE TYPE recurring_cadence AS ENUM ('DAILY','WEEKLY','MONTHLY','YEARLY');
EXCEPTION
Expand Down Expand Up @@ -123,3 +148,47 @@ CREATE TABLE IF NOT EXISTS audit_logs (
action VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);


-- Rule-based auto-tagging
DO \$\$ BEGIN
CREATE TYPE rule_field AS ENUM ('payee','amount','description','notes');
EXCEPTION WHEN duplicate_object THEN NULL;
END \$\$;

DO \$\$ BEGIN
CREATE TYPE rule_operator AS ENUM ('contains','equals','regex','gt','lt','gte','lte','startswith','endswith');
EXCEPTION WHEN duplicate_object THEN NULL;
END \$\$;

DO \$\$ BEGIN
CREATE TYPE condition_type AS ENUM ('AND','OR');
EXCEPTION WHEN duplicate_object THEN NULL;
END \$\$;

CREATE TABLE IF NOT EXISTS categorization_rules (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
field rule_field NOT NULL,
operator rule_operator NOT NULL,
value VARCHAR(500) NOT NULL,
category_id INT REFERENCES categories(id) ON DELETE SET NULL,
tag VARCHAR(100),
priority INT NOT NULL DEFAULT 0,
condition_type condition_type NOT NULL DEFAULT 'AND',
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_categorization_rules_user_priority ON categorization_rules(user_id, priority DESC);

CREATE TABLE IF NOT EXISTS rule_conditions (
id SERIAL PRIMARY KEY,
rule_id INT NOT NULL REFERENCES categorization_rules(id) ON DELETE CASCADE,
field rule_field NOT NULL,
operator rule_operator NOT NULL,
value VARCHAR(500) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rule_conditions_rule ON rule_conditions(rule_id);
105 changes: 105 additions & 0 deletions packages/backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,42 @@
from .extensions import db


class AccountType(str, Enum):
CHECKING = "CHECKING"
SAVINGS = "SAVINGS"
CREDIT = "CREDIT"
INVESTMENT = "INVESTMENT"
CASH = "CASH"
OTHER = "OTHER"


class Account(db.Model):
"""Financial account for multi-account tracking."""
__tablename__ = "accounts"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
name = db.Column(db.String(200), nullable=False)
account_type = db.Column(SAEnum(AccountType), default=AccountType.CHECKING, nullable=False)
balance = db.Column(db.Numeric(12, 2), default=0.00, nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"name": self.name,
"account_type": self.account_type.value if self.account_type else None,
"balance": float(self.balance or 0),
"currency": self.currency,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}


class Role(str, Enum):
USER = "USER"
ADMIN = "ADMIN"
Expand Down Expand Up @@ -32,6 +68,7 @@ class Expense(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=True)
account_id = db.Column(db.Integer, db.ForeignKey("accounts.id"), nullable=True)
amount = db.Column(db.Numeric(12, 2), nullable=False)
currency = db.Column(db.String(10), default="INR", nullable=False)
expense_type = db.Column(db.String(20), default="EXPENSE", nullable=False)
Expand Down Expand Up @@ -133,3 +170,71 @@ class AuditLog(db.Model):
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
action = db.Column(db.String(100), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)


class RuleField(str, Enum):
PAYEE = 'payee'
AMOUNT = 'amount'
DESCRIPTION = 'description'
NOTES = 'notes'

class RuleOperator(str, Enum):
CONTAINS = 'contains'
EQUALS = 'equals'
REGEX = 'regex'
GT = 'gt'
LT = 'lt'
GTE = 'gte'
LTE = 'lte'
STARTSWITH = 'startswith'
ENDSWITH = 'endswith'

class ConditionType(str, Enum):
AND = 'AND'
OR = 'OR'

class CategorizationRule(db.Model):
__tablename__ = 'categorization_rules'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
name = db.Column(db.String(100), nullable=False)
field = db.Column(SAEnum(RuleField), nullable=False)
operator = db.Column(SAEnum(RuleOperator), nullable=False)
value = db.Column(db.String(500), nullable=False)
category_id = db.Column(db.Integer, db.ForeignKey('categories.id'), nullable=True)
tag = db.Column(db.String(100), nullable=True)
priority = db.Column(db.Integer, default=0, nullable=False)
condition_type = db.Column(SAEnum(ConditionType), default=ConditionType.AND, nullable=False)
active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

def to_dict(self):
return {
'id': self.id, 'name': self.name,
'field': self.field.value if self.field else None,
'operator': self.operator.value if self.operator else None,
'value': self.value, 'category_id': self.category_id,
'tag': self.tag, 'priority': self.priority,
'condition_type': self.condition_type.value if self.condition_type else None,
'active': self.active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}

class RuleCondition(db.Model):
__tablename__ = 'rule_conditions'
id = db.Column(db.Integer, primary_key=True)
rule_id = db.Column(db.Integer, db.ForeignKey('categorization_rules.id', ondelete='CASCADE'), nullable=False)
field = db.Column(SAEnum(RuleField), nullable=False)
operator = db.Column(SAEnum(RuleOperator), nullable=False)
value = db.Column(db.String(500), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)

def to_dict(self):
return {
'id': self.id, 'rule_id': self.rule_id,
'field': self.field.value if self.field else None,
'operator': self.operator.value if self.operator else None,
'value': self.value,
}
22 changes: 14 additions & 8 deletions packages/backend/app/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@
from .categories import bp as categories_bp
from .docs import bp as docs_bp
from .dashboard import bp as dashboard_bp
from .savings_opportunities import bp as savings_opportunities_bp
from .rules import bp as rules_bp
from .analytics import bp as analytics_bp


def register_routes(app: Flask):
app.register_blueprint(auth_bp, url_prefix="/auth")
app.register_blueprint(expenses_bp, url_prefix="/expenses")
app.register_blueprint(bills_bp, url_prefix="/bills")
app.register_blueprint(reminders_bp, url_prefix="/reminders")
app.register_blueprint(insights_bp, url_prefix="/insights")
app.register_blueprint(categories_bp, url_prefix="/categories")
app.register_blueprint(docs_bp, url_prefix="/docs")
app.register_blueprint(dashboard_bp, url_prefix="/dashboard")
app.register_blueprint(auth_bp, url_prefix='/auth')
app.register_blueprint(expenses_bp, url_prefix='/expenses')
app.register_blueprint(bills_bp, url_prefix='/bills')
app.register_blueprint(reminders_bp, url_prefix='/reminders')
app.register_blueprint(insights_bp, url_prefix='/insights')
app.register_blueprint(categories_bp, url_prefix='/categories')
app.register_blueprint(docs_bp, url_prefix='/docs')
app.register_blueprint(dashboard_bp, url_prefix='/dashboard')
app.register_blueprint(savings_opportunities_bp, url_prefix='/savings-opportunities')
app.register_blueprint(rules_bp, url_prefix='/rules')
app.register_blueprint(analytics_bp, url_prefix='/analytics')
Loading