Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,19 @@ COMMONLY_API_TOKEN=your_commonly_agent_token
# ===========================
COMMONLY_BOT_TOKEN=your_commonly_bot_token
CLAWDBOT_BRIDGE_TOKEN=your_clawdbot_bridge_token
CLAWDBOT_GATEWAY_TOKEN=your_gateway_token
CLAWDBOT_GATEWAY_TOKEN=your_gateway_token

# ── Billing (Stripe) ────────────────────────────────────────────────────────
# All three must be set for billing to work; any missing one makes every
# billing route return 503 rather than half-working.
# STRIPE_SECRET_KEY sk_live_… / sk_test_… — server-side only, NEVER in the
# frontend bundle. Store in GCP Secret Manager and sync
# via ExternalSecret like every other credential.
# STRIPE_PRICE_ID price_… for the $12/human/month recurring Price.
# STRIPE_WEBHOOK_SECRET whsec_… from the endpoint you register at
# https://api.commonly.me/api/billing/webhook — this is
# what authenticates Stripe to us. Entitlements are only
# ever granted by a signature-verified webhook.
STRIPE_SECRET_KEY=
STRIPE_PRICE_ID=
STRIPE_WEBHOOK_SECRET=
189 changes: 189 additions & 0 deletions backend/__tests__/unit/routes/billing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* /api/billing routes.
*
* The security boundary here is the webhook signature: that endpoint has NO
* auth middleware because Stripe is the caller, so the signature is the only
* thing standing between a stranger and a free Pro subscription. It gets the
* most tests.
*/

const express = require('express');
const request = require('supertest');

jest.mock('express-rate-limit', () => {
const factory = () => (_req, _res, next) => next();
factory.default = factory;
factory.ipKeyGenerator = (ip) => ip;
return factory;
});

jest.mock('../../../middleware/auth', () => (req, _res, next) => { req.userId = 'u-1'; next(); });

// `mock`-prefixed so jest's hoisted factories may reference them.
const mockSave = jest.fn();
const mockCurrentUser = { value: null };
jest.mock('../../../models/User', () => ({
findById: jest.fn(() => Promise.resolve(mockCurrentUser.value)),
}));

const mockConstructEvent = jest.fn();
const mockSessionsCreate = jest.fn();
const mockCustomersCreate = jest.fn();
const mockPortalCreate = jest.fn();
const mockHandleEvent = jest.fn();

jest.mock('../../../services/billingService', () => ({
STRIPE_ENABLED: () => Boolean(process.env.STRIPE_SECRET_KEY),
stripe: () => ({
webhooks: { constructEvent: mockConstructEvent },
checkout: { sessions: { create: mockSessionsCreate } },
customers: { create: mockCustomersCreate },
billingPortal: { sessions: { create: mockPortalCreate } },
}),
handleEvent: mockHandleEvent,
}));

const app = express();
// Mirrors server.ts: raw for the webhook, JSON for everything else.
app.use('/api/billing/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
app.use('/api/billing', require('../../../routes/billing'));

const ORIGINAL_ENV = process.env;

describe('/api/billing', () => {
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...ORIGINAL_ENV, STRIPE_SECRET_KEY: 'sk_test', STRIPE_PRICE_ID: 'price_1', STRIPE_WEBHOOK_SECRET: 'whsec' };
mockSave.mockResolvedValue(undefined);
mockCurrentUser.value = { _id: 'u-1', email: 'a@b.c', isBot: false, billing: { customerId: 'cus_1' }, save: mockSave };
mockSessionsCreate.mockResolvedValue({ url: 'https://checkout.stripe.test/s/1' });
mockPortalCreate.mockResolvedValue({ url: 'https://billing.stripe.test/p/1' });
mockHandleEvent.mockResolvedValue({ outcome: 'applied' });
});
afterAll(() => { process.env = ORIGINAL_ENV; });

