e43428c1a2
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 20s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Failing after 24s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 1m34s
891 lines
31 KiB
Python
891 lines
31 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.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
|
|
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:
|
|
from app.ledger.models import Posting
|
|
|
|
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;
|
|
- 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}
|
|
categories = db.query(Category).filter_by(space_id=space_id, archived=False).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
|
|
|
|
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
|
|
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":
|
|
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
|
|
)
|
|
|
|
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),
|
|
"planned": 0,
|
|
"funded": funded_period[cat.id],
|
|
"spent": monthly_consumption[cat.id],
|
|
"available": available,
|
|
}
|
|
)
|
|
envelope_rows: list[dict[str, Any]] = []
|
|
for cat in categories:
|
|
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
|
|
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),
|
|
"funded_total": funded_all_time[cat.id],
|
|
"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"]) 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")
|
|
category = Category(space_id=space_id, kind=kind, custom_name=name)
|
|
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 == "credit_reserve":
|
|
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 rename_category(
|
|
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID, name: str
|
|
) -> Category:
|
|
category = get_category(db, space_id=space_id, category_id=category_id)
|
|
if category.system_key is not None:
|
|
category.name_override = name
|
|
else:
|
|
category.custom_name = name
|
|
audit.record(
|
|
db,
|
|
event_type="category.renamed",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={"category_id": str(category.id)},
|
|
)
|
|
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 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")
|
|
get_category(db, space_id=space_id, category_id=category_id)
|
|
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}
|