mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
Remove Ahorro from budget UI, add SALARY type and savings auto-accrual
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
All checks were successful
Deploy to VPS / deploy (push) Successful in 23s
Ahorro was already deducted from gross salary so displaying it in budget projections was misleading. This removes the Ahorro card, summary line, Proyecciones column, and Ahorro Anual card from the UI, and strips all savings fields from budget API responses. Adds SALARY TransactionType so salary deposits can be distinguished from generic DEPOSITO transfers. When a SALARY transaction arrives, the system auto-increments MEMP and MPAT savings account balances (+200K CRC each) once per month via an idempotent accrual log. New CRUD endpoints at /api/v1/savings-accrual/ allow manual correction of the accrual history. Feb+Mar 2026 are seeded as historical baseline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,9 @@ def list_recurring_items(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
query = select(RecurringItem)
|
||||
query = select(RecurringItem).where(
|
||||
RecurringItem.item_type != RecurringItemType.SAVINGS
|
||||
)
|
||||
if item_type:
|
||||
query = query.where(RecurringItem.item_type == item_type)
|
||||
if is_active is not None:
|
||||
@@ -101,7 +103,6 @@ class MonthlyProjectionResponse(BaseModel):
|
||||
year: int
|
||||
projected_income: float
|
||||
projected_fixed_expenses: float
|
||||
projected_savings: float
|
||||
actual_credit_card: float
|
||||
actual_cash: float
|
||||
actual_transfers: float
|
||||
@@ -118,7 +119,6 @@ class YearlyProjectionResponse(BaseModel):
|
||||
months: list[MonthlyProjectionResponse]
|
||||
annual_income: float
|
||||
annual_expenses: float
|
||||
annual_savings: float
|
||||
annual_net: float
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ def get_yearly_projection(
|
||||
months = []
|
||||
annual_income = 0.0
|
||||
annual_expenses = 0.0
|
||||
annual_savings = 0.0
|
||||
annual_net = 0.0
|
||||
|
||||
for data in months_data:
|
||||
@@ -147,7 +146,6 @@ def get_yearly_projection(
|
||||
year=data["year"],
|
||||
projected_income=data["projected_income"],
|
||||
projected_fixed_expenses=data["projected_fixed_expenses"],
|
||||
projected_savings=data["projected_savings"],
|
||||
actual_credit_card=data["actual_credit_card"],
|
||||
actual_cash=data["actual_cash"],
|
||||
actual_transfers=data["actual_transfers"],
|
||||
@@ -161,7 +159,6 @@ def get_yearly_projection(
|
||||
months.append(monthly)
|
||||
annual_income += data["projected_income"]
|
||||
annual_expenses += data["gran_total_egresos"]
|
||||
annual_savings += data["projected_savings"]
|
||||
annual_net += data["net_balance"]
|
||||
|
||||
return YearlyProjectionResponse(
|
||||
@@ -169,7 +166,6 @@ def get_yearly_projection(
|
||||
months=months,
|
||||
annual_income=annual_income,
|
||||
annual_expenses=annual_expenses,
|
||||
annual_savings=annual_savings,
|
||||
annual_net=annual_net,
|
||||
)
|
||||
|
||||
@@ -204,11 +200,9 @@ class MonthlyDetailResponse(BaseModel):
|
||||
month: int
|
||||
income_items: list[RecurringItemDetail]
|
||||
expense_items: list[RecurringItemDetail]
|
||||
savings_items: list[RecurringItemDetail]
|
||||
actuals_by_source: list[ActualsBySource]
|
||||
total_projected_income: float
|
||||
total_projected_expenses: float
|
||||
total_projected_savings: float
|
||||
uncovered_actual: float
|
||||
gran_total_egresos: float
|
||||
net_balance: float
|
||||
@@ -228,11 +222,9 @@ def get_monthly_detail(
|
||||
month=data["month"],
|
||||
income_items=[RecurringItemDetail(**i) for i in data["income_items"]],
|
||||
expense_items=[RecurringItemDetail(**i) for i in data["expense_items"]],
|
||||
savings_items=[RecurringItemDetail(**i) for i in data["savings_items"]],
|
||||
actuals_by_source=[ActualsBySource(**a) for a in data["actuals_by_source"]],
|
||||
total_projected_income=data["projected_income"],
|
||||
total_projected_expenses=data["projected_fixed_expenses"],
|
||||
total_projected_savings=data["projected_savings"],
|
||||
uncovered_actual=data["uncovered_actual"],
|
||||
gran_total_egresos=data["gran_total_egresos"],
|
||||
net_balance=data["net_balance"],
|
||||
|
||||
@@ -12,6 +12,8 @@ from app.services.exchange_rate import get_converted_amount_expr
|
||||
|
||||
router = APIRouter(prefix="/salarios", tags=["salarios"])
|
||||
|
||||
SALARIO_TYPES = (TransactionType.SALARY, TransactionType.DEPOSITO)
|
||||
|
||||
|
||||
class SalariosSummary(BaseModel):
|
||||
count: int
|
||||
@@ -28,7 +30,7 @@ def list_salarios(
|
||||
):
|
||||
query = (
|
||||
select(Transaction)
|
||||
.where(Transaction.transaction_type == TransactionType.DEPOSITO)
|
||||
.where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||
.order_by(col(Transaction.date).desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
@@ -47,7 +49,7 @@ def salarios_summary(
|
||||
func.count(),
|
||||
func.coalesce(func.sum(amount_crc), 0),
|
||||
func.max(Transaction.date),
|
||||
).where(Transaction.transaction_type == TransactionType.DEPOSITO)
|
||||
).where(col(Transaction.transaction_type).in_(SALARIO_TYPES))
|
||||
).first()
|
||||
return SalariosSummary(
|
||||
count=result[0] if result else 0,
|
||||
|
||||
83
backend/app/api/v1/endpoints/savings_accrual.py
Normal file
83
backend/app/api/v1/endpoints/savings_accrual.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.db import get_session
|
||||
from app.models.models import (
|
||||
SavingsAccrual,
|
||||
SavingsAccrualCreate,
|
||||
SavingsAccrualRead,
|
||||
SavingsAccrualUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/savings-accrual", tags=["savings-accrual"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[SavingsAccrualRead])
|
||||
def list_accruals(
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
query = select(SavingsAccrual).order_by(
|
||||
col(SavingsAccrual.year).desc(), col(SavingsAccrual.month).desc()
|
||||
)
|
||||
return session.exec(query).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=SavingsAccrualRead, status_code=201)
|
||||
def create_accrual(
|
||||
data: SavingsAccrualCreate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
existing = session.exec(
|
||||
select(SavingsAccrual).where(
|
||||
SavingsAccrual.year == data.year,
|
||||
SavingsAccrual.month == data.month,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Accrual for {data.year}-{data.month:02d} already exists (id={existing.id})",
|
||||
)
|
||||
accrual = SavingsAccrual.model_validate(data)
|
||||
accrual.applied_at = datetime.utcnow()
|
||||
session.add(accrual)
|
||||
session.commit()
|
||||
session.refresh(accrual)
|
||||
return accrual
|
||||
|
||||
|
||||
@router.patch("/{accrual_id}", response_model=SavingsAccrualRead)
|
||||
def update_accrual(
|
||||
accrual_id: int,
|
||||
data: SavingsAccrualUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
accrual = session.get(SavingsAccrual, accrual_id)
|
||||
if not accrual:
|
||||
raise HTTPException(status_code=404, detail="Accrual not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(accrual, key, value)
|
||||
session.add(accrual)
|
||||
session.commit()
|
||||
session.refresh(accrual)
|
||||
return accrual
|
||||
|
||||
|
||||
@router.delete("/{accrual_id}", status_code=204)
|
||||
def delete_accrual(
|
||||
accrual_id: int = Path(...),
|
||||
session: Session = Depends(get_session),
|
||||
_user: str = Depends(get_current_user),
|
||||
):
|
||||
accrual = session.get(SavingsAccrual, accrual_id)
|
||||
if not accrual:
|
||||
raise HTTPException(status_code=404, detail="Accrual not found")
|
||||
session.delete(accrual)
|
||||
session.commit()
|
||||
@@ -199,14 +199,20 @@ def create_transaction(
|
||||
symbols = {Currency.CRC: "₡", Currency.USD: "$", Currency.EUR: "€"}
|
||||
symbol = symbols.get(tx.currency, tx.currency.value)
|
||||
amount_str = f"{symbol}{tx.amount:,.0f}" if tx.currency == Currency.CRC else f"{symbol}{tx.amount:,.2f}"
|
||||
is_deposit = tx.transaction_type == TransactionType.DEPOSITO
|
||||
is_income = tx.transaction_type in (TransactionType.DEPOSITO, TransactionType.SALARY)
|
||||
is_salary = tx.transaction_type == TransactionType.SALARY
|
||||
label = "salario" if is_salary else ("depósito" if is_income else tx.transaction_type.value.lower())
|
||||
send_push_to_all(
|
||||
session,
|
||||
title=f"{'🏦' if is_deposit else '💳'} {tx.merchant}",
|
||||
body=f"{amount_str} — {tx.bank.value} {'depósito' if is_deposit else tx.transaction_type.value.lower()}",
|
||||
url="/salarios" if is_deposit else "/budget",
|
||||
title=f"{'🏦' if is_income else '💳'} {tx.merchant}",
|
||||
body=f"{amount_str} — {tx.bank.value} {label}",
|
||||
url="/salarios" if is_income else "/budget",
|
||||
)
|
||||
|
||||
if is_salary:
|
||||
from app.services.savings_accrual import maybe_apply_monthly_savings
|
||||
maybe_apply_monthly_savings(session, tx)
|
||||
|
||||
return tx
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user