Files
finance/backend/app/budgeting/service.py
T
k1nq 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
Implement Gitea CI and real statement imports
2026-09-18 17:59:02 +05:00

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}