63d065011b
- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes. - Added new files related to MCC categories and optional hold reasons in the manifest. - Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
983 lines
35 KiB
Python
983 lines
35 KiB
Python
import calendar
|
|
import uuid
|
|
from collections import defaultdict
|
|
from datetime import UTC, date, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.audit import service as audit
|
|
from app.budgeting.models import (
|
|
BudgetPeriod,
|
|
ExpectedIncome,
|
|
ExpectedIncomeMatch,
|
|
FundingMovement,
|
|
PeriodClosure,
|
|
PlanAssignment,
|
|
)
|
|
from app.categorization import mcc
|
|
from app.categorization.service import resolve_category_name
|
|
from app.errors import bad_request, conflict, not_found
|
|
from app.identity.models import User
|
|
from app.ledger.models import Account, Category, Operation, Posting
|
|
from app.ledger.service import ensure_period_open_for_date as _ledger_guard # noqa: F401
|
|
|
|
|
|
def validate_year_month(year: int, month: int) -> None:
|
|
if not 1 <= month <= 12 or year < 2000:
|
|
raise bad_request("BUDGET_PERIOD_INVALID", "Invalid year/month")
|
|
|
|
|
|
def period_bounds(year: int, month: int) -> tuple[date, date]:
|
|
start = date(year, month, 1)
|
|
if month == 12:
|
|
end = date(year + 1, 1, 1)
|
|
else:
|
|
end = date(year, month + 1, 1)
|
|
return start, end
|
|
|
|
|
|
def shift_month(d: date, months: int) -> date:
|
|
index = d.year * 12 + (d.month - 1) + months
|
|
year, month = divmod(index, 12)
|
|
month += 1
|
|
return date(year, month, min(d.day, calendar.monthrange(year, month)[1]))
|
|
|
|
|
|
def get_period(db: Session, *, space_id: uuid.UUID, period_id: uuid.UUID) -> BudgetPeriod:
|
|
period = db.query(BudgetPeriod).filter_by(id=period_id, space_id=space_id).one_or_none()
|
|
if period is None:
|
|
raise not_found("BUDGET_PERIOD_NOT_FOUND", "Budget period not found")
|
|
return period
|
|
|
|
|
|
def get_or_create_period(
|
|
db: Session, *, space_id: uuid.UUID, year: int, month: int
|
|
) -> BudgetPeriod:
|
|
validate_year_month(year, month)
|
|
period = (
|
|
db.query(BudgetPeriod).filter_by(space_id=space_id, year=year, month=month).one_or_none()
|
|
)
|
|
if period is None:
|
|
period = BudgetPeriod(space_id=space_id, year=year, month=month, status="open")
|
|
db.add(period)
|
|
db.flush()
|
|
return period
|
|
|
|
|
|
def list_periods(db: Session, *, space_id: uuid.UUID) -> list[BudgetPeriod]:
|
|
return (
|
|
db.query(BudgetPeriod)
|
|
.filter_by(space_id=space_id)
|
|
.order_by(BudgetPeriod.year.desc(), BudgetPeriod.month.desc())
|
|
.all()
|
|
)
|
|
|
|
|
|
def _previous_ym(year: int, month: int) -> tuple[int, int]:
|
|
return (year - 1, 12) if month == 1 else (year, month - 1)
|
|
|
|
|
|
def _closure_snapshot(db: Session, *, space_id: uuid.UUID, year: int, month: int) -> dict | None:
|
|
period = (
|
|
db.query(BudgetPeriod)
|
|
.filter_by(space_id=space_id, year=year, month=month, status="closed")
|
|
.one_or_none()
|
|
)
|
|
if period is None:
|
|
return None
|
|
closure = (
|
|
db.query(PeriodClosure)
|
|
.filter_by(period_id=period.id)
|
|
.order_by(PeriodClosure.closed_at.desc())
|
|
.first()
|
|
)
|
|
return closure.snapshot if closure else None
|
|
|
|
|
|
def _balance_until(db: Session, account: Account, end: date) -> int:
|
|
total = (
|
|
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
|
|
.join(Operation, Operation.id == Posting.operation_id)
|
|
.filter(
|
|
Posting.account_id == account.id,
|
|
Operation.status == "confirmed",
|
|
func.coalesce(Operation.posted_on, Operation.occurred_on) <= end,
|
|
)
|
|
.scalar()
|
|
)
|
|
return account.opening_amount_kopecks + int(total or 0)
|
|
|
|
|
|
def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeriod) -> dict:
|
|
"""Deterministic budget state.
|
|
|
|
Resource model:
|
|
- income lands in the general pool;
|
|
- categorized debit/cash expenses consume their category's available amount;
|
|
- uncategorized debit/cash expenses reduce the pool directly (unallocated deficit);
|
|
- credit expenses grow debt; the funded portion of a credit expense moves from
|
|
the category into the account's credit reserve (derived, not stored);
|
|
- a credit payment is covered by the credit reserve first, the remainder
|
|
reduces the pool (which may go negative);
|
|
- a refund on a credit card reduces debt and releases any surplus reserve;
|
|
- a same-period refund restores the parent purchase's category; a refund on
|
|
an uncategorized expense restores the pool;
|
|
- transfers into the system savings envelope are carved out of the pool as
|
|
its backing; transfers back out return to the pool;
|
|
- adjustments reconcile a stored balance through one signed posting and are
|
|
excluded from income/expense totals, category spend and cash flow;
|
|
- monthly categories fold their residual into the pool at close; savings
|
|
envelopes and credit reserves persist via all-time/ snapshot carries.
|
|
"""
|
|
start, end = period_bounds(period.year, period.month)
|
|
|
|
accounts = db.query(Account).filter_by(space_id=space_id).all()
|
|
account_by_id = {a.id: a for a in accounts}
|
|
savings_account = next((a for a in accounts if a.system_key == "savings"), None)
|
|
# Archived categories stay in the computation: any plan/funding/operations
|
|
# history in the selected period must keep affecting totals.
|
|
categories = db.query(Category).filter_by(space_id=space_id).all()
|
|
|
|
ops = (
|
|
db.query(Operation)
|
|
.filter(
|
|
Operation.space_id == space_id,
|
|
Operation.status == "confirmed",
|
|
Operation.occurred_on >= start,
|
|
Operation.occurred_on < end,
|
|
)
|
|
.all()
|
|
)
|
|
|
|
income_total = 0
|
|
monthly_consumption: dict[Any, int] = defaultdict(int)
|
|
envelope_consumption_total: dict[Any, int] = defaultdict(int)
|
|
credit_expense_period: dict[Any, int] = defaultdict(int)
|
|
asset_expense_period_uncategorized = 0
|
|
portions_by_account: dict[Any, int] = defaultdict(int)
|
|
expense_account_by_cat: dict[Any, dict] = defaultdict(lambda: defaultdict(int))
|
|
payments_by_account: dict[Any, int] = defaultdict(int)
|
|
refunds_to_credit_by_account: dict[Any, int] = defaultdict(int)
|
|
refund_back_uncategorized_period = 0
|
|
# Net transfers into the system savings envelope for this month (+ into,
|
|
# - back out). Adjustments are excluded from every flow aggregate.
|
|
savings_net_period = 0
|
|
|
|
for op in ops:
|
|
if op.type == "income":
|
|
income_total += op.amount_kopecks
|
|
elif op.type == "expense":
|
|
account = account_by_id[op.account_id]
|
|
if account.kind == "credit":
|
|
credit_expense_period[op.category_id] += op.amount_kopecks
|
|
expense_account_by_cat[op.category_id][account.id] += op.amount_kopecks
|
|
if op.category_id is None:
|
|
if account.kind != "credit":
|
|
asset_expense_period_uncategorized += op.amount_kopecks
|
|
else:
|
|
monthly_consumption[op.category_id] += op.amount_kopecks
|
|
elif op.type == "transfer":
|
|
counter = (
|
|
account_by_id.get(op.counter_account_id)
|
|
if op.counter_account_id is not None
|
|
else None
|
|
)
|
|
if counter is not None and counter.kind == "credit":
|
|
payments_by_account[counter.id] += op.amount_kopecks
|
|
source = account_by_id.get(op.account_id)
|
|
if counter is not None and counter.system_key == "savings":
|
|
savings_net_period += op.amount_kopecks
|
|
if source is not None and source.system_key == "savings":
|
|
savings_net_period -= op.amount_kopecks
|
|
elif op.type == "refund":
|
|
parent_account = account_by_id.get(op.account_id)
|
|
parent = db.get(Operation, op.parent_operation_id) if op.parent_operation_id else None
|
|
if parent_account is not None and parent_account.kind == "credit":
|
|
refunds_to_credit_by_account[parent_account.id] += op.amount_kopecks
|
|
elif parent is not None and parent.category_id is not None:
|
|
same_period = start <= parent.occurred_on < end
|
|
if same_period:
|
|
monthly_consumption[parent.category_id] -= op.amount_kopecks
|
|
else:
|
|
refund_back_uncategorized_period += op.amount_kopecks
|
|
else:
|
|
refund_back_uncategorized_period += op.amount_kopecks
|
|
|
|
# Funding movements of this period and all-time envelope funding.
|
|
funded_period: dict[Any, int] = defaultdict(int)
|
|
for kind, sign in (("fund", 1), ("unfund", -1)):
|
|
rows = (
|
|
db.query(FundingMovement.category_id, func.sum(FundingMovement.amount_kopecks))
|
|
.filter(
|
|
FundingMovement.space_id == space_id,
|
|
FundingMovement.period_id == period.id,
|
|
FundingMovement.kind == kind,
|
|
)
|
|
.group_by(FundingMovement.category_id)
|
|
.all()
|
|
)
|
|
for category_id, total in rows:
|
|
funded_period[category_id] += sign * int(total or 0)
|
|
funding_total = sum(funded_period.values())
|
|
|
|
funded_all_time: dict[Any, int] = defaultdict(int)
|
|
for kind, sign in (("fund", 1), ("unfund", -1)):
|
|
rows = (
|
|
db.query(FundingMovement.category_id, func.sum(FundingMovement.amount_kopecks))
|
|
.filter(
|
|
FundingMovement.space_id == space_id,
|
|
FundingMovement.kind == kind,
|
|
)
|
|
.group_by(FundingMovement.category_id)
|
|
.all()
|
|
)
|
|
for category_id, total in rows:
|
|
funded_all_time[category_id] += sign * int(total or 0)
|
|
|
|
# All-time envelope consumption up to period end.
|
|
for cat in categories:
|
|
if cat.kind != "savings_envelope" or cat.system_key == "savings":
|
|
continue
|
|
consumed = (
|
|
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
|
|
.filter(
|
|
Operation.space_id == space_id,
|
|
Operation.status == "confirmed",
|
|
Operation.type == "expense",
|
|
Operation.category_id == cat.id,
|
|
Operation.occurred_on < end,
|
|
)
|
|
.scalar()
|
|
)
|
|
restored = 0
|
|
refunds = (
|
|
db.query(Operation)
|
|
.filter(
|
|
Operation.space_id == space_id,
|
|
Operation.status == "confirmed",
|
|
Operation.type == "refund",
|
|
Operation.occurred_on < end,
|
|
Operation.parent_operation_id.is_not(None),
|
|
)
|
|
.all()
|
|
)
|
|
for refund in refunds:
|
|
parent = db.get(Operation, refund.parent_operation_id)
|
|
if (
|
|
parent is not None
|
|
and parent.category_id == cat.id
|
|
and parent.occurred_on.year == refund.occurred_on.year
|
|
and parent.occurred_on.month == refund.occurred_on.month
|
|
):
|
|
restored += refund.amount_kopecks
|
|
envelope_consumption_total[cat.id] += int(consumed or 0) - restored
|
|
|
|
# Credit funded portions per category (period-scoped derivation).
|
|
credit_funded_portion: dict[Any, int] = defaultdict(int)
|
|
for category_id, credit_amount in list(credit_expense_period.items()):
|
|
if category_id is None:
|
|
continue
|
|
noncredit_consumption = monthly_consumption[category_id] - credit_amount
|
|
available = funded_period[category_id] - noncredit_consumption
|
|
portion = min(max(available, 0), credit_amount)
|
|
credit_funded_portion[category_id] += portion
|
|
if credit_amount:
|
|
allocation = expense_account_by_cat[category_id]
|
|
remaining = portion
|
|
for index, (account_id, amount) in enumerate(allocation.items()):
|
|
account_portion = (
|
|
remaining if index == len(allocation) - 1 else portion * amount // credit_amount
|
|
)
|
|
portions_by_account[account_id] += account_portion
|
|
remaining -= account_portion
|
|
|
|
prev_snapshot = _closure_snapshot(
|
|
db,
|
|
space_id=space_id,
|
|
year=_previous_ym(period.year, period.month)[0],
|
|
month=_previous_ym(period.year, period.month)[1],
|
|
)
|
|
carry_pool = int(prev_snapshot.get("carry_pool", 0)) if prev_snapshot else 0
|
|
carry_reserves: dict[str, int] = (
|
|
{k: int(v) for k, v in prev_snapshot.get("credit_reserves", {}).items()}
|
|
if prev_snapshot
|
|
else {}
|
|
)
|
|
|
|
# Reserve and debt per credit account.
|
|
credit_reserves: dict[str, int] = {}
|
|
credit_debt: dict[str, int] = {}
|
|
releases_payment_total = 0
|
|
surplus_release_total = 0
|
|
for account in accounts:
|
|
if account.kind != "credit":
|
|
continue
|
|
key = str(account.id)
|
|
reserve = carry_reserves.get(key, 0) + portions_by_account[account.id]
|
|
payment = payments_by_account[account.id]
|
|
covered = min(reserve, payment)
|
|
releases_payment_total += covered
|
|
reserve -= covered
|
|
reserve -= refunds_to_credit_by_account[account.id]
|
|
debt = max(-_balance_until(db, account, end), 0)
|
|
surplus = max(0, reserve - debt)
|
|
surplus_release_total += surplus
|
|
credit_reserves[key] = min(max(reserve, 0), debt)
|
|
credit_debt[key] = debt
|
|
|
|
uncategorized_outflow = asset_expense_period_uncategorized - refund_back_uncategorized_period
|
|
payments_total = sum(payments_by_account.values())
|
|
|
|
pool = (
|
|
carry_pool
|
|
+ income_total
|
|
- uncategorized_outflow
|
|
- (payments_total - releases_payment_total)
|
|
+ surplus_release_total
|
|
- funding_total
|
|
- savings_net_period
|
|
)
|
|
|
|
monthly_rows: list[dict[str, Any]] = []
|
|
monthly_residual_total = 0
|
|
for cat in categories:
|
|
if cat.kind != "monthly":
|
|
continue
|
|
available = funded_period[cat.id] - monthly_consumption[cat.id]
|
|
monthly_residual_total += available
|
|
monthly_rows.append(
|
|
{
|
|
"category_id": str(cat.id),
|
|
"name": resolve_category_name(cat),
|
|
"name_key": cat.system_key,
|
|
"system": not mcc.is_budgetable_category_key(cat.system_key),
|
|
"archived": cat.archived,
|
|
"planned": 0,
|
|
"funded": funded_period[cat.id],
|
|
"spent": monthly_consumption[cat.id],
|
|
"available": available,
|
|
}
|
|
)
|
|
# All-time net transfer movement into the system savings account (+ in, - out).
|
|
savings_net_all_time = 0
|
|
if savings_account is not None:
|
|
savings_net_all_time = int(
|
|
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
|
|
.join(Operation, Operation.id == Posting.operation_id)
|
|
.filter(
|
|
Posting.account_id == savings_account.id,
|
|
Operation.status == "confirmed",
|
|
Operation.type == "transfer",
|
|
)
|
|
.scalar()
|
|
or 0
|
|
)
|
|
|
|
envelope_rows: list[dict[str, Any]] = []
|
|
for cat in categories:
|
|
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
|
|
continue
|
|
if cat.system_key == "savings":
|
|
# System savings envelope: funded shows the net transfer movement of
|
|
# the selected month, spent is always zero and available tracks the
|
|
# linked account balance at month end (adjustments included).
|
|
available = _balance_until(db, savings_account, end) if savings_account else 0
|
|
envelope_rows.append(
|
|
{
|
|
"category_id": str(cat.id),
|
|
"name": resolve_category_name(cat),
|
|
"name_key": cat.system_key,
|
|
"archived": cat.archived,
|
|
"system": True,
|
|
"funded_total": savings_net_all_time,
|
|
"planned": 0,
|
|
"funded": savings_net_period,
|
|
"spent": 0,
|
|
"available": available,
|
|
}
|
|
)
|
|
continue
|
|
available = funded_all_time[cat.id] - envelope_consumption_total[cat.id]
|
|
envelope_rows.append(
|
|
{
|
|
"category_id": str(cat.id),
|
|
"name": resolve_category_name(cat),
|
|
"name_key": cat.system_key,
|
|
"archived": cat.archived,
|
|
"system": cat.system_key is not None,
|
|
"funded_total": funded_all_time[cat.id],
|
|
"planned": 0,
|
|
"funded": funded_period[cat.id],
|
|
"spent": funded_all_time[cat.id] - available,
|
|
"available": available,
|
|
}
|
|
)
|
|
|
|
assignments = db.query(PlanAssignment).filter_by(period_id=period.id).all()
|
|
planned_by_cat = {a.category_id: a.planned_kopecks for a in assignments}
|
|
for row in monthly_rows:
|
|
cid = uuid.UUID(str(row["category_id"]))
|
|
row["planned"] = planned_by_cat.get(cid, 0)
|
|
planned_total = sum(planned_by_cat.values())
|
|
|
|
expected = db.query(ExpectedIncome).filter_by(period_id=period.id).all()
|
|
expected_total = sum(e.amount_kopecks for e in expected)
|
|
matched_by_expected: dict[Any, int] = defaultdict(int)
|
|
for m in (
|
|
db.query(ExpectedIncomeMatch)
|
|
.filter(ExpectedIncomeMatch.expected_income_id.in_([e.id for e in expected] or [None]))
|
|
.all()
|
|
if expected
|
|
else []
|
|
):
|
|
matched_by_expected[m.expected_income_id] += m.amount_kopecks
|
|
|
|
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
|
|
negative_envelopes = [
|
|
str(row["name"] or row["name_key"] or row["category_id"])
|
|
for row in envelope_rows
|
|
if int(row["available"]) < 0
|
|
]
|
|
|
|
warnings = []
|
|
if planned_total > carry_pool + expected_total:
|
|
warnings.append("plan_imbalance")
|
|
for key, debt in credit_debt.items():
|
|
if debt > credit_reserves.get(key, 0):
|
|
warnings.append("underfunded_credit_debt")
|
|
break
|
|
if uncategorized_count:
|
|
warnings.append("uncategorized_expenses")
|
|
if negative_envelopes:
|
|
warnings.append("negative_envelopes")
|
|
|
|
return {
|
|
"period": {
|
|
"id": str(period.id),
|
|
"year": period.year,
|
|
"month": period.month,
|
|
"status": period.status,
|
|
},
|
|
"pool": pool,
|
|
"carry_pool": carry_pool,
|
|
"income_total": income_total,
|
|
"planned_total": planned_total,
|
|
"expected_income_total": expected_total,
|
|
"expected_incomes": [
|
|
{
|
|
"id": str(e.id),
|
|
"expected_on": e.expected_on.isoformat(),
|
|
"amount_kopecks": e.amount_kopecks,
|
|
"source_name": e.source_name,
|
|
"matched_kopecks": matched_by_expected.get(e.id, 0),
|
|
}
|
|
for e in expected
|
|
],
|
|
"monthly_categories": monthly_rows,
|
|
"envelopes": envelope_rows,
|
|
"credit_reserves": credit_reserves,
|
|
"credit_debt": credit_debt,
|
|
"uncategorized_expenses": uncategorized_count,
|
|
"warnings": warnings,
|
|
"close_blockers": (
|
|
[]
|
|
if period.status == "closed"
|
|
else _close_blockers(
|
|
db,
|
|
space_id=space_id,
|
|
period=period,
|
|
uncategorized_count=uncategorized_count,
|
|
negative_envelopes=negative_envelopes,
|
|
)
|
|
),
|
|
}
|
|
|
|
|
|
def _close_blockers(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
period: BudgetPeriod,
|
|
uncategorized_count: int,
|
|
negative_envelopes: list[str],
|
|
) -> list[dict]:
|
|
from app.imports.models import HoldResolution, SourceRecord, StatementDocument
|
|
|
|
blockers = []
|
|
unresolved = (
|
|
db.query(StatementDocument.id)
|
|
.filter(
|
|
StatementDocument.space_id == space_id,
|
|
StatementDocument.status.in_(
|
|
["uploaded", "queued", "processing", "ready", "review_required"]
|
|
),
|
|
)
|
|
.count()
|
|
)
|
|
if unresolved:
|
|
blockers.append({"code": "IMPORTS_UNRESOLVED", "count": unresolved})
|
|
start, end = period_bounds(period.year, period.month)
|
|
unresolved_holds = (
|
|
db.query(SourceRecord.id)
|
|
.join(
|
|
StatementDocument,
|
|
(StatementDocument.space_id == SourceRecord.space_id)
|
|
& (StatementDocument.id == SourceRecord.document_id),
|
|
)
|
|
.outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id)
|
|
.filter(
|
|
SourceRecord.space_id == space_id,
|
|
SourceRecord.bank_status == "hold",
|
|
StatementDocument.status == "confirmed",
|
|
StatementDocument.active_parse_run_id == SourceRecord.parse_run_id,
|
|
HoldResolution.id.is_(None),
|
|
SourceRecord.occurred_on >= start,
|
|
SourceRecord.occurred_on < end,
|
|
)
|
|
.count()
|
|
)
|
|
if unresolved_holds:
|
|
blockers.append({"code": "IMPORT_UNRESOLVED_HOLDS", "count": unresolved_holds})
|
|
if uncategorized_count:
|
|
blockers.append({"code": "BUDGET_UNCATEGORIZED_EXPENSES", "count": uncategorized_count})
|
|
if negative_envelopes:
|
|
blockers.append({"code": "BUDGET_NEGATIVE_ENVELOPE", "categories": negative_envelopes})
|
|
return blockers
|
|
|
|
|
|
def require_open(period: BudgetPeriod) -> None:
|
|
if period.status != "open":
|
|
raise conflict(
|
|
"BUDGET_PERIOD_CLOSED", "Budget period is closed", details={"period_id": str(period.id)}
|
|
)
|
|
|
|
|
|
def get_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID) -> Category:
|
|
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
|
|
if category is None:
|
|
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
|
|
return category
|
|
|
|
|
|
def create_category(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
user: User,
|
|
kind: str,
|
|
name: str,
|
|
) -> Category:
|
|
if kind not in ("monthly", "savings_envelope"):
|
|
raise bad_request("BUDGET_CATEGORY_KIND_INVALID", "Unknown category kind")
|
|
trimmed = name.strip()
|
|
if not trimmed:
|
|
raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required")
|
|
category = Category(space_id=space_id, kind=kind, custom_name=trimmed[:200])
|
|
db.add(category)
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="category.created",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"category_id": str(category.id)},
|
|
)
|
|
return category
|
|
|
|
|
|
def ensure_credit_reserve_category(
|
|
db: Session, *, space_id: uuid.UUID, account: Account
|
|
) -> Category:
|
|
category = (
|
|
db.query(Category)
|
|
.filter_by(space_id=space_id, system_key="credit_reserve", linked_account_id=account.id)
|
|
.one_or_none()
|
|
)
|
|
if category is None:
|
|
category = Category(
|
|
space_id=space_id,
|
|
kind="savings_envelope",
|
|
system_key="credit_reserve",
|
|
linked_account_id=account.id,
|
|
)
|
|
db.add(category)
|
|
db.flush()
|
|
return category
|
|
|
|
|
|
def list_categories(
|
|
db: Session, *, space_id: uuid.UUID, include_archived: bool = False
|
|
) -> list[Category]:
|
|
query = db.query(Category).filter_by(space_id=space_id)
|
|
if not include_archived:
|
|
query = query.filter(Category.archived.is_(False))
|
|
return query.order_by(Category.created_at).all()
|
|
|
|
|
|
def archive_category(
|
|
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID
|
|
) -> Category:
|
|
category = get_category(db, space_id=space_id, category_id=category_id)
|
|
if category.system_key is not None:
|
|
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived")
|
|
category.archived = True
|
|
audit.record(
|
|
db,
|
|
event_type="category.archived",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"category_id": str(category.id)},
|
|
)
|
|
return category
|
|
|
|
|
|
def update_category_definition(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
user: User,
|
|
category_id: uuid.UUID,
|
|
name: str | None = None,
|
|
archived: bool | None = None,
|
|
) -> Category:
|
|
"""Rename and/or archive/restore one user category definition.
|
|
|
|
System categories reject both changes; ``archived=False`` restores an
|
|
archived user category. History (plans, funding, operations) is preserved.
|
|
"""
|
|
category = get_category(db, space_id=space_id, category_id=category_id)
|
|
if category.system_key is not None:
|
|
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be renamed or archived")
|
|
changes: dict[str, bool | str] = {}
|
|
if name is not None:
|
|
trimmed = name.strip()
|
|
if not trimmed:
|
|
raise bad_request("BUDGET_CATEGORY_NAME_REQUIRED", "Category name is required")
|
|
category.custom_name = trimmed[:200]
|
|
changes["renamed"] = True
|
|
if archived is not None:
|
|
category.archived = archived
|
|
changes["archived"] = archived
|
|
if changes:
|
|
audit.record(
|
|
db,
|
|
event_type="category.updated",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"category_id": str(category.id), **changes},
|
|
)
|
|
return category
|
|
|
|
|
|
def set_assignments(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
user: User,
|
|
period: BudgetPeriod,
|
|
items: list[dict],
|
|
) -> list[PlanAssignment]:
|
|
require_open(period)
|
|
result = []
|
|
for item in items:
|
|
category = get_category(db, space_id=space_id, category_id=item["category_id"])
|
|
if not mcc.is_budgetable_category_key(category.system_key):
|
|
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be planned")
|
|
if category.archived:
|
|
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
|
planned = item["planned_kopecks"]
|
|
if planned < 0:
|
|
raise bad_request("BUDGET_PLANNED_NEGATIVE", "Planned amount cannot be negative")
|
|
assignment = (
|
|
db.query(PlanAssignment)
|
|
.filter_by(period_id=period.id, category_id=category.id)
|
|
.one_or_none()
|
|
)
|
|
if assignment is None:
|
|
assignment = PlanAssignment(
|
|
space_id=space_id,
|
|
period_id=period.id,
|
|
category_id=category.id,
|
|
planned_kopecks=planned,
|
|
)
|
|
db.add(assignment)
|
|
else:
|
|
assignment.planned_kopecks = planned
|
|
result.append(assignment)
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="budget.assignments_set",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"period_id": str(period.id), "count": len(result)},
|
|
)
|
|
return result
|
|
|
|
|
|
def add_expected_income(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
user: User,
|
|
period: BudgetPeriod,
|
|
expected_on: date,
|
|
amount_kopecks: int,
|
|
source_name: str | None,
|
|
note: str | None = None,
|
|
) -> ExpectedIncome:
|
|
require_open(period)
|
|
if amount_kopecks <= 0:
|
|
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
|
income = ExpectedIncome(
|
|
space_id=space_id,
|
|
period_id=period.id,
|
|
expected_on=expected_on,
|
|
amount_kopecks=amount_kopecks,
|
|
source_name=source_name,
|
|
note=note,
|
|
)
|
|
db.add(income)
|
|
db.flush()
|
|
return income
|
|
|
|
|
|
def match_expected_income(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
user: User,
|
|
expected_income_id: uuid.UUID,
|
|
operation_id: uuid.UUID,
|
|
amount_kopecks: int | None = None,
|
|
) -> ExpectedIncomeMatch:
|
|
expected = (
|
|
db.query(ExpectedIncome).filter_by(id=expected_income_id, space_id=space_id).one_or_none()
|
|
)
|
|
if expected is None:
|
|
raise not_found("BUDGET_EXPECTED_INCOME_NOT_FOUND", "Expected income not found")
|
|
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
|
|
if operation is None or operation.type != "income" or operation.status != "confirmed":
|
|
raise not_found("BUDGET_INCOME_OPERATION_NOT_FOUND", "Confirmed income operation not found")
|
|
existing = db.query(ExpectedIncomeMatch).filter_by(operation_id=operation.id).one_or_none()
|
|
if existing is not None:
|
|
raise conflict("BUDGET_INCOME_ALREADY_MATCHED", "Income already matched to expected income")
|
|
amount = (
|
|
amount_kopecks
|
|
if amount_kopecks is not None
|
|
else min(operation.amount_kopecks, expected.amount_kopecks)
|
|
)
|
|
if amount <= 0 or amount > operation.amount_kopecks:
|
|
raise bad_request(
|
|
"BUDGET_MATCH_AMOUNT_INVALID",
|
|
"Match amount must be within the income operation",
|
|
)
|
|
match = ExpectedIncomeMatch(
|
|
space_id=space_id,
|
|
expected_income_id=expected.id,
|
|
operation_id=operation.id,
|
|
amount_kopecks=amount,
|
|
)
|
|
db.add(match)
|
|
db.flush()
|
|
return match
|
|
|
|
|
|
def fund(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
user: User,
|
|
period: BudgetPeriod,
|
|
category_id: uuid.UUID,
|
|
amount_kopecks: int,
|
|
kind: str,
|
|
note: str | None = None,
|
|
) -> FundingMovement:
|
|
require_open(period)
|
|
if kind not in ("fund", "unfund"):
|
|
raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind")
|
|
if amount_kopecks <= 0:
|
|
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
|
category = get_category(db, space_id=space_id, category_id=category_id)
|
|
if not mcc.is_budgetable_category_key(category.system_key):
|
|
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be funded")
|
|
if category.archived:
|
|
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
|
movement = FundingMovement(
|
|
space_id=space_id,
|
|
period_id=period.id,
|
|
category_id=category_id,
|
|
kind=kind,
|
|
amount_kopecks=amount_kopecks,
|
|
note=note,
|
|
created_by_user_id=user.id,
|
|
)
|
|
db.add(movement)
|
|
db.flush()
|
|
return movement
|
|
|
|
|
|
def close_period(db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod) -> dict:
|
|
period = (
|
|
db.query(BudgetPeriod).filter_by(id=period.id, space_id=space_id).with_for_update().one()
|
|
)
|
|
require_open(period)
|
|
previous_year, previous_month = _previous_ym(period.year, period.month)
|
|
earlier_period = (
|
|
db.query(BudgetPeriod)
|
|
.filter(
|
|
BudgetPeriod.space_id == space_id,
|
|
(BudgetPeriod.year < period.year)
|
|
| ((BudgetPeriod.year == period.year) & (BudgetPeriod.month < period.month)),
|
|
)
|
|
.order_by(BudgetPeriod.year.desc(), BudgetPeriod.month.desc())
|
|
.with_for_update()
|
|
.first()
|
|
)
|
|
if earlier_period is not None and (
|
|
earlier_period.year != previous_year
|
|
or earlier_period.month != previous_month
|
|
or earlier_period.status != "closed"
|
|
):
|
|
raise conflict(
|
|
"BUDGET_CLOSE_OUT_OF_ORDER",
|
|
"Close the previous calendar month before this period",
|
|
details={"year": previous_year, "month": previous_month},
|
|
)
|
|
state = compute_period_state(db, space_id=space_id, period=period)
|
|
if state["close_blockers"]:
|
|
raise conflict(
|
|
"BUDGET_CLOSE_BLOCKED",
|
|
"Period has blocking issues",
|
|
details={"blockers": state["close_blockers"]},
|
|
)
|
|
monthly_residual = sum(row["available"] for row in state["monthly_categories"])
|
|
snapshot = {
|
|
"period": state["period"],
|
|
"closed_at": datetime.now(UTC).isoformat(),
|
|
"pool": state["pool"],
|
|
"carry_pool": state["pool"] + monthly_residual,
|
|
"income_total": state["income_total"],
|
|
"planned_total": state["planned_total"],
|
|
"monthly_categories": state["monthly_categories"],
|
|
"envelopes": state["envelopes"],
|
|
"credit_reserves": state["credit_reserves"],
|
|
"credit_debt": state["credit_debt"],
|
|
"warnings": state["warnings"],
|
|
}
|
|
closure = PeriodClosure(
|
|
space_id=space_id,
|
|
period_id=period.id,
|
|
snapshot=snapshot,
|
|
closed_at=datetime.now(UTC),
|
|
closed_by_user_id=user.id,
|
|
)
|
|
period.status = "closed"
|
|
db.add(closure)
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="budget.closed",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"period_id": str(period.id)},
|
|
)
|
|
return snapshot
|
|
|
|
|
|
def reopen_period(
|
|
db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod
|
|
) -> list[BudgetPeriod]:
|
|
period = (
|
|
db.query(BudgetPeriod).filter_by(id=period.id, space_id=space_id).with_for_update().one()
|
|
)
|
|
if period.status != "closed":
|
|
raise conflict("BUDGET_PERIOD_NOT_CLOSED", "Only closed periods can be reopened")
|
|
affected = (
|
|
db.query(BudgetPeriod)
|
|
.filter(
|
|
BudgetPeriod.space_id == space_id,
|
|
BudgetPeriod.status == "closed",
|
|
(BudgetPeriod.year > period.year)
|
|
| ((BudgetPeriod.year == period.year) & (BudgetPeriod.month >= period.month)),
|
|
)
|
|
.order_by(BudgetPeriod.year, BudgetPeriod.month)
|
|
.all()
|
|
)
|
|
for p in affected:
|
|
p.status = "open"
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="budget.reopened",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"period_id": str(period.id), "impacted": [str(p.id) for p in affected]},
|
|
)
|
|
return affected
|
|
|
|
|
|
def copy_previous_month(
|
|
db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod
|
|
) -> dict:
|
|
require_open(period)
|
|
prev_year, prev_month = _previous_ym(period.year, period.month)
|
|
previous = (
|
|
db.query(BudgetPeriod)
|
|
.filter_by(space_id=space_id, year=prev_year, month=prev_month)
|
|
.one_or_none()
|
|
)
|
|
if previous is None:
|
|
raise not_found("BUDGET_PREVIOUS_PERIOD_MISSING", "Previous month period does not exist")
|
|
copied_assignments = 0
|
|
for assignment in db.query(PlanAssignment).filter_by(period_id=previous.id).all():
|
|
category = get_category(db, space_id=space_id, category_id=assignment.category_id)
|
|
if category.archived:
|
|
continue
|
|
existing = (
|
|
db.query(PlanAssignment)
|
|
.filter_by(period_id=period.id, category_id=assignment.category_id)
|
|
.one_or_none()
|
|
)
|
|
if existing is None:
|
|
db.add(
|
|
PlanAssignment(
|
|
space_id=space_id,
|
|
period_id=period.id,
|
|
category_id=assignment.category_id,
|
|
planned_kopecks=assignment.planned_kopecks,
|
|
)
|
|
)
|
|
else:
|
|
existing.planned_kopecks = assignment.planned_kopecks
|
|
copied_assignments += 1
|
|
copied_expected = 0
|
|
for expected in db.query(ExpectedIncome).filter_by(period_id=previous.id).all():
|
|
db.add(
|
|
ExpectedIncome(
|
|
space_id=space_id,
|
|
period_id=period.id,
|
|
expected_on=shift_month(expected.expected_on, 1),
|
|
amount_kopecks=expected.amount_kopecks,
|
|
source_name=expected.source_name,
|
|
note=expected.note,
|
|
)
|
|
)
|
|
copied_expected += 1
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="budget.copied",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={
|
|
"period_id": str(period.id),
|
|
"assignments": copied_assignments,
|
|
"expected": copied_expected,
|
|
},
|
|
)
|
|
return {"assignments_copied": copied_assignments, "expected_incomes_copied": copied_expected}
|