mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
Add accounts expansion, analytics, exchange rates, API tokens, PWA support, and UI overhaul
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s
All checks were successful
Deploy to VPS / deploy (push) Successful in 45s
- Expand Account model with account_type (pension, savings, liability, crypto), new banks/currencies (BTC, XMR, FCL, ROP, VOL, MEMP, MPAT, MORTGAGE), and next_payment field - Add exchange rate endpoint (BCCR integration), analytics endpoint, paste-import for transactions, and API token management - Add PWA manifest, service worker, and app icons - Redesign dashboard, transactions, transfers, and login pages with theme support - Add billing cycle selector, confirm dialog, and paste import modal components - One-time DB reset in deploy workflow for schema migration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
184
backend/app/api/v1/endpoints/analytics.py
Normal file
184
backend/app/api/v1/endpoints/analytics.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, func, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import Category, Transaction
|
||||
from app.api.v1.endpoints.transactions import get_cycle_range
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
class CategorySpending(BaseModel):
|
||||
category_id: Optional[int]
|
||||
category_name: str
|
||||
total: float
|
||||
count: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class MonthlyTrend(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
label: str
|
||||
total_crc: float
|
||||
total_usd: float
|
||||
count: int
|
||||
|
||||
|
||||
class DailySpending(BaseModel):
|
||||
date: str
|
||||
total: float
|
||||
count: int
|
||||
|
||||
|
||||
@router.get("/by-category", response_model=list[CategorySpending])
|
||||
def spending_by_category(
|
||||
cycle_year: Optional[int] = None,
|
||||
cycle_month: Optional[int] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
query = (
|
||||
select(
|
||||
Transaction.category_id,
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
.group_by(Transaction.category_id)
|
||||
)
|
||||
|
||||
if cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||
|
||||
rows = session.exec(query).all()
|
||||
|
||||
grand_total = sum(r[1] for r in rows) or 1
|
||||
|
||||
results = []
|
||||
for category_id, total, count in rows:
|
||||
cat_name = "Uncategorized"
|
||||
if category_id:
|
||||
cat = session.get(Category, category_id)
|
||||
if cat:
|
||||
cat_name = cat.name
|
||||
results.append(
|
||||
CategorySpending(
|
||||
category_id=category_id,
|
||||
category_name=cat_name,
|
||||
total=float(total),
|
||||
count=count,
|
||||
percentage=round(float(total) / grand_total * 100, 1),
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(results, key=lambda x: x.total, reverse=True)
|
||||
|
||||
|
||||
@router.get("/monthly-trend", response_model=list[MonthlyTrend])
|
||||
def monthly_trend(
|
||||
months: int = 6,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
"""Monthly spending totals using billing cycle boundaries (18th-18th)."""
|
||||
now = datetime.now()
|
||||
results = []
|
||||
month_names = [
|
||||
"", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
]
|
||||
|
||||
y, m = now.year, now.month
|
||||
for _ in range(months):
|
||||
start, end = get_cycle_range(y, m)
|
||||
|
||||
row = session.exec(
|
||||
select(
|
||||
func.count(),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.currency == "CRC", Transaction.amount),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
func.coalesce(
|
||||
func.sum(
|
||||
func.case(
|
||||
(Transaction.currency == "USD", Transaction.amount),
|
||||
else_=0,
|
||||
)
|
||||
),
|
||||
0,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
Transaction.transaction_type == "COMPRA",
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
)
|
||||
).first()
|
||||
|
||||
count = row[0] if row else 0
|
||||
total_crc = float(row[1]) if row else 0.0
|
||||
total_usd = float(row[2]) if row else 0.0
|
||||
|
||||
end_month = m + 1 if m < 12 else 1
|
||||
label = f"{month_names[m]} - {month_names[end_month]}"
|
||||
|
||||
results.append(
|
||||
MonthlyTrend(
|
||||
year=y,
|
||||
month=m,
|
||||
label=label,
|
||||
total_crc=total_crc,
|
||||
total_usd=total_usd,
|
||||
count=count,
|
||||
)
|
||||
)
|
||||
|
||||
# Previous month
|
||||
if m == 1:
|
||||
y, m = y - 1, 12
|
||||
else:
|
||||
m -= 1
|
||||
|
||||
return list(reversed(results))
|
||||
|
||||
|
||||
@router.get("/daily-spending", response_model=list[DailySpending])
|
||||
def daily_spending(
|
||||
cycle_year: Optional[int] = None,
|
||||
cycle_month: Optional[int] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
query = (
|
||||
select(
|
||||
func.date(Transaction.date).label("day"),
|
||||
func.sum(Transaction.amount).label("total"),
|
||||
func.count().label("count"),
|
||||
)
|
||||
.where(Transaction.transaction_type == "COMPRA")
|
||||
.group_by(func.date(Transaction.date))
|
||||
.order_by(func.date(Transaction.date))
|
||||
)
|
||||
|
||||
if cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||
|
||||
rows = session.exec(query).all()
|
||||
return [
|
||||
DailySpending(date=str(day), total=float(total), count=count)
|
||||
for day, total, count in rows
|
||||
]
|
||||
Reference in New Issue
Block a user