from typing import List, Sequence from .charity_restricted_funds_types import Fund, FundAllocation, FundDraw, FundSpend from .money_amount import Money, money def _same_currency(currency: str, amount: Money) -> None: if amount.currency != currency: raise ValueError("currency mismatch: %s and %s" % (amount.currency, currency)) def allocate_fund_spend(funds: Sequence[Fund], spends: Sequence[FundSpend]) -> FundAllocation: """Charge spending to funds the way charity fund accounting requires: a spend for a restricted purpose uses that purpose's restricted funds first (in the order given), and only what they cannot cover falls on the unrestricted funds. General spending never touches a restricted fund, and no fund may go below zero. """ if len(funds) > 0: currency = funds[0].balance.currency elif len(spends) > 0: currency = spends[0].amount.currency else: currency = "GBP" seen = set() for fund in funds: if fund.id in seen: raise ValueError('duplicate fund id "%s"' % (fund.id,)) seen.add(fund.id) _same_currency(currency, fund.balance) if fund.restricted and fund.purpose is None: raise ValueError('restricted fund "%s" needs a purpose' % (fund.id,)) if not fund.restricted and fund.purpose is not None: raise ValueError('unrestricted fund "%s" must not have a purpose' % (fund.id,)) if fund.balance.minor < 0: raise ValueError('fund "%s" balance must not be negative, received %d' % (fund.id, fund.balance.minor)) for spend in spends: _same_currency(currency, spend.amount) if spend.amount.minor < 0: raise ValueError('spend "%s" amount must not be negative, received %d' % (spend.id, spend.amount.minor)) balances = [f.balance.minor for f in funds] draws: List[FundDraw] = [] for spend in spends: remaining = spend.amount.minor order = [] if spend.purpose is not None: order += [i for i, f in enumerate(funds) if f.restricted and f.purpose == spend.purpose] order += [i for i, f in enumerate(funds) if not f.restricted] for i in order: amount = min(remaining, balances[i]) if amount <= 0: continue balances[i] -= amount remaining -= amount draws.append(FundDraw(spend_id=spend.id, fund_id=funds[i].id, amount=money(amount, currency))) if remaining > 0: raise ValueError('insufficient funds for spend "%s": short by %d' % (spend.id, remaining)) return FundAllocation( draws=draws, funds=[ Fund(id=f.id, restricted=f.restricted, purpose=f.purpose, balance=money(balances[i], currency)) for i, f in enumerate(funds) ], )