Compare commits
2 Commits
60fc0cd8df
...
python
| Author | SHA1 | Date | |
|---|---|---|---|
| fd3214ff70 | |||
| d60875a80f |
10
.env.example
10
.env.example
@@ -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"
|
||||
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -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
15
backend/Dockerfile
Normal 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
43
backend/alembic.ini
Normal 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
48
backend/alembic/env.py
Normal 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()
|
||||
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/alembic/versions/.gitkeep
Normal file
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
33
backend/app/auth.py
Normal file
33
backend/app/auth.py
Normal 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
|
||||
0
backend/app/csv/__init__.py
Normal file
0
backend/app/csv/__init__.py
Normal file
BIN
backend/app/csv/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/app/csv/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/csv/__pycache__/bank_profiles.cpython-314.pyc
Normal file
BIN
backend/app/csv/__pycache__/bank_profiles.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/csv/__pycache__/normalizer.cpython-314.pyc
Normal file
BIN
backend/app/csv/__pycache__/normalizer.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/csv/__pycache__/parser.cpython-314.pyc
Normal file
BIN
backend/app/csv/__pycache__/parser.cpython-314.pyc
Normal file
Binary file not shown.
79
backend/app/csv/bank_profiles.py
Normal file
79
backend/app/csv/bank_profiles.py
Normal 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
|
||||
114
backend/app/csv/normalizer.py
Normal file
114
backend/app/csv/normalizer.py
Normal 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
35
backend/app/csv/parser.py
Normal 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
12
backend/app/database.py
Normal 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
33
backend/app/deps.py
Normal 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
32
backend/app/main.py
Normal 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
251
backend/app/models.py
Normal 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()),
|
||||
)
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
161
backend/app/routers/accounts.py
Normal file
161
backend/app/routers/accounts.py
Normal 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()
|
||||
45
backend/app/routers/admin.py
Normal file
45
backend/app/routers/admin.py
Normal 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)}))
|
||||
50
backend/app/routers/auth.py
Normal file
50
backend/app/routers/auth.py
Normal 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}
|
||||
77
backend/app/routers/budget_rules.py
Normal file
77
backend/app/routers/budget_rules.py
Normal 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()
|
||||
102
backend/app/routers/budgets.py
Normal file
102
backend/app/routers/budgets.py
Normal 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()
|
||||
170
backend/app/routers/dashboard.py
Normal file
170
backend/app/routers/dashboard.py
Normal 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
|
||||
]))
|
||||
128
backend/app/routers/graphs.py
Normal file
128
backend/app/routers/graphs.py
Normal 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,
|
||||
}))
|
||||
244
backend/app/routers/transactions.py
Normal file
244
backend/app/routers/transactions.py
Normal 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)))
|
||||
60
backend/app/routers/transfer_rules.py
Normal file
60
backend/app/routers/transfer_rules.py
Normal 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()
|
||||
226
backend/app/routers/upload.py
Normal file
226
backend/app/routers/upload.py
Normal 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")
|
||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
30
backend/app/schemas/account.py
Normal file
30
backend/app/schemas/account.py
Normal 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]
|
||||
12
backend/app/schemas/base.py
Normal file
12
backend/app/schemas/base.py
Normal 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,
|
||||
)
|
||||
28
backend/app/schemas/budget.py
Normal file
28
backend/app/schemas/budget.py
Normal 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]
|
||||
46
backend/app/schemas/transaction.py
Normal file
46
backend/app/schemas/transaction.py
Normal 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
|
||||
27
backend/app/schemas/upload.py
Normal file
27
backend/app/schemas/upload.py
Normal 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
12
backend/requirements.txt
Normal 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
24
backend/seed.py
Normal 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}")
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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', {
|
||||
email: form.get('email') as string,
|
||||
password: form.get('password') as string,
|
||||
redirect: false,
|
||||
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,
|
||||
}),
|
||||
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 (
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { handlers } from '@/lib/auth'
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
return NextResponse.json({ status: 'ok' })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
22
src/lib/api.ts
Normal 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
59
src/lib/types.ts
Normal 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
|
||||
}
|
||||
@@ -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).*)'],
|
||||
|
||||
Reference in New Issue
Block a user