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, 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, "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 category.system_key is not None: 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 category.system_key is not None: 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}