167 lines
5.7 KiB
Python
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
|