edtech / apps /api /test /billing.test.ts
CognxSafeTrack
feat(billing): complete billing system, push notifications, and tech debt fixes
8280d7d
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock infrastructure before any imports
vi.mock('../src/services/prisma', () => ({
prisma: {
organization: {
findUnique: vi.fn(),
update: vi.fn(),
},
usageEvent: {
aggregate: vi.fn(),
groupBy: vi.fn(),
findMany: vi.fn(),
},
},
}));
import { prisma } from '../src/services/prisma';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function makeOrgFixture(overrides: Record<string, unknown> = {}) {
return {
subscriptionPlan: 'STARTER',
subscriptionStatus: 'ACTIVE',
aiCreditsUsed: 120,
aiCreditsLimit: 500,
whatsappMessagesSent: 300,
billingPeriodStart: new Date('2026-05-01'),
...overrides,
};
}
function makeAggFixture(costUsd = 0.05, count = 120) {
return {
_sum: { costUsd, tokensIn: 80000, tokensOut: 20000 },
_count: { id: count },
};
}
// ─── Unit tests for billing summary logic ────────────────────────────────────
describe('Billing β€” summary computation', () => {
beforeEach(() => vi.clearAllMocks());
it('computes percentUsed correctly for STARTER plan', () => {
const org = makeOrgFixture({ aiCreditsUsed: 425, aiCreditsLimit: 500 });
const pct = org.aiCreditsLimit > 0
? Math.round((org.aiCreditsUsed / org.aiCreditsLimit) * 100)
: 0;
expect(pct).toBe(85);
});
it('caps percentUsed at 100 when over-limit', () => {
const org = makeOrgFixture({ aiCreditsUsed: 600, aiCreditsLimit: 500 });
const pct = Math.min(
org.aiCreditsLimit > 0 ? Math.round((org.aiCreditsUsed / org.aiCreditsLimit) * 100) : 0,
100
);
expect(pct).toBe(100);
});
it('converts costUsd to FCFA at 600x rate', () => {
const costUsd = 0.05;
const costFcfa = Math.round(costUsd * 600);
expect(costFcfa).toBe(30);
});
it('plan label maps correctly', () => {
const PLAN_LIMITS: Record<string, { label: string }> = {
STARTER: { label: 'DΓ©marrage' },
GROWTH: { label: 'Croissance' },
SCALE: { label: 'Scale' },
ENTERPRISE: { label: 'Enterprise' },
};
expect(PLAN_LIMITS['GROWTH'].label).toBe('Croissance');
expect(PLAN_LIMITS['ENTERPRISE'].label).toBe('Enterprise');
});
});
// ─── Unit tests for billing history grouping ─────────────────────────────────
describe('Billing β€” history day grouping', () => {
it('groups events by YYYY-MM-DD', () => {
const events = [
{ type: 'AI_TEXT', costUsd: 0.01, createdAt: new Date('2026-05-10T10:00:00Z') },
{ type: 'AI_TEXT', costUsd: 0.02, createdAt: new Date('2026-05-10T15:00:00Z') },
{ type: 'WHATSAPP_SENT', costUsd: 0, createdAt: new Date('2026-05-11T08:00:00Z') },
] as any[];
const byDay: Record<string, { aiCalls: number; whatsappMessages: number; costUsd: number }> = {};
for (const e of events) {
const day = e.createdAt.toISOString().substring(0, 10);
if (!byDay[day]) byDay[day] = { aiCalls: 0, whatsappMessages: 0, costUsd: 0 };
if (e.type === 'WHATSAPP_SENT') byDay[day].whatsappMessages++;
else { byDay[day].aiCalls++; byDay[day].costUsd += e.costUsd; }
}
expect(byDay['2026-05-10'].aiCalls).toBe(2);
expect(byDay['2026-05-10'].costUsd).toBeCloseTo(0.03);
expect(byDay['2026-05-11'].whatsappMessages).toBe(1);
expect(byDay['2026-05-11'].aiCalls).toBe(0);
});
it('caps history to max 90 days', () => {
const days = Math.min(parseInt('200', 10), 90);
expect(days).toBe(90);
});
});
// ─── Unit tests for API key plan guard logic ──────────────────────────────────
describe('Billing β€” API key plan guard', () => {
it('allows SCALE plan to set own API keys', () => {
const allowedPlans = ['SCALE', 'ENTERPRISE'];
expect(allowedPlans.includes('SCALE')).toBe(true);
expect(allowedPlans.includes('ENTERPRISE')).toBe(true);
});
it('rejects STARTER plan from setting own API keys', () => {
const allowedPlans = ['SCALE', 'ENTERPRISE'];
expect(allowedPlans.includes('STARTER')).toBe(false);
expect(allowedPlans.includes('GROWTH')).toBe(false);
});
});
// ─── Unit tests for quota alert threshold ────────────────────────────────────
describe('Billing β€” quota alert threshold', () => {
it('detects 85% threshold crossing', () => {
const THRESHOLD = 85;
const limit = 500;
function crossesThreshold(creditsBefore: number, creditsAfter: number): boolean {
const pctBefore = Math.floor((creditsBefore / limit) * 100);
const pctAfter = Math.floor((creditsAfter / limit) * 100);
return pctBefore < THRESHOLD && pctAfter >= THRESHOLD;
}
expect(crossesThreshold(424, 425)).toBe(true); // 84% β†’ 85%
expect(crossesThreshold(425, 426)).toBe(false); // 85% β†’ 85% (already crossed)
expect(crossesThreshold(400, 410)).toBe(false); // 80% β†’ 82% (not yet)
expect(crossesThreshold(499, 500)).toBe(false); // 99% β†’ 100% (already past)
});
it('does not alert when limit is 0', () => {
const limit = 0;
const pct = limit > 0 ? Math.floor((425 / limit) * 100) : 0;
expect(pct).toBe(0); // Never alert when no limit
});
});