Files
mousehold/apps/api/mousehold_api/services/settlement.py
2026-06-28 13:48:15 +02:00

167 lines
5.7 KiB
Python

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