Compare commits

...

2 Commits

Author SHA1 Message Date
fd3214ff70 Fixed Savings CSV detection 2026-08-04 09:18:40 -04:00
d60875a80f New logic for updated Capital One Accounts 2026-08-04 09:00:09 -04:00
75 changed files with 2404 additions and 1358 deletions

View File

@@ -1,11 +1,10 @@
# Database
DATABASE_URL="postgresql://financeapp:password@localhost:5432/financeapp"
# NextAuth
NEXTAUTH_SECRET="<generate with: openssl rand -base64 32>"
NEXTAUTH_URL="http://localhost:3000"
# JWT (used by FastAPI backend)
JWT_SECRET_KEY="<generate with: openssl rand -base64 32>"
# Seed user (used by prisma/seed.ts only)
# Seed user (used by backend/seed.py)
SEED_EMAIL="your@email.com"
SEED_PASSWORD="your-secure-password"
@@ -13,3 +12,6 @@ SEED_PASSWORD="your-secure-password"
POSTGRES_USER="financeapp"
POSTGRES_PASSWORD="password"
POSTGRES_DB="financeapp"
# Next.js → FastAPI proxy (set in docker-compose.yml; override for local dev)
BACKEND_URL="http://localhost:8000"

View File

@@ -187,20 +187,21 @@ model BalanceSnapshot {
All 4 user banks with exact column mappings. Implement in `src/lib/csv/bank-profiles.ts`.
### Discover High Yield Savings → `BANK`
- **Strategy**: B (split debit/credit columns)
- **Strategy**: C (single unsigned amount column + type column)
- `date` ← `Transaction Date`
- `description` ← `Transaction Description`
- `debitAmount` ← `Debit`
- `creditAmount` `Credit`
- `amount` ← `Transaction Amount`
- `type` ← `Transaction Type` — value is literally `Debit` or `Credit`
- `balance` ← `Balance` (reference only)
- Ignore: `Transaction Type`
- Ignore: `Account Number`
### Discover Credit Card → `CREDIT_CARD`
- **Strategy**: A (single signed column)
- `date` ← `Trans. Date`
- **Strategy**: B (split debit/credit columns)
- `date` ← `Transaction Date`
- `description` ← `Description`
- `amount` ← `Amount` — **positive = DEBIT (charge), negative = CREDIT (payment/refund)**
- Ignore: `Post Date`, `Category`
- `debitAmount` ← `Debit`
- `creditAmount` ← `Credit`
- Ignore: `Posted Date`, `Card No.`, `Category`
### Huntington Checking → `BANK`
- **Strategy**: A (single signed column)
@@ -224,10 +225,14 @@ All 4 user banks with exact column mappings. Implement in `src/lib/csv/bank-prof
// Sign convention varies per bank; each profile has invertAmountSign: boolean
function parseStrategyA(raw: string, invert: boolean): { amountCents: number; type: TransactionType }
// Strategy B — separate debit/credit columns (Discover Savings)
// Strategy B — separate debit/credit columns (Discover Credit Card)
// The non-empty/non-zero column determines type and amount
function parseStrategyB(debitRaw: string, creditRaw: string): { amountCents: number; type: TransactionType }
// Strategy C — single unsigned amount column + separate type column (Discover Savings)
// The type column's literal value ("Debit" / "Credit") determines type
function parseStrategyC(amountRaw: string, typeRaw: string): { amountCents: number; type: TransactionType }
// All amounts via:
function parseCents(raw: string): number {
return Math.round(parseFloat(raw.replace(/[$,\s]/g, '')) * 100)

15
backend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

43
backend/alembic.ini Normal file
View File

@@ -0,0 +1,43 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
# URL is read from env in env.py — do not hard-code credentials here.
sqlalchemy.url =
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

48
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,48 @@
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from sqlmodel import SQLModel
# Import all models so their metadata is registered before autogenerate.
import app.models # noqa: F401
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
DATABASE_URL = os.environ["DATABASE_URL"]
def run_migrations_offline() -> None:
context.configure(
url=DATABASE_URL,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = DATABASE_URL
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

33
backend/app/auth.py Normal file
View File

@@ -0,0 +1,33 @@
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60
# bcrypt cost=12 matches the TypeScript bcryptjs configuration.
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=12)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(plain: str) -> str:
return pwd_context.hash(plain)
def create_access_token(user_id: str, email: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode({"sub": user_id, "email": email, "exp": expire}, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
return None

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,79 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class BankProfile:
id: str
name: str
account_type: str # BANK | CREDIT_CARD | INVESTMENT
strategy: str # A | B
date_column: str
description_column: str
detect_columns: list[str]
category_column: Optional[str] = None
# Strategy A
amount_column: Optional[str] = None
invert_amount_sign: Optional[bool] = None
# Strategy B
debit_column: Optional[str] = None
credit_column: Optional[str] = None
# Strategy C
type_column: Optional[str] = None
BANK_PROFILES: list[BankProfile] = [
BankProfile(
id="discover-savings",
name="Discover High Yield Savings",
account_type="BANK",
strategy="C",
date_column="Transaction Date",
description_column="Transaction Description",
amount_column="Transaction Amount",
type_column="Transaction Type",
detect_columns=["Transaction Date", "Transaction Description", "Transaction Type", "Transaction Amount", "Balance"],
),
BankProfile(
id="discover-cc",
name="Discover Credit Card",
account_type="CREDIT_CARD",
strategy="B",
date_column="Transaction Date",
description_column="Description",
debit_column="Debit",
credit_column="Credit",
category_column="Category",
detect_columns=["Transaction Date", "Posted Date", "Card No.", "Description", "Category", "Debit", "Credit"],
),
BankProfile(
id="huntington-checking",
name="Huntington Checking",
account_type="BANK",
strategy="A",
date_column="Date",
description_column="Description",
amount_column="Amount",
invert_amount_sign=True, # negative = DEBIT (withdrawal), positive = CREDIT (deposit)
detect_columns=["Date", "Description", "Amount", "Split", "Tags"],
),
BankProfile(
id="fidelity",
name="Fidelity",
account_type="BANK",
strategy="A",
date_column="Run Date",
description_column="Description",
amount_column="Amount($)",
invert_amount_sign=True, # negative = DEBIT (purchase/withdrawal), positive = CREDIT
detect_columns=["Run Date", "Description", "Amount($)"],
),
]
def detect_profile(headers: list[str]) -> Optional[BankProfile]:
header_set = {h.strip() for h in headers}
for profile in BANK_PROFILES:
if all(col in header_set for col in profile.detect_columns):
return profile
return None

View File

@@ -0,0 +1,114 @@
import re
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Optional
from .bank_profiles import BankProfile
@dataclass
class NormalizedRow:
date: datetime
description: str
amount_cents: int
type: str # DEBIT | CREDIT | TRANSFER
category: Optional[str] = None
budget_id: Optional[str] = None
def parse_cents(raw: str) -> int:
cleaned = re.sub(r"[$,\s]", "", raw)
if not cleaned or cleaned == "-":
return 0
return round(float(cleaned) * 100)
def parse_date(raw: str) -> datetime:
raw = raw.strip()
# MM/DD/YYYY or MM/DD/YY
m = re.match(r"^(\d{1,2})/(\d{1,2})/(\d{2}|\d{4})$", raw)
if m:
month, day, year = int(m.group(1)), int(m.group(2)), int(m.group(3))
if year < 100:
year += 2000
return datetime(year, month, day)
# ISO 8601 and other formats
for fmt in ("%Y-%m-%d", "%m-%d-%Y", "%B %d, %Y"):
try:
return datetime.strptime(raw, fmt)
except ValueError:
pass
raise ValueError(f"Cannot parse date: {raw!r}")
def _strategy_a(raw: str, invert: bool) -> tuple[int, str]:
cents = parse_cents(raw)
is_positive = cents >= 0
# invert=True: positive=CREDIT, negative=DEBIT (Huntington, Fidelity, Discover CC)
# invert=False: positive=DEBIT, negative=CREDIT
tx_type = ("CREDIT" if is_positive else "DEBIT") if invert else ("DEBIT" if is_positive else "CREDIT")
return abs(cents), tx_type
def _strategy_b(debit_raw: str, credit_raw: str) -> tuple[int, str]:
debit = parse_cents(debit_raw)
if debit > 0:
return debit, "DEBIT"
return abs(parse_cents(credit_raw)), "CREDIT"
def _strategy_c(amount_raw: str, type_raw: str) -> tuple[int, str]:
cents = abs(parse_cents(amount_raw))
type_normalized = type_raw.strip().upper()
if type_normalized == "CREDIT":
return cents, "CREDIT"
if type_normalized == "DEBIT":
return cents, "DEBIT"
raise ValueError(f"Unknown transaction type: {type_raw!r}")
def normalize_rows(
rows: list[dict[str, str]],
config: Any, # BankProfile or ColumnMapping — both expose the same attribute names
) -> list[NormalizedRow]:
out: list[NormalizedRow] = []
# Both BankProfile (dataclass) and ColumnMapping (Pydantic) expose the same attribute names.
strategy = config.strategy
date_col = config.date_column
desc_col = config.description_column
amount_col = getattr(config, "amount_column", None)
invert = getattr(config, "invert_amount_sign", False) or False
debit_col = getattr(config, "debit_column", None)
credit_col = getattr(config, "credit_column", None)
type_col = getattr(config, "type_column", None)
category_col = getattr(config, "category_column", None)
for row in rows:
try:
description = row.get(desc_col, "").strip()
if not description:
continue
date = parse_date(row.get(date_col, ""))
if strategy == "A" and amount_col:
amount_cents, tx_type = _strategy_a(row.get(amount_col, "0"), invert)
elif strategy == "B" and debit_col and credit_col:
amount_cents, tx_type = _strategy_b(row.get(debit_col, ""), row.get(credit_col, ""))
elif strategy == "C" and amount_col and type_col:
amount_cents, tx_type = _strategy_c(row.get(amount_col, "0"), row.get(type_col, ""))
else:
continue
if amount_cents == 0:
continue
category = row.get(category_col, "").strip() if category_col else None
out.append(NormalizedRow(
date=date,
description=description,
amount_cents=amount_cents,
type=tx_type,
category=category or None,
))
except Exception:
pass # skip unparseable rows
return out

35
backend/app/csv/parser.py Normal file
View File

@@ -0,0 +1,35 @@
import csv
import io
from typing import Union
from .bank_profiles import BankProfile, detect_profile
ParsedRow = dict[str, str]
def parse_csv_content(content: str) -> tuple[list[str], list[ParsedRow]]:
"""Return (headers, rows). Headers are trimmed; rows keyed by trimmed header."""
reader = csv.DictReader(io.StringIO(content))
if reader.fieldnames is None:
return [], []
headers = [h.strip() for h in reader.fieldnames]
rows: list[ParsedRow] = []
for raw_row in reader:
if not any(v.strip() for v in raw_row.values()):
continue # skip empty rows
rows.append({h.strip(): (v or "").strip() for h, v in raw_row.items() if h is not None})
return headers, rows
ParseResult = Union[
dict, # {"detected": BankProfile, "headers": ..., "rows": ...}
dict, # {"requires_mapping": True, "headers": ..., "sample_rows": ...}
]
def parse_and_detect(content: str) -> dict:
headers, rows = parse_csv_content(content)
detected = detect_profile(headers)
if detected:
return {"detected": detected, "headers": headers, "rows": rows}
return {"requires_mapping": True, "headers": headers, "sample_rows": rows[:5], "rows": rows}

12
backend/app/database.py Normal file
View File

@@ -0,0 +1,12 @@
import os
from sqlmodel import Session, create_engine
DATABASE_URL = os.environ["DATABASE_URL"]
engine = create_engine(DATABASE_URL, echo=False)
def get_session():
with Session(engine) as session:
yield session

33
backend/app/deps.py Normal file
View File

@@ -0,0 +1,33 @@
from typing import Optional
from fastapi import Cookie, Depends, Header, HTTPException
from sqlmodel import Session
from .auth import decode_token
from .database import get_session
from .models import User
def get_current_user(
session: Session = Depends(get_session),
auth_token: Optional[str] = Cookie(default=None),
authorization: Optional[str] = Header(default=None),
) -> User:
token: Optional[str] = None
if auth_token:
token = auth_token
elif authorization and authorization.startswith("Bearer "):
token = authorization[7:]
if not token:
raise HTTPException(status_code=401, detail="Unauthorized")
payload = decode_token(token)
if not payload:
raise HTTPException(status_code=401, detail="Unauthorized")
user = session.get(User, payload["sub"])
if not user:
raise HTTPException(status_code=401, detail="Unauthorized")
return user

32
backend/app/main.py Normal file
View File

@@ -0,0 +1,32 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .routers import accounts, admin, auth, budget_rules, budgets, dashboard, graphs, transactions, transfer_rules, upload
app = FastAPI(title="Finance API", version="1.0.0")
# In production the Next.js frontend and FastAPI sit behind the same reverse
# proxy, so browser requests appear same-origin. CORS is only needed in dev.
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(accounts.router)
app.include_router(transactions.router)
app.include_router(budgets.router)
app.include_router(budget_rules.router)
app.include_router(transfer_rules.router)
app.include_router(upload.router)
app.include_router(dashboard.router)
app.include_router(graphs.router)
app.include_router(admin.router)
@app.get("/health")
def health():
return {"status": "ok"}

251
backend/app/models.py Normal file
View File

@@ -0,0 +1,251 @@
from __future__ import annotations
import uuid
from datetime import datetime
from enum import Enum as PyEnum
from typing import List, Optional
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy import Enum as SAEnum
from sqlalchemy.sql import func
from sqlmodel import Field, Relationship, SQLModel
def new_id() -> str:
return str(uuid.uuid4())
class AccountType(str, PyEnum):
BANK = "BANK"
CREDIT_CARD = "CREDIT_CARD"
INVESTMENT = "INVESTMENT"
class TransactionType(str, PyEnum):
DEBIT = "DEBIT"
CREDIT = "CREDIT"
TRANSFER = "TRANSFER"
# Reference existing PostgreSQL enums created by Prisma — do not re-create them.
_sa_account_type = SAEnum(AccountType, name="AccountType", create_type=False)
_sa_transaction_type = SAEnum(TransactionType, name="TransactionType", create_type=False)
class User(SQLModel, table=True):
__tablename__ = "User"
id: str = Field(default_factory=new_id, primary_key=True)
email: str = Field(sa_column=Column("email", String, unique=True, nullable=False))
password_hash: str = Field(sa_column=Column("passwordHash", String, nullable=False))
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column("createdAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column=Column("updatedAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
accounts: List["Account"] = Relationship(back_populates="user")
budgets: List["Budget"] = Relationship(back_populates="user")
budget_rules: List["BudgetRule"] = Relationship(back_populates="user")
transfer_rules: List["TransferRule"] = Relationship(back_populates="user")
class Account(SQLModel, table=True):
__tablename__ = "Account"
__table_args__ = (Index("Account_userId_idx", "userId"),)
id: str = Field(default_factory=new_id, primary_key=True)
user_id: str = Field(
sa_column=Column("userId", String, ForeignKey("User.id", ondelete="CASCADE"), nullable=False)
)
name: str
institution: Optional[str] = Field(default=None)
type: AccountType = Field(sa_column=Column("type", _sa_account_type, nullable=False))
currency: str = Field(default="USD")
current_balance_cents: int = Field(
default=0,
sa_column=Column("currentBalanceCents", Integer, nullable=False, server_default="0"),
)
is_active: bool = Field(
default=True,
sa_column=Column("isActive", Boolean, nullable=False, server_default="true"),
)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column("createdAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column=Column("updatedAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
user: Optional["User"] = Relationship(back_populates="accounts")
transactions: List["Transaction"] = Relationship(back_populates="account")
uploads: List["CsvUpload"] = Relationship(back_populates="account")
class Budget(SQLModel, table=True):
__tablename__ = "Budget"
__table_args__ = (Index("Budget_userId_idx", "userId"),)
id: str = Field(default_factory=new_id, primary_key=True)
user_id: str = Field(
sa_column=Column("userId", String, ForeignKey("User.id", ondelete="CASCADE"), nullable=False)
)
name: str
limit_cents: Optional[int] = Field(
default=None, sa_column=Column("limitCents", Integer, nullable=True)
)
color: Optional[str] = Field(default=None)
is_active: bool = Field(
default=True,
sa_column=Column("isActive", Boolean, nullable=False, server_default="true"),
)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column("createdAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column=Column("updatedAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
user: Optional["User"] = Relationship(back_populates="budgets")
transactions: List["Transaction"] = Relationship(back_populates="budget")
rules: List["BudgetRule"] = Relationship(back_populates="budget")
class CsvUpload(SQLModel, table=True):
__tablename__ = "CsvUpload"
__table_args__ = (Index("CsvUpload_accountId_idx", "accountId"),)
id: str = Field(default_factory=new_id, primary_key=True)
account_id: str = Field(
sa_column=Column("accountId", String, ForeignKey("Account.id", ondelete="CASCADE"), nullable=False)
)
file_name: str = Field(sa_column=Column("fileName", String, nullable=False))
row_count: int = Field(sa_column=Column("rowCount", Integer, nullable=False))
imported_count: int = Field(sa_column=Column("importedCount", Integer, nullable=False))
skipped_count: int = Field(sa_column=Column("skippedCount", Integer, nullable=False))
status: str
error_message: Optional[str] = Field(
default=None, sa_column=Column("errorMessage", String, nullable=True)
)
uploaded_at: Optional[datetime] = Field(
default=None,
sa_column=Column("uploadedAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
account: Optional["Account"] = Relationship(back_populates="uploads")
transactions: List["Transaction"] = Relationship(back_populates="upload")
class Transaction(SQLModel, table=True):
__tablename__ = "Transaction"
__table_args__ = (
Index("Transaction_accountId_date_idx", "accountId", "date"),
Index("Transaction_date_idx", "date"),
Index("Transaction_budgetId_idx", "budgetId"),
)
id: str = Field(default_factory=new_id, primary_key=True)
account_id: str = Field(
sa_column=Column("accountId", String, ForeignKey("Account.id", ondelete="CASCADE"), nullable=False)
)
upload_id: Optional[str] = Field(
default=None,
sa_column=Column("uploadId", String, ForeignKey("CsvUpload.id", ondelete="SET NULL"), nullable=True),
)
budget_id: Optional[str] = Field(
default=None,
sa_column=Column("budgetId", String, ForeignKey("Budget.id", ondelete="SET NULL"), nullable=True),
)
date: datetime = Field(sa_column=Column("date", DateTime(timezone=True), nullable=False))
description: str
amount_cents: int = Field(sa_column=Column("amountCents", Integer, nullable=False))
type: TransactionType = Field(sa_column=Column("type", _sa_transaction_type, nullable=False))
category: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column("createdAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column=Column("updatedAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
account: Optional["Account"] = Relationship(back_populates="transactions")
upload: Optional["CsvUpload"] = Relationship(back_populates="transactions")
budget: Optional["Budget"] = Relationship(back_populates="transactions")
class BudgetRule(SQLModel, table=True):
__tablename__ = "BudgetRule"
__table_args__ = (
Index("BudgetRule_userId_idx", "userId"),
Index("BudgetRule_budgetId_idx", "budgetId"),
)
id: str = Field(default_factory=new_id, primary_key=True)
user_id: str = Field(
sa_column=Column("userId", String, ForeignKey("User.id", ondelete="CASCADE"), nullable=False)
)
budget_id: str = Field(
sa_column=Column("budgetId", String, ForeignKey("Budget.id", ondelete="CASCADE"), nullable=False)
)
pattern: str
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column("createdAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
user: Optional["User"] = Relationship(back_populates="budget_rules")
budget: Optional["Budget"] = Relationship(back_populates="rules")
class TransferRule(SQLModel, table=True):
__tablename__ = "TransferRule"
__table_args__ = (Index("TransferRule_userId_idx", "userId"),)
id: str = Field(default_factory=new_id, primary_key=True)
user_id: str = Field(
sa_column=Column("userId", String, ForeignKey("User.id", ondelete="CASCADE"), nullable=False)
)
pattern: str
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column("createdAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)
user: Optional["User"] = Relationship(back_populates="transfer_rules")
class BalanceSnapshot(SQLModel, table=True):
__tablename__ = "BalanceSnapshot"
__table_args__ = (
UniqueConstraint("accountId", "year", "month", name="BalanceSnapshot_accountId_year_month_key"),
Index("BalanceSnapshot_year_month_idx", "year", "month"),
)
id: str = Field(default_factory=new_id, primary_key=True)
account_id: str = Field(sa_column=Column("accountId", String, nullable=False))
year: int
month: int
balance_cents: int = Field(sa_column=Column("balanceCents", Integer, nullable=False))
computed_at: Optional[datetime] = Field(
default=None,
sa_column=Column("computedAt", DateTime(timezone=True), nullable=False, server_default=func.now()),
)

View File

View File

@@ -0,0 +1,161 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from fastapi.encoders import jsonable_encoder
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Account, AccountType, CsvUpload, User
from ..schemas.account import AccountCreate, AccountResponse, AccountUpdate
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
def _serialize(account: Account) -> dict:
return AccountResponse.model_validate(account).model_dump(by_alias=True)
@router.get("")
def list_accounts(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
accounts = session.exec(
select(Account).where(Account.user_id == current_user.id).order_by(Account.created_at)
).all()
return JSONResponse(content=jsonable_encoder([_serialize(a) for a in accounts]))
@router.post("", status_code=201)
def create_account(
body: AccountCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
try:
account_type = AccountType(body.type)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid account type: {body.type}")
account = Account(
user_id=current_user.id,
name=body.name,
institution=body.institution,
type=account_type,
currency=body.currency,
)
session.add(account)
session.commit()
session.refresh(account)
return JSONResponse(content=jsonable_encoder(_serialize(account)), status_code=201)
@router.get("/{account_id}")
def get_account(
account_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
account = session.exec(
select(Account).where(Account.id == account_id, Account.user_id == current_user.id)
).first()
if not account:
raise HTTPException(status_code=404, detail="Not found")
return JSONResponse(content=jsonable_encoder(_serialize(account)))
@router.patch("/{account_id}")
def update_account(
account_id: str,
body: AccountUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
account = session.exec(
select(Account).where(Account.id == account_id, Account.user_id == current_user.id)
).first()
if not account:
raise HTTPException(status_code=404, detail="Not found")
updates = body.model_dump(exclude_unset=True, by_alias=False)
for field, value in updates.items():
setattr(account, field, value)
account.updated_at = datetime.utcnow()
session.add(account)
session.commit()
session.refresh(account)
return JSONResponse(content=jsonable_encoder(_serialize(account)))
@router.post("/{account_id}/record-value")
def record_investment_value(
account_id: str,
body: dict,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
from datetime import datetime as dt, timezone
account = session.exec(
select(Account).where(Account.id == account_id, Account.user_id == current_user.id, Account.type == AccountType.INVESTMENT)
).first()
if not account:
raise HTTPException(status_code=404, detail="Investment account not found")
value_cents = body.get("valueCents")
date_str = body.get("date", "")
if value_cents is None or not isinstance(value_cents, int):
raise HTTPException(status_code=400, detail="valueCents must be an integer")
if not date_str or len(date_str) != 10:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
try:
d = dt.fromisoformat(date_str + "T12:00:00")
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
year, month = d.year, d.month
account.current_balance_cents = value_cents
account.updated_at = dt.utcnow()
session.add(account)
from ..models import BalanceSnapshot
existing = session.exec(
select(BalanceSnapshot).where(
BalanceSnapshot.account_id == account_id,
BalanceSnapshot.year == year,
BalanceSnapshot.month == month,
)
).first()
if existing:
existing.balance_cents = value_cents
existing.computed_at = dt.utcnow()
session.add(existing)
else:
session.add(BalanceSnapshot(account_id=account_id, year=year, month=month, balance_cents=value_cents))
session.commit()
return JSONResponse(content={"ok": True})
@router.delete("/{account_id}", status_code=204)
def delete_account(
account_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
account = session.exec(
select(Account).where(Account.id == account_id, Account.user_id == current_user.id)
).first()
if not account:
raise HTTPException(status_code=404, detail="Not found")
# CsvUpload rows cascade via FK, but delete explicitly to match TS behavior
uploads = session.exec(select(CsvUpload).where(CsvUpload.account_id == account_id)).all()
for upload in uploads:
session.delete(upload)
session.delete(account)
session.commit()

View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter, Depends
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Account, User
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.post("/recalculate-balances")
def recalculate_balances(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
accounts = session.exec(
select(Account).where(Account.user_id == current_user.id)
).all()
for account in accounts:
result = session.exec(
text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :account_id AND type != \'TRANSFER\''
).bindparams(account_id=account.id)
).first()
account.current_balance_cents = int(result[0]) if result else 0
session.add(account)
session.commit()
# Remove snapshots for accounts that have no transactions
session.exec(
text(
'DELETE FROM "BalanceSnapshot" WHERE "accountId" IN '
'(SELECT id FROM "Account" WHERE "userId" = :user_id) '
'AND NOT EXISTS (SELECT 1 FROM "Transaction" WHERE "accountId" = "BalanceSnapshot"."accountId")'
).bindparams(user_id=current_user.id)
)
session.commit()
return JSONResponse(content=jsonable_encoder({"ok": True, "accounts": len(accounts)}))

View File

@@ -0,0 +1,50 @@
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from sqlmodel import Session, select
from ..auth import create_access_token, verify_password
from ..database import get_session
from ..deps import get_current_user
from ..models import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, response: Response, session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.email == body.email)).first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token(user.id, user.email)
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=True,
samesite="strict",
max_age=3600,
)
return TokenResponse(access_token=token)
@router.post("/logout")
def logout(response: Response):
response.delete_cookie("auth_token")
return {"ok": True}
@router.get("/me")
def me_endpoint(current_user: User = Depends(get_current_user)):
return {"id": current_user.id, "email": current_user.email}

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Budget, BudgetRule, User
router = APIRouter(prefix="/api/budget-rules", tags=["budget-rules"])
class BudgetRuleCreate(BaseModel):
budget_id: str
pattern: str
model_config = {"alias_generator": lambda s: "".join(w.capitalize() if i else w for i, w in enumerate(s.split("_"))), "populate_by_name": True}
@router.get("")
def list_budget_rules(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
rules = session.exec(
select(BudgetRule).where(BudgetRule.user_id == current_user.id).order_by(BudgetRule.created_at)
).all()
return JSONResponse(content=jsonable_encoder([
{"id": r.id, "budgetId": r.budget_id, "pattern": r.pattern} for r in rules
]))
@router.post("", status_code=201)
def create_budget_rule(
body: BudgetRuleCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
if not body.pattern.strip():
raise HTTPException(status_code=400, detail="pattern is required")
if len(body.pattern) > 200:
raise HTTPException(status_code=400, detail="pattern must be ≤ 200 characters")
budget = session.exec(
select(Budget).where(Budget.id == body.budget_id, Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
rule = BudgetRule(
user_id=current_user.id,
budget_id=body.budget_id,
pattern=body.pattern.strip(),
)
session.add(rule)
session.commit()
session.refresh(rule)
return JSONResponse(
content=jsonable_encoder({"id": rule.id, "budgetId": rule.budget_id, "pattern": rule.pattern}),
status_code=201,
)
@router.delete("/{rule_id}", status_code=204)
def delete_budget_rule(
rule_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
rule = session.exec(
select(BudgetRule).where(BudgetRule.id == rule_id, BudgetRule.user_id == current_user.id)
).first()
if not rule:
raise HTTPException(status_code=404, detail="Not found")
session.delete(rule)
session.commit()

View File

@@ -0,0 +1,102 @@
import re
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Budget, User
from ..schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
router = APIRouter(prefix="/api/budgets", tags=["budgets"])
HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
def _serialize(budget: Budget) -> dict:
return BudgetResponse.model_validate(budget).model_dump(by_alias=True)
def _validate_color(color: str | None) -> None:
if color and not HEX_COLOR.match(color):
raise HTTPException(status_code=400, detail="color must be a 6-digit hex string e.g. #6366f1")
@router.get("")
def list_budgets(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
budgets = session.exec(
select(Budget).where(Budget.user_id == current_user.id).order_by(Budget.created_at)
).all()
return JSONResponse(content=jsonable_encoder([_serialize(b) for b in budgets]))
@router.post("", status_code=201)
def create_budget(
body: BudgetCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
_validate_color(body.color)
if body.limit_cents is not None and body.limit_cents <= 0:
raise HTTPException(status_code=400, detail="limitCents must be positive")
budget = Budget(
user_id=current_user.id,
name=body.name,
limit_cents=body.limit_cents,
color=body.color,
)
session.add(budget)
session.commit()
session.refresh(budget)
return JSONResponse(content=jsonable_encoder(_serialize(budget)), status_code=201)
@router.patch("/{budget_id}")
def update_budget(
budget_id: str,
body: BudgetUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
budget = session.exec(
select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Not found")
updates = body.model_dump(exclude_unset=True, by_alias=False)
if "color" in updates:
_validate_color(updates["color"])
if "limit_cents" in updates and updates["limit_cents"] is not None and updates["limit_cents"] <= 0:
raise HTTPException(status_code=400, detail="limitCents must be positive")
for field, value in updates.items():
setattr(budget, field, value)
budget.updated_at = datetime.utcnow()
session.add(budget)
session.commit()
session.refresh(budget)
return JSONResponse(content=jsonable_encoder(_serialize(budget)))
@router.delete("/{budget_id}", status_code=204)
def delete_budget(
budget_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
budget = session.exec(
select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Not found")
# ON DELETE SET NULL on Transaction.budgetId handled by the DB FK
session.delete(budget)
session.commit()

View File

@@ -0,0 +1,170 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Account, Budget, Transaction, User
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _month_bounds(year: int, month: int) -> tuple[datetime, datetime]:
start = datetime(year, month, 1)
end = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1)) - timedelta(milliseconds=1)
return start, end
def _month_label(year: int, month: int) -> str:
return datetime(year, month, 1).strftime("%B %Y")
@router.get("")
def get_dashboard(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
month: int = Query(default=0, ge=0, le=12),
year: int = Query(default=0, ge=0),
):
now = datetime.utcnow()
resolved_month = month or now.month
resolved_year = year or now.year
is_current_month = resolved_month == now.month and resolved_year == now.year
start, end = _month_bounds(resolved_year, resolved_month)
user_id = current_user.id
# Cash flow (BANK accounts only, TRANSFER excluded)
cf_rows = session.exec(text(
'SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type = \'BANK\' AND t.type != \'TRANSFER\' '
'AND t.date >= :start AND t.date <= :end GROUP BY t.type'
).bindparams(uid=user_id, start=start, end=end)).all()
cf_map = {r[0]: int(r[1]) for r in cf_rows}
credits_cents = cf_map.get("CREDIT", 0)
debits_cents = cf_map.get("DEBIT", 0)
# Budgets with spend for selected month
budgets = session.exec(
select(Budget).where(Budget.user_id == user_id, Budget.is_active == True).order_by(Budget.name)
).all()
spend_rows = session.exec(text(
'SELECT t."budgetId", COALESCE(SUM(CASE WHEN t.type = \'DEBIT\' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t."budgetId" IS NOT NULL '
'AND t.date >= :start AND t.date <= :end GROUP BY t."budgetId"'
).bindparams(uid=user_id, start=start, end=end)).all()
spend_map = {r[0]: int(r[1]) for r in spend_rows}
# Recent transactions for selected month
recent_tx = session.exec(
text(
'SELECT t.id, t.date, t.description, t."amountCents", t.type, a.name AS account_name '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t.date >= :start AND t.date <= :end '
'ORDER BY t.date DESC LIMIT 5'
).bindparams(uid=user_id, start=start, end=end)
).all()
# Net worth
if is_current_month:
accounts = session.exec(
select(Account).where(
Account.user_id == user_id,
Account.is_active == True,
Account.type.in_(["BANK", "INVESTMENT"]),
).order_by(Account.name)
).all()
net_worth_accounts = [
{"id": a.id, "name": a.name, "balanceCents": a.current_balance_cents} for a in accounts
]
else:
snap_rows = session.exec(text(
'SELECT bs."accountId", a.name, bs."balanceCents" '
'FROM "BalanceSnapshot" bs JOIN "Account" a ON bs."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type IN (\'BANK\', \'INVESTMENT\') '
'AND bs.year = :year AND bs.month = :month'
).bindparams(uid=user_id, year=resolved_year, month=resolved_month)).all()
net_worth_accounts = [
{"id": r[0], "name": r[1], "balanceCents": int(r[2])} for r in snap_rows
]
net_worth_cents = sum(a["balanceCents"] for a in net_worth_accounts)
return JSONResponse(content=jsonable_encoder({
"monthLabel": _month_label(resolved_year, resolved_month),
"netWorthCents": net_worth_cents,
"bankAccounts": net_worth_accounts,
"cashFlow": {
"creditsCents": credits_cents,
"debitsCents": debits_cents,
"netCents": credits_cents - debits_cents,
},
"budgets": [
{
"id": b.id, "name": b.name, "limitCents": b.limit_cents,
"color": b.color, "spendCents": spend_map.get(b.id, 0),
}
for b in budgets
],
"recentTransactions": [
{
"id": r[0], "date": r[1].isoformat(), "description": r[2],
"amountCents": r[3], "type": r[4], "accountName": r[5],
}
for r in recent_tx
],
}))
@router.get("/budgets")
def get_budget_summary(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
month: int = Query(default=0, ge=0, le=12),
year: int = Query(default=0, ge=0),
):
"""Budgets with spend and rules for a given month — used by the Budgets page."""
from ..models import BudgetRule
now = datetime.utcnow()
resolved_month = month or now.month
resolved_year = year or now.year
start, end = _month_bounds(resolved_year, resolved_month)
user_id = current_user.id
budgets = session.exec(
select(Budget).where(Budget.user_id == user_id).order_by(Budget.created_at)
).all()
spend_rows = session.exec(text(
'SELECT t."budgetId", COALESCE(SUM(CASE WHEN t.type = \'DEBIT\' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t."budgetId" IS NOT NULL '
'AND t.date >= :start AND t.date <= :end GROUP BY t."budgetId"'
).bindparams(uid=user_id, start=start, end=end)).all()
spend_map = {r[0]: int(r[1]) for r in spend_rows}
all_rules = session.exec(
select(BudgetRule).where(BudgetRule.user_id == user_id).order_by(BudgetRule.created_at)
).all()
rules_map: dict[str, list] = {}
for rule in all_rules:
rules_map.setdefault(rule.budget_id, []).append({"id": rule.id, "pattern": rule.pattern})
return JSONResponse(content=jsonable_encoder([
{
"id": b.id, "name": b.name, "limitCents": b.limit_cents,
"color": b.color, "isActive": b.is_active,
"spendCents": spend_map.get(b.id, 0),
"rules": rules_map.get(b.id, []),
}
for b in budgets
]))

View File

@@ -0,0 +1,128 @@
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Budget, User
router = APIRouter(prefix="/api/graphs", tags=["graphs"])
def _month_bounds(year: int, month: int) -> tuple[datetime, datetime]:
start = datetime(year, month, 1)
end = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1)) - timedelta(milliseconds=1)
return start, end
def _format_year_month(year: int, month: int) -> str:
return datetime(year, month, 1).strftime("%b %y") # "Jan 25"
def _last_n_months_start(n: int) -> datetime:
now = datetime.utcnow()
month = now.month - (n - 1)
year = now.year
while month <= 0:
month += 12
year -= 1
return datetime(year, month, 1)
@router.get("")
def get_graphs(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
now = datetime.utcnow()
month_start, month_end = _month_bounds(now.year, now.month)
six_months_ago = _last_n_months_start(6)
user_id = current_user.id
# Net worth trend (all time, BANK accounts from BalanceSnapshot, last 24 points)
nw_rows = session.exec(text(
'SELECT bs.year, bs.month, COALESCE(SUM(bs."balanceCents"), 0)::bigint AS total '
'FROM "BalanceSnapshot" bs JOIN "Account" a ON bs."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type = \'BANK\' '
'GROUP BY bs.year, bs.month ORDER BY bs.year, bs.month'
).bindparams(uid=user_id)).all()
net_worth_data = [
{"label": _format_year_month(r[0], r[1]), "totalCents": int(r[2])}
for r in nw_rows[-24:]
]
# Cash flow trend (last 6 months, BANK only)
cf_rows = session.exec(text(
'SELECT EXTRACT(YEAR FROM t.date)::int AS year, EXTRACT(MONTH FROM t.date)::int AS month, '
't.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND a.type = \'BANK\' AND t.date >= :six_ago '
'GROUP BY year, month, t.type ORDER BY year, month'
).bindparams(uid=user_id, six_ago=six_months_ago)).all()
cf_map: dict[str, dict] = {}
for r in cf_rows:
key = f"{r[0]}-{r[1]}"
if key not in cf_map:
cf_map[key] = {"label": _format_year_month(r[0], r[1]), "creditsCents": 0, "debitsCents": 0}
if r[2] == "CREDIT":
cf_map[key]["creditsCents"] = int(r[3])
else:
cf_map[key]["debitsCents"] = int(r[3])
cash_flow_data = list(cf_map.values())
# Monthly spending (last 6 months, all accounts, DEBIT only)
spend_rows = session.exec(text(
'SELECT EXTRACT(YEAR FROM t.date)::int AS year, EXTRACT(MONTH FROM t.date)::int AS month, '
'COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t.type = \'DEBIT\' AND t.date >= :six_ago '
'GROUP BY year, month ORDER BY year, month'
).bindparams(uid=user_id, six_ago=six_months_ago)).all()
monthly_spend_data = [
{"label": _format_year_month(r[0], r[1]), "totalCents": int(r[2])} for r in spend_rows
]
# Category breakdown (current month, all accounts, DEBIT)
cat_rows = session.exec(text(
'SELECT COALESCE(t.category, \'Uncategorized\') AS category, '
'COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t.type = \'DEBIT\' '
'AND t.date >= :start AND t.date <= :end '
'GROUP BY category ORDER BY total DESC'
).bindparams(uid=user_id, start=month_start, end=month_end)).all()
category_data = [{"category": r[0], "totalCents": int(r[1])} for r in cat_rows]
# Budget performance (current month, DEBIT)
budgets = session.exec(
select(Budget).where(Budget.user_id == user_id, Budget.is_active == True).order_by(Budget.name)
).all()
budget_spend_rows = session.exec(text(
'SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total '
'FROM "Transaction" t JOIN "Account" a ON t."accountId" = a.id '
'WHERE a."userId" = :uid AND t."budgetId" IS NOT NULL AND t.type = \'DEBIT\' '
'AND t.date >= :start AND t.date <= :end GROUP BY t."budgetId"'
).bindparams(uid=user_id, start=month_start, end=month_end)).all()
budget_spend_map = {r[0]: int(r[1]) for r in budget_spend_rows}
budget_data = [
{
"name": b.name,
"spendCents": budget_spend_map.get(b.id, 0),
"limitCents": b.limit_cents or 0,
"color": b.color,
}
for b in budgets
]
return JSONResponse(content=jsonable_encoder({
"netWorthData": net_worth_data,
"cashFlowData": cash_flow_data,
"monthlySpendData": monthly_spend_data,
"categoryData": category_data,
"budgetData": budget_data,
}))

View File

@@ -0,0 +1,244 @@
import math
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import Account, Budget, Transaction, TransactionType, User
from ..schemas.transaction import (
AccountRef,
BudgetRef,
TransactionResponse,
TransactionUpdate,
)
router = APIRouter(prefix="/api/transactions", tags=["transactions"])
def _serialize(tx: Transaction, account: Optional[Account] = None, budget: Optional[Budget] = None) -> dict:
data = TransactionResponse.model_validate(tx)
if account:
data.account = AccountRef(name=account.name, type=account.type)
if budget:
data.budget = BudgetRef(id=budget.id, name=budget.name, color=budget.color)
return data.model_dump(by_alias=True)
@router.get("")
def list_transactions(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
account_id: Optional[str] = Query(default=None, alias="accountId"),
date_from: Optional[str] = Query(default=None, alias="dateFrom"),
date_to: Optional[str] = Query(default=None, alias="dateTo"),
type: Optional[str] = Query(default=None),
search: Optional[str] = Query(default=None),
budget_id: Optional[str] = Query(default=None, alias="budgetId"),
page: int = Query(default=1, ge=1),
limit: int = Query(default=50, ge=1, le=100),
):
stmt = (
select(Transaction)
.join(Account, Transaction.account_id == Account.id)
.where(Account.user_id == current_user.id)
)
if account_id:
stmt = stmt.where(Transaction.account_id == account_id)
if type:
try:
stmt = stmt.where(Transaction.type == TransactionType(type))
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid type: {type}")
if budget_id is not None:
stmt = stmt.where(Transaction.budget_id == (None if budget_id == "" else budget_id))
if search:
stmt = stmt.where(Transaction.description.ilike(f"%{search}%"))
if date_from:
stmt = stmt.where(Transaction.date >= datetime.fromisoformat(date_from))
if date_to:
stmt = stmt.where(Transaction.date <= datetime.fromisoformat(date_to + "T23:59:59.999"))
count_stmt = select(Transaction.id).join(Account, Transaction.account_id == Account.id)
if account_id:
count_stmt = count_stmt.where(Transaction.account_id == account_id)
if type:
count_stmt = count_stmt.where(Transaction.type == TransactionType(type))
if budget_id is not None:
count_stmt = count_stmt.where(Transaction.budget_id == (None if budget_id == "" else budget_id))
if search:
count_stmt = count_stmt.where(Transaction.description.ilike(f"%{search}%"))
if date_from:
count_stmt = count_stmt.where(Transaction.date >= datetime.fromisoformat(date_from))
if date_to:
count_stmt = count_stmt.where(Transaction.date <= datetime.fromisoformat(date_to + "T23:59:59.999"))
count_stmt = count_stmt.where(Account.user_id == current_user.id)
total = len(session.exec(count_stmt).all())
stmt = stmt.order_by(Transaction.date.desc()).offset((page - 1) * limit).limit(limit)
transactions = session.exec(stmt).all()
account_ids = {tx.account_id for tx in transactions}
budget_ids = {tx.budget_id for tx in transactions if tx.budget_id}
accounts = {a.id: a for a in session.exec(select(Account).where(Account.id.in_(account_ids))).all()}
budgets = {b.id: b for b in session.exec(select(Budget).where(Budget.id.in_(budget_ids))).all()}
rows = [_serialize(tx, accounts.get(tx.account_id), budgets.get(tx.budget_id)) for tx in transactions]
result = {
"transactions": rows,
"total": total,
"page": page,
"limit": limit,
"totalPages": math.ceil(total / limit),
}
return JSONResponse(content=jsonable_encoder(result))
@router.post("/bulk")
def bulk_transactions(
body: dict,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
action = body.get("action")
ids: list[str] = body.get("ids", [])
if not ids or action not in ("delete", "assignBudget", "addNotes", "markTransfer"):
raise HTTPException(status_code=400, detail="Invalid bulk request")
# Verify ownership
owned = session.exec(
select(Transaction).join(Account, Transaction.account_id == Account.id).where(
Transaction.id.in_(ids), Account.user_id == current_user.id
)
).all()
if len(owned) != len(ids):
raise HTTPException(status_code=404, detail="One or more transactions not found")
if action == "delete":
affected_accounts = {tx.account_id for tx in owned}
affected_months = {(tx.account_id, tx.date.year, tx.date.month) for tx in owned}
for tx in owned:
session.delete(tx)
session.commit()
for account_id in affected_accounts:
_recompute_balance(session, account_id)
for account_id, year, month in affected_months:
_recompute_snapshot(session, account_id, year, month)
session.commit()
return JSONResponse(content={"deleted": len(ids)})
if action == "assignBudget":
budget_id = body.get("budgetId")
if budget_id:
budget = session.exec(
select(Budget).where(Budget.id == budget_id, Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
for tx in owned:
tx.budget_id = budget_id
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
return JSONResponse(content={"updated": len(ids)})
if action == "addNotes":
notes = body.get("notes") or None
for tx in owned:
tx.notes = notes
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
return JSONResponse(content={"updated": len(ids)})
# markTransfer
for tx in owned:
tx.type = TransactionType.TRANSFER
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
return JSONResponse(content={"updated": len(ids)})
def _recompute_balance(session, account_id: str) -> None:
from sqlalchemy import text as sa_text
result = session.exec(
sa_text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :aid AND type != \'TRANSFER\''
).bindparams(aid=account_id)
).first()
account = session.get(Account, account_id)
if account:
account.current_balance_cents = int(result[0]) if result else 0
account.updated_at = datetime.utcnow()
session.add(account)
def _recompute_snapshot(session, account_id: str, year: int, month: int) -> None:
from sqlalchemy import text as sa_text
from ..models import BalanceSnapshot
end = (datetime(year + 1, 1, 1) if month == 12 else datetime(year, month + 1, 1))
result = session.exec(
sa_text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :aid AND date < :end AND type != \'TRANSFER\''
).bindparams(aid=account_id, end=end)
).first()
balance = int(result[0]) if result else 0
existing = session.exec(
select(BalanceSnapshot).where(
BalanceSnapshot.account_id == account_id,
BalanceSnapshot.year == year,
BalanceSnapshot.month == month,
)
).first()
if balance == 0:
if existing:
session.delete(existing)
elif existing:
existing.balance_cents = balance
existing.computed_at = datetime.utcnow()
session.add(existing)
else:
session.add(BalanceSnapshot(account_id=account_id, year=year, month=month, balance_cents=balance))
@router.patch("/{transaction_id}")
def update_transaction(
transaction_id: str,
body: TransactionUpdate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
tx = session.exec(
select(Transaction)
.join(Account, Transaction.account_id == Account.id)
.where(Transaction.id == transaction_id, Account.user_id == current_user.id)
).first()
if not tx:
raise HTTPException(status_code=404, detail="Not found")
updates = body.model_dump(exclude_unset=True, by_alias=False)
if "budget_id" in updates and updates["budget_id"]:
budget = session.exec(
select(Budget).where(Budget.id == updates["budget_id"], Budget.user_id == current_user.id)
).first()
if not budget:
raise HTTPException(status_code=404, detail="Budget not found")
for field, value in updates.items():
setattr(tx, field, value)
tx.updated_at = datetime.utcnow()
session.add(tx)
session.commit()
session.refresh(tx)
return JSONResponse(content=jsonable_encoder(_serialize(tx)))

View File

@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from ..database import get_session
from ..deps import get_current_user
from ..models import TransferRule, User
router = APIRouter(prefix="/api/transfer-rules", tags=["transfer-rules"])
class TransferRuleCreate(BaseModel):
pattern: str
@router.get("")
def list_transfer_rules(
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
rules = session.exec(
select(TransferRule).where(TransferRule.user_id == current_user.id).order_by(TransferRule.created_at)
).all()
return JSONResponse(content=jsonable_encoder([{"id": r.id, "pattern": r.pattern} for r in rules]))
@router.post("", status_code=201)
def create_transfer_rule(
body: TransferRuleCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
pattern = body.pattern.strip()
if not pattern:
raise HTTPException(status_code=400, detail="pattern is required")
if len(pattern) > 200:
raise HTTPException(status_code=400, detail="pattern must be ≤ 200 characters")
rule = TransferRule(user_id=current_user.id, pattern=pattern)
session.add(rule)
session.commit()
session.refresh(rule)
return JSONResponse(content=jsonable_encoder({"id": rule.id, "pattern": rule.pattern}), status_code=201)
@router.delete("/{rule_id}", status_code=204)
def delete_transfer_rule(
rule_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
rule = session.exec(
select(TransferRule).where(TransferRule.id == rule_id, TransferRule.user_id == current_user.id)
).first()
if not rule:
raise HTTPException(status_code=404, detail="Not found")
session.delete(rule)
session.commit()

View File

@@ -0,0 +1,226 @@
import json
from dataclasses import replace as dc_replace
from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from sqlalchemy import text
from sqlmodel import Session, select
from ..csv.bank_profiles import BankProfile, detect_profile
from ..csv.normalizer import normalize_rows
from ..csv.parser import parse_csv_content
from ..database import get_session
from ..deps import get_current_user
from ..models import (
Account,
AccountType,
BalanceSnapshot,
BudgetRule,
CsvUpload,
Transaction,
TransactionType,
TransferRule,
User,
)
from ..schemas.upload import ColumnMapping
router = APIRouter(prefix="/api/upload", tags=["upload"])
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
VALID_MIMES = {"text/csv", "text/plain", "application/vnd.ms-excel", "application/csv", ""}
@router.post("")
async def upload_csv(
file: UploadFile = File(...),
account_id: str = Form(..., alias="accountId"),
column_mapping: str | None = Form(default=None, alias="columnMapping"),
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
# --- Validate file ---
raw = await file.read()
if len(raw) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
if not (file.filename or "").lower().endswith(".csv"):
raise HTTPException(status_code=400, detail="File must be a .csv file")
if file.content_type and file.content_type not in VALID_MIMES:
raise HTTPException(status_code=400, detail="Invalid file type")
account = session.exec(
select(Account).where(Account.id == account_id, Account.user_id == current_user.id)
).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
content = raw.decode("utf-8-sig") # strip BOM if present
headers, all_rows = parse_csv_content(content)
detected = detect_profile(headers)
if not detected and not column_mapping:
return JSONResponse(content={
"requiresMapping": True,
"headers": headers,
"sampleRows": all_rows[:5],
})
if detected and not column_mapping:
config = detected
else:
try:
mapping_data = json.loads(column_mapping)
except (json.JSONDecodeError, TypeError):
raise HTTPException(status_code=400, detail="Invalid columnMapping JSON")
config = ColumnMapping.model_validate(mapping_data)
# For all BANK accounts using Strategy A, positive = CREDIT (deposit)
if account.type in (AccountType.BANK, AccountType.INVESTMENT) and getattr(config, "strategy", None) == "A":
if isinstance(config, BankProfile):
config = dc_replace(config, invert_amount_sign=True)
else:
config = config.model_copy(update={"invert_amount_sign": True})
normalized = normalize_rows(all_rows, config)
# Apply transfer rules (takes priority), then budget rules
budget_rules = session.exec(
select(BudgetRule).where(BudgetRule.user_id == current_user.id).order_by(BudgetRule.created_at)
).all()
transfer_rules = session.exec(
select(TransferRule).where(TransferRule.user_id == current_user.id).order_by(TransferRule.created_at)
).all()
for row in normalized:
desc_lower = row.description.lower()
matched_transfer = any(tr.pattern.lower() in desc_lower for tr in transfer_rules)
if matched_transfer:
row.type = "TRANSFER"
row.budget_id = None
continue
for rule in budget_rules:
if rule.pattern.lower() in desc_lower:
row.budget_id = rule.budget_id
break
# Create upload record
upload = CsvUpload(
account_id=account_id,
file_name=file.filename or "upload.csv",
row_count=len(all_rows),
imported_count=0,
skipped_count=0,
status="PENDING",
)
session.add(upload)
session.commit()
session.refresh(upload)
try:
tx_objects = [
Transaction(
account_id=account_id,
upload_id=upload.id,
date=row.date,
description=row.description,
amount_cents=row.amount_cents,
type=TransactionType(row.type),
category=row.category,
budget_id=row.budget_id,
)
for row in normalized
]
for tx in tx_objects:
session.add(tx)
try:
session.flush()
except Exception:
session.rollback()
# Re-add excluding any that would violate constraints
session.add(upload)
imported_count = 0
for tx in tx_objects:
try:
session.add(tx)
session.flush()
imported_count += 1
except Exception:
session.rollback()
else:
imported_count = len(tx_objects)
session.commit()
skipped_count = len(normalized) - imported_count
# Recompute current balance from all transactions
bal_result = session.exec(
text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :account_id AND type != \'TRANSFER\''
).bindparams(account_id=account_id)
).first()
account.current_balance_cents = int(bal_result[0]) if bal_result else 0
account.updated_at = datetime.utcnow()
session.add(account)
session.commit()
# Upsert BalanceSnapshot for each affected month
seen_months: dict[str, tuple[int, int]] = {}
for row in normalized:
key = f"{row.date.year}-{row.date.month}"
seen_months[key] = (row.date.year, row.date.month)
for year, month in seen_months.values():
end_of_month = datetime(year, month + 1, 1) if month < 12 else datetime(year + 1, 1, 1)
snap_result = session.exec(
text(
'SELECT COALESCE(SUM(CASE WHEN type = \'CREDIT\' THEN "amountCents" ELSE -"amountCents" END), 0)::bigint '
'FROM "Transaction" WHERE "accountId" = :account_id AND date < :end AND type != \'TRANSFER\''
).bindparams(account_id=account_id, end=end_of_month)
).first()
snap_balance = int(snap_result[0]) if snap_result else 0
existing = session.exec(
select(BalanceSnapshot).where(
BalanceSnapshot.account_id == account_id,
BalanceSnapshot.year == year,
BalanceSnapshot.month == month,
)
).first()
if existing:
existing.balance_cents = snap_balance
existing.computed_at = datetime.utcnow()
session.add(existing)
else:
session.add(BalanceSnapshot(
account_id=account_id,
year=year,
month=month,
balance_cents=snap_balance,
))
session.commit()
status = "FAILED" if imported_count == 0 else ("PARTIAL" if skipped_count > 0 else "SUCCESS")
upload.imported_count = imported_count
upload.skipped_count = skipped_count
upload.status = status
session.add(upload)
session.commit()
return JSONResponse(content=jsonable_encoder({
"success": True,
"detected": detected.name if detected else None,
"importedCount": imported_count,
"skippedCount": skipped_count,
"fileName": file.filename,
}))
except Exception as exc:
session.rollback()
upload.status = "FAILED"
upload.error_message = str(exc)
session.add(upload)
session.commit()
raise HTTPException(status_code=500, detail="Import failed")

View File

View File

@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Optional
from .base import CamelModel
class AccountCreate(CamelModel):
name: str
institution: Optional[str] = None
type: str # BANK | CREDIT_CARD | INVESTMENT
currency: str = "USD"
class AccountUpdate(CamelModel):
name: Optional[str] = None
institution: Optional[str] = None
is_active: Optional[bool] = None
class AccountResponse(CamelModel):
id: str
user_id: str
name: str
institution: Optional[str]
type: str
currency: str
current_balance_cents: int
is_active: bool
created_at: Optional[datetime]
updated_at: Optional[datetime]

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class CamelModel(BaseModel):
"""Base for all request/response schemas. Accepts snake_case input and emits camelCase JSON."""
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=True,
from_attributes=True,
)

View File

@@ -0,0 +1,28 @@
from datetime import datetime
from typing import Optional
from .base import CamelModel
class BudgetCreate(CamelModel):
name: str
limit_cents: Optional[int] = None
color: Optional[str] = None
class BudgetUpdate(CamelModel):
name: Optional[str] = None
limit_cents: Optional[int] = None
color: Optional[str] = None
is_active: Optional[bool] = None
class BudgetResponse(CamelModel):
id: str
user_id: str
name: str
limit_cents: Optional[int]
color: Optional[str]
is_active: bool
created_at: Optional[datetime]
updated_at: Optional[datetime]

View File

@@ -0,0 +1,46 @@
from datetime import datetime
from typing import Optional
from .base import CamelModel
class TransactionUpdate(CamelModel):
notes: Optional[str] = None
category: Optional[str] = None
budget_id: Optional[str] = None
class BudgetRef(CamelModel):
id: str
name: str
color: Optional[str]
class AccountRef(CamelModel):
name: str
type: str
class TransactionResponse(CamelModel):
id: str
account_id: str
upload_id: Optional[str]
budget_id: Optional[str]
date: datetime
description: str
amount_cents: int
type: str
category: Optional[str]
notes: Optional[str]
created_at: Optional[datetime]
updated_at: Optional[datetime]
account: Optional[AccountRef] = None
budget: Optional[BudgetRef] = None
class TransactionListResponse(CamelModel):
transactions: list[TransactionResponse]
total: int
page: int
limit: int
total_pages: int

View File

@@ -0,0 +1,27 @@
from typing import Optional
from .base import CamelModel
class ColumnMapping(CamelModel):
strategy: str # A | B
date_column: str
description_column: str
amount_column: Optional[str] = None
invert_amount_sign: Optional[bool] = None
debit_column: Optional[str] = None
credit_column: Optional[str] = None
class UploadResult(CamelModel):
success: bool
detected: Optional[str]
imported_count: int
skipped_count: int
file_name: str
class MappingRequired(CamelModel):
requires_mapping: bool = True
headers: list[str]
sample_rows: list[dict]

12
backend/requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
sqlmodel>=0.0.21
sqlalchemy>=2.0.36
psycopg2-binary>=2.9.0
alembic>=1.14.0
python-jose[cryptography]>=3.3.0
passlib[bcrypt]>=1.7.4
python-multipart>=0.0.12
pydantic>=2.0.0
pydantic-settings>=2.0.0
python-dateutil>=2.9.0

24
backend/seed.py Normal file
View File

@@ -0,0 +1,24 @@
"""Seed the database with an initial user. Run with: python seed.py"""
import os
from sqlmodel import Session, create_engine, select
from app.auth import hash_password
from app.models import User
DATABASE_URL = os.environ["DATABASE_URL"]
SEED_EMAIL = os.environ["SEED_EMAIL"]
SEED_PASSWORD = os.environ["SEED_PASSWORD"]
engine = create_engine(DATABASE_URL)
with Session(engine) as session:
existing = session.exec(select(User).where(User.email == SEED_EMAIL)).first()
if existing:
print(f"User {SEED_EMAIL} already exists — skipping.")
else:
user = User(email=SEED_EMAIL, password_hash=hash_password(SEED_PASSWORD))
session.add(user)
session.commit()
print(f"Created user: {SEED_EMAIL}")

View File

@@ -16,17 +16,31 @@ services:
timeout: 5s
retries: 5
backend:
build: ./backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
app:
build: .
restart: unless-stopped
ports:
- "0.0.0.0:3000:3000"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
BACKEND_URL: http://backend:8000
depends_on:
db:
backend:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
@@ -35,16 +49,15 @@ services:
retries: 3
start_period: 60s
# First-run setup: applies the schema and seeds the user.
# Uses the builder stage so all dev tools (prisma CLI, tsx, bcryptjs) are available.
# Run once with: docker compose --profile setup run --rm setup
# Run once on first deploy: stamps Alembic baseline then seeds the user.
# docker compose --profile setup run --rm setup
setup:
build:
context: .
target: builder
command: sh -c "npx prisma db push && npx prisma db seed"
context: ./backend
command: sh -c "alembic stamp head && python seed.py"
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
JWT_SECRET_KEY: ${JWT_SECRET_KEY}
SEED_EMAIL: ${SEED_EMAIL}
SEED_PASSWORD: ${SEED_PASSWORD}
depends_on:

View File

@@ -1,5 +1,7 @@
import type { NextConfig } from 'next'
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://backend:8000'
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
@@ -24,9 +26,24 @@ const securityHeaders = [
const nextConfig: NextConfig = {
output: 'standalone',
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }]
},
// Proxy all /api/* calls to the FastAPI backend.
// afterFiles runs after Next.js file-system routes, so /api/health (which
// still has a route.ts) is served by Next.js and never reaches this rule.
async rewrites() {
return {
afterFiles: [
{
source: '/api/:path*',
destination: `${BACKEND_URL}/api/:path*`,
},
],
}
},
}
export default nextConfig

View File

@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { serverFetch } from '@/lib/api'
import type { Account, Budget } from '@/lib/types'
import { AccountBadge } from '@/components/accounts/AccountBadge'
import { formatCents } from '@/lib/utils/currency'
import { TransactionTable } from '@/components/transactions/TransactionTable'
@@ -13,45 +13,24 @@ type Props = {
}
export default async function AccountDetailPage({ params, searchParams }: Props) {
const session = await auth()
if (!session?.user?.id) return null
const { id } = await params
const sp = await searchParams
const page = Math.max(1, Number(sp.page) || 1)
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1)
const [account, budgets] = await Promise.all([
prisma.account.findFirst({ where: { id, userId: session.user.id } }),
prisma.budget.findMany({
where: { userId: session.user.id, isActive: true },
select: { id: true, name: true, color: true },
orderBy: { name: 'asc' },
}),
const [accountRes, txRes, budgetsRes] = await Promise.all([
serverFetch(`/api/accounts/${id}`),
serverFetch(`/api/transactions?accountId=${id}&page=${page}&limit=${PAGE_LIMIT}`),
serverFetch('/api/budgets'),
])
if (!account) notFound()
if (!accountRes.ok) notFound()
const where = { accountId: id }
const [transactions, total] = await Promise.all([
prisma.transaction.findMany({
where,
include: {
account: { select: { name: true, type: true } },
budget: { select: { id: true, name: true, color: true } },
},
orderBy: { date: 'desc' },
skip: (page - 1) * PAGE_LIMIT,
take: PAGE_LIMIT,
}),
prisma.transaction.count({ where }),
])
const rows = transactions.map((tx) => ({
...tx,
date: tx.date.toISOString(),
createdAt: undefined,
updatedAt: undefined,
}))
const account: Account = await accountRes.json()
const txData = txRes.ok ? await txRes.json() : { transactions: [], total: 0 }
const allBudgets: Budget[] = budgetsRes.ok ? await budgetsRes.json() : []
const budgetOptions = allBudgets
.filter((b) => b.isActive)
.map(({ id: bid, name, color }) => ({ id: bid, name, color }))
return (
<div className="p-6 space-y-6">
@@ -76,12 +55,12 @@ export default async function AccountDetailPage({ params, searchParams }: Props)
</div>
<TransactionTable
transactions={rows}
total={total}
transactions={txData.transactions ?? []}
total={txData.total ?? 0}
page={page}
limit={PAGE_LIMIT}
showAccount={false}
budgets={budgets}
budgets={budgetOptions}
/>
</div>
)

View File

@@ -1,15 +1,11 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { serverFetch } from '@/lib/api'
import type { Account } from '@/lib/types'
import { AccountList } from '@/components/accounts/AccountList'
export default async function AccountsPage() {
const session = await auth()
if (!session?.user?.id) return null
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
const res = await serverFetch('/api/accounts')
if (!res.ok) return null
const accounts: Account[] = await res.json()
return (
<div className="p-6">

View File

@@ -1,65 +1,21 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds } from '@/lib/utils/dates'
import { serverFetch } from '@/lib/api'
import type { BudgetWithSpend } from '@/lib/types'
import { BudgetList } from '@/components/budgets/BudgetList'
import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function BudgetsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [budgets, spendRows, rules] = await Promise.all([
prisma.budget.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.budgetRule.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
}),
])
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const rulesMap = new Map<string, { id: string; pattern: string }[]>()
for (const rule of rules) {
const existing = rulesMap.get(rule.budgetId) ?? []
existing.push({ id: rule.id, pattern: rule.pattern })
rulesMap.set(rule.budgetId, existing)
}
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
name: b.name,
limitCents: b.limitCents,
color: b.color,
isActive: b.isActive,
spendCents: spendMap.get(b.id) ?? 0,
rules: rulesMap.get(b.id) ?? [],
}))
const res = await serverFetch(`/api/dashboard/budgets?month=${month}&year=${year}`)
if (!res.ok) return null
const budgets: BudgetWithSpend[] = await res.json()
return (
<div className="p-6 space-y-4">
@@ -67,7 +23,7 @@ export default async function BudgetsPage({ searchParams }: { searchParams: Sear
<h1 className="text-2xl font-bold">Budgets</h1>
<MonthYearPicker />
</div>
<BudgetList budgets={budgetsWithSpend} />
<BudgetList budgets={budgets} />
</div>
)
}

View File

@@ -1,6 +1,4 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel } from '@/lib/utils/dates'
import { serverFetch } from '@/lib/api'
import { NetWorthCard } from '@/components/dashboard/NetWorthCard'
import { CashFlowCard } from '@/components/dashboard/CashFlowCard'
import { RecentTransactions } from '@/components/dashboard/RecentTransactions'
@@ -10,111 +8,16 @@ import { MonthYearPicker } from '@/components/dashboard/MonthYearPicker'
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function DashboardPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const userId = session.user.id
const sp = await searchParams
const get = (k: string) => (Array.isArray(sp[k]) ? sp[k][0] : sp[k]) ?? ''
const now = new Date()
const month = Number(get('month')) || (now.getMonth() + 1)
const year = Number(get('year')) || now.getFullYear()
const isCurrentMonth = month === (now.getMonth() + 1) && year === now.getFullYear()
const selectedDate = new Date(year, month - 1, 1)
const { start, end } = monthBounds(selectedDate)
const [cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.type != 'TRANSFER'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t.type
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId",
COALESCE(SUM(CASE WHEN t.type = 'DEBIT' THEN t."amountCents" ELSE -t."amountCents" END), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.transaction.findMany({
where: {
account: { userId },
date: { gte: start, lte: end },
},
include: { account: { select: { name: true } } },
orderBy: { date: 'desc' },
take: 5,
}),
])
// Net worth: live balances for current month, snapshots for past months
let netWorthCents: number
let netWorthAccounts: { id: string; name: string; balanceCents: number }[]
if (isCurrentMonth) {
const accounts = await prisma.account.findMany({
where: { userId, isActive: true, type: { in: ['BANK', 'INVESTMENT'] } },
select: { id: true, name: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
})
netWorthAccounts = accounts.map((a) => ({ id: a.id, name: a.name, balanceCents: a.currentBalanceCents }))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
} else {
const snapshots = await prisma.$queryRaw<{ accountId: string; name: string; balanceCents: bigint }[]>`
SELECT bs."accountId", a.name, bs."balanceCents"
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type IN ('BANK', 'INVESTMENT')
AND bs.year = ${year}
AND bs.month = ${month}
`
netWorthAccounts = snapshots.map((s) => ({
id: s.accountId,
name: s.name,
balanceCents: Number(s.balanceCents),
}))
netWorthCents = netWorthAccounts.reduce((s, a) => s + a.balanceCents, 0)
}
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0
const debitsCents = cfMap['DEBIT'] ?? 0
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
const recentTransactions = recentTx.map((t) => ({
id: t.id,
date: t.date.toISOString(),
description: t.description,
amountCents: t.amountCents,
type: t.type,
accountName: t.account.name,
}))
const budgetsWithSpend = budgets.map((b) => ({
id: b.id,
name: b.name,
limitCents: b.limitCents,
color: b.color,
spendCents: spendMap.get(b.id) ?? 0,
}))
const res = await serverFetch(`/api/dashboard?month=${month}&year=${year}`)
if (!res.ok) return null
const data = await res.json()
return (
<div className="p-6 space-y-6">
@@ -124,18 +27,18 @@ export default async function DashboardPage({ searchParams }: { searchParams: Se
</div>
<div className="grid gap-4 sm:grid-cols-2">
<NetWorthCard netWorthCents={netWorthCents} bankAccounts={netWorthAccounts} />
<NetWorthCard netWorthCents={data.netWorthCents} bankAccounts={data.bankAccounts} />
<CashFlowCard
monthLabel={monthLabel(selectedDate)}
creditsCents={creditsCents}
debitsCents={debitsCents}
netCents={creditsCents - debitsCents}
monthLabel={data.monthLabel}
creditsCents={data.cashFlow.creditsCents}
debitsCents={data.cashFlow.debitsCents}
netCents={data.cashFlow.netCents}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<RecentTransactions transactions={recentTransactions} />
<BudgetSummary budgets={budgetsWithSpend} />
<RecentTransactions transactions={data.recentTransactions} />
<BudgetSummary budgets={data.budgets} />
</div>
</div>
)

View File

@@ -1,6 +1,5 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel, formatYearMonth, lastNMonthsStart } from '@/lib/utils/dates'
import { serverFetch } from '@/lib/api'
import { monthLabel } from '@/lib/utils/dates'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { NetWorthTrendChart } from '@/components/graphs/NetWorthTrendChart'
import { CashFlowChart } from '@/components/graphs/CashFlowChart'
@@ -9,130 +8,17 @@ import { CategoryBreakdownChart } from '@/components/graphs/CategoryBreakdownCha
import { BudgetChart } from '@/components/graphs/BudgetChart'
export default async function GraphsPage() {
const session = await auth()
if (!session?.user?.id) return null
const res = await serverFetch('/api/graphs')
if (!res.ok) return null
const { netWorthData, cashFlowData, monthlySpendData, categoryData, budgetData } = await res.json()
const userId = session.user.id
const { start: monthStart, end: monthEnd } = monthBounds()
const sixMonthsAgo = lastNMonthsStart(6)
const [netWorthRows, cashFlowRows, spendingRows, categoryRows, budgetSpendRows, budgets] =
await Promise.all([
// Net worth trend from BalanceSnapshot (all time, BANK only)
prisma.$queryRaw<{ year: number; month: number; total: bigint }[]>`
SELECT bs.year, bs.month, COALESCE(SUM(bs."balanceCents"), 0)::bigint AS total
FROM "BalanceSnapshot" bs
JOIN "Account" a ON bs."accountId" = a.id
WHERE a."userId" = ${userId} AND a.type = 'BANK'
GROUP BY bs.year, bs.month
ORDER BY bs.year, bs.month
`,
// Monthly cash flow (last 6 months, BANK only)
prisma.$queryRaw<{ year: number; month: number; type: string; total: bigint }[]>`
SELECT
EXTRACT(YEAR FROM t.date)::int AS year,
EXTRACT(MONTH FROM t.date)::int AS month,
t.type,
COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.date >= ${sixMonthsAgo}
GROUP BY year, month, t.type
ORDER BY year, month
`,
// Monthly total spending across ALL accounts (last 6 months, DEBIT only)
prisma.$queryRaw<{ year: number; month: number; total: bigint }[]>`
SELECT
EXTRACT(YEAR FROM t.date)::int AS year,
EXTRACT(MONTH FROM t.date)::int AS month,
COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t.type = 'DEBIT'
AND t.date >= ${sixMonthsAgo}
GROUP BY year, month
ORDER BY year, month
`,
// Category breakdown (current month, all accounts, DEBIT)
prisma.$queryRaw<{ category: string; total: bigint }[]>`
SELECT
COALESCE(t.category, 'Uncategorized') AS category,
COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t.type = 'DEBIT'
AND t.date >= ${monthStart}
AND t.date <= ${monthEnd}
GROUP BY category
ORDER BY total DESC
`,
// Budget spend (current month)
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${monthStart}
AND t.date <= ${monthEnd}
GROUP BY t."budgetId"
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
])
// Net worth trend: take last 24 data points
const netWorthData = netWorthRows.slice(-24).map((r) => ({
label: formatYearMonth(r.year, r.month),
totalCents: Number(r.total),
}))
// Cash flow: build month-keyed map then fill in credits/debits
const cfMap = new Map<string, { label: string; creditsCents: number; debitsCents: number }>()
for (const r of cashFlowRows) {
const key = `${r.year}-${r.month}`
if (!cfMap.has(key)) {
cfMap.set(key, { label: formatYearMonth(r.year, r.month), creditsCents: 0, debitsCents: 0 })
}
const entry = cfMap.get(key)!
if (r.type === 'CREDIT') entry.creditsCents = Number(r.total)
else entry.debitsCents = Number(r.total)
}
const cashFlowData = Array.from(cfMap.values())
// Monthly spending (all accounts)
const monthlySpendData = spendingRows.map((r) => ({
label: formatYearMonth(r.year, r.month),
totalCents: Number(r.total),
}))
// Category breakdown
const categoryData = categoryRows.map((r) => ({
category: r.category,
totalCents: Number(r.total),
}))
// Budget chart
const spendMap = new Map(budgetSpendRows.map((r) => [r.budgetId, Number(r.total)]))
const budgetData = budgets.map((b) => ({
name: b.name,
spendCents: spendMap.get(b.id) ?? 0,
limitCents: b.limitCents ?? 0,
color: b.color,
}))
const currentMonthLabel = monthLabel()
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold">Graphs</h1>
<p className="text-sm text-muted-foreground">{monthLabel()}</p>
<p className="text-sm text-muted-foreground">{currentMonthLabel}</p>
</div>
<div className="grid gap-6 lg:grid-cols-2">
@@ -165,7 +51,7 @@ export default async function GraphsPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Spending by Category · {monthLabel()}</CardTitle>
<CardTitle className="text-base">Spending by Category · {currentMonthLabel}</CardTitle>
</CardHeader>
<CardContent>
<CategoryBreakdownChart data={categoryData} />
@@ -174,7 +60,7 @@ export default async function GraphsPage() {
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Budget Performance · {monthLabel()}</CardTitle>
<CardTitle className="text-base">Budget Performance · {currentMonthLabel}</CardTitle>
</CardHeader>
<CardContent>
<BudgetChart data={budgetData} />

View File

@@ -1,6 +1,5 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { Prisma } from '@/generated/prisma/client'
import { serverFetch } from '@/lib/api'
import type { Account, Budget, TransferRule } from '@/lib/types'
import { TransactionFilters } from '@/components/transactions/TransactionFilters'
import { TransactionTable } from '@/components/transactions/TransactionTable'
import { TransferRulesButton } from '@/components/transactions/TransferRulesButton'
@@ -10,9 +9,6 @@ const PAGE_LIMIT = 50
type SearchParams = Promise<Record<string, string | string[] | undefined>>
export default async function TransactionsPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth()
if (!session?.user?.id) return null
const sp = await searchParams
const get = (key: string) => (Array.isArray(sp[key]) ? sp[key][0] : sp[key]) ?? ''
@@ -20,58 +16,30 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
const accountId = get('accountId')
const dateFrom = get('dateFrom')
const dateTo = get('dateTo')
const type = get('type') as 'DEBIT' | 'CREDIT' | ''
const type = get('type')
const search = get('search')
const where: Prisma.TransactionWhereInput = {
account: { userId: session.user.id },
...(accountId && { accountId }),
...(type && { type }),
...(search && { description: { contains: search, mode: 'insensitive' } }),
...((dateFrom || dateTo) && {
date: {
...(dateFrom && { gte: new Date(dateFrom) }),
...(dateTo && { lte: new Date(dateTo + 'T23:59:59.999Z') }),
},
}),
}
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_LIMIT) })
if (accountId) params.set('accountId', accountId)
if (dateFrom) params.set('dateFrom', dateFrom)
if (dateTo) params.set('dateTo', dateTo)
if (type) params.set('type', type)
if (search) params.set('search', search)
const [transactions, total, accounts, budgets, transferRules] = await Promise.all([
prisma.transaction.findMany({
where,
include: {
account: { select: { name: true, type: true } },
budget: { select: { id: true, name: true, color: true } },
},
orderBy: { date: 'desc' },
skip: (page - 1) * PAGE_LIMIT,
take: PAGE_LIMIT,
}),
prisma.transaction.count({ where }),
prisma.account.findMany({
where: { userId: session.user.id },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
prisma.budget.findMany({
where: { userId: session.user.id, isActive: true },
select: { id: true, name: true, color: true },
orderBy: { name: 'asc' },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
}),
const [txRes, accountsRes, budgetsRes, rulesRes] = await Promise.all([
serverFetch(`/api/transactions?${params}`),
serverFetch('/api/accounts'),
serverFetch('/api/budgets'),
serverFetch('/api/transfer-rules'),
])
// Serialize dates for client components
const rows = transactions.map((tx) => ({
...tx,
date: tx.date.toISOString(),
createdAt: undefined,
updatedAt: undefined,
}))
const txData = txRes.ok ? await txRes.json() : { transactions: [], total: 0 }
const allAccounts: Account[] = accountsRes.ok ? await accountsRes.json() : []
const allBudgets: Budget[] = budgetsRes.ok ? await budgetsRes.json() : []
const transferRules: TransferRule[] = rulesRes.ok ? await rulesRes.json() : []
const accountOptions = allAccounts.map(({ id, name }) => ({ id, name }))
const budgetOptions = allBudgets.filter((b) => b.isActive).map(({ id, name, color }) => ({ id, name, color }))
return (
<div className="p-6">
@@ -79,14 +47,14 @@ export default async function TransactionsPage({ searchParams }: { searchParams:
<h1 className="text-2xl font-bold">Transactions</h1>
<TransferRulesButton rules={transferRules} />
</div>
<TransactionFilters accounts={accounts} />
<TransactionFilters accounts={accountOptions} />
<TransactionTable
transactions={rows}
total={total}
transactions={txData.transactions ?? []}
total={txData.total ?? 0}
page={page}
limit={PAGE_LIMIT}
showAccount
budgets={budgets}
budgets={budgetOptions}
/>
</div>
)

View File

@@ -1,16 +1,14 @@
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { serverFetch } from '@/lib/api'
import type { Account } from '@/lib/types'
import { UploadForm } from '@/components/upload/UploadForm'
export default async function UploadPage() {
const session = await auth()
if (!session?.user?.id) return null
const accounts = await prisma.account.findMany({
where: { userId: session.user.id, isActive: true },
orderBy: { name: 'asc' },
select: { id: true, name: true },
})
const res = await serverFetch('/api/accounts')
if (!res.ok) return null
const accounts: Pick<Account, 'id' | 'name'>[] = (await res.json() as Account[])
.filter((a) => a.isActive)
.map(({ id, name }) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name))
return (
<div className="p-6 max-w-2xl">

View File

@@ -1,8 +1,7 @@
'use client'
import { useState } from 'react'
import { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@@ -10,6 +9,7 @@ import { Button } from '@/components/ui/button'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -19,18 +19,24 @@ export default function LoginPage() {
setLoading(true)
const form = new FormData(e.currentTarget)
const result = await signIn('credentials', {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: form.get('email') as string,
password: form.get('password') as string,
redirect: false,
}),
credentials: 'include',
})
if (result?.error) {
if (!res.ok) {
setError('Invalid email or password')
setLoading(false)
} else {
router.push('/dashboard')
return
}
const callbackUrl = searchParams.get('callbackUrl') ?? '/dashboard'
router.push(callbackUrl)
}
return (

View File

@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
valueCents: z.number().int(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
})
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const account = await prisma.account.findFirst({
where: { id, userId: session.user.id, type: 'INVESTMENT' },
})
if (!account) return NextResponse.json({ error: 'Account not found' }, { status: 404 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { valueCents, date } = result.data
const d = new Date(date + 'T12:00:00')
const year = d.getFullYear()
const month = d.getMonth() + 1
await prisma.account.update({
where: { id },
data: { currentBalanceCents: valueCents },
})
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId: id, year, month } },
update: { balanceCents: valueCents, computedAt: new Date() },
create: { accountId: id, year, month, balanceCents: valueCents },
})
return NextResponse.json({ ok: true })
}

View File

@@ -1,64 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { updateAccountSchema } from '@/lib/validations/account'
type Params = { params: Promise<{ id: string }> }
export async function GET(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const account = await prisma.account.findFirst({
where: { id, userId: session.user.id },
})
if (!account) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(account)
}
export async function PATCH(req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const parsed = updateAccountSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const existing = await prisma.account.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const account = await prisma.account.update({
where: { id },
data: parsed.data,
})
return NextResponse.json(account)
}
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existing = await prisma.account.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.csvUpload.deleteMany({ where: { accountId: id } })
await prisma.account.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,37 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createAccountSchema } from '@/lib/validations/account'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json(accounts)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const parsed = createAccountSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const account = await prisma.account.create({
data: { ...parsed.data, userId: session.user.id },
})
return NextResponse.json(account, { status: 201 })
}

View File

@@ -1,42 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function POST() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const userId = session.user.id
const accounts = await prisma.account.findMany({
where: { userId },
select: { id: true },
})
for (const account of accounts) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${account.id}
`
await prisma.account.update({
where: { id: account.id },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
// Remove all snapshots for accounts that now have no transactions
await prisma.$executeRaw`
DELETE FROM "BalanceSnapshot"
WHERE "accountId" IN (
SELECT id FROM "Account" WHERE "userId" = ${userId}
)
AND NOT EXISTS (
SELECT 1 FROM "Transaction" WHERE "accountId" = "BalanceSnapshot"."accountId"
)
`
return NextResponse.json({ ok: true, accounts: accounts.length })
}

View File

@@ -1,3 +0,0 @@
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers

View File

@@ -1,16 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.budgetRule.findFirst({ where: { id, userId: session.user.id } })
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.budgetRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,37 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createBudgetRuleSchema } from '@/lib/validations/budget-rule'
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const parsed = createBudgetRuleSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
const { budgetId, pattern } = parsed.data
// Verify budget belongs to this user
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId: session.user.id } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
const rule = await prisma.budgetRule.create({
data: { userId: session.user.id, budgetId, pattern },
select: { id: true, budgetId: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -1,45 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { updateBudgetSchema } from '@/lib/validations/budget'
type Params = { params: Promise<{ id: string }> }
export async function PATCH(req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const parsed = updateBudgetSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const existing = await prisma.budget.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
const budget = await prisma.budget.update({ where: { id }, data: parsed.data })
return NextResponse.json(budget)
}
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const existing = await prisma.budget.findFirst({
where: { id, userId: session.user.id },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// onDelete: SetNull in schema nulls out Transaction.budgetId automatically
await prisma.budget.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,37 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createBudgetSchema } from '@/lib/validations/budget'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const budgets = await prisma.budget.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
})
return NextResponse.json(budgets)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const parsed = createBudgetSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const budget = await prisma.budget.create({
data: { ...parsed.data, userId: session.user.id },
})
return NextResponse.json(budget, { status: 201 })
}

View File

@@ -1,78 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { monthBounds, monthLabel } from '@/lib/utils/dates'
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const { start, end } = monthBounds()
const [accounts, cashFlowRows, budgets, spendRows, recentTx] = await Promise.all([
prisma.account.findMany({
where: { userId, isActive: true },
select: { id: true, name: true, type: true, currentBalanceCents: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ type: string; total: bigint }[]>`
SELECT t.type, COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND a.type = 'BANK'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t.type
`,
prisma.budget.findMany({
where: { userId, isActive: true },
orderBy: { name: 'asc' },
}),
prisma.$queryRaw<{ budgetId: string; total: bigint }[]>`
SELECT t."budgetId", COALESCE(SUM(t."amountCents"), 0)::bigint AS total
FROM "Transaction" t
JOIN "Account" a ON t."accountId" = a.id
WHERE a."userId" = ${userId}
AND t."budgetId" IS NOT NULL
AND t.type = 'DEBIT'
AND t.date >= ${start}
AND t.date <= ${end}
GROUP BY t."budgetId"
`,
prisma.transaction.findMany({
where: { account: { userId } },
include: { account: { select: { name: true } } },
orderBy: { date: 'desc' },
take: 5,
}),
])
const bankAccounts = accounts.filter((a) => a.type === 'BANK')
const netWorthCents = bankAccounts.reduce((s, a) => s + a.currentBalanceCents, 0)
const cfMap = Object.fromEntries(cashFlowRows.map((r) => [r.type, Number(r.total)]))
const creditsCents = cfMap['CREDIT'] ?? 0
const debitsCents = cfMap['DEBIT'] ?? 0
const spendMap = new Map(spendRows.map((r) => [r.budgetId, Number(r.total)]))
return NextResponse.json({
monthLabel: monthLabel(),
netWorthCents,
bankAccounts,
cashFlow: { creditsCents, debitsCents, netCents: creditsCents - debitsCents },
budgets: budgets.map((b) => ({ ...b, spendCents: spendMap.get(b.id) ?? 0 })),
recentTransactions: recentTx.map((t) => ({
id: t.id,
date: t.date.toISOString(),
description: t.description,
amountCents: t.amountCents,
type: t.type,
accountName: t.account.name,
})),
})
}

View File

@@ -1,11 +1,5 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
await prisma.$queryRaw`SELECT 1`
return NextResponse.json({ status: 'ok' })
} catch {
return NextResponse.json({ status: 'error', detail: 'database unreachable' }, { status: 503 })
}
}

