diff --git a/backend/.env.example b/backend/.env.example index 3c0dafa9..215f2f28 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 \ No newline at end of file +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= diff --git a/backend/__tests__/unit/routes/billing.test.js b/backend/__tests__/unit/routes/billing.test.js new file mode 100644 index 00000000..9e83e13d --- /dev/null +++ b/backend/__tests__/unit/routes/billing.test.js @@ -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'); + }); + }); +}); diff --git a/backend/__tests__/unit/services/billingService.test.js b/backend/__tests__/unit/services/billingService.test.js new file mode 100644 index 00000000..023a0b4c --- /dev/null +++ b/backend/__tests__/unit/services/billingService.test.js @@ -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(); + }); +}); diff --git a/backend/models/BillingEvent.ts b/backend/models/BillingEvent.ts new file mode 100644 index 00000000..f293fdb9 --- /dev/null +++ b/backend/models/BillingEvent.ts @@ -0,0 +1,59 @@ +import mongoose, { Document, Model, Schema } from 'mongoose'; + +/** + * Every Stripe webhook event we have already processed. + * + * Stripe retries a webhook until it gets a 2xx — for up to three days — and + * explicitly does not guarantee once-only delivery or ordering. So a handler + * that is merely *correct* is not enough; it has to be *idempotent*, and the + * cheapest honest way to get that is to refuse an event id we have seen. + * + * The unique index IS the lock: two concurrent deliveries of the same event + * both attempt the insert, one wins, the loser takes E11000 and returns + * early. That is the same shape as PodMemberFirstMessage (#834) and + * AgentFirstContact — a durable marker whose uniqueness constraint does the + * mutual exclusion, rather than an in-process guard that a second pod would + * not see. + * + * Kept as its own collection rather than a field on User because an event can + * arrive that we cannot map to a user (unknown customer, deleted account) and + * we still must not process it twice. + */ +export interface IBillingEvent extends Document { + eventId: string; + type: string; + customerId?: string; + userId?: mongoose.Types.ObjectId; + outcome: 'applied' | 'ignored' | 'unmapped' | 'error'; + detail?: string; + createdAt: Date; +} + +const BillingEventSchema = new Schema( + { + eventId: { type: String, required: true }, + type: { type: String, required: true }, + customerId: { type: String }, + userId: { type: Schema.Types.ObjectId, ref: 'User' }, + // Recorded rather than inferred: "why does this customer not have Pro" + // is the first support question, and `unmapped` vs `ignored` is the + // difference between a bug and a no-op. + outcome: { + type: String, + enum: ['applied', 'ignored', 'unmapped', 'error'], + required: true, + }, + detail: { type: String }, + }, + { timestamps: { createdAt: true, updatedAt: false }, versionKey: false }, +); + +BillingEventSchema.index({ eventId: 1 }, { unique: true }); + +const BillingEvent: Model = + (mongoose.models.BillingEvent as Model) + || mongoose.model('BillingEvent', BillingEventSchema); + +export default BillingEvent; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/backend/models/User.ts b/backend/models/User.ts index 79019202..991c4d15 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -83,8 +83,22 @@ export interface IUser extends Document { // BYO agents are NEVER gated by this: "agents you bring connect free and // unlimited" is the product's standing promise, and a per-agent cap is the // thing this pricing model exists to avoid. + // NEVER set from a client response — `billingService` writes it from a + // signature-verified Stripe webhook, the only source of truth about + // whether money actually moved. pro: boolean; }; + // Stripe linkage. `customerId` is the join key from webhook -> user; it is + // set at checkout creation so an event can always be resolved even if the + // session metadata is missing. `subscriptionStatus` mirrors Stripe rather + // than being derived, so support can see WHY someone lost access. + billing?: { + customerId?: string; + subscriptionId?: string; + subscriptionStatus?: string; + currentPeriodEnd?: Date; + cancelAtPeriodEnd?: boolean; + }; apiToken?: string; apiTokenCreatedAt?: Date; apiTokenScopes: string[]; @@ -189,6 +203,14 @@ const userSchema = new Schema({ cloudAgents: { type: Boolean, default: false }, pro: { type: Boolean, default: false }, }, + billing: { + // Indexed: every webhook resolves a user by this in the hot path. + customerId: { type: String, index: true, sparse: true }, + subscriptionId: { type: String }, + subscriptionStatus: { type: String }, + currentPeriodEnd: { type: Date }, + cancelAtPeriodEnd: { type: Boolean, default: false }, + }, // `select: false` so a live bearer credential can never ride along on an // incidental `findById().select('-password')` or a populate(). Queries that // FILTER on this field still work (projection is separate from the filter), diff --git a/backend/package-lock.json b/backend/package-lock.json index 53d2c072..71e56fbf 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,13 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", + "license": "Apache-2.0", "dependencies": { "@google-cloud/secret-manager": "^6.1.1", "@google/generative-ai": "^0.2.1", @@ -35,6 +36,7 @@ "pg": "^8.11.3", "redis": "^4.7.0", "socket.io": "^4.7.2", + "stripe": "^22.4.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, @@ -5651,94 +5653,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/gcp-metadata/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gcp-metadata/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -10877,6 +10791,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stripe": { + "version": "22.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz", + "integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", @@ -16030,70 +15961,6 @@ } } }, - "gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "optional": true, - "peer": true, - "requires": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "dependencies": { - "agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "optional": true, - "peer": true, - "requires": { - "debug": "4" - } - }, - "debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "optional": true, - "peer": true, - "requires": { - "ms": "^2.1.3" - } - }, - "gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "optional": true, - "peer": true, - "requires": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - } - }, - "https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "optional": true, - "peer": true, - "requires": { - "agent-base": "6", - "debug": "4" - } - }, - "ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "optional": true, - "peer": true - } - } - }, "generic-pool": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", @@ -19601,6 +19468,12 @@ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, + "stripe": { + "version": "22.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz", + "integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==", + "requires": {} + }, "stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 52987a75..da8264d9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,7 @@ "pg": "^8.11.3", "redis": "^4.7.0", "socket.io": "^4.7.2", + "stripe": "^22.4.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, diff --git a/backend/routes/billing.ts b/backend/routes/billing.ts new file mode 100644 index 00000000..b2331f7d --- /dev/null +++ b/backend/routes/billing.ts @@ -0,0 +1,155 @@ +import rateLimit from 'express-rate-limit'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const express = require('express'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const auth = require('../middleware/auth'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const User = require('../models/User'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const billingService = require('../services/billingService'); + +const router = express.Router(); + +const checkoutLimit = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 20, + standardHeaders: 'draft-7', + legacyHeaders: false, +}); + +const FRONTEND = () => process.env.FRONTEND_URL || 'https://commonly.me'; + +/** + * POST /api/billing/checkout — start a subscription. + * + * Creates (or reuses) a Stripe customer, stamps `billing.customerId` BEFORE + * redirecting, and returns the Checkout URL. Stamping first matters: if the + * user pays and the `checkout.session.completed` metadata is somehow absent, + * the customer id is still a valid join key back to this account. + * + * Grants nothing. Only the webhook does. + */ +router.post('/checkout', checkoutLimit, auth, async (req: any, res: any) => { + try { + if (!billingService.STRIPE_ENABLED()) { + return res.status(503).json({ error: 'billing_not_configured' }); + } + const priceId = process.env.STRIPE_PRICE_ID; + if (!priceId) return res.status(503).json({ error: 'billing_not_configured' }); + + const user = await User.findById(req.userId); + if (!user) return res.status(404).json({ error: 'User not found' }); + if (user.isBot) return res.status(400).json({ error: 'Agents do not hold subscriptions' }); + + const stripe = billingService.stripe(); + let customerId = user.billing?.customerId; + if (!customerId) { + const customer = await stripe.customers.create({ + email: user.email, + metadata: { userId: String(user._id), username: user.username || '' }, + }); + customerId = customer.id; + user.billing = { ...(user.billing || {}), customerId }; + await user.save(); + } + + // No `automatic_tax` on purpose. Stripe Tax is not activated on the + // account, and enabling it here would fail session creation outright. + // The price is configured `tax_behavior: inclusive`, so the customer pays + // exactly the advertised $12 whether or not tax is ever calculated — + // turning Stripe Tax on later changes what we remit, never the sticker + // price. Enabling it also requires `customer_update: { address: 'auto' }`, + // since an existing customer needs an address before tax can be computed. + const session = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price: priceId, quantity: 1 }], + // Both, deliberately: `client_reference_id` survives places metadata does + // not, and the webhook reads either. + client_reference_id: String(user._id), + subscription_data: { metadata: { userId: String(user._id) } }, + metadata: { userId: String(user._id) }, + success_url: `${FRONTEND()}/v2/settings?upgraded=1`, + cancel_url: `${FRONTEND()}/v2/settings?upgrade=cancelled`, + allow_promotion_codes: true, + }); + + return res.json({ url: session.url }); + } catch (err) { + console.error('[billing] checkout failed:', (err as Error).message); + return res.status(500).json({ error: 'checkout_failed' }); + } +}); + +/** + * POST /api/billing/portal — manage or cancel. + * + * Self-serve cancellation is not a nicety: without it every downgrade becomes + * a support email, and a user who cannot find the exit disputes the charge + * instead. + */ +router.post('/portal', checkoutLimit, auth, async (req: any, res: any) => { + try { + if (!billingService.STRIPE_ENABLED()) { + return res.status(503).json({ error: 'billing_not_configured' }); + } + const user = await User.findById(req.userId); + const customerId = user?.billing?.customerId; + if (!customerId) return res.status(400).json({ error: 'no_subscription' }); + + const session = await billingService.stripe().billingPortal.sessions.create({ + customer: customerId, + return_url: `${FRONTEND()}/v2/settings`, + }); + return res.json({ url: session.url }); + } catch (err) { + console.error('[billing] portal failed:', (err as Error).message); + return res.status(500).json({ error: 'portal_failed' }); + } +}); + +/** + * POST /api/billing/webhook — the ONLY writer of entitlements.pro. + * + * NO `auth` middleware by design: Stripe is the caller, and the signature is + * the authentication. Mounted with `express.raw` in server.ts before the + * global JSON parser, because `constructEvent` needs the exact bytes — a + * re-serialized body fails verification, which is the single most common way + * this integration breaks. + * + * Returns 200 on anything we have durably recorded, including events we chose + * to ignore. Returning non-2xx makes Stripe retry for three days, so a bug in + * our handling would turn into a retry storm; only a signature failure or an + * unrecorded error is worth a retry. + */ +router.post('/webhook', async (req: any, res: any) => { + const secret = process.env.STRIPE_WEBHOOK_SECRET; + if (!billingService.STRIPE_ENABLED() || !secret) { + return res.status(503).json({ error: 'billing_not_configured' }); + } + + let event; + try { + event = billingService.stripe().webhooks.constructEvent( + req.body, + req.headers['stripe-signature'], + secret, + ); + } catch (err) { + // Never log the body — it is signed but not ours to spill. + console.warn('[billing] webhook signature rejected:', (err as Error).message); + return res.status(400).json({ error: 'invalid_signature' }); + } + + try { + const result = await billingService.handleEvent(event); + return res.json({ received: true, outcome: result.outcome }); + } catch (err) { + console.error('[billing] webhook handling failed:', (err as Error).message); + // Unrecorded failure — let Stripe retry. + return res.status(500).json({ error: 'handler_failed' }); + } +}); + +module.exports = router; diff --git a/backend/server.ts b/backend/server.ts index 7b3480ba..cdafdddd 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -158,6 +158,12 @@ app.use( // Raw body middleware for Discord signature verification app.use('/api/discord/interactions', express.raw({ type: 'application/json' })); +// Stripe verifies the EXACT bytes it sent, so this route must never see a +// re-serialized body. Mounted here, before the global express.json() below — +// putting it after would make every webhook fail signature verification, +// which is the most common way this integration breaks. +app.use('/api/billing/webhook', express.raw({ type: 'application/json' })); + // Slack needs the exact raw payload for signature verification; capture it while still parsing JSON app.use( '/api/webhooks/slack', @@ -186,6 +192,7 @@ app.use('/api/users', userRoutes); // otherwise consume that request as a pod lookup. app.use('/api', podInvitesRoutes); app.use('/api/pods', podRoutes); +app.use('/api/billing', require('./routes/billing')); app.use('/api/messages', messageRoutes); app.use('/api/uploads', uploadsRoutes); app.use('/api/docs', docsRoutes); diff --git a/backend/services/billingService.ts b/backend/services/billingService.ts new file mode 100644 index 00000000..04eca88a --- /dev/null +++ b/backend/services/billingService.ts @@ -0,0 +1,179 @@ +/** + * Stripe billing — the only writer of `entitlements.pro`. + * + * Design rules, each of which exists because the obvious alternative is a way + * to lose money or give it away: + * + * 1. **The webhook is the only source of truth.** A client returning from + * Checkout proves nothing — the redirect is attacker-controllable and the + * session can be abandoned after redirect. Nothing in the success path + * grants Pro; only a signature-verified event does. + * + * 2. **Idempotent by durable marker.** Stripe retries for up to three days and + * guarantees neither once-only delivery nor ordering. `BillingEvent`'s + * unique index is the lock (see that model). + * + * 3. **Entitlement is derived from subscription status, never toggled.** Every + * handler recomputes `pro` from the status Stripe reports, so an + * out-of-order delivery converges instead of latching. `active` and + * `trialing` grant; everything else revokes. + * + * 4. **Grant on `checkout.session.completed` only when payment actually + * settled** (`payment_status === 'paid'`). A completed session with an + * async or failed payment is not money. + */ + +import mongoose from 'mongoose'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const Stripe = require('stripe'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const User = require('../models/User'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const BillingEvent = require('../models/BillingEvent'); + +export const STRIPE_ENABLED = (): boolean => Boolean(process.env.STRIPE_SECRET_KEY); + +let cached: any = null; +export const stripe = (): any => { + if (!STRIPE_ENABLED()) return null; + if (!cached) cached = new Stripe(process.env.STRIPE_SECRET_KEY as string); + return cached; +}; + +/** Statuses that mean the customer currently has what they paid for. */ +const ENTITLED_STATUSES = new Set(['active', 'trialing']); + +export const statusGrantsPro = (status?: string | null): boolean => ( + ENTITLED_STATUSES.has(String(status || '')) +); + +interface ApplyArgs { + customerId?: string | null; + subscriptionId?: string | null; + status?: string | null; + currentPeriodEnd?: number | null; + cancelAtPeriodEnd?: boolean | null; + userIdHint?: string | null; +} + +/** + * Resolve the user and set their entitlement from the reported status. + * Returns the outcome recorded on the BillingEvent row. + */ +export const applySubscriptionState = async ({ + customerId, + subscriptionId, + status, + currentPeriodEnd, + cancelAtPeriodEnd, + userIdHint, +}: ApplyArgs): Promise<{ outcome: 'applied' | 'unmapped'; userId?: string; pro?: boolean }> => { + let user: any = null; + + // Prefer the metadata hint (set at checkout creation) because it survives a + // customer being recreated; fall back to the customer id, which is what + // subscription lifecycle events carry. + if (userIdHint && mongoose.Types.ObjectId.isValid(String(userIdHint))) { + user = await User.findById(userIdHint); + } + if (!user && customerId) { + user = await User.findOne({ 'billing.customerId': customerId }); + } + if (!user) return { outcome: 'unmapped' }; + + const pro = statusGrantsPro(status); + + user.entitlements = { ...(user.entitlements || {}), pro }; + user.billing = { + ...(user.billing || {}), + ...(customerId ? { customerId } : {}), + ...(subscriptionId ? { subscriptionId } : {}), + subscriptionStatus: status || undefined, + currentPeriodEnd: currentPeriodEnd ? new Date(currentPeriodEnd * 1000) : user.billing?.currentPeriodEnd, + cancelAtPeriodEnd: Boolean(cancelAtPeriodEnd), + }; + await user.save(); + + return { outcome: 'applied', userId: String(user._id), pro }; +}; + +/** + * Process one already-verified Stripe event. Caller must have checked the + * signature — this function trusts its input by contract. + */ +export const handleEvent = async (event: any): Promise<{ outcome: string; detail?: string }> => { + // Idempotency gate. The insert is the lock: a duplicate delivery loses the + // unique index and returns without touching the user. + try { + await BillingEvent.create({ + eventId: event.id, + type: event.type, + outcome: 'ignored', + detail: 'claimed', + }); + } catch (err: any) { + if (err?.code === 11000) return { outcome: 'duplicate' }; + throw err; + } + + const obj = event?.data?.object || {}; + let result: { outcome: string; detail?: string; userId?: string } = { outcome: 'ignored' }; + + switch (event.type) { + case 'checkout.session.completed': { + // Only money that actually settled. A completed session whose payment is + // still processing (or failed) must not grant anything. + if (obj.payment_status !== 'paid') { + result = { outcome: 'ignored', detail: `payment_status=${obj.payment_status}` }; + break; + } + const applied = await applySubscriptionState({ + customerId: obj.customer, + subscriptionId: obj.subscription, + status: 'active', + userIdHint: obj.metadata?.userId || obj.client_reference_id, + }); + result = { ...applied, detail: 'checkout paid' }; + break; + } + + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + // `deleted` still carries a status ('canceled'), so the same derivation + // handles all three — no special-casing, no latching. + const applied = await applySubscriptionState({ + customerId: obj.customer, + subscriptionId: obj.id, + status: obj.status, + currentPeriodEnd: obj.current_period_end, + cancelAtPeriodEnd: obj.cancel_at_period_end, + userIdHint: obj.metadata?.userId, + }); + result = { ...applied, detail: `status=${obj.status}` }; + break; + } + + default: + result = { outcome: 'ignored', detail: 'unhandled type' }; + } + + await BillingEvent.updateOne( + { eventId: event.id }, + { + $set: { + outcome: result.outcome === 'duplicate' ? 'ignored' : result.outcome, + detail: result.detail, + customerId: obj.customer || undefined, + ...(result.userId ? { userId: result.userId } : {}), + }, + }, + ); + + return result; +}; + +export default { stripe, STRIPE_ENABLED, handleEvent, applySubscriptionState, statusGrantsPro }; +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 52bec8fe..facb2c5b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -581,7 +581,6 @@ } }, "pro": { - "badge": "Free in beta", "name": "Pro", "price": "$12", "period": "/human/mo", @@ -1425,5 +1424,22 @@ "redeemFailed": "Could not redeem that code.", "openRoomFailed": "Could not open a 1:1 with this agent — try again." } + }, + "billing": { + "title": "Plan", + "tier": { + "free": "Free", + "pro": "Pro" + }, + "freeNote": "Unlimited agents you bring, and 30 days of message history. Upgrade for unlimited history and to list pods in Community.", + "proNote": "Unlimited message history, Community listing, and hosted agent seats when they land.", + "cancelling": "Your plan is set to cancel at the end of the current period. You keep Pro until then.", + "upgrade": "Upgrade to Pro — $12/month", + "manage": "Manage billing", + "opening": "Opening…", + "errors": { + "generic": "Could not open billing just now. Please try again.", + "notConfigured": "Billing isn't switched on yet. Please contact us and we'll set you up." + } } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 73f759fe..cdf8e8db 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -581,7 +581,6 @@ } }, "pro": { - "badge": "公测期免费", "name": "Pro", "price": "$12", "period": "/人/月", @@ -1421,5 +1420,22 @@ "redeemFailed": "无法兑换该邀请码。", "openRoomFailed": "无法与该智能体开启一对一对话 —— 请重试。" } + }, + "billing": { + "title": "订阅方案", + "tier": { + "free": "免费版", + "pro": "Pro" + }, + "freeNote": "自带 agent 不限数量,消息历史保留 30 天。升级后可获得无限历史记录,并可将 Pod 收录进「社区」。", + "proNote": "无限消息历史、社区收录,以及托管 agent 席位(上线即包含)。", + "cancelling": "你的方案将在本计费周期结束时取消。在此之前仍可使用 Pro。", + "upgrade": "升级到 Pro —— $12/月", + "manage": "管理订阅", + "opening": "正在打开…", + "errors": { + "generic": "暂时无法打开付款页面,请稍后再试。", + "notConfigured": "付款功能尚未开启,请联系我们为你开通。" + } } } diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 59159ea7..ed306392 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -16,6 +16,7 @@ import VerifyEmail from '../components/VerifyEmail'; import DiscordCallback from '../components/DiscordCallback'; import V2LandingPage from './landing/V2LandingPage'; import V2Showcase from './showcase/V2Showcase'; +import V2BillingPanel from './components/V2BillingPanel'; import V2AgentProfile from './agents/V2AgentProfile'; import UseCasePage from '../components/landing/UseCasePage'; import PostFeed from '../components/PostFeed'; @@ -267,7 +268,12 @@ const V2App: React.FC = () => { /> )} + element={feature('Settings', 'Plan and billing, profile, avatar, app management, and API token settings.', ( + <> + + + + ))} /> ({ + useV2Api: () => ({ post: mockPost, get: jest.fn() }), +})); + +const mockUser: { value: unknown } = { value: null }; +jest.mock('../../context/AuthContext', () => ({ + useAuth: () => ({ currentUser: mockUser.value }), +})); + +const free = { entitlements: { pro: false } }; +const pro = { entitlements: { pro: true } }; + +describe('V2BillingPanel', () => { + const originalLocation = window.location; + + beforeEach(() => { + jest.clearAllMocks(); + mockUser.value = free; + // jsdom refuses assignment to window.location.href without this. + delete (window as unknown as { location?: unknown }).location; + (window as unknown as { location: { href: string } }).location = { href: '' }; + }); + + afterAll(() => { + (window as unknown as { location: unknown }).location = originalLocation; + }); + + describe('the tier shown is the one the backend gates on', () => { + test('a free user sees Free and an upgrade action', () => { + render(); + expect(screen.getByText('Free')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Upgrade to Pro/ })).toBeInTheDocument(); + }); + + test('a Pro user sees Pro and a manage action, never an upgrade', () => { + mockUser.value = pro; + render(); + expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Manage billing/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Upgrade/ })).not.toBeInTheDocument(); + }); + + // `pro: true` is the only truthy value that counts — mirrors the backend's + // `!== true` check, so a stray truthy never shows a tier the API refuses. + test('a truthy-but-not-true entitlement still reads as Free', () => { + mockUser.value = { entitlements: { pro: 'yes' } }; + render(); + expect(screen.getByText('Free')).toBeInTheDocument(); + }); + + test('a user with no entitlements object reads as Free', () => { + mockUser.value = {}; + render(); + expect(screen.getByText('Free')).toBeInTheDocument(); + }); + }); + + describe('upgrading hands off to Stripe and grants nothing locally', () => { + test('redirects to the returned checkout url', async () => { + mockPost.mockResolvedValue({ url: 'https://checkout.stripe.com/s/1' }); + render(); + fireEvent.click(screen.getByRole('button', { name: /Upgrade to Pro/ })); + await waitFor(() => { + expect(window.location.href).toBe('https://checkout.stripe.com/s/1'); + }); + expect(mockPost).toHaveBeenCalledWith('/api/billing/checkout', {}); + }); + + test('a configuration error tells the user to contact us, not "try again"', async () => { + mockPost.mockRejectedValue({ response: { data: { error: 'billing_not_configured' } } }); + render(); + fireEvent.click(screen.getByRole('button', { name: /Upgrade to Pro/ })); + expect(await screen.findByRole('alert')).toHaveTextContent(/contact us/i); + }); + + test('a transient failure is recoverable and re-enables the button', async () => { + mockPost.mockRejectedValue(new Error('network')); + render(); + const btn = screen.getByRole('button', { name: /Upgrade to Pro/ }); + fireEvent.click(btn); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + await waitFor(() => expect(btn).not.toBeDisabled()); + }); + }); + + test('a pending cancellation is stated without removing access', () => { + mockUser.value = { entitlements: { pro: true }, billing: { cancelAtPeriodEnd: true } }; + render(); + expect(screen.getByText(/set to cancel/i)).toBeInTheDocument(); + // Still Pro until the period ends — the panel must not pre-revoke. + expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Manage billing/ })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/v2/__tests__/pricing-copy-invariants.test.ts b/frontend/src/v2/__tests__/pricing-copy-invariants.test.ts new file mode 100644 index 00000000..1cfdc8ae --- /dev/null +++ b/frontend/src/v2/__tests__/pricing-copy-invariants.test.ts @@ -0,0 +1,71 @@ +import en from '../../i18n/locales/en.json'; +import zhCN from '../../i18n/locales/zh-CN.json'; + +/** + * Pricing copy must not promise a price we do not charge. + * + * This exists because it already happened: the Pro card shipped carrying a + * "Free in beta" / "公测期免费" badge directly above "$12/human/mo", and stayed + * that way through the commit that turned real billing on. A user who signs up + * under a free-in-beta banner and is then charged has a dispute, not a + * misunderstanding — so a free-claim on a priced tier is a money bug, not a + * copy nit, and it belongs in the test suite rather than in a reviewer's head. + * + * The check is deliberately narrow. Only the fields that DESCRIBE COST are + * scanned (`price`, `period`, `note`, and any `badge`); the feature bullets are + * exempt because they legitimately name other tiers — "Everything in Cloud + * free" is a cross-reference, not a price claim. + */ + +const LOCALES = { en, 'zh-CN': zhCN } as Record; + +// Fields that make a statement about what the tier costs. +const COST_FIELDS = ['badge', 'price', 'period', 'note']; + +// A free-claim in each language we ship. Kept as plain substrings: this must +// stay readable by someone adding a locale, who has to extend it. +const FREE_CLAIMS = [/free/i, /免费/, /\$0\b/]; + +/** A tier is "priced" when its price field names a non-zero amount. */ +const isPriced = (tier: Record): boolean => { + const price = String(tier.price ?? ''); + const digits = price.replace(/[^\d.]/g, ''); + return digits !== '' && parseFloat(digits) > 0; +}; + +describe('landing pricing copy', () => { + Object.entries(LOCALES).forEach(([locale, bundle]) => { + describe(locale, () => { + const pricing = bundle.landing.pricing as Record; + const tiers = Object.entries(pricing).filter( + ([, v]) => v && typeof v === 'object' && 'name' in v, + ); + + test('has tiers to check (guards against the copy moving out from under this test)', () => { + expect(tiers.length).toBeGreaterThan(0); + }); + + test.each(tiers)('the %s tier never claims to be free while showing a price', (name, tier) => { + if (!isPriced(tier)) return; // Free tiers are free; nothing to defend. + + COST_FIELDS.forEach((field) => { + const text = tier[field]; + if (typeof text !== 'string') return; + FREE_CLAIMS.forEach((claim) => { + expect(`${name}.${field}: ${text}`).not.toMatch(claim); + }); + }); + }); + }); + }); + + test('Pro is priced, so the rule above actually applies to it', () => { + // Without this, deleting the price would silently disarm every assertion. + expect(isPriced(en.landing.pricing.pro)).toBe(true); + expect(isPriced(zhCN.landing.pricing.pro)).toBe(true); + }); + + test('the two locales price Pro identically — currency is not translated', () => { + expect(zhCN.landing.pricing.pro.price).toBe(en.landing.pricing.pro.price); + }); +}); diff --git a/frontend/src/v2/components/V2BillingPanel.tsx b/frontend/src/v2/components/V2BillingPanel.tsx new file mode 100644 index 00000000..9a8bcd13 --- /dev/null +++ b/frontend/src/v2/components/V2BillingPanel.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAuth } from '../../context/AuthContext'; +import { useV2Api } from '../hooks/useV2Api'; + +/** + * Plan panel on /v2/settings. + * + * Reads the tier from `entitlements.pro` on the current user — the same flag + * the backend gates on, so the UI can never claim a tier the API would refuse. + * It NEVER writes that flag: upgrading redirects to Stripe Checkout and the + * entitlement arrives later via the signed webhook. A user returning from a + * successful checkout may briefly still read as Free; that is correct, and the + * copy says so rather than faking the optimistic state. + */ +const V2BillingPanel: React.FC = () => { + const { t } = useTranslation(); + const { currentUser } = useAuth() as { + currentUser?: { + entitlements?: { pro?: boolean }; + billing?: { subscriptionStatus?: string; cancelAtPeriodEnd?: boolean; currentPeriodEnd?: string }; + } | null; + }; + const api = useV2Api(); + const [busy, setBusy] = useState<'upgrade' | 'manage' | null>(null); + const [error, setError] = useState(null); + + const isPro = currentUser?.entitlements?.pro === true; + const cancelling = currentUser?.billing?.cancelAtPeriodEnd === true; + + const go = async (kind: 'upgrade' | 'manage') => { + setBusy(kind); + setError(null); + try { + const path = kind === 'upgrade' ? '/api/billing/checkout' : '/api/billing/portal'; + const res = await api.post<{ url?: string; error?: string }>(path, {}); + if (res?.url) { + // Full navigation, not a router push: Stripe is a different origin. + window.location.href = res.url; + return; + } + setError(t('billing.errors.generic')); + } catch (err) { + const code = (err as { response?: { data?: { error?: string } } })?.response?.data?.error; + setError(code === 'billing_not_configured' + ? t('billing.errors.notConfigured') + : t('billing.errors.generic')); + } finally { + setBusy(null); + } + }; + + return ( +
+
+

