from typing import List, Sequence from .hospitality_bill_split_types import BillItem, DinerShare, SplitMethod from .money_allocate import allocate from .money_amount import Money, money from .money_split_even import split_even from .money_sum import sum_money def _whole(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) def bill_split( items: Sequence[BillItem], service_charge: Money, diners: int, method: SplitMethod, shares: Sequence[int], ) -> List[DinerShare]: """Split a bill between diners so that the shares add up to the bill exactly. even and by-share divide the whole bill (items plus service) by weight, so even totals never differ by more than a penny. by-item gives each diner what they ordered, with shared items split evenly between the people who shared them. In every method the service charge is then shared in proportion to what each diner pays for, so nobody pays service on someone else's steak. """ if not _whole(diners) or diners < 1: raise ValueError("diners must be at least 1, received %s" % (diners,)) if method not in ("even", "by-item", "by-share"): raise ValueError('unknown split method "%s"' % (method,)) if service_charge.minor < 0: raise ValueError("serviceCharge must not be negative, received %d" % (service_charge.minor,)) for item in items: if item.amount.minor < 0: raise ValueError( 'item amounts must not be negative, received %d for "%s"' % (item.amount.minor, item.description) ) currency = service_charge.currency items_total = sum_money([item.amount for item in items], currency) if method == "by-share": if len(shares) != diners: raise ValueError( "shares must have one entry per diner: %d diners, %d shares" % (diners, len(shares)) ) for s in shares: if not _whole(s) or s < 0: raise ValueError("shares must not be negative, received %s" % (s,)) if all(s == 0 for s in shares): raise ValueError("shares must not all be zero") elif len(shares) != 0: raise ValueError("shares are only used by the by-share method; pass []") if method == "by-item": item_shares = [0] * diners for item in items: who = list(range(diners)) if len(item.diners) == 0 else list(item.diners) for i, d in enumerate(who): if not _whole(d) or d < 0 or d >= diners: raise ValueError( "diner %s is not at the table: diners are numbered 0 to %d" % (d, diners - 1) ) if who.index(d) != i: raise ValueError('item "%s" lists diner %d twice' % (item.description, d)) parts = split_even(item.amount, len(who)) for i, d in enumerate(who): item_shares[d] += parts[i].minor if items_total.minor == 0: if service_charge.minor != 0: raise ValueError("a service charge cannot be shared on a bill with no items") sc_shares = [0] * diners else: sc_shares = [m.minor for m in allocate(service_charge, item_shares)] else: weights = [1] * diners if method == "even" else list(shares) grand = money(items_total.minor + service_charge.minor, currency) totals = [m.minor for m in allocate(grand, weights)] sc_shares = [0] * diners if grand.minor == 0 else [m.minor for m in allocate(service_charge, totals)] item_shares = [t - sc_shares[i] for i, t in enumerate(totals)] return [ DinerShare( diner=d, items=money(item_shares[d], currency), service_charge=money(sc_shares[d], currency), total=money(item_shares[d] + sc_shares[d], currency), ) for d in range(diners) ]