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 | */ |
| 11 | import { describe, expect, test } from 'bun:test'; |
| 12 | |
| 13 | import { |
| 14 | buildPlanMix, |
| 15 | computeMrr, |
| 16 | countChurnWithinWindow, |
| 17 | isChurnWithinWindow, |
| 18 | paidSubscribers, |
| 19 | type PlanMix, |
| 20 | } from './mavi-pay.js'; |
| 21 | |
| 22 | describe('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 | |
| 39 | describe('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 | |
| 61 | describe('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 | |
| 77 | describe('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 | }); |