View File

@@ -1,41 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { updateTransactionSchema } from '@/lib/validations/transaction'
type Params = { params: Promise<{ id: string }> }
export async function PATCH(req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
const parsed = updateTransactionSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
// Scope check via the account's userId
const existing = await prisma.transaction.findFirst({
where: { id, account: { userId: session.user.id } },
})
if (!existing) return NextResponse.json({ error: 'Not found' }, { status: 404 })
// Validate budgetId belongs to this user if provided
if (parsed.data.budgetId) {
const budget = await prisma.budget.findFirst({
where: { id: parsed.data.budgetId, userId: session.user.id },
})
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
}
const transaction = await prisma.transaction.update({
where: { id },
data: parsed.data,
})
return NextResponse.json(transaction)
}

View File

@@ -1,132 +0,0 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const bulkSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('delete'),
ids: z.array(z.string()).min(1),
}),
z.object({
action: z.literal('assignBudget'),
ids: z.array(z.string()).min(1),
budgetId: z.string().nullable(),
}),
z.object({
action: z.literal('addNotes'),
ids: z.array(z.string()).min(1),
notes: z.string().max(500),
}),
z.object({
action: z.literal('markTransfer'),
ids: z.array(z.string()).min(1),
}),
])
async function recomputeAccount(accountId: string) {
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
`
await prisma.account.update({
where: { id: accountId },
data: { currentBalanceCents: Number(balRow.balance) },
})
}
async function recomputeSnapshot(accountId: string, year: number, month: number) {
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
AND date <= ${endOfMonth}
`
const balanceCents = Number(snap.balance)
if (balanceCents === 0) {
await prisma.balanceSnapshot.deleteMany({
where: { accountId, year, month },
})
} else {
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId, year, month } },
update: { balanceCents, computedAt: new Date() },
create: { accountId, year, month, balanceCents },
})
}
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const body = await req.json()
const result = bulkSchema.safeParse(body)
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const { action, ids } = result.data
const userId = session.user.id
// Verify all transaction IDs belong to this user
const owned = await prisma.transaction.findMany({
where: { id: { in: ids }, account: { userId } },
select: { id: true, accountId: true, date: true },
})
if (owned.length !== ids.length) {
return NextResponse.json({ error: 'One or more transactions not found' }, { status: 404 })
}
if (action === 'delete') {
// Collect affected account+month combos before deleting
const accountIds = [...new Set(owned.map((t) => t.accountId))]
const monthKeys = new Map<string, { accountId: string; year: number; month: number }>()
for (const t of owned) {
const y = t.date.getFullYear()
const m = t.date.getMonth() + 1
monthKeys.set(`${t.accountId}-${y}-${m}`, { accountId: t.accountId, year: y, month: m })
}
await prisma.transaction.deleteMany({ where: { id: { in: ids } } })
// Recompute current balance and snapshots for affected accounts/months
await Promise.all(accountIds.map(recomputeAccount))
await Promise.all([...monthKeys.values()].map((k) => recomputeSnapshot(k.accountId, k.year, k.month)))
return NextResponse.json({ deleted: ids.length })
}
if (action === 'assignBudget') {
const { budgetId } = result.data
if (budgetId !== null) {
const budget = await prisma.budget.findFirst({ where: { id: budgetId, userId } })
if (!budget) return NextResponse.json({ error: 'Budget not found' }, { status: 404 })
}
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { budgetId },
})
return NextResponse.json({ updated: ids.length })
}
if (action === 'addNotes') {
const { notes } = result.data
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { notes: notes || null },
})
return NextResponse.json({ updated: ids.length })
}
// markTransfer
await prisma.transaction.updateMany({
where: { id: { in: ids } },
data: { type: 'TRANSFER' },
})
return NextResponse.json({ updated: ids.length })
}

