File size: 6,025 Bytes
8280d7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
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
    });
});