first ruggedy draft
This commit is contained in:
166
apps/api/mousehold_api/services/settlement.py
Normal file
166
apps/api/mousehold_api/services/settlement.py
Normal file
@@ -0,0 +1,166 @@
|
||||
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
|
||||
Reference in New Issue
Block a user