View File

@@ -1,50 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { transactionQuerySchema } from '@/lib/validations/transaction'
import { Prisma } from '@/generated/prisma/client'
export async function GET(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(req.url)
const parsed = transactionQuerySchema.safeParse(Object.fromEntries(searchParams))
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
}
const { accountId, dateFrom, dateTo, type, search, budgetId, page, limit } = parsed.data
const where: Prisma.TransactionWhereInput = {
account: { userId: session.user.id },
...(accountId && { accountId }),
...(type && { type }),
...(budgetId !== undefined && { budgetId: budgetId || null }),
...(search && { description: { contains: search, mode: 'insensitive' } }),
...((dateFrom || dateTo) && {
date: {
...(dateFrom && { gte: new Date(dateFrom) }),
...(dateTo && { lte: new Date(dateTo + 'T23:59:59.999Z') }),
},
}),
}
const [transactions, total] = await prisma.$transaction([
prisma.transaction.findMany({
where,
include: {
account: { select: { name: true, type: true } },
budget: { select: { id: true, name: true, color: true } },
},
orderBy: { date: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.transaction.count({ where }),
])
return NextResponse.json({ transactions, total, page, limit, totalPages: Math.ceil(total / limit) })
}

View File

@@ -1,19 +0,0 @@
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
type Params = { params: Promise<{ id: string }> }
export async function DELETE(_req: Request, { params }: Params) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { id } = await params
const rule = await prisma.transferRule.findFirst({
where: { id, userId: session.user.id },
})
if (!rule) return NextResponse.json({ error: 'Not found' }, { status: 404 })
await prisma.transferRule.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}

View File

@@ -1,34 +0,0 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
const schema = z.object({
pattern: z.string().min(1).max(200).trim(),
})
export async function GET() {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const rules = await prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { id: true, pattern: true },
})
return NextResponse.json(rules)
}
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const result = schema.safeParse(await req.json())
if (!result.success) return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
const rule = await prisma.transferRule.create({
data: { userId: session.user.id, pattern: result.data.pattern },
select: { id: true, pattern: true },
})
return NextResponse.json(rule, { status: 201 })
}

