- DB migration 023: tenant_subscriptions and usage_events tables - UsageMeteringMiddleware: in-memory counters, 60s flush to DB via UPSERT - FreeTierEnforcementMiddleware: 10 agents / 1,000 calls/day limits, Redis cache - UsageService: getDailyUsage and getActiveAgentCount - BillingService: Stripe checkout sessions, webhook verification, subscription status - POST /billing/checkout, POST /billing/webhook, GET /billing/usage endpoints - BILLING_ENABLED=false disables enforcement without breaking metering - Dashboard: Usage tab with Free Tier/Pro badges and metric cards - 19 unit tests passing across billing services and middleware Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
263 lines
9.4 KiB
TypeScript
263 lines
9.4 KiB
TypeScript
/**
|
|
* Unit tests for src/services/BillingService.ts and src/services/UsageService.ts
|
|
*/
|
|
|
|
import { Pool, QueryResult } from 'pg';
|
|
import Stripe from 'stripe';
|
|
import { BillingService } from '../../../src/services/BillingService';
|
|
import { UsageService } from '../../../src/services/UsageService';
|
|
|
|
// ── Mock pg Pool ─────────────────────────────────────────────────────────────
|
|
|
|
function makePool(queryFn: jest.Mock): Pool {
|
|
return { query: queryFn } as unknown as Pool;
|
|
}
|
|
|
|
// ── Mock Stripe ───────────────────────────────────────────────────────────────
|
|
|
|
function makeStripe(overrides: Partial<{
|
|
checkoutUrl: string;
|
|
constructEvent: () => Stripe.Event;
|
|
retrieveCustomer: () => Stripe.Customer;
|
|
}> = {}): Stripe {
|
|
const checkoutUrl = overrides.checkoutUrl ?? 'https://checkout.stripe.com/session_test';
|
|
|
|
const fakeSubscription: Stripe.Subscription = {
|
|
id: 'sub_1',
|
|
object: 'subscription',
|
|
status: 'active',
|
|
customer: 'cus_1',
|
|
billing_cycle_anchor: Math.floor(Date.now() / 1000) + 86400,
|
|
ended_at: null,
|
|
} as unknown as Stripe.Subscription;
|
|
|
|
const fakeEvent: Stripe.Event = {
|
|
id: 'evt_1',
|
|
type: 'customer.subscription.created',
|
|
object: 'event',
|
|
data: { object: fakeSubscription },
|
|
api_version: '2026-03-25.dahlia',
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
pending_webhooks: 0,
|
|
request: null,
|
|
} as unknown as Stripe.Event;
|
|
|
|
const fakeCustomer: Stripe.Customer = {
|
|
id: 'cus_1',
|
|
object: 'customer',
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
metadata: { tenant_id: 'tenant-uuid-123' },
|
|
deleted: undefined,
|
|
} as unknown as Stripe.Customer;
|
|
|
|
return {
|
|
checkout: {
|
|
sessions: {
|
|
create: jest.fn().mockResolvedValue({ url: checkoutUrl, id: 'cs_1' }),
|
|
},
|
|
},
|
|
webhooks: {
|
|
constructEvent: overrides.constructEvent
|
|
? jest.fn(overrides.constructEvent)
|
|
: jest.fn().mockReturnValue(fakeEvent),
|
|
},
|
|
customers: {
|
|
retrieve: overrides.retrieveCustomer
|
|
? jest.fn(overrides.retrieveCustomer)
|
|
: jest.fn().mockResolvedValue(fakeCustomer),
|
|
},
|
|
} as unknown as Stripe;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// UsageService
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('UsageService', () => {
|
|
describe('getDailyUsage()', () => {
|
|
it('should return correct IUsageSummary with api_calls from DB', async () => {
|
|
const mockQuery = jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{ count: '42' }] } as unknown as QueryResult)
|
|
.mockResolvedValueOnce({ rows: [{ count: '5' }] } as unknown as QueryResult);
|
|
|
|
const service = new UsageService(makePool(mockQuery));
|
|
const result = await service.getDailyUsage('tenant-1', '2026-04-02');
|
|
|
|
expect(result).toEqual({
|
|
tenantId: 'tenant-1',
|
|
date: '2026-04-02',
|
|
apiCalls: 42,
|
|
agentCount: 5,
|
|
});
|
|
});
|
|
|
|
it('should default apiCalls to 0 when no usage_events row exists', async () => {
|
|
const mockQuery = jest.fn()
|
|
.mockResolvedValueOnce({ rows: [{ count: '0' }] } as unknown as QueryResult)
|
|
.mockResolvedValueOnce({ rows: [{ count: '2' }] } as unknown as QueryResult);
|
|
|
|
const service = new UsageService(makePool(mockQuery));
|
|
const result = await service.getDailyUsage('tenant-2', '2026-04-02');
|
|
|
|
expect(result.apiCalls).toBe(0);
|
|
expect(result.agentCount).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('getActiveAgentCount()', () => {
|
|
it('should return the count of non-decommissioned agents', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ count: '7' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new UsageService(makePool(mockQuery));
|
|
const count = await service.getActiveAgentCount('tenant-1');
|
|
|
|
expect(count).toBe(7);
|
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
expect.stringContaining("status != 'decommissioned'"),
|
|
['tenant-1'],
|
|
);
|
|
});
|
|
|
|
it('should return 0 when no agents exist', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{ count: '0' }],
|
|
} as unknown as QueryResult);
|
|
|
|
const service = new UsageService(makePool(mockQuery));
|
|
const count = await service.getActiveAgentCount('tenant-empty');
|
|
|
|
expect(count).toBe(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// BillingService
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('BillingService', () => {
|
|
describe('createCheckoutSession()', () => {
|
|
it('should return the Stripe checkout URL', async () => {
|
|
const mockQuery = jest.fn();
|
|
const stripe = makeStripe({ checkoutUrl: 'https://checkout.stripe.com/test' });
|
|
const service = new BillingService(makePool(mockQuery), stripe);
|
|
|
|
const url = await service.createCheckoutSession(
|
|
'tenant-1',
|
|
'https://app.com/success',
|
|
'https://app.com/cancel',
|
|
);
|
|
|
|
expect(url).toBe('https://checkout.stripe.com/test');
|
|
});
|
|
});
|
|
|
|
describe('getSubscriptionStatus()', () => {
|
|
it('should return free status when no subscription row exists', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
const stripe = makeStripe();
|
|
const service = new BillingService(makePool(mockQuery), stripe);
|
|
|
|
const status = await service.getSubscriptionStatus('tenant-free');
|
|
|
|
expect(status).toEqual({
|
|
tenantId: 'tenant-free',
|
|
status: 'free',
|
|
currentPeriodEnd: null,
|
|
stripeSubscriptionId: null,
|
|
});
|
|
});
|
|
|
|
it('should return subscription data when a row exists', async () => {
|
|
const periodEnd = new Date('2026-05-01T00:00:00Z');
|
|
const mockQuery = jest.fn().mockResolvedValue({
|
|
rows: [{
|
|
status: 'active',
|
|
current_period_end: periodEnd,
|
|
stripe_subscription_id: 'sub_abc123',
|
|
}],
|
|
} as unknown as QueryResult);
|
|
|
|
const stripe = makeStripe();
|
|
const service = new BillingService(makePool(mockQuery), stripe);
|
|
|
|
const status = await service.getSubscriptionStatus('tenant-paid');
|
|
|
|
expect(status.status).toBe('active');
|
|
expect(status.stripeSubscriptionId).toBe('sub_abc123');
|
|
expect(status.currentPeriodEnd).toEqual(periodEnd);
|
|
});
|
|
});
|
|
|
|
describe('handleWebhookEvent()', () => {
|
|
it('should process customer.subscription.created and upsert DB', async () => {
|
|
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
|
const stripe = makeStripe();
|
|
const service = new BillingService(makePool(mockQuery), stripe);
|
|
|
|
await service.handleWebhookEvent(
|
|
Buffer.from('{}'),
|
|
'stripe-sig-header',
|
|
'whsec_test',
|
|
);
|
|
|
|
// constructEvent should have been called with raw body, sig, and secret
|
|
expect(stripe.webhooks.constructEvent).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
'stripe-sig-header',
|
|
'whsec_test',
|
|
);
|
|
|
|
// DB upsert should have been called
|
|
expect(mockQuery).toHaveBeenCalledWith(
|
|
expect.stringContaining('INSERT INTO tenant_subscriptions'),
|
|
expect.any(Array),
|
|
);
|
|
});
|
|
|
|
it('should throw when Stripe signature verification fails', async () => {
|
|
const mockQuery = jest.fn();
|
|
const sigError = new Error('Stripe signature verification failed');
|
|
const stripe = makeStripe({
|
|
constructEvent: () => { throw sigError; },
|
|
});
|
|
|
|
const service = new BillingService(makePool(mockQuery), stripe);
|
|
|
|
await expect(
|
|
service.handleWebhookEvent(Buffer.from('{}'), 'bad-sig', 'whsec_test'),
|
|
).rejects.toThrow('Stripe signature verification failed');
|
|
});
|
|
|
|
it('should skip processing for unrecognised event types', async () => {
|
|
const mockQuery = jest.fn();
|
|
|
|
const unknownEvent: unknown = {
|
|
id: 'evt_unknown',
|
|
type: 'payment_intent.created',
|
|
object: 'event',
|
|
data: { object: {} },
|
|
api_version: '2026-03-25.dahlia',
|
|
created: Math.floor(Date.now() / 1000),
|
|
livemode: false,
|
|
pending_webhooks: 0,
|
|
request: null,
|
|
};
|
|
|
|
const stripe = makeStripe({
|
|
constructEvent: () => unknownEvent as Stripe.Event,
|
|
});
|
|
|
|
const service = new BillingService(makePool(mockQuery), stripe);
|
|
await service.handleWebhookEvent(Buffer.from('{}'), 'sig', 'whsec_test');
|
|
|
|
// No DB query for unrecognised events
|
|
expect(mockQuery).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|