describe('POST /webhook — the signature IS the authentication', () => {
test('a bad signature is rejected and nothing is handled', async () => {
mockConstructEvent.mockImplementation(() => { throw new Error('no signatures found'); });
const res = await request(app).post('/api/billing/webhook')
.set('stripe-signature', 'forged')
.set('Content-Type', 'application/json')
.send(Buffer.from(JSON.stringify({ type: 'customer.subscription.updated' })));
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_signature');
expect(mockHandleEvent).not.toHaveBeenCalled();
});

test('a missing signature header is rejected', async () => {
mockConstructEvent.mockImplementation(() => { throw new Error('missing header'); });
const res = await request(app).post('/api/billing/webhook')
.set('Content-Type', 'application/json')
.send(Buffer.from('{}'));
expect(res.status).toBe(400);
expect(mockHandleEvent).not.toHaveBeenCalled();
});

// The classic breakage: verification needs the exact bytes, so the handler
// must receive a Buffer, not a parsed object.
test('the handler is given the RAW body, not parsed JSON', async () => {
mockConstructEvent.mockReturnValue({ id: 'evt_1', type: 'x', data: { object: {} } });
await request(app).post('/api/billing/webhook')
.set('stripe-signature', 'sig')
.set('Content-Type', 'application/json')
.send(Buffer.from(JSON.stringify({ hello: 'world' })));
expect(Buffer.isBuffer(mockConstructEvent.mock.calls[0][0])).toBe(true);
});

test('a verified event is handled and acknowledged 200', async () => {
mockConstructEvent.mockReturnValue({ id: 'evt_1', type: 'checkout.session.completed', data: { object: {} } });
const res = await request(app).post('/api/billing/webhook')
.set('stripe-signature', 'sig')
.set('Content-Type', 'application/json')
.send(Buffer.from('{}'));
expect(res.status).toBe(200);
expect(mockHandleEvent).toHaveBeenCalled();
});

// A non-2xx makes Stripe retry for three days. An event we deliberately
// ignored must still be acknowledged or we cause our own retry storm.
test('an ignored event still returns 200', async () => {
mockConstructEvent.mockReturnValue({ id: 'evt_2', type: 'invoice.created', data: { object: {} } });
mockHandleEvent.mockResolvedValue({ outcome: 'ignored' });
const res = await request(app).post('/api/billing/webhook')
.set('stripe-signature', 'sig').set('Content-Type', 'application/json').send(Buffer.from('{}'));
expect(res.status).toBe(200);
});

// ...but a genuine handler failure SHOULD retry.
test('a handler exception returns 500 so Stripe retries', async () => {
mockConstructEvent.mockReturnValue({ id: 'evt_3', type: 'x', data: { object: {} } });
mockHandleEvent.mockRejectedValue(new Error('mongo down'));
const res = await request(app).post('/api/billing/webhook')
.set('stripe-signature', 'sig').set('Content-Type', 'application/json').send(Buffer.from('{}'));
expect(res.status).toBe(500);
});

test('unconfigured billing refuses rather than half-working', async () => {
delete process.env.STRIPE_WEBHOOK_SECRET;
const res = await request(app).post('/api/billing/webhook')
.set('stripe-signature', 'sig').set('Content-Type', 'application/json').send(Buffer.from('{}'));
expect(res.status).toBe(503);
});
});

describe('POST /checkout', () => {
test('returns a Checkout url and grants nothing', async () => {
const res = await request(app).post('/api/billing/checkout').send({});
expect(res.status).toBe(200);
expect(res.body.url).toContain('checkout.stripe.test');
// Entitlement must come from the webhook only.
expect(mockCurrentUser.value.entitlements).toBeUndefined();
});

test('creates and stamps a customer id before redirecting', async () => {
mockCurrentUser.value = { _id: 'u-1', email: 'a@b.c', isBot: false, save: mockSave };
mockCustomersCreate.mockResolvedValue({ id: 'cus_new' });
await request(app).post('/api/billing/checkout').send({});
expect(mockCurrentUser.value.billing.customerId).toBe('cus_new');
expect(mockSave).toHaveBeenCalled();
});

test('carries the user id both ways so the webhook can always resolve it', async () => {
await request(app).post('/api/billing/checkout').send({});
const arg = mockSessionsCreate.mock.calls[0][0];
expect(arg.client_reference_id).toBe('u-1');
expect(arg.metadata.userId).toBe('u-1');
expect(arg.subscription_data.metadata.userId).toBe('u-1');
});

test('agents cannot subscribe', async () => {
mockCurrentUser.value = { _id: 'b-1', isBot: true, save: mockSave };
const res = await request(app).post('/api/billing/checkout').send({});
expect(res.status).toBe(400);
expect(mockSessionsCreate).not.toHaveBeenCalled();
});

test('missing price config refuses rather than creating a broken session', async () => {
delete process.env.STRIPE_PRICE_ID;
const res = await request(app).post('/api/billing/checkout').send({});
expect(res.status).toBe(503);
expect(mockSessionsCreate).not.toHaveBeenCalled();
});
});