View File

@@ -1,207 +0,0 @@
import { NextResponse } from 'next/server'
import Papa from 'papaparse'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { detectProfile } from '@/lib/csv/bank-profiles'
import { normalizeRows } from '@/lib/csv/normalizer'
import { columnMappingSchema } from '@/lib/validations/upload'
import type { NormalizerConfig } from '@/lib/csv/bank-profiles'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
export async function POST(req: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get('file') as File | null
const accountId = formData.get('accountId') as string | null
const columnMappingRaw = formData.get('columnMapping') as string | null
if (!file || !accountId) {
return NextResponse.json({ error: 'file and accountId are required' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: 'File too large (max 10 MB)' }, { status: 400 })
}
if (!file.name.toLowerCase().endsWith('.csv')) {
return NextResponse.json({ error: 'File must be a .csv file' }, { status: 400 })
}
const validMimes = ['text/csv', 'text/plain', 'application/vnd.ms-excel', 'application/csv', '']
if (file.type && !validMimes.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type' }, { status: 400 })
}
const account = await prisma.account.findFirst({
where: { id: accountId, userId: session.user.id },
})
if (!account) {
return NextResponse.json({ error: 'Account not found' }, { status: 404 })
}
const content = await file.text()
// Parse all rows once — used for both detection and normalization
const parsed = Papa.parse<Record<string, string>>(content, {
header: true,
skipEmptyLines: true,
transformHeader: (h) => h.trim(),
})
const headers = (parsed.meta.fields ?? []).map((h) => h.trim())
const allRows = parsed.data
// Detect profile or use provided manual mapping
const detected = detectProfile(headers)
if (!detected && !columnMappingRaw) {
return NextResponse.json({
requiresMapping: true,
headers,
sampleRows: allRows.slice(0, 5),
})
}
let config: NormalizerConfig
if (detected && !columnMappingRaw) {
config = detected
} else {
const result = columnMappingSchema.safeParse(JSON.parse(columnMappingRaw!))
if (!result.success) {
return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
}
config = result.data
}
// For all BANK accounts using a single amount column, positive = deposit (CREDIT),
// negative = withdrawal (DEBIT). Override whatever the profile or manual mapping says.
if (account.type === 'BANK' && config.strategy === 'A') {
config = { ...config, invertAmountSign: true }
}
const normalized = normalizeRows(allRows, accountId, config)
// Apply transfer rules first, then budget rules (transfer takes priority)
const [budgetRules, transferRules] = await Promise.all([
prisma.budgetRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true, budgetId: true },
}),
prisma.transferRule.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: 'asc' },
select: { pattern: true },
}),
])
const rowsWithBudgets = normalized.map((row) => {
const desc = row.description.toLowerCase()
for (const rule of transferRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, type: 'TRANSFER' as const, budgetId: null }
}
}
for (const rule of budgetRules) {
if (desc.includes(rule.pattern.toLowerCase())) {
return { ...row, budgetId: rule.budgetId }
}
}
return { ...row, budgetId: null }
})
const upload = await prisma.csvUpload.create({
data: {
accountId,
fileName: file.name,
rowCount: allRows.length,
importedCount: 0,
skippedCount: 0,
status: 'PENDING',
},
})
try {
const { count: importedCount } = await prisma.transaction.createMany({
data: rowsWithBudgets.map((r) => ({
accountId,
uploadId: upload.id,
date: r.date,
description: r.description,
amountCents: r.amountCents,
type: r.type,
category: r.category ?? null,
budgetId: r.budgetId,
})),
})
const skippedCount = normalized.length - importedCount
// Recompute current balance
const [balRow] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
`
await prisma.account.update({
where: { id: accountId },
data: { currentBalanceCents: Number(balRow.balance) },
})
// Upsert balance snapshots for each affected month
const months = [
...new Map(
rowsWithBudgets.map((r) => {
const y = r.date.getFullYear()
const m = r.date.getMonth() + 1
return [`${y}-${m}`, { year: y, month: m }]
}),
).values(),
]
for (const { year, month } of months) {
const endOfMonth = new Date(year, month, 0, 23, 59, 59, 999)
const [snap] = await prisma.$queryRaw<[{ balance: bigint }]>`
SELECT COALESCE(SUM(
CASE WHEN type = 'CREDIT' THEN "amountCents" ELSE -"amountCents" END
), 0)::bigint AS balance
FROM "Transaction"
WHERE "accountId" = ${accountId}
AND date <= ${endOfMonth}
`
await prisma.balanceSnapshot.upsert({
where: { accountId_year_month: { accountId, year, month } },
update: { balanceCents: Number(snap.balance), computedAt: new Date() },
create: { accountId, year, month, balanceCents: Number(snap.balance) },
})
}
const status =
importedCount === 0 ? 'FAILED'
: skippedCount > 0 ? 'PARTIAL'
: 'SUCCESS'
await prisma.csvUpload.update({
where: { id: upload.id },
data: { importedCount, skippedCount, status },
})
return NextResponse.json({
success: true,
detected: detected?.name,
importedCount,
skippedCount,
fileName: file.name,
})
} catch (err) {
await prisma.csvUpload.update({
where: { id: upload.id },
data: {
status: 'FAILED',
errorMessage: err instanceof Error ? err.message : 'Unknown error',
},
})
return NextResponse.json({ error: 'Import failed' }, { status: 500 })
}
}

View File

@@ -1,5 +1,5 @@
import { Badge } from '@/components/ui/badge'
import { AccountType } from '@/generated/prisma/client'
import type { AccountType } from '@/lib/types'
export function AccountBadge({ type }: { type: AccountType }) {
if (type === 'BANK') return <Badge variant="secondary">Bank</Badge>

View File

@@ -3,7 +3,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { Account } from '@/generated/prisma/client'
import type { Account } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Account } from '@/generated/prisma/client'
import type { Account } from '@/lib/types'
import { Button } from '@/components/ui/button'
import { Plus } from 'lucide-react'
import { AccountCard } from './AccountCard'

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Account } from '@/generated/prisma/client'
import type { Account } from '@/lib/types'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'

View File

@@ -1,8 +1,7 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { usePathname, useRouter } from 'next/navigation'
import { cn } from '@/lib/utils'
import {
LayoutDashboard, CreditCard, ArrowLeftRight,
@@ -21,6 +20,12 @@ const navItems = [
export function Sidebar() {
const pathname = usePathname()
const router = useRouter()
async function handleSignOut() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
router.push('/login')
}
return (
<aside className="flex w-56 shrink-0 flex-col border-r bg-card">
@@ -48,7 +53,7 @@ export function Sidebar() {
<Separator />
<div className="p-2">
<button
onClick={() => signOut({ callbackUrl: '/login' })}
onClick={handleSignOut}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<LogOut className="h-4 w-4 shrink-0" />

22
src/lib/api.ts Normal file
View File

@@ -0,0 +1,22 @@
import { cookies } from 'next/headers'
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://backend:8000'
/**
* Server-side fetch that forwards the auth_token cookie to the FastAPI backend.
* Use only in Server Components and Route Handlers.
*/
export async function serverFetch(path: string, init?: RequestInit): Promise<Response> {
const cookieStore = await cookies()
const token = cookieStore.get('auth_token')?.value
return fetch(`${BACKEND_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
...(token ? { Cookie: `auth_token=${token}` } : {}),
},
cache: 'no-store',
})
}

