mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 08:48:48 +02:00
Add deferred transactions, revamp budget projections and UI
Adds deferred_to_next_cycle flag to transactions for billing cycle bleed-over handling. Overhauls budget projection engine and refreshes Budget page with improved monthly detail and transaction columns. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -194,6 +194,11 @@ class ActualsBySource(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class CCCategorySpending(BaseModel):
|
||||
category_name: str
|
||||
amount: float
|
||||
|
||||
|
||||
class MonthlyDetailResponse(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
@@ -207,6 +212,7 @@ class MonthlyDetailResponse(BaseModel):
|
||||
uncovered_actual: float
|
||||
gran_total_egresos: float
|
||||
net_balance: float
|
||||
cc_by_category: list[CCCategorySpending]
|
||||
|
||||
|
||||
@router.get("/month/{year}/{month}", response_model=MonthlyDetailResponse)
|
||||
@@ -230,6 +236,7 @@ def get_monthly_detail(
|
||||
uncovered_actual=data["uncovered_actual"],
|
||||
gran_total_egresos=data["gran_total_egresos"],
|
||||
net_balance=data["net_balance"],
|
||||
cc_by_category=[CCCategorySpending(**c) for c in data["cc_by_category"]],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -19,19 +19,11 @@ from app.models.models import (
|
||||
TransactionUpdate,
|
||||
)
|
||||
|
||||
from app.services.budget_projection import get_cycle_range, get_previous_cycle
|
||||
|
||||
router = APIRouter(prefix="/transactions", tags=["transactions"])
|
||||
|
||||
|
||||
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
|
||||
start = datetime(year, month, 18)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 18)
|
||||
else:
|
||||
end = datetime(year, month + 1, 18)
|
||||
return start, end
|
||||
|
||||
|
||||
class BillingCycle(BaseModel):
|
||||
year: int
|
||||
month: int
|
||||
@@ -54,6 +46,7 @@ def auto_categorize(merchant: str, session: Session) -> Optional[int]:
|
||||
@router.get("/", response_model=list[TransactionRead])
|
||||
def list_transactions(
|
||||
source: Optional[TransactionSource] = None,
|
||||
exclude_source: Optional[TransactionSource] = None,
|
||||
search: Optional[str] = None,
|
||||
category_id: Optional[int] = None,
|
||||
cycle_year: Optional[int] = None,
|
||||
@@ -68,13 +61,32 @@ def list_transactions(
|
||||
query = select(Transaction)
|
||||
if source:
|
||||
query = query.where(Transaction.source == source)
|
||||
if exclude_source:
|
||||
query = query.where(Transaction.source != exclude_source)
|
||||
if category_id:
|
||||
query = query.where(Transaction.category_id == category_id)
|
||||
if search:
|
||||
query = query.where(col(Transaction.merchant).ilike(f"%{search}%"))
|
||||
if cycle_year and cycle_month:
|
||||
start, end = get_cycle_range(cycle_year, cycle_month)
|
||||
query = query.where(Transaction.date >= start, Transaction.date < end)
|
||||
prev_y, prev_m = get_previous_cycle(cycle_year, cycle_month)
|
||||
prev_start, prev_end = get_cycle_range(prev_y, prev_m)
|
||||
# Normal transactions in this cycle (not deferred) + deferred from previous cycle
|
||||
from sqlalchemy import or_, and_
|
||||
query = query.where(
|
||||
or_(
|
||||
and_(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
),
|
||||
and_(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
),
|
||||
)
|
||||
)
|
||||
elif start_date and end_date:
|
||||
query = query.where(
|
||||
Transaction.date >= datetime.fromisoformat(start_date),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from sqlalchemy import text
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
from app.config import settings
|
||||
@@ -9,6 +10,20 @@ def init_db():
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def run_migrations():
|
||||
"""Run idempotent schema migrations for columns added after initial create."""
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE transaction ADD COLUMN deferred_to_next_cycle BOOLEAN NOT NULL DEFAULT 0"
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
@@ -5,13 +5,14 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1.router import api_router
|
||||
from app.config import settings
|
||||
from app.db import init_db
|
||||
from app.db import init_db, run_migrations
|
||||
from app.seed import seed_db
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
run_migrations()
|
||||
seed_db()
|
||||
yield
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ class TransactionBase(SQLModel):
|
||||
bank: Bank = Bank.BAC
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = Field(default=None, foreign_key="category.id")
|
||||
deferred_to_next_cycle: bool = Field(default=False)
|
||||
|
||||
|
||||
class Transaction(TransactionBase, table=True):
|
||||
@@ -168,6 +169,7 @@ class TransactionUpdate(SQLModel):
|
||||
source: Optional[TransactionSource] = None
|
||||
notes: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
deferred_to_next_cycle: Optional[bool] = None
|
||||
|
||||
|
||||
# --- Exchange Rate ---
|
||||
|
||||
@@ -71,81 +71,308 @@ def get_month_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
return start, end
|
||||
|
||||
|
||||
def get_cycle_range(year: int, month: int) -> tuple[datetime, datetime]:
|
||||
"""Return (start, end) for billing cycle: month/18 to month+1/18."""
|
||||
start = datetime(year, month, 18)
|
||||
if month == 12:
|
||||
end = datetime(year + 1, 1, 18)
|
||||
else:
|
||||
end = datetime(year, month + 1, 18)
|
||||
return start, end
|
||||
|
||||
|
||||
def get_previous_cycle(year: int, month: int) -> tuple[int, int]:
|
||||
"""Return (year, month) for the billing cycle preceding the given one."""
|
||||
if month == 1:
|
||||
return year - 1, 12
|
||||
return year, month - 1
|
||||
|
||||
|
||||
def compute_actuals_by_source(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict[str, dict]:
|
||||
"""Query actual transaction totals for a calendar month, grouped by source."""
|
||||
start, end = get_month_range(year, month)
|
||||
"""Query actual transaction totals grouped by source.
|
||||
|
||||
Credit card uses billing cycle (18th-18th) with deferred logic.
|
||||
Cash/Transfer use calendar month (1st-1st).
|
||||
"""
|
||||
# CC billing cycle for budget month M is the cycle that *ends* around the 18th of M
|
||||
# i.e. cycle (M-1): from (M-1)/18 to M/18, paid with month M salary
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
cal_start, cal_end = get_month_range(year, month)
|
||||
|
||||
results = {}
|
||||
for source in TransactionSource:
|
||||
compra = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
)
|
||||
).one()
|
||||
devolucion = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
)
|
||||
).one()
|
||||
count = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
).one()
|
||||
if source == TransactionSource.CREDIT_CARD:
|
||||
start, end = cc_start, cc_end
|
||||
# Normal transactions in this cycle (not deferred)
|
||||
compra_normal = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
# Deferred from previous cycle
|
||||
compra_deferred = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
compra = float(compra_normal) + float(compra_deferred)
|
||||
|
||||
compra_val = float(compra)
|
||||
devolucion_val = float(devolucion)
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra_val,
|
||||
"total_devolucion": devolucion_val,
|
||||
"net": compra_val - devolucion_val,
|
||||
"count": count,
|
||||
}
|
||||
dev_normal = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
dev_deferred = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
devolucion = float(dev_normal) + float(dev_deferred)
|
||||
|
||||
count_normal = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
count_deferred = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
).one()
|
||||
count = count_normal + count_deferred
|
||||
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra,
|
||||
"total_devolucion": devolucion,
|
||||
"net": compra - devolucion,
|
||||
"count": count,
|
||||
}
|
||||
else:
|
||||
# Cash / Transfer: calendar month, no deferred logic
|
||||
compra = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.COMPRA,
|
||||
)
|
||||
).one()
|
||||
devolucion = session.exec(
|
||||
select(func.coalesce(func.sum(Transaction.amount), 0)).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type == TransactionType.DEVOLUCION,
|
||||
)
|
||||
).one()
|
||||
count = session.exec(
|
||||
select(func.count()).where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source == source,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
).one()
|
||||
|
||||
compra_val = float(compra)
|
||||
devolucion_val = float(devolucion)
|
||||
results[source.value] = {
|
||||
"source": source.value,
|
||||
"total_compra": compra_val,
|
||||
"total_devolucion": devolucion_val,
|
||||
"net": compra_val - devolucion_val,
|
||||
"count": count,
|
||||
}
|
||||
return results
|
||||
|
||||
|
||||
def compute_actuals_by_category(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict[int, float]:
|
||||
"""Return {category_id: net_amount} for actual transactions in a calendar month."""
|
||||
start, end = get_month_range(year, month)
|
||||
"""Return {category_id: net_amount} for actual transactions.
|
||||
|
||||
rows = session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
Credit card uses billing cycle (18th-18th) with deferred logic.
|
||||
Cash/Transfer use calendar month (1st-1st).
|
||||
"""
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
cal_start, cal_end = get_month_range(year, month)
|
||||
|
||||
totals: dict[int, float] = {}
|
||||
for cat_id, tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||
|
||||
def _merge_rows(rows: list) -> None:
|
||||
for cat_id, tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||
|
||||
# 1) CC normal in this cycle (not deferred)
|
||||
_merge_rows(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# 2) CC deferred from previous cycle
|
||||
_merge_rows(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# 3) Non-CC: calendar month
|
||||
_merge_rows(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_not(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def compute_cc_by_category(
|
||||
session: Session, year: int, month: int
|
||||
) -> list[dict]:
|
||||
"""Return credit card spending by category for the billing cycle."""
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
|
||||
totals: dict[int | None, float] = {}
|
||||
|
||||
def _merge(rows: list) -> None:
|
||||
for cat_id, tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
totals[cat_id] = totals.get(cat_id, 0) + val
|
||||
|
||||
# CC normal in this cycle
|
||||
_merge(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
# CC deferred from previous cycle
|
||||
_merge(
|
||||
session.exec(
|
||||
select(
|
||||
Transaction.category_id,
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.category_id, Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
# Resolve category names
|
||||
from app.models.models import Category
|
||||
|
||||
result = []
|
||||
for cat_id, amount in totals.items():
|
||||
if amount <= 0:
|
||||
continue
|
||||
if cat_id is not None:
|
||||
cat = session.get(Category, cat_id)
|
||||
name = cat.name if cat else "Sin categoría"
|
||||
else:
|
||||
name = "Sin categoría"
|
||||
result.append({"category_name": name, "amount": round(amount, 2)})
|
||||
|
||||
return sorted(result, key=lambda x: x["amount"], reverse=True)
|
||||
|
||||
|
||||
def compute_monthly_projection(
|
||||
session: Session, year: int, month: int
|
||||
) -> dict:
|
||||
@@ -215,30 +442,71 @@ def compute_monthly_projection(
|
||||
if cat_id not in covered_category_ids:
|
||||
uncovered_actual += amount
|
||||
|
||||
# Also add transactions with no category
|
||||
start, end = get_month_range(year, month)
|
||||
uncategorized = session.exec(
|
||||
select(
|
||||
Transaction.transaction_type,
|
||||
func.sum(Transaction.amount),
|
||||
)
|
||||
.where(
|
||||
Transaction.date >= start,
|
||||
Transaction.date < end,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
for tx_type, amount in uncategorized:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
uncovered_actual += val
|
||||
# Also add transactions with no category (hybrid ranges + deferred)
|
||||
cc_cycle_y, cc_cycle_m = get_previous_cycle(year, month)
|
||||
cc_start, cc_end = get_cycle_range(cc_cycle_y, cc_cycle_m)
|
||||
prev_cc_y, prev_cc_m = get_previous_cycle(cc_cycle_y, cc_cycle_m)
|
||||
prev_start, prev_end = get_cycle_range(prev_cc_y, prev_cc_m)
|
||||
cal_start, cal_end = get_month_range(year, month)
|
||||
|
||||
def _sum_uncategorized(rows: list) -> float:
|
||||
total = 0.0
|
||||
for tx_type, amount in rows:
|
||||
val = float(amount)
|
||||
if tx_type == TransactionType.DEVOLUCION:
|
||||
val = -val
|
||||
total += val
|
||||
return total
|
||||
|
||||
# CC uncategorized: this cycle (not deferred)
|
||||
uncovered_actual += _sum_uncategorized(
|
||||
session.exec(
|
||||
select(Transaction.transaction_type, func.sum(Transaction.amount))
|
||||
.where(
|
||||
Transaction.date >= cc_start,
|
||||
Transaction.date < cc_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == False, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
# CC uncategorized: deferred from previous cycle
|
||||
uncovered_actual += _sum_uncategorized(
|
||||
session.exec(
|
||||
select(Transaction.transaction_type, func.sum(Transaction.amount))
|
||||
.where(
|
||||
Transaction.date >= prev_start,
|
||||
Transaction.date < prev_end,
|
||||
Transaction.source == TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
Transaction.deferred_to_next_cycle == True, # noqa: E712
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
# Non-CC uncategorized: calendar month
|
||||
uncovered_actual += _sum_uncategorized(
|
||||
session.exec(
|
||||
select(Transaction.transaction_type, func.sum(Transaction.amount))
|
||||
.where(
|
||||
Transaction.date >= cal_start,
|
||||
Transaction.date < cal_end,
|
||||
Transaction.source != TransactionSource.CREDIT_CARD,
|
||||
Transaction.category_id.is_(None), # type: ignore[union-attr]
|
||||
Transaction.transaction_type != TransactionType.DEPOSITO,
|
||||
)
|
||||
.group_by(Transaction.transaction_type)
|
||||
).all()
|
||||
)
|
||||
|
||||
actual_credit_card = actuals_by_source.get("CREDIT_CARD", {}).get("net", 0)
|
||||
actual_cash = actuals_by_source.get("CASH", {}).get("net", 0)
|
||||
actual_transfers = actuals_by_source.get("TRANSFER", {}).get("net", 0)
|
||||
cc_by_category = compute_cc_by_category(session, year, month)
|
||||
|
||||
gran_total = total_fixed_expenses + uncovered_actual
|
||||
# Savings are NOT deducted — they are already deducted from gross salary
|
||||
@@ -261,6 +529,7 @@ def compute_monthly_projection(
|
||||
"expense_items": expense_items,
|
||||
"savings_items": savings_items,
|
||||
"actuals_by_source": list(actuals_by_source.values()),
|
||||
"cc_by_category": cc_by_category,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user