feat(phase-4): WS6 — Billing & Usage Metering (Stripe, free tier enforcement)
- 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>
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
||||
rateLimitHitsTotal,
|
||||
dbPoolActiveConnections,
|
||||
dbPoolWaitingRequests,
|
||||
tenantApiCallsTotal,
|
||||
billingLimitRejectionsTotal,
|
||||
} from '../../../src/metrics/registry';
|
||||
|
||||
describe('metricsRegistry', () => {
|
||||
@@ -33,9 +35,9 @@ describe('metricsRegistry', () => {
|
||||
expect(metricsRegistry).not.toBe(register);
|
||||
});
|
||||
|
||||
it('contains exactly 12 metric entries', async () => {
|
||||
it('contains exactly 14 metric entries', async () => {
|
||||
const entries = await metricsRegistry.getMetricsAsJSON();
|
||||
expect(entries).toHaveLength(12);
|
||||
expect(entries).toHaveLength(14);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
@@ -54,6 +56,8 @@ describe('metricsRegistry', () => {
|
||||
'agentidp_rate_limit_hits_total',
|
||||
'agentidp_db_pool_active_connections',
|
||||
'agentidp_db_pool_waiting_requests',
|
||||
'agentidp_tenant_api_calls_total',
|
||||
'agentidp_billing_limit_rejections_total',
|
||||
])('registers metric "%s"', async (metricName) => {
|
||||
const entries = await metricsRegistry.getMetricsAsJSON();
|
||||
const names = entries.map((e) => e.name);
|
||||
@@ -200,4 +204,33 @@ describe('metricsRegistry', () => {
|
||||
expect(() => dbPoolWaitingRequests.set(2)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tenantApiCallsTotal', () => {
|
||||
it('has name agentidp_tenant_api_calls_total', () => {
|
||||
const metric = tenantApiCallsTotal as unknown as { name: string };
|
||||
expect(metric.name).toBe('agentidp_tenant_api_calls_total');
|
||||
});
|
||||
|
||||
it('increments with tenant_id label without throwing', () => {
|
||||
expect(() =>
|
||||
tenantApiCallsTotal.inc({ tenant_id: 'org-test-001' }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('billingLimitRejectionsTotal', () => {
|
||||
it('has name agentidp_billing_limit_rejections_total', () => {
|
||||
const metric = billingLimitRejectionsTotal as unknown as { name: string };
|
||||
expect(metric.name).toBe('agentidp_billing_limit_rejections_total');
|
||||
});
|
||||
|
||||
it('increments with tenant_id and limit_type labels without throwing', () => {
|
||||
expect(() =>
|
||||
billingLimitRejectionsTotal.inc({ tenant_id: 'org-test-001', limit_type: 'agent_limit' }),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
billingLimitRejectionsTotal.inc({ tenant_id: 'org-test-002', limit_type: 'api_limit' }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
304
tests/unit/middleware/billing.test.ts
Normal file
304
tests/unit/middleware/billing.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Unit tests for billing middleware:
|
||||
* - FreeTierEnforcementMiddleware
|
||||
* - BillingController.handleWebhook
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { createFreeTierEnforcementMiddleware } from '../../../src/middleware/freeTierEnforcementMiddleware';
|
||||
import { BillingController } from '../../../src/controllers/BillingController';
|
||||
import { BillingService } from '../../../src/services/BillingService';
|
||||
import { UsageService } from '../../../src/services/UsageService';
|
||||
import { ITokenPayload } from '../../../src/types/index';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makePool(queryFn: jest.Mock): Pool {
|
||||
return { query: queryFn } as unknown as Pool;
|
||||
}
|
||||
|
||||
type RedisClientMock = {
|
||||
get: jest.Mock;
|
||||
set: jest.Mock;
|
||||
};
|
||||
|
||||
function makeRedis(overrides: Partial<RedisClientMock> = {}): RedisClientMock {
|
||||
return {
|
||||
get: overrides.get ?? jest.fn().mockResolvedValue(null),
|
||||
set: overrides.set ?? jest.fn().mockResolvedValue('OK'),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRequest(overrides: Partial<{
|
||||
method: string;
|
||||
path: string;
|
||||
organizationId: string | undefined;
|
||||
}>): Request {
|
||||
const organizationId = overrides.organizationId ?? 'org-uuid-123';
|
||||
const user: ITokenPayload | undefined = organizationId !== undefined
|
||||
? {
|
||||
sub: 'agent-1',
|
||||
client_id: 'agent-1',
|
||||
scope: 'agents:read',
|
||||
jti: 'jti-1',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
organization_id: organizationId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
method: overrides.method ?? 'GET',
|
||||
path: overrides.path ?? '/api/v1/agents',
|
||||
user,
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
function makeResponse(): Response {
|
||||
return {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// FreeTierEnforcementMiddleware
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('createFreeTierEnforcementMiddleware', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should call next() immediately when BILLING_ENABLED=false', () => {
|
||||
process.env['BILLING_ENABLED'] = 'false';
|
||||
|
||||
const pool = makePool(jest.fn());
|
||||
const redis = makeRedis();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
const middleware = createFreeTierEnforcementMiddleware(pool, redis as never);
|
||||
middleware(makeRequest({}), makeResponse(), next);
|
||||
|
||||
// next() called synchronously because billing is disabled
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect((next as jest.Mock).mock.calls[0]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should call next() without error for unauthenticated request', () => {
|
||||
process.env['BILLING_ENABLED'] = 'true';
|
||||
|
||||
const pool = makePool(jest.fn());
|
||||
const redis = makeRedis();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
const req: Request = {
|
||||
method: 'GET',
|
||||
path: '/api/v1/agents',
|
||||
user: undefined,
|
||||
headers: {},
|
||||
} as unknown as Request;
|
||||
|
||||
const middleware = createFreeTierEnforcementMiddleware(pool, redis as never);
|
||||
middleware(req, makeResponse(), next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect((next as jest.Mock).mock.calls[0]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should call next(error) with FREE_TIER_API_LIMIT when daily API calls >= 1000', async () => {
|
||||
process.env['BILLING_ENABLED'] = 'true';
|
||||
|
||||
// Query sequence:
|
||||
// 1: isFreeTenant → no subscription row (free)
|
||||
// 2: getDailyUsage → usage_events count = 1000
|
||||
// 3: getDailyUsage → agent count (getActiveAgentCount called inside getDailyUsage)
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [] } as unknown as QueryResult) // isFreeTenant
|
||||
.mockResolvedValueOnce({ rows: [{ count: '1000' }] } as unknown as QueryResult) // usage_events
|
||||
.mockResolvedValueOnce({ rows: [{ count: '5' }] } as unknown as QueryResult); // agents count
|
||||
|
||||
// Cache miss → hits DB
|
||||
const redis = makeRedis({ get: jest.fn().mockResolvedValue(null) });
|
||||
|
||||
const nextCalled = new Promise<unknown>((resolve) => {
|
||||
const next = ((err?: unknown) => { resolve(err); }) as NextFunction;
|
||||
const middleware = createFreeTierEnforcementMiddleware(makePool(mockQuery), redis as never);
|
||||
middleware(makeRequest({ method: 'GET', path: '/api/v1/agents' }), makeResponse(), next);
|
||||
});
|
||||
|
||||
const callArg = await nextCalled;
|
||||
expect(callArg).toBeDefined();
|
||||
expect((callArg as { code: string }).code).toBe('FREE_TIER_API_LIMIT');
|
||||
});
|
||||
|
||||
it('should call next(error) with FREE_TIER_AGENT_LIMIT when agent count >= 10 on POST /agents', async () => {
|
||||
process.env['BILLING_ENABLED'] = 'true';
|
||||
|
||||
// Query sequence:
|
||||
// 1: isFreeTenant → free
|
||||
// 2: usage_events → 500 calls (below limit, so continue)
|
||||
// 3: agents count for getDailyUsage
|
||||
// 4: agents count for isAgentCreation check
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [] } as unknown as QueryResult) // isFreeTenant
|
||||
.mockResolvedValueOnce({ rows: [{ count: '500' }] } as unknown as QueryResult) // usage_events
|
||||
.mockResolvedValueOnce({ rows: [{ count: '3' }] } as unknown as QueryResult) // getDailyUsage agent count
|
||||
.mockResolvedValueOnce({ rows: [{ count: '10' }] } as unknown as QueryResult); // isAgentCreation check
|
||||
|
||||
const redis = makeRedis({ get: jest.fn().mockResolvedValue(null) });
|
||||
|
||||
const nextCalled = new Promise<unknown>((resolve) => {
|
||||
const next = ((err?: unknown) => { resolve(err); }) as NextFunction;
|
||||
const middleware = createFreeTierEnforcementMiddleware(makePool(mockQuery), redis as never);
|
||||
middleware(makeRequest({ method: 'POST', path: '/agents' }), makeResponse(), next);
|
||||
});
|
||||
|
||||
const callArg = await nextCalled;
|
||||
expect(callArg).toBeDefined();
|
||||
expect((callArg as { code: string }).code).toBe('FREE_TIER_AGENT_LIMIT');
|
||||
});
|
||||
|
||||
it('should call next() without error for paid tenant regardless of limits', async () => {
|
||||
process.env['BILLING_ENABLED'] = 'true';
|
||||
|
||||
// Active subscription → paid
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ status: 'active' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const redis = makeRedis();
|
||||
|
||||
const nextCalled = new Promise<unknown>((resolve) => {
|
||||
const next = ((err?: unknown) => { resolve(err); }) as NextFunction;
|
||||
const middleware = createFreeTierEnforcementMiddleware(makePool(mockQuery), redis as never);
|
||||
middleware(makeRequest({ method: 'POST', path: '/agents' }), makeResponse(), next);
|
||||
});
|
||||
|
||||
const callArg = await nextCalled;
|
||||
// next() called with no error
|
||||
expect(callArg).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use Redis cache and skip DB usage query on cache hit', async () => {
|
||||
process.env['BILLING_ENABLED'] = 'true';
|
||||
|
||||
// Only 1 DB query: isFreeTenant (no subscription)
|
||||
// The second query for usage is replaced by a Redis cache hit
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [] } as unknown as QueryResult); // isFreeTenant
|
||||
|
||||
// Cache returns api_calls = 100 (below 1000 limit)
|
||||
const redis = makeRedis({ get: jest.fn().mockResolvedValue('100') });
|
||||
|
||||
const nextCalled = new Promise<unknown>((resolve) => {
|
||||
const next = ((err?: unknown) => { resolve(err); }) as NextFunction;
|
||||
const middleware = createFreeTierEnforcementMiddleware(makePool(mockQuery), redis as never);
|
||||
middleware(makeRequest({ method: 'GET', path: '/api/v1/agents' }), makeResponse(), next);
|
||||
});
|
||||
|
||||
const callArg = await nextCalled;
|
||||
// Only 1 query (isFreeTenant); no DB call for usage
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
expect(callArg).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// BillingController.handleWebhook
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('BillingController.handleWebhook', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, STRIPE_WEBHOOK_SECRET: 'whsec_test' };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
function makeBillingController(
|
||||
handleWebhookEventFn: jest.Mock = jest.fn().mockResolvedValue(undefined),
|
||||
): BillingController {
|
||||
const billingService = {
|
||||
handleWebhookEvent: handleWebhookEventFn,
|
||||
createCheckoutSession: jest.fn(),
|
||||
getSubscriptionStatus: jest.fn().mockResolvedValue({
|
||||
tenantId: 'test',
|
||||
status: 'free',
|
||||
currentPeriodEnd: null,
|
||||
stripeSubscriptionId: null,
|
||||
}),
|
||||
} as unknown as BillingService;
|
||||
|
||||
const usageService = {
|
||||
getDailyUsage: jest.fn().mockResolvedValue({
|
||||
tenantId: 'test',
|
||||
date: '2026-04-02',
|
||||
apiCalls: 0,
|
||||
agentCount: 0,
|
||||
}),
|
||||
getActiveAgentCount: jest.fn().mockResolvedValue(0),
|
||||
} as unknown as UsageService;
|
||||
|
||||
return new BillingController(billingService, usageService);
|
||||
}
|
||||
|
||||
it('should return 200 { received: true } for valid Stripe-Signature', async () => {
|
||||
const controller = makeBillingController();
|
||||
const req = {
|
||||
headers: { 'stripe-signature': 'valid-sig' },
|
||||
body: Buffer.from('{}'),
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await controller.handleWebhook(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ received: true });
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call next() with ValidationError when Stripe-Signature header is missing', async () => {
|
||||
const controller = makeBillingController();
|
||||
const req = {
|
||||
headers: {},
|
||||
body: Buffer.from('{}'),
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await controller.handleWebhook(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
const callArg = (next as jest.Mock).mock.calls[0]?.[0];
|
||||
expect((callArg as { code: string }).code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('should call next() with the error when BillingService throws', async () => {
|
||||
const stripeError = new Error('Invalid signature');
|
||||
const controller = makeBillingController(jest.fn().mockRejectedValue(stripeError));
|
||||
|
||||
const req = {
|
||||
headers: { 'stripe-signature': 'bad-sig' },
|
||||
body: Buffer.from('{}'),
|
||||
} as unknown as Request;
|
||||
const res = makeResponse();
|
||||
const next = jest.fn() as NextFunction;
|
||||
|
||||
await controller.handleWebhook(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalledWith(stripeError);
|
||||
});
|
||||
});
|
||||
262
tests/unit/services/BillingService.test.ts
Normal file
262
tests/unit/services/BillingService.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user