59
src/lib/types.ts Normal file
View File

@@ -0,0 +1,59 @@
export type AccountType = 'BANK' | 'CREDIT_CARD' | 'INVESTMENT'
export type TransactionType = 'DEBIT' | 'CREDIT' | 'TRANSFER'
export interface Account {
id: string
userId: string
name: string
institution: string | null
type: AccountType
currency: string
currentBalanceCents: number
isActive: boolean
createdAt: string
updatedAt: string
}
export interface Budget {
id: string
userId: string
name: string
limitCents: number | null
color: string | null
isActive: boolean
createdAt: string
updatedAt: string
}
export interface BudgetWithSpend extends Budget {
spendCents: number
rules: BudgetRule[]
}
export interface Transaction {
id: string
accountId: string
uploadId: string | null
budgetId: string | null
date: string
description: string
amountCents: number
type: TransactionType
category: string | null
notes: string | null
createdAt?: string
updatedAt?: string
account?: { name: string; type: string }
budget?: { id: string; name: string; color: string | null } | null
}
export interface BudgetRule {
id: string
budgetId: string
pattern: string
}
export interface TransferRule {
id: string
pattern: string
}

View File

@@ -1,11 +1,6 @@
import NextAuth from 'next-auth'
import { authConfig } from '@/lib/auth.config'
import { NextResponse, type NextRequest } from 'next/server'
import { type NextRequest, NextResponse } from 'next/server'
// Use the Edge-compatible config so no Node.js-only modules are bundled here.
const { auth } = NextAuth(authConfig)
// Process-local store — adequate for a self-hosted single-instance deployment.
// Process-local rate limit store — fine for single-instance self-hosted deployment.
const rateLimitStore = new Map<string, { count: number; resetAt: number }>()
const RATE_LIMIT = 10
const RATE_WINDOW_MS = 15 * 60 * 1000
@@ -33,30 +28,24 @@ function isRateLimited(ip: string): boolean {
function hasValidOrigin(req: NextRequest): boolean {
const origin = req.headers.get('origin')
if (!origin) return true // non-browser (curl, server-to-server)
// Accept the host the browser actually used to reach this server.
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host')
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
const requestOrigin = host ? `${proto}://${host}` : null
if (requestOrigin && origin === requestOrigin) return true
// Also accept the statically configured NEXTAUTH_URL origin (reverse-proxy setups).
if (process.env.NEXTAUTH_URL && origin === new URL(process.env.NEXTAUTH_URL).origin) return true
return false
return !!(requestOrigin && origin === requestOrigin)
}
// Build an absolute URL using the Host header the browser sent, not the
// internal hostname Next.js resolves to inside Docker.
function siteUrl(req: NextRequest, path: string): URL {
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost:3000'
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
return new URL(path, `${proto}://${host}`)
}
export default auth((req) => {
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
const method = req.method
// Rate-limit login POST before anything else
if (pathname === '/api/auth/callback/credentials' && method === 'POST') {
// Rate-limit login endpoint
if (pathname === '/api/auth/login' && method === 'POST') {
if (isRateLimited(clientIp(req))) {
return NextResponse.json(
{ error: 'Too many login attempts. Try again in 15 minutes.' },
@@ -65,25 +54,23 @@ export default auth((req) => {
}
}
// Let NextAuth handle its own routes
if (pathname.startsWith('/api/auth')) return NextResponse.next()
// All /api/* calls are proxied to FastAPI which enforces auth itself.
if (pathname.startsWith('/api/')) return NextResponse.next()
// Origin check on all state-mutating API calls
if (
pathname.startsWith('/api/') &&
['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
) {
// Origin check on state-mutating non-API requests
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
if (!hasValidOrigin(req)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
// Auth gate
const isLoggedIn = !!req.auth
// UI auth gate: redirect unauthenticated visitors to /login.
// Token validity is enforced by FastAPI on every API call; here we only
// check for cookie presence to avoid rendering the app shell for logged-out users.
const token = req.cookies.get('auth_token')
const isLoggedIn = !!token?.value
if (!isLoggedIn) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (pathname !== '/login') {
const loginUrl = siteUrl(req, '/login')
loginUrl.searchParams.set('callbackUrl', pathname)
@@ -92,13 +79,12 @@ export default auth((req) => {
return NextResponse.next()
}
// Logged-in users hitting /login get sent to the dashboard
if (pathname === '/login') {
return NextResponse.redirect(siteUrl(req, '/dashboard'))
}
return NextResponse.next()
})
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],