describe('POST /portal — the exit must always work', () => {
test('returns a portal url', async () => {
const res = await request(app).post('/api/billing/portal').send({});
expect(res.status).toBe(200);
expect(res.body.url).toContain('billing.stripe.test');
});

test('a user who never subscribed gets a clear error, not a crash', async () => {
mockCurrentUser.value = { _id: 'u-1', save: mockSave };
const res = await request(app).post('/api/billing/portal').send({});
expect(res.status).toBe(400);
expect(res.body.error).toBe('no_subscription');
});
});
});
183 changes: 183 additions & 0 deletions backend/__tests__/unit/services/billingService.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* billingService — the only writer of `entitlements.pro`.
*
* These tests exist because every bug here is either "we gave away the product"
* or "we took money and withheld it". The four properties defended:
*
* 1. a replayed event never applies twice (Stripe retries for 3 days)
* 2. entitlement is DERIVED from status, so out-of-order delivery converges
* 3. an unsettled payment grants nothing
* 4. an unmappable event is recorded, not silently dropped
*/

jest.mock('stripe', () => jest.fn(() => ({})));

const save = jest.fn();
const users = { byId: null, byCustomer: null };

jest.mock('../../../models/User', () => ({
findById: jest.fn(() => Promise.resolve(users.byId)),
findOne: jest.fn(() => Promise.resolve(users.byCustomer)),
}));

const created = [];
jest.mock('../../../models/BillingEvent', () => ({
create: jest.fn((doc) => {
if (created.some((d) => d.eventId === doc.eventId)) {
const e = new Error('E11000 duplicate key');
e.code = 11000;
return Promise.reject(e);
}
created.push(doc);
return Promise.resolve(doc);
}),
updateOne: jest.fn(() => Promise.resolve({})),
}));

const BillingEvent = require('../../../models/BillingEvent');
const { handleEvent, statusGrantsPro, applySubscriptionState } = require('../../../services/billingService');

const mkUser = (over = {}) => ({
_id: 'u-1',
email: 'a@b.c',
entitlements: { cloudAgents: true, pro: false },
billing: { customerId: 'cus_1' },
save,
...over,
});

const evt = (type, object, id = `evt_${Math.random().toString(36).slice(2)}`) => ({
id, type, data: { object },
});

describe('billingService', () => {
beforeEach(() => {
jest.clearAllMocks();
created.length = 0;
save.mockResolvedValue(undefined);
users.byId = null;
users.byCustomer = mkUser();
});

describe('entitlement is derived from status, never toggled', () => {
test.each([['active', true], ['trialing', true], ['past_due', false],
['canceled', false], ['unpaid', false], ['incomplete', false], [undefined, false]])(
'status %s grants pro = %s', (status, expected) => {
expect(statusGrantsPro(status)).toBe(expected);
},
);

// The property that makes out-of-order delivery safe: replaying an older
// event recomputes from ITS status rather than flipping a toggle, so the
// final write always matches the last event applied.
test('an out-of-order revoke then re-grant converges, not latches', async () => {
users.byCustomer = mkUser({ entitlements: { pro: true } });
await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'canceled' }));
expect(users.byCustomer.entitlements.pro).toBe(false);

await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'active' }));
expect(users.byCustomer.entitlements.pro).toBe(true);
});
});

describe('idempotency — Stripe retries for three days', () => {
test('a replayed event id is a no-op', async () => {
const e = evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'active' }, 'evt_same');
const first = await handleEvent(e);
expect(first.outcome).toBe('applied');
save.mockClear();

const second = await handleEvent(e);
expect(second.outcome).toBe('duplicate');
expect(save).not.toHaveBeenCalled();
});

