mavi-pay.test.ts103 lines · main
1/**
2 * Pins the pure decisions in the Mavi Pay seam so a change to any rule shows up
3 * as a red test:
4 * - paidSubscribers : paid = pro + team (free never counts).
5 * - buildPlanMix : free from projects, pro/team from active subs.
6 * - computeMrr : MRR = pro×proPrice + team×teamPrice.
7 * - churn window : canceled AND updated within 30 days.
8 * The DB grouping + the live Polar price fetch are exercised by the boot + the
9 * admin overview smoke; these cover the math in isolation (no DB, no Polar).
10 */
11import { describe, expect, test } from 'bun:test';
12
13import {
14 buildPlanMix,
15 computeMrr,
16 countChurnWithinWindow,
17 isChurnWithinWindow,
18 paidSubscribers,
19 type PlanMix,
20} from './mavi-pay.js';
21
22describe('paidSubscribers', () => {
23 test('counts pro + team, never free', () => {
24 const mix: PlanMix = { free: 10, pro: 3, team: 2 };
25 expect(paidSubscribers(mix)).toBe(5);
26 });
27
28 test('is 0 when every project is on free', () => {
29 const mix: PlanMix = { free: 7, pro: 0, team: 0 };
30 expect(paidSubscribers(mix)).toBe(0);
31 });
32
33 test('is 0 on an empty platform', () => {
34 const mix: PlanMix = { free: 0, pro: 0, team: 0 };
35 expect(paidSubscribers(mix)).toBe(0);
36 });
37});
38
39describe('buildPlanMix', () => {
40 test('takes free from the project count and pro/team from active subs', () => {
41 const mix = buildPlanMix(42, [
42 { tier: 'pro', count: 5 },
43 { tier: 'team', count: 2 },
44 ]);
45 expect(mix).toEqual({ free: 42, pro: 5, team: 2 });
46 });
47
48 test('seeds every tier at 0 when there are no subs', () => {
49 expect(buildPlanMix(0, [])).toEqual({ free: 0, pro: 0, team: 0 });
50 });
51
52 test('ignores a stray free-tier sub row (free comes from projects)', () => {
53 const mix = buildPlanMix(3, [
54 { tier: 'free', count: 9 },
55 { tier: 'pro', count: 1 },
56 ]);
57 expect(mix).toEqual({ free: 3, pro: 1, team: 0 });
58 });
59});
60
61describe('computeMrr', () => {
62 test('sums pro and team subs at their monthly prices', () => {
63 // 5 pro @ €19 + 2 team @ €49 = 95 + 98 = 193
64 expect(computeMrr({ pro: 5, team: 2 }, { pro: 19, team: 49 })).toBe(193);
65 });
66
67 test('is 0 with no paying subs', () => {
68 expect(computeMrr({ pro: 0, team: 0 }, { pro: 19, team: 49 })).toBe(0);
69 });
70
71 test('handles fractional (cents-derived) prices', () => {
72 // price_amount 1999c -> €19.99
73 expect(computeMrr({ pro: 2, team: 0 }, { pro: 19.99, team: 49.99 })).toBeCloseTo(39.98, 2);
74 });
75});
76
77describe('churn window', () => {
78 const now = new Date('2026-06-27T00:00:00.000Z');
79 const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
80
81 test('counts a sub canceled 5 days ago', () => {
82 expect(isChurnWithinWindow({ status: 'canceled', updatedAt: daysAgo(5) }, now)).toBe(true);
83 });
84
85 test('excludes a sub canceled 40 days ago (outside the 30d window)', () => {
86 expect(isChurnWithinWindow({ status: 'canceled', updatedAt: daysAgo(40) }, now)).toBe(false);
87 });
88
89 test('excludes a non-canceled sub even if updated recently', () => {
90 expect(isChurnWithinWindow({ status: 'active', updatedAt: daysAgo(1) }, now)).toBe(false);
91 expect(isChurnWithinWindow({ status: 'past_due', updatedAt: daysAgo(1) }, now)).toBe(false);
92 });
93
94 test('counts only the in-window canceled subs across a mixed set', () => {
95 const subs = [
96 { status: 'canceled' as const, updatedAt: daysAgo(2) },
97 { status: 'canceled' as const, updatedAt: daysAgo(29) },
98 { status: 'canceled' as const, updatedAt: daysAgo(31) },
99 { status: 'active' as const, updatedAt: daysAgo(1) },
100 ];
101 expect(countChurnWithinWindow(subs, now)).toBe(2);
102 });
103});