from typing import List, Sequence from .finance_invoice_calculate import Invoice, InvoiceLine, calculate_invoice from .finance_invoice_line_total import line_total from .money_allocate import allocate from .money_amount import assert_same_currency, money from .money_apply_rate import apply_rate from .subscriptions_invoice_types import Discount def _is_int(value: object) -> bool: return not isinstance(value, bool) and isinstance(value, int) def _check_discount(d: Discount, currency: str) -> None: if (d.basis_points is None) == (d.amount_off is None): raise ValueError('a discount needs exactly one of basisPoints and amountOff: "%s"' % (d.description,)) if d.basis_points is not None and (not _is_int(d.basis_points) or d.basis_points < 0 or d.basis_points > 10000): raise ValueError("discount basis points must be between 0 and 10000, received %r" % (d.basis_points,)) if d.amount_off is not None: assert_same_currency(money(0, currency), d.amount_off) if d.amount_off.minor < 0: raise ValueError("discount amount must not be negative, received %d" % (d.amount_off.minor,)) if not _is_int(d.start_period) or d.start_period < 1: raise ValueError("discount start period must be 1 or more, received %r" % (d.start_period,)) if d.duration == "repeating": if d.duration_periods is None or not _is_int(d.duration_periods) or d.duration_periods < 1: raise ValueError( "a repeating discount needs durationPeriods of 1 or more, received %r" % (d.duration_periods,) ) elif d.duration in ("once", "forever"): if d.duration_periods is not None: raise ValueError('durationPeriods applies only to a repeating discount: "%s"' % (d.description,)) else: raise ValueError('unknown discount duration "%s": expected once, repeating or forever' % (d.duration,)) def _applies(d: Discount, period: int) -> bool: if d.duration == "once": return period == d.start_period if d.duration == "repeating": return d.start_period <= period < d.start_period + d.duration_periods return period >= d.start_period def renewal_invoice( plan: InvoiceLine, add_ons: Sequence[InvoiceLine], usage: Sequence[InvoiceLine], discounts: Sequence[Discount], period_number: int, jurisdiction: str, invoice_date: str, ) -> Invoice: """The renewal invoice for one billing period: plan, add-ons and usage, less the coupons in force in that period, taxed per line by finance.invoice.calculate. Each coupon becomes negative lines, one per VAT category, so the reduction is taxed at the rate of what it reduces. """ if not _is_int(period_number) or period_number < 1: raise ValueError("period number must be 1 or more, received %r" % (period_number,)) currency = plan.unit_price.currency items = [plan, *add_ons, *usage] categories: List[str] = [] category_net: List[int] = [] running = 0 for item in items: net = line_total(item.unit_price, item.quantity, item.discount_basis_points) assert_same_currency(money(0, currency), net) if item.tax_category not in categories: categories.append(item.tax_category) category_net.append(0) k = categories.index(item.tax_category) category_net[k] += net.minor running += net.minor # Credit-only categories take no share of a discount. weights = [max(n, 0) for n in category_net] discount_lines: List[InvoiceLine] = [] for d in discounts: _check_discount(d, currency) if not _applies(d, period_number) or running <= 0: continue if d.basis_points is not None: amount = apply_rate(money(running, currency), d.basis_points, "half-up").minor else: amount = min(d.amount_off.minor, running) if amount == 0: continue running -= amount for k, share in enumerate(allocate(money(amount, currency), weights)): if share.minor != 0: discount_lines.append( InvoiceLine( description=d.description, unit_price=money(-share.minor, currency), quantity=1, discount_basis_points=0, tax_category=categories[k], ) ) return calculate_invoice(items + discount_lines, jurisdiction, invoice_date)