335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""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
|