test('the marker is claimed BEFORE the user is touched', async () => {
await handleEvent(evt('customer.subscription.updated', { customer: 'cus_1', id: 'sub_1', status: 'active' }));
// If the write happened first, a crash between them would apply the
// change with no record, and the retry would apply it again.
expect(BillingEvent.create).toHaveBeenCalled();
const claimOrder = BillingEvent.create.mock.invocationCallOrder[0];
const saveOrder = save.mock.invocationCallOrder[0];
expect(claimOrder).toBeLessThan(saveOrder);
});
});

describe('checkout only grants when money actually settled', () => {
test('payment_status=paid grants pro', async () => {
const res = await handleEvent(evt('checkout.session.completed', {
customer: 'cus_1', subscription: 'sub_1', payment_status: 'paid', metadata: { userId: 'u-1' },
}));
expect(res.outcome).toBe('applied');
expect(users.byCustomer.entitlements.pro).toBe(true);
});

test.each(['unpaid', 'no_payment_required', undefined])(
'payment_status=%s grants nothing', async (status) => {
await handleEvent(evt('checkout.session.completed', {
customer: 'cus_1', subscription: 'sub_1', payment_status: status,
}));
expect(save).not.toHaveBeenCalled();
},
);
});

describe('resolving the user', () => {
test('prefers the metadata hint over the customer id', async () => {
users.byId = mkUser({ _id: 'u-hint' });
users.byCustomer = mkUser({ _id: 'u-wrong' });
const res = await applySubscriptionState({
customerId: 'cus_1', status: 'active', userIdHint: '507f1f77bcf86cd799439011',
});
expect(res.userId).toBe('u-hint');
});

test('falls back to customer id when there is no hint', async () => {
const res = await applySubscriptionState({ customerId: 'cus_1', status: 'active' });
expect(res.userId).toBe('u-1');
});

// An event we cannot map must be RECORDED, not dropped — otherwise a
// paying customer with no access leaves no trace to debug.
test('an unmappable event is reported as unmapped', async () => {
users.byCustomer = null;
const res = await handleEvent(evt('customer.subscription.updated', { customer: 'cus_ghost', status: 'active' }));
expect(res.outcome).toBe('unmapped');
expect(BillingEvent.updateOne).toHaveBeenCalledWith(
expect.objectContaining({ eventId: expect.any(String) }),
expect.objectContaining({ $set: expect.objectContaining({ outcome: 'unmapped' }) }),
);
});
});

describe('cancellation', () => {
test('subscription.deleted revokes pro', async () => {
users.byCustomer = mkUser({ entitlements: { cloudAgents: true, pro: true } });
await handleEvent(evt('customer.subscription.deleted', { customer: 'cus_1', id: 'sub_1', status: 'canceled' }));
expect(users.byCustomer.entitlements.pro).toBe(false);
});

// Losing Pro must not also lose hosted agents — separate entitlements.
test('revoking pro leaves cloudAgents intact', async () => {
users.byCustomer = mkUser({ entitlements: { cloudAgents: true, pro: true } });
await handleEvent(evt('customer.subscription.deleted', { customer: 'cus_1', id: 'sub_1', status: 'canceled' }));
expect(users.byCustomer.entitlements.cloudAgents).toBe(true);
});

test('cancel_at_period_end is recorded without revoking early', async () => {
await handleEvent(evt('customer.subscription.updated', {
customer: 'cus_1', id: 'sub_1', status: 'active', cancel_at_period_end: true,
current_period_end: 1800000000,
}));
expect(users.byCustomer.entitlements.pro).toBe(true);
expect(users.byCustomer.billing.cancelAtPeriodEnd).toBe(true);
expect(users.byCustomer.billing.currentPeriodEnd).toEqual(new Date(1800000000 * 1000));
});
});

test('an unhandled event type is ignored, not an error', async () => {
const res = await handleEvent(evt('invoice.created', { customer: 'cus_1' }));
expect(res.outcome).toBe('ignored');
expect(save).not.toHaveBeenCalled();
});
});
Loading
Loading