Coverage for polar/metrics/metrics.py: 98%
523 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1from collections import deque
2from collections.abc import Iterable
3from datetime import datetime
4from enum import StrEnum
5from typing import TYPE_CHECKING, ClassVar, Protocol, cast
7if TYPE_CHECKING: 7 ↛ 8line 7 didn't jump to line 8 because the condition on line 7 was never true
8 from .schemas import MetricsPeriod
10from sqlalchemy import (
11 ColumnElement,
12 Float,
13 Integer,
14 SQLColumnExpression,
15 case,
16 func,
17 or_,
18 type_coerce,
19)
21from polar.enums import SubscriptionRecurringInterval
22from polar.kit.time_queries import TimeInterval
23from polar.models import Checkout, Order, Subscription
24from polar.models.checkout import CheckoutStatus
25from polar.models.event import Event
26from polar.models.subscription import CustomerCancellationReason
28from .queries import MetricQuery
31class MetricType(StrEnum):
32 scalar = "scalar"
33 currency = "currency"
34 currency_sub_cent = "currency_sub_cent"
35 percentage = "percentage"
38def cumulative_sum(periods: Iterable["MetricsPeriod"], slug: str) -> int | float:
39 return sum(getattr(p, slug) for p in periods)
42def cumulative_last(periods: Iterable["MetricsPeriod"], slug: str) -> int | float:
43 dd = deque((getattr(p, slug) for p in periods), maxlen=1)
44 return dd.pop()
47class Metric(Protocol):
48 slug: ClassVar[str]
49 display_name: ClassVar[str]
50 type: ClassVar[MetricType]
52 @classmethod
53 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float: ... 53 ↛ anywhereline 53 didn't jump anywhere: it always raised an exception.
56class SQLMetric(Metric, Protocol):
57 query: ClassVar[MetricQuery]
59 @classmethod
60 def get_sql_expression( 60 ↛ anywhereline 60 didn't jump anywhere: it always raised an exception.
61 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
62 ) -> ColumnElement[int] | ColumnElement[float]: ...
65class MetaMetric(Metric, Protocol):
66 @classmethod
67 def compute_from_period(cls, period: "MetricsPeriod") -> int | float: ... 67 ↛ exitline 67 didn't return from function 'compute_from_period' because
70class OrdersMetric(SQLMetric):
71 slug = "orders"
72 display_name = "Orders"
73 type = MetricType.scalar
74 query = MetricQuery.orders
76 @classmethod
77 def get_sql_expression(
78 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
79 ) -> ColumnElement[int]:
80 return func.count(Order.id)
82 @classmethod
83 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
84 return cumulative_sum(periods, cls.slug)
87class RevenueMetric(SQLMetric):
88 slug = "revenue"
89 display_name = "Revenue"
90 type = MetricType.currency
91 query = MetricQuery.orders
93 @classmethod
94 def get_sql_expression(
95 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
96 ) -> ColumnElement[int]:
97 return func.sum(Order.net_amount)
99 @classmethod
100 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
101 return cumulative_sum(periods, cls.slug)
104class NetRevenueMetric(SQLMetric):
105 slug = "net_revenue"
106 display_name = "Net Revenue"
107 type = MetricType.currency
108 query = MetricQuery.orders
110 @classmethod
111 def get_sql_expression(
112 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
113 ) -> ColumnElement[int]:
114 return func.sum(Order.payout_amount)
116 @classmethod
117 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
118 return cumulative_sum(periods, cls.slug)
121class CumulativeRevenueMetric(SQLMetric):
122 slug = "cumulative_revenue"
123 display_name = "Cumulative Revenue"
124 type = MetricType.currency
125 query = MetricQuery.orders
127 @classmethod
128 def get_sql_expression(
129 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
130 ) -> ColumnElement[int]:
131 return func.sum(Order.net_amount)
133 @classmethod
134 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
135 return cumulative_last(periods, cls.slug)
138class NetCumulativeRevenueMetric(SQLMetric):
139 slug = "net_cumulative_revenue"
140 display_name = "Net Cumulative Revenue"
141 type = MetricType.currency
142 query = MetricQuery.orders
144 @classmethod
145 def get_sql_expression(
146 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
147 ) -> ColumnElement[int]:
148 return func.sum(Order.payout_amount)
150 @classmethod
151 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
152 return cumulative_last(periods, cls.slug)
155class AverageOrderValueMetric(SQLMetric):
156 slug = "average_order_value"
157 display_name = "Average Order Value"
158 type = MetricType.currency
159 query = MetricQuery.orders
161 @classmethod
162 def get_sql_expression(
163 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
164 ) -> ColumnElement[int]:
165 return func.cast(func.ceil(func.avg(Order.net_amount)), Integer)
167 @classmethod
168 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
169 total_orders = sum(getattr(p, "orders") for p in periods)
170 revenue = sum(getattr(p, "revenue") for p in periods)
171 return revenue / total_orders if total_orders > 0 else 0.0
174class NetAverageOrderValueMetric(SQLMetric):
175 slug = "net_average_order_value"
176 display_name = "Net Average Order Value"
177 type = MetricType.currency
178 query = MetricQuery.orders
180 @classmethod
181 def get_sql_expression(
182 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
183 ) -> ColumnElement[int]:
184 return func.cast(func.ceil(func.avg(Order.payout_amount)), Integer)
186 @classmethod
187 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
188 total_orders = sum(getattr(p, "orders") for p in periods)
189 revenue = sum(getattr(p, "net_revenue") for p in periods)
190 return revenue / total_orders if total_orders > 0 else 0.0
193class OneTimeProductsMetric(SQLMetric):
194 slug = "one_time_products"
195 display_name = "One-Time Products"
196 type = MetricType.scalar
197 query = MetricQuery.orders
199 @classmethod
200 def get_sql_expression(
201 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
202 ) -> ColumnElement[int]:
203 return func.count(Order.id).filter(Order.subscription_id.is_(None))
205 @classmethod
206 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
207 return cumulative_sum(periods, cls.slug)
210class OneTimeProductsRevenueMetric(SQLMetric):
211 slug = "one_time_products_revenue"
212 display_name = "One-Time Products Revenue"
213 type = MetricType.currency
214 query = MetricQuery.orders
216 @classmethod
217 def get_sql_expression(
218 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
219 ) -> ColumnElement[int]:
220 return func.sum(Order.net_amount).filter(Order.subscription_id.is_(None))
222 @classmethod
223 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
224 return cumulative_sum(periods, cls.slug)
227class OneTimeProductsNetRevenueMetric(SQLMetric):
228 slug = "one_time_products_net_revenue"
229 display_name = "One-Time Products Net Revenue"
230 type = MetricType.currency
231 query = MetricQuery.orders
233 @classmethod
234 def get_sql_expression(
235 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
236 ) -> ColumnElement[int]:
237 return func.sum(Order.payout_amount).filter(Order.subscription_id.is_(None))
239 @classmethod
240 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
241 return cumulative_sum(periods, cls.slug)
244class NewSubscriptionsMetric(SQLMetric):
245 slug = "new_subscriptions"
246 display_name = "New Subscriptions"
247 type = MetricType.scalar
248 query = MetricQuery.active_subscriptions
250 @classmethod
251 def get_sql_expression(
252 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
253 ) -> ColumnElement[int]:
254 return func.count(Subscription.id).filter(
255 i.sql_date_trunc(
256 cast(SQLColumnExpression[datetime], Subscription.started_at)
257 )
258 == i.sql_date_trunc(t)
259 )
261 @classmethod
262 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
263 return cumulative_sum(periods, cls.slug)
266class NewSubscriptionsRevenueMetric(SQLMetric):
267 slug = "new_subscriptions_revenue"
268 display_name = "New Subscriptions Revenue"
269 type = MetricType.currency
270 query = MetricQuery.orders
272 @classmethod
273 def get_sql_expression(
274 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
275 ) -> ColumnElement[int]:
276 return func.sum(Order.net_amount).filter(
277 i.sql_date_trunc(
278 cast(SQLColumnExpression[datetime], Subscription.started_at)
279 )
280 == i.sql_date_trunc(t)
281 )
283 @classmethod
284 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
285 return cumulative_sum(periods, cls.slug)
288class NewSubscriptionsNetRevenueMetric(SQLMetric):
289 slug = "new_subscriptions_net_revenue"
290 display_name = "New Subscriptions Net Revenue"
291 type = MetricType.currency
292 query = MetricQuery.orders
294 @classmethod
295 def get_sql_expression(
296 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
297 ) -> ColumnElement[int]:
298 return func.sum(Order.payout_amount).filter(
299 i.sql_date_trunc(
300 cast(SQLColumnExpression[datetime], Subscription.started_at)
301 )
302 == i.sql_date_trunc(t)
303 )
305 @classmethod
306 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
307 return cumulative_sum(periods, cls.slug)
310class RenewedSubscriptionsMetric(SQLMetric):
311 slug = "renewed_subscriptions"
312 display_name = "Renewed Subscriptions"
313 type = MetricType.scalar
314 query = MetricQuery.orders
316 @classmethod
317 def get_sql_expression(
318 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
319 ) -> ColumnElement[int]:
320 return func.count(Subscription.id.distinct()).filter(
321 i.sql_date_trunc(
322 cast(SQLColumnExpression[datetime], Subscription.started_at)
323 )
324 != i.sql_date_trunc(t)
325 )
327 @classmethod
328 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
329 return cumulative_sum(periods, cls.slug)
332class RenewedSubscriptionsRevenueMetric(SQLMetric):
333 slug = "renewed_subscriptions_revenue"
334 display_name = "Renewed Subscriptions Revenue"
335 type = MetricType.currency
336 query = MetricQuery.orders
338 @classmethod
339 def get_sql_expression(
340 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
341 ) -> ColumnElement[int]:
342 return func.sum(Order.net_amount).filter(
343 i.sql_date_trunc(
344 cast(SQLColumnExpression[datetime], Subscription.started_at)
345 )
346 != i.sql_date_trunc(t)
347 )
349 @classmethod
350 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
351 return cumulative_sum(periods, cls.slug)
354class RenewedSubscriptionsNetRevenueMetric(SQLMetric):
355 slug = "renewed_subscriptions_net_revenue"
356 display_name = "Renewed Subscriptions Net Revenue"
357 type = MetricType.currency
358 query = MetricQuery.orders
360 @classmethod
361 def get_sql_expression(
362 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
363 ) -> ColumnElement[int]:
364 return func.sum(Order.payout_amount).filter(
365 i.sql_date_trunc(
366 cast(SQLColumnExpression[datetime], Subscription.started_at)
367 )
368 != i.sql_date_trunc(t)
369 )
371 @classmethod
372 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
373 return cumulative_sum(periods, cls.slug)
376class ActiveSubscriptionsMetric(SQLMetric):
377 slug = "active_subscriptions"
378 display_name = "Active Subscriptions"
379 type = MetricType.scalar
380 query = MetricQuery.active_subscriptions
382 @classmethod
383 def get_sql_expression(
384 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
385 ) -> ColumnElement[int]:
386 return func.count(Subscription.id)
388 @classmethod
389 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
390 return cumulative_last(periods, cls.slug)
393class MonthlyRecurringRevenueMetric(SQLMetric):
394 slug = "monthly_recurring_revenue"
395 display_name = "Monthly Recurring Revenue"
396 type = MetricType.currency
397 query = MetricQuery.active_subscriptions
399 @classmethod
400 def get_sql_expression(
401 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
402 ) -> ColumnElement[int]:
403 return func.coalesce(
404 func.sum(
405 case(
406 (
407 Subscription.recurring_interval
408 == SubscriptionRecurringInterval.year,
409 func.round(Subscription.amount / 12),
410 ),
411 (
412 Subscription.recurring_interval
413 == SubscriptionRecurringInterval.month,
414 Subscription.amount,
415 ),
416 (
417 Subscription.recurring_interval
418 == SubscriptionRecurringInterval.week,
419 func.round(Subscription.amount * 4),
420 ),
421 (
422 Subscription.recurring_interval
423 == SubscriptionRecurringInterval.day,
424 func.round(Subscription.amount * 30),
425 ),
426 )
427 ),
428 0,
429 )
431 @classmethod
432 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
433 return cumulative_last(periods, cls.slug)
436class CommittedMonthlyRecurringRevenueMetric(SQLMetric):
437 slug = "committed_monthly_recurring_revenue"
438 display_name = "Committed Monthly Recurring Revenue"
439 type = MetricType.currency
440 query = MetricQuery.active_subscriptions
442 @classmethod
443 def get_sql_expression(
444 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
445 ) -> ColumnElement[int]:
446 return func.coalesce(
447 func.sum(
448 case(
449 (
450 Subscription.recurring_interval
451 == SubscriptionRecurringInterval.year,
452 func.round(Subscription.amount / 12),
453 ),
454 (
455 Subscription.recurring_interval
456 == SubscriptionRecurringInterval.month,
457 Subscription.amount,
458 ),
459 (
460 Subscription.recurring_interval
461 == SubscriptionRecurringInterval.week,
462 func.round(Subscription.amount * 4),
463 ),
464 (
465 Subscription.recurring_interval
466 == SubscriptionRecurringInterval.day,
467 func.round(Subscription.amount * 30),
468 ),
469 )
470 ).filter(
471 or_(
472 func.coalesce(Subscription.ended_at, Subscription.ends_at).is_(
473 None
474 ),
475 i.sql_date_trunc(
476 cast(
477 SQLColumnExpression[datetime],
478 func.coalesce(Subscription.ended_at, Subscription.ends_at),
479 )
480 )
481 < i.sql_date_trunc(now),
482 )
483 ),
484 0,
485 )
487 @classmethod
488 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
489 return cumulative_last(periods, cls.slug)
492class CheckoutsMetric(SQLMetric):
493 slug = "checkouts"
494 display_name = "Checkouts"
495 type = MetricType.scalar
496 query = MetricQuery.checkouts
498 @classmethod
499 def get_sql_expression(
500 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
501 ) -> ColumnElement[int]:
502 return func.count(Checkout.id)
504 @classmethod
505 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
506 return cumulative_sum(periods, cls.slug)
509class SucceededCheckoutsMetric(SQLMetric):
510 slug = "succeeded_checkouts"
511 display_name = "Succeeded Checkouts"
512 type = MetricType.scalar
513 query = MetricQuery.checkouts
515 @classmethod
516 def get_sql_expression(
517 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
518 ) -> ColumnElement[int]:
519 return func.count(Checkout.id).filter(
520 Checkout.status == CheckoutStatus.succeeded
521 )
523 @classmethod
524 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
525 return cumulative_sum(periods, cls.slug)
528class CheckoutsConversionMetric(SQLMetric):
529 slug = "checkouts_conversion"
530 display_name = "Checkouts Conversion Rate"
531 type = MetricType.percentage
532 query = MetricQuery.checkouts
534 @classmethod
535 def get_sql_expression(
536 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
537 ) -> ColumnElement[float]:
538 return type_coerce(
539 case(
540 (func.count(Checkout.id) == 0, 0),
541 else_=func.count(Checkout.id).filter(
542 Checkout.status == CheckoutStatus.succeeded
543 )
544 / func.count(Checkout.id),
545 ),
546 Float,
547 )
549 @classmethod
550 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
551 total_checkouts = sum(getattr(p, "checkouts") for p in periods)
552 total_succeeded = sum(getattr(p, "succeeded_checkouts") for p in periods)
553 return total_succeeded / total_checkouts if total_checkouts > 0 else 0.0
556class CanceledSubscriptionsMetric(SQLMetric):
557 slug = "canceled_subscriptions"
558 display_name = "Canceled Subscriptions"
559 type = MetricType.scalar
560 query = MetricQuery.canceled_subscriptions
562 @classmethod
563 def get_sql_expression(
564 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
565 ) -> ColumnElement[int]:
566 return func.count(Subscription.id)
568 @classmethod
569 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
570 return cumulative_sum(periods, cls.slug)
573class CanceledSubscriptionsCustomerServiceMetric(SQLMetric):
574 slug = "canceled_subscriptions_customer_service"
575 display_name = "Canceled Subscriptions - Customer Service"
576 type = MetricType.scalar
577 query = MetricQuery.canceled_subscriptions
579 @classmethod
580 def get_sql_expression(
581 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
582 ) -> ColumnElement[int]:
583 return func.count(Subscription.id).filter(
584 Subscription.customer_cancellation_reason
585 == CustomerCancellationReason.customer_service
586 )
588 @classmethod
589 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
590 return cumulative_sum(periods, cls.slug)
593class CanceledSubscriptionsLowQualityMetric(SQLMetric):
594 slug = "canceled_subscriptions_low_quality"
595 display_name = "Canceled Subscriptions - Low Quality"
596 type = MetricType.scalar
597 query = MetricQuery.canceled_subscriptions
599 @classmethod
600 def get_sql_expression(
601 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
602 ) -> ColumnElement[int]:
603 return func.count(Subscription.id).filter(
604 Subscription.customer_cancellation_reason
605 == CustomerCancellationReason.low_quality
606 )
608 @classmethod
609 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
610 return cumulative_sum(periods, cls.slug)
613class CanceledSubscriptionsMissingFeaturesMetric(SQLMetric):
614 slug = "canceled_subscriptions_missing_features"
615 display_name = "Canceled Subscriptions - Missing Features"
616 type = MetricType.scalar
617 query = MetricQuery.canceled_subscriptions
619 @classmethod
620 def get_sql_expression(
621 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
622 ) -> ColumnElement[int]:
623 return func.count(Subscription.id).filter(
624 Subscription.customer_cancellation_reason
625 == CustomerCancellationReason.missing_features
626 )
628 @classmethod
629 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
630 return cumulative_sum(periods, cls.slug)
633class CanceledSubscriptionsSwitchedServiceMetric(SQLMetric):
634 slug = "canceled_subscriptions_switched_service"
635 display_name = "Canceled Subscriptions - Switched Service"
636 type = MetricType.scalar
637 query = MetricQuery.canceled_subscriptions
639 @classmethod
640 def get_sql_expression(
641 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
642 ) -> ColumnElement[int]:
643 return func.count(Subscription.id).filter(
644 Subscription.customer_cancellation_reason
645 == CustomerCancellationReason.switched_service
646 )
648 @classmethod
649 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
650 return cumulative_sum(periods, cls.slug)
653class CanceledSubscriptionsTooComplexMetric(SQLMetric):
654 slug = "canceled_subscriptions_too_complex"
655 display_name = "Canceled Subscriptions - Too Complex"
656 type = MetricType.scalar
657 query = MetricQuery.canceled_subscriptions
659 @classmethod
660 def get_sql_expression(
661 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
662 ) -> ColumnElement[int]:
663 return func.count(Subscription.id).filter(
664 Subscription.customer_cancellation_reason
665 == CustomerCancellationReason.too_complex
666 )
668 @classmethod
669 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
670 return cumulative_sum(periods, cls.slug)
673class CanceledSubscriptionsTooExpensiveMetric(SQLMetric):
674 slug = "canceled_subscriptions_too_expensive"
675 display_name = "Canceled Subscriptions - Too Expensive"
676 type = MetricType.scalar
677 query = MetricQuery.canceled_subscriptions
679 @classmethod
680 def get_sql_expression(
681 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
682 ) -> ColumnElement[int]:
683 return func.count(Subscription.id).filter(
684 Subscription.customer_cancellation_reason
685 == CustomerCancellationReason.too_expensive
686 )
688 @classmethod
689 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
690 return cumulative_sum(periods, cls.slug)
693class CanceledSubscriptionsUnusedMetric(SQLMetric):
694 slug = "canceled_subscriptions_unused"
695 display_name = "Canceled Subscriptions - Unused"
696 type = MetricType.scalar
697 query = MetricQuery.canceled_subscriptions
699 @classmethod
700 def get_sql_expression(
701 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
702 ) -> ColumnElement[int]:
703 return func.count(Subscription.id).filter(
704 Subscription.customer_cancellation_reason
705 == CustomerCancellationReason.unused
706 )
708 @classmethod
709 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
710 return cumulative_sum(periods, cls.slug)
713class CanceledSubscriptionsOtherMetric(SQLMetric):
714 slug = "canceled_subscriptions_other"
715 display_name = "Canceled Subscriptions - Other"
716 type = MetricType.scalar
717 query = MetricQuery.canceled_subscriptions
719 @classmethod
720 def get_sql_expression(
721 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
722 ) -> ColumnElement[int]:
723 return func.count(Subscription.id).filter(
724 or_(
725 Subscription.customer_cancellation_reason
726 == CustomerCancellationReason.other,
727 Subscription.customer_cancellation_reason.is_(None),
728 )
729 )
731 @classmethod
732 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
733 return cumulative_sum(periods, cls.slug)
736class ChurnedSubscriptionsMetric(SQLMetric):
737 slug = "churned_subscriptions"
738 display_name = "Churned Subscriptions"
739 type = MetricType.scalar
740 query = MetricQuery.churned_subscriptions
742 @classmethod
743 def get_sql_expression(
744 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
745 ) -> ColumnElement[int]:
746 return func.count(Subscription.id)
748 @classmethod
749 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
750 return cumulative_sum(periods, cls.slug)
753class CostsMetric(SQLMetric):
754 slug = "costs"
755 display_name = "Costs"
756 type = MetricType.currency_sub_cent
757 query = MetricQuery.events
759 @classmethod
760 def get_sql_expression(
761 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
762 ) -> ColumnElement[int]:
763 return func.sum(
764 Event.user_metadata["_cost"]["amount"].as_numeric(17, 12)
765 ).filter(Event.user_metadata["_cost"].is_not(None))
767 @classmethod
768 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
769 return cumulative_sum(periods, cls.slug)
772class CumulativeCostsMetric(SQLMetric):
773 slug = "cumulative_costs"
774 display_name = "Cumulative Costs"
775 type = MetricType.currency_sub_cent
776 query = MetricQuery.events
778 @classmethod
779 def get_sql_expression(
780 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
781 ) -> ColumnElement[int]:
782 return func.sum(
783 Event.user_metadata["_cost"]["amount"].as_numeric(17, 12)
784 ).filter(Event.user_metadata["_cost"].is_not(None))
786 @classmethod
787 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
788 return cumulative_last(periods, cls.slug)
791class AverageRevenuePerUserMetric(SQLMetric):
792 slug = "average_revenue_per_user"
793 display_name = "Average Revenue Per User"
794 type = MetricType.currency
795 query = MetricQuery.active_subscriptions
797 @classmethod
798 def get_sql_expression(
799 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
800 ) -> ColumnElement[int]:
801 return func.cast(
802 case(
803 (func.count(Subscription.customer_id.distinct()) == 0, 0),
804 else_=func.coalesce(
805 func.sum(
806 case(
807 (
808 Subscription.recurring_interval
809 == SubscriptionRecurringInterval.year,
810 func.round(Subscription.amount / 12),
811 ),
812 (
813 Subscription.recurring_interval
814 == SubscriptionRecurringInterval.month,
815 Subscription.amount,
816 ),
817 (
818 Subscription.recurring_interval
819 == SubscriptionRecurringInterval.week,
820 func.round(Subscription.amount * 4),
821 ),
822 (
823 Subscription.recurring_interval
824 == SubscriptionRecurringInterval.day,
825 func.round(Subscription.amount * 30),
826 ),
827 )
828 ),
829 0,
830 )
831 / func.count(Subscription.customer_id.distinct()),
832 ),
833 Integer,
834 )
836 @classmethod
837 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int | float:
838 return cumulative_last(periods, cls.slug)
841class CostPerUserMetric(SQLMetric):
842 slug = "cost_per_user"
843 display_name = "Cost Per User"
844 type = MetricType.currency_sub_cent
845 query = MetricQuery.events
847 @classmethod
848 def get_sql_expression(
849 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
850 ) -> ColumnElement[float]:
851 total_customers = func.count(func.distinct(Event.customer_id)) + func.count(
852 func.distinct(Event.external_customer_id)
853 )
855 total_costs = func.sum(
856 func.coalesce(Event.user_metadata["_cost"]["amount"].as_numeric(17, 12), 0)
857 )
859 return type_coerce(
860 case(
861 (total_customers == 0, 0),
862 else_=total_costs / total_customers,
863 ),
864 Float,
865 )
867 @classmethod
868 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
869 total_active_users = cumulative_last(periods, ActiveSubscriptionsMetric.slug)
870 total_costs = sum(getattr(p, CostsMetric.slug) for p in periods)
871 return total_costs / total_active_users if total_active_users > 0 else 0.0
874class GrossMarginMetric(MetaMetric):
875 slug = "gross_margin"
876 display_name = "Gross Margin"
877 type = MetricType.currency
879 @classmethod
880 def compute_from_period(cls, period: "MetricsPeriod") -> float:
881 revenue = period.cumulative_revenue
882 costs = period.cumulative_costs
883 return revenue - costs
885 @classmethod
886 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
887 return cumulative_last(periods, cls.slug)
890class GrossMarginPercentageMetric(MetaMetric):
891 slug = "gross_margin_percentage"
892 display_name = "Gross Margin %"
893 type = MetricType.percentage
895 @classmethod
896 def compute_from_period(cls, period: "MetricsPeriod") -> float:
897 revenue = period.cumulative_revenue
898 costs = period.cumulative_costs
899 return (revenue - costs) / revenue if revenue > 0 else 0.0
901 @classmethod
902 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
903 return cumulative_last(periods, cls.slug)
906class CashflowMetric(MetaMetric):
907 slug = "cashflow"
908 display_name = "Cashflow"
909 type = MetricType.currency
911 @classmethod
912 def compute_from_period(cls, period: "MetricsPeriod") -> float:
913 revenue = period.revenue
914 costs = period.costs
915 return revenue - costs
917 @classmethod
918 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
919 return cumulative_sum(periods, cls.slug)
922class ChurnRateMetric(MetaMetric):
923 slug = "churn_rate"
924 display_name = "Churn Rate"
925 type = MetricType.percentage
927 @classmethod
928 def compute_from_period(cls, period: "MetricsPeriod") -> float:
929 active_during = period.active_subscriptions
930 new = period.new_subscriptions
931 churned = period.churned_subscriptions
932 canceled = period.canceled_subscriptions
933 active_at_start = active_during - new + churned
934 return canceled / active_at_start if active_at_start > 0 else 0.0
936 @classmethod
937 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> float:
938 return cumulative_last(periods, cls.slug)
941class LTVMetric(MetaMetric):
942 slug = "ltv"
943 display_name = "Lifetime Value"
944 type = MetricType.currency
946 @classmethod
947 def compute_from_period(cls, period: "MetricsPeriod") -> int:
948 arpu = period.average_revenue_per_user
949 cost_per_user = period.cost_per_user
950 churn_rate = period.churn_rate
952 if churn_rate == 0: 952 ↛ 955line 952 didn't jump to line 955 because the condition on line 952 was always true
953 return 0
955 net_revenue_per_user = arpu - cost_per_user
956 ltv = int(net_revenue_per_user / churn_rate)
958 return max(0, ltv)
960 @classmethod
961 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int:
962 return int(cumulative_last(periods, cls.slug))
965class ActiveUserMetric(SQLMetric):
966 slug = "active_user_by_event"
967 display_name = "Active User (By event)"
968 type = MetricType.scalar
969 query = MetricQuery.events
971 @classmethod
972 def get_sql_expression(
973 cls, t: ColumnElement[datetime], i: TimeInterval, now: datetime
974 ) -> ColumnElement[int]:
975 return func.count(func.distinct(Event.customer_id)) + func.count(
976 func.distinct(Event.external_customer_id)
977 )
979 @classmethod
980 def get_cumulative(cls, periods: Iterable["MetricsPeriod"]) -> int:
981 return int(cumulative_last(periods, ActiveSubscriptionsMetric.slug))
984METRICS_SQL: list[type[SQLMetric]] = [
985 OrdersMetric,
986 RevenueMetric,
987 NetRevenueMetric,
988 CumulativeRevenueMetric,
989 NetCumulativeRevenueMetric,
990 CostsMetric,
991 CumulativeCostsMetric,
992 AverageOrderValueMetric,
993 NetAverageOrderValueMetric,
994 AverageRevenuePerUserMetric,
995 CostPerUserMetric,
996 ActiveUserMetric,
997 OneTimeProductsMetric,
998 OneTimeProductsRevenueMetric,
999 OneTimeProductsNetRevenueMetric,
1000 NewSubscriptionsMetric,
1001 NewSubscriptionsRevenueMetric,
1002 NewSubscriptionsNetRevenueMetric,
1003 RenewedSubscriptionsMetric,
1004 RenewedSubscriptionsRevenueMetric,
1005 RenewedSubscriptionsNetRevenueMetric,
1006 ActiveSubscriptionsMetric,
1007 MonthlyRecurringRevenueMetric,
1008 CommittedMonthlyRecurringRevenueMetric,
1009 CheckoutsMetric,
1010 SucceededCheckoutsMetric,
1011 CheckoutsConversionMetric,
1012 CanceledSubscriptionsMetric,
1013 CanceledSubscriptionsCustomerServiceMetric,
1014 CanceledSubscriptionsLowQualityMetric,
1015 CanceledSubscriptionsMissingFeaturesMetric,
1016 CanceledSubscriptionsSwitchedServiceMetric,
1017 CanceledSubscriptionsTooComplexMetric,
1018 CanceledSubscriptionsTooExpensiveMetric,
1019 CanceledSubscriptionsUnusedMetric,
1020 CanceledSubscriptionsOtherMetric,
1021 ChurnedSubscriptionsMetric,
1022]
1024METRICS_POST_COMPUTE: list[type[MetaMetric]] = [
1025 ChurnRateMetric,
1026 LTVMetric,
1027 GrossMarginMetric,
1028 GrossMarginPercentageMetric,
1029 CashflowMetric,
1030]
1032METRICS: list[type[Metric]] = [
1033 *METRICS_SQL,
1034 *METRICS_POST_COMPUTE,
1035]
1037__all__ = [
1038 "MetricType",
1039 "Metric",
1040 "SQLMetric",
1041 "MetaMetric",
1042 "METRICS_SQL",
1043 "METRICS_POST_COMPUTE",
1044 "METRICS",
1045]