from typing import Optional from .math_round_div import round_div from .money_amount import Money, assert_same_currency from .subscriptions_churn_types import ChurnMetrics def _rate(numerator: int, denominator: int) -> Optional[int]: return None if denominator == 0 else round_div(numerator * 10000, denominator, "half-up") def _is_count(value: object) -> bool: return not isinstance(value, bool) and isinstance(value, int) and value >= 0 def churn_metrics( customers_at_start: int, customers_lost: int, mrr_at_start: Money, expansion_mrr: Money, contraction_mrr: Money, churned_mrr: Money, ) -> ChurnMetrics: """Logo churn, gross and net revenue churn, and gross and net revenue retention for one period, each as basis points of the starting cohort. """ if not _is_count(customers_at_start) or not _is_count(customers_lost): raise ValueError( "customer counts must be whole numbers of 0 or more, received %r and %r" % (customers_at_start, customers_lost) ) if customers_lost > customers_at_start: raise ValueError( "customers lost must not exceed customers at start, received %d of %d" % (customers_lost, customers_at_start) ) for amount in (expansion_mrr, contraction_mrr, churned_mrr): assert_same_currency(mrr_at_start, amount) start = mrr_at_start.minor expansion = expansion_mrr.minor lost = contraction_mrr.minor + churned_mrr.minor if start < 0 or expansion < 0 or contraction_mrr.minor < 0 or churned_mrr.minor < 0: raise ValueError("MRR amounts must not be negative") if lost > start: raise ValueError( "contraction and churned MRR must not exceed MRR at start, received %d of %d" % (lost, start) ) return ChurnMetrics( logo_churn_basis_points=_rate(customers_lost, customers_at_start), gross_revenue_churn_basis_points=_rate(lost, start), net_revenue_churn_basis_points=_rate(lost - expansion, start), gross_revenue_retention_basis_points=_rate(start - lost, start), net_revenue_retention_basis_points=_rate(start + expansion - lost, start), )