"""System savings account/envelope, balance adjustments and reversible conversions. The savings "convert" identity is defined exclusively by ``system_key``: user-created accounts or categories that merely share the name «Накопления» are never captured. Every command is tenant-scoped, period-guarded and audited; balances stay derived (postings only, never stored mutations). """ import uuid from datetime import date from sqlalchemy.orm import Session from app.audit import service as audit from app.errors import bad_request, conflict, not_found from app.identity.models import User from app.ledger.models import Account, Category, Operation, Posting SAVINGS_SYSTEM_KEY = "savings" SAVINGS_ACCOUNT_NAME = "Накопления" def get_savings_account(db: Session, *, space_id: uuid.UUID) -> Account | None: return ( db.query(Account) .filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY) .one_or_none() ) def _savings_category(db: Session, *, space_id: uuid.UUID) -> Category | None: return ( db.query(Category) .filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY) .one_or_none() ) def ensure_savings_resources(db: Session, *, space_id: uuid.UUID) -> tuple[Account, Category]: """Idempotently return the single system savings account and its envelope. Any duplicate or inconsistent system pair is an explicit conflict, never a random pick: identity is keyed by ``system_key`` only. """ accounts = db.query(Account).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all() if len(accounts) > 1: raise conflict( "LEDGER_SAVINGS_CONFLICT", "Multiple system savings accounts exist in this space", ) account: Account | None = accounts[0] if accounts else None created = False if account is None: account = Account( space_id=space_id, name=SAVINGS_ACCOUNT_NAME, kind="savings", system_key=SAVINGS_SYSTEM_KEY, opened_on=date.today(), opening_amount_kopecks=0, ) db.add(account) db.flush() created = True categories = ( db.query(Category).filter_by(space_id=space_id, system_key=SAVINGS_SYSTEM_KEY).all() ) if len(categories) > 1: raise conflict( "LEDGER_SAVINGS_CONFLICT", "Multiple system savings categories exist in this space", ) category: Category | None = categories[0] if categories else None if category is not None and category.linked_account_id != account.id: raise conflict( "LEDGER_SAVINGS_CONFLICT", "System savings category is linked to a different account", ) if category is None: category = Category( space_id=space_id, kind="savings_envelope", system_key=SAVINGS_SYSTEM_KEY, linked_account_id=account.id, ) db.add(category) db.flush() created = True if created: audit.record( db, event_type="savings.resources_ensured", space_id=space_id, detail={ "account_id": str(account.id), "category_id": str(category.id), }, ) return account, category def transfer_category_for_accounts( db: Session, *, space_id: uuid.UUID, source: Account, target: Account ) -> Category | None: """System category for a manual transfer touching the savings account.""" if SAVINGS_SYSTEM_KEY not in {source.system_key, target.system_key}: return None _, category = ensure_savings_resources(db, space_id=space_id) return category def adjust_balance( db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID, target_balance_kopecks: int, occurred_on: date, reason: str, ) -> tuple[Operation, int, int]: """Reconcile a derived balance with reality via one audited adjustment. The operation carries one signed posting for the delta; ``amount_kopecks`` stays positive and the sign lives in the posting. Adjustments never touch income/expense totals, category spend, cash flow or the budget pool. """ if not reason.strip(): raise bad_request("LEDGER_ADJUST_REASON_REQUIRED", "An adjustment reason is required") account = ( db.query(Account) .filter_by(id=account_id, space_id=space_id) .with_for_update() .one_or_none() ) if account is None: raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found") if account.status != "active": raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived") if account.kind == "credit": raise conflict( "LEDGER_ADJUST_CREDIT_UNSUPPORTED", "Credit account balances cannot be adjusted with a non-negative target", ) if target_balance_kopecks < 0: raise bad_request( "LEDGER_ADJUST_TARGET_NEGATIVE", "Target balance cannot be negative" ) from app.ledger.service import account_balance, ensure_period_open_for_date ensure_period_open_for_date(db, space_id=space_id, day=occurred_on) old_balance = account_balance(db, account) delta = target_balance_kopecks - old_balance if delta == 0: raise conflict( "LEDGER_ADJUST_NOOP", "Balance already equals the target; nothing to adjust", details={"balance_kopecks": old_balance}, ) operation = Operation( space_id=space_id, type="adjustment", amount_kopecks=abs(delta), account_id=account.id, occurred_on=occurred_on, description=reason.strip(), created_by_user_id=user.id, ) db.add(operation) db.flush() db.add( Posting( space_id=space_id, operation_id=operation.id, account_id=account.id, amount_kopecks=delta, ) ) audit.record( db, event_type="account.balance_adjusted", space_id=space_id, user_id=user.id, detail={ "account_id": str(account.id), "operation_id": str(operation.id), "old_balance_kopecks": old_balance, "new_balance_kopecks": target_balance_kopecks, "delta_kopecks": delta, "reason": reason.strip(), }, ) return operation, old_balance, target_balance_kopecks def convert_expense_to_savings( db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID ) -> Operation: """Turn a confirmed expense into a transfer into the system savings account. Keeps the original negative posting plus description, payee, request key and import provenance; adds one positive posting on the savings account and remembers the previous category for exact reversion. """ operation = ( db.query(Operation) .filter_by(id=operation_id, space_id=space_id) .with_for_update() .one_or_none() ) if operation is None: raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found") if operation.status != "confirmed": raise conflict( "LEDGER_SAVINGS_CONVERT_INVALID", "Only confirmed operations can be converted", ) if operation.type != "expense": raise conflict( "LEDGER_SAVINGS_CONVERT_INVALID", "Only expenses can be converted into savings transfers", ) if operation.converted_from_expense: raise conflict( "LEDGER_SAVINGS_CONVERT_INVALID", "Operation is already a savings transfer", ) source_account = ( db.query(Account).filter_by(id=operation.account_id, space_id=space_id).one_or_none() ) if source_account is None: raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found") if source_account.system_key is not None: raise conflict( "LEDGER_SAVINGS_CONVERT_INVALID", "Operations on system accounts cannot be converted", ) confirmed_refund = ( db.query(Operation.id) .filter_by( space_id=space_id, parent_operation_id=operation.id, type="refund", status="confirmed", ) .first() ) if confirmed_refund is not None: raise conflict( "LEDGER_SAVINGS_CONVERT_HAS_REFUNDS", "Expenses with confirmed refunds cannot be converted into savings transfers", ) from app.ledger.service import ensure_period_open_for_date ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on) savings_account, savings_category = ensure_savings_resources(db, space_id=space_id) previous_category_id = operation.category_id operation.previous_category_id = previous_category_id operation.converted_from_expense = True operation.type = "transfer" operation.counter_account_id = savings_account.id operation.category_id = savings_category.id db.add( Posting( space_id=space_id, operation_id=operation.id, account_id=savings_account.id, amount_kopecks=operation.amount_kopecks, ) ) audit.record( db, event_type="operation.savings_transfer", space_id=space_id, user_id=user.id, detail={ "operation_id": str(operation.id), "savings_account_id": str(savings_account.id), "amount_kopecks": operation.amount_kopecks, "previous_category_id": ( str(previous_category_id) if previous_category_id else None ), }, ) return operation def revert_savings_conversion( db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID ) -> Operation: """Undo a marked conversion: restore the expense type, category and postings.""" operation = ( db.query(Operation) .filter_by(id=operation_id, space_id=space_id) .with_for_update() .one_or_none() ) if operation is None: raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found") if operation.status != "confirmed": raise conflict( "LEDGER_SAVINGS_REVERT_INVALID", "Only confirmed operations can be reverted", ) if operation.type != "transfer" or not operation.converted_from_expense: raise conflict( "LEDGER_SAVINGS_REVERT_INVALID", "Only converted expenses can be reverted from savings", ) from app.ledger.service import ensure_period_open_for_date ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on) counter_account_id = operation.counter_account_id if counter_account_id is not None: savings_posting = ( db.query(Posting) .filter_by(operation_id=operation.id, account_id=counter_account_id) .one_or_none() ) if savings_posting is not None: db.delete(savings_posting) operation.type = "expense" operation.counter_account_id = None operation.category_id = operation.previous_category_id operation.converted_from_expense = False operation.previous_category_id = None audit.record( db, event_type="operation.savings_transfer_reverted", space_id=space_id, user_id=user.id, detail={"operation_id": str(operation.id)}, ) return operation