from typing import Dict, Sequence from .math_rational import add_rational, multiply_rational, rational, rational_to_integer from .money_amount import assert_same_currency, money from .subscriptions_mrr_arr_types import MrrArr, RecurringCharge # 1.0.0 declared its own BillingInterval, which callers imported from this # module; it is now subscriptions.next-billing-date's, imported here so they # still can. from .subscriptions_next_billing_date import BillingInterval PER_YEAR: Dict[BillingInterval, int] = {"day": 365, "week": 52, "month": 12, "quarter": 4, "year": 1} def mrr_arr(subscriptions: Sequence[RecurringCharge], currency: str) -> MrrArr: """MRR and ARR from active and past-due subscriptions. Every charge becomes an exact annual amount first, and each total is rounded once at the end, so a list of annual plans does not lose a penny per plan. """ zero = money(0, currency) annual = rational(0, 1) counted = 0 excluded = 0 for s in subscriptions: assert_same_currency(zero, s.amount) if s.amount.minor < 0: raise ValueError("recurring amount must not be negative, received %d" % (s.amount.minor,)) if isinstance(s.interval_count, bool) or not isinstance(s.interval_count, int) or s.interval_count < 1: raise ValueError("interval count must be 1 or more, received %r" % (s.interval_count,)) if s.interval not in PER_YEAR: raise ValueError( 'unknown billing interval "%s": expected day, week, month, quarter or year' % (s.interval,) ) if s.status in ("active", "past-due"): counted += 1 annual = add_rational(annual, rational(s.amount.minor * PER_YEAR[s.interval], s.interval_count)) elif s.status in ("trialing", "paused", "canceled"): excluded += 1 else: raise ValueError( 'unknown subscription status "%s": expected active, past-due, trialing, paused or canceled' % (s.status,) ) return MrrArr( mrr=money(rational_to_integer(multiply_rational(annual, rational(1, 12)), "half-up"), currency), arr=money(rational_to_integer(annual, "half-up"), currency), counted=counted, excluded=excluded, )