{t('billing.title')}

+ + {isPro ? t('billing.tier.pro') : t('billing.tier.free')} + +
+ +

+ {isPro ? t('billing.proNote') : t('billing.freeNote')} +

+ + {cancelling && ( +

{t('billing.cancelling')}

+ )} + + {error &&

{error}

} + +
+ {isPro ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default V2BillingPanel; diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index 01a7a358..273dee57 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -675,9 +675,11 @@ const V2LandingPage: React.FC = () => { {primaryLabel} - {/* Pro — featured */} + {/* Pro — featured. No badge: the accent border carries the emphasis, + and every short badge we could put on a paid tier is either a + price promise we'd have to keep or a popularity claim we can't + substantiate. */}
-
{t('landing.pricing.pro.badge')}
{t('landing.pricing.pro.name')}
{t('landing.pricing.pro.price')}{t('landing.pricing.pro.period')}
{t('landing.pricing.pro.note')}
diff --git a/frontend/src/v2/landing/v2-landing.css b/frontend/src/v2/landing/v2-landing.css index 174f657d..1c205ad6 100644 --- a/frontend/src/v2/landing/v2-landing.css +++ b/frontend/src/v2/landing/v2-landing.css @@ -664,19 +664,6 @@ } /* Featured tier: accent border (borders-not-shadows), no elevation. */ .v2-landing__tier--featured { border-color: var(--v2-accent); } -.v2-landing__tier-badge { - position: absolute; - top: -11px; - left: 24px; - background: var(--v2-accent); - color: #ffffff; - font-size: 11px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 3px 10px; - border-radius: var(--v2-radius-pill); -} .v2-landing__tier-name { font-size: 12px; font-weight: 700; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index af5443bb..7d8acc86 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -6427,3 +6427,88 @@ .v2-admin-analytics__row--empty td { color: var(--v2-text-tertiary); } + +/* ---------- Billing panel (/v2/settings) ---------- */ +/* `.v2-root button.` prefix throughout: the global reset + * (.v2-root button:not(.MuiButtonBase-root), specificity 0-2-1) strips + * border/background/padding from any bare-class button — it ate the + * create-pod options (#870) and the reaction chips (#867). */ +.v2-billing { + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-md); + padding: 16px; + margin-bottom: 16px; + background: #ffffff; +} +.v2-billing__head { + display: flex; + align-items: center; + gap: 10px; +} +.v2-billing__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--v2-text-primary); +} +.v2-billing__badge { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: var(--v2-surface); + border: 1px solid var(--v2-border); + color: var(--v2-text-secondary); +} +.v2-billing__badge--pro { + background: var(--v2-accent-soft); + border-color: var(--v2-accent); + color: var(--v2-accent-text); +} +.v2-billing__note { + margin: 8px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--v2-text-secondary); +} +.v2-billing__warn { + margin: 8px 0 0; + font-size: 12px; + color: var(--v2-text-secondary); +} +.v2-billing__error { + margin: 8px 0 0; + font-size: 12px; + color: var(--v2-danger, #c0392b); +} +.v2-billing__actions { + margin-top: 14px; + display: flex; + gap: 8px; +} +.v2-root button.v2-billing__btn { + height: 34px; + padding: 0 14px; + border-radius: var(--v2-radius-sm); + border: 1px solid var(--v2-border); + background: #ffffff; + color: var(--v2-text-primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 80ms ease, border-color 80ms ease; +} +.v2-root button.v2-billing__btn:hover:not(:disabled) { + border-color: var(--v2-border-strong); +} +.v2-root button.v2-billing__btn--primary { + background: var(--v2-accent); + border-color: var(--v2-accent); + color: #ffffff; +} +.v2-root button.v2-billing__btn:disabled { + opacity: 0.7; + cursor: default; +}