from __future__ import annotations from decimal import Decimal from mousehold_domain import AccountType, FinancialEventStatus, FinancialEventType from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from .. import models from ..schemas import SettlementSummary, SettlementTransfer, SettlementUserBalance SETTLEMENT_STATUSES = { FinancialEventStatus.CONFIRMED, FinancialEventStatus.BOOKED, FinancialEventStatus.SETTLED, } SETTLEMENT_EVENT_TYPES = { FinancialEventType.EXPENSE, FinancialEventType.FEE, FinancialEventType.INTEREST, FinancialEventType.REFUND, FinancialEventType.REIMBURSEMENT, } def _money(value: Decimal | int | str = "0.00") -> Decimal: return Decimal(value).quantize(Decimal("0.01")) def calculate_settlement( session: Session, *, household_id: str, project_id: str | None = None, currency: str = "EUR", include_announced: bool = False, ) -> SettlementSummary: accepted_statuses = set(SETTLEMENT_STATUSES) if include_announced: accepted_statuses.update({FinancialEventStatus.ANNOUNCED, FinancialEventStatus.OUTSTANDING}) query = ( select(models.FinancialEvent) .where(models.FinancialEvent.household_id == household_id) .options(selectinload(models.FinancialEvent.split_lines)) .order_by(models.FinancialEvent.event_date, models.FinancialEvent.created_at) ) if project_id: query = query.where(models.FinancialEvent.project_id == project_id) events = list(session.scalars(query).all()) accounts_by_id = { account.id: account for account in session.scalars( select(models.Account).where(models.Account.household_id == household_id) ).all() } paid: dict[str, Decimal] = {} share: dict[str, Decimal] = {} included: list[str] = [] excluded: list[str] = [] explanation: list[str] = [] for event in events: if ( event.currency != currency or event.status not in accepted_statuses or event.event_type not in SETTLEMENT_EVENT_TYPES ): excluded.append(event.id) continue payment_account = accounts_by_id.get( event.payment_account_id or event.source_account_id or "" ) if ( payment_account and payment_account.account_type == AccountType.SHARED_CHECKING and not payment_account.owner_user_id ): excluded.append(event.id) explanation.append(f"{event.id} excluded because a neutral shared account paid it.") continue if not event.payer_user_id or not event.split_lines: excluded.append(event.id) explanation.append(f"{event.id} excluded because payer or split lines are missing.") continue included.append(event.id) amount = _money(event.amount) multiplier = ( Decimal("-1.00") if event.event_type == FinancialEventType.REFUND else Decimal("1.00") ) paid[event.payer_user_id] = _money( paid.get(event.payer_user_id, Decimal("0.00")) + (amount * multiplier) ) for split in event.split_lines: share[split.user_id] = _money( share.get(split.user_id, Decimal("0.00")) + (split.share_amount * multiplier) ) user_ids = sorted(set(paid) | set(share)) balances = [ SettlementUserBalance( user_id=user_id, paid=_money(paid.get(user_id, Decimal("0.00"))), share=_money(share.get(user_id, Decimal("0.00"))), net=_money(paid.get(user_id, Decimal("0.00")) - share.get(user_id, Decimal("0.00"))), ) for user_id in user_ids ] transfers = _minimize_transfers(balances, currency) explanation.append( f"Included {len(included)} confirmed/booked/settled events" + (" plus announced/outstanding events." if include_announced else ".") ) if not include_announced: explanation.append( "Announced and outstanding events are excluded from settlement by default." ) return SettlementSummary( household_id=household_id, project_id=project_id, currency=currency, included_event_ids=included, excluded_event_ids=excluded, balances=balances, suggested_transfers=transfers, explanation=explanation, ) def _minimize_transfers( balances: list[SettlementUserBalance], currency: str ) -> list[SettlementTransfer]: debtors = [ {"user_id": balance.user_id, "amount": _money(-balance.net)} for balance in balances if balance.net < 0 ] creditors = [ {"user_id": balance.user_id, "amount": _money(balance.net)} for balance in balances if balance.net > 0 ] transfers: list[SettlementTransfer] = [] debtor_index = 0 creditor_index = 0 while debtor_index < len(debtors) and creditor_index < len(creditors): amount = min(debtors[debtor_index]["amount"], creditors[creditor_index]["amount"]) if amount > 0: transfers.append( SettlementTransfer( from_user_id=debtors[debtor_index]["user_id"], to_user_id=creditors[creditor_index]["user_id"], amount=_money(amount), currency=currency, ) ) debtors[debtor_index]["amount"] = _money(debtors[debtor_index]["amount"] - amount) creditors[creditor_index]["amount"] = _money(creditors[creditor_index]["amount"] - amount) if debtors[debtor_index]["amount"] == 0: debtor_index += 1 if creditors[creditor_index]["amount"] == 0: creditor_index += 1 return transfers