From 5382232ec3c128d84892d34611e3174d06c73d0f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:35:43 -0700 Subject: [PATCH 1/3] feat(billing): Stripe subscriptions end to end (#768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three users want to pay, so this wires the money path onto the entitlement seam that already existed. `entitlements.pro` was already the single gate the product reads; Stripe now writes it. **The webhook is the only source of truth.** Nothing in the checkout success path grants anything — the redirect is attacker-controllable and a session can be abandoned after redirect. Only a signature-verified event moves the flag. **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, claimed BEFORE the user is touched (test asserts the ordering): a crash between the two would otherwise apply a change with no record, and the retry would apply it twice. **Entitlement is DERIVED from subscription status, never toggled.** Every handler recomputes `pro` from the status Stripe reports, so out-of-order delivery converges instead of latching — pinned by a revoke-then-regrant test. `active`/`trialing` grant; everything else revokes. **Checkout grants only when money settled** (`payment_status === 'paid'`). A completed session with an async or failed payment is not money. **Raw body, mounted before express.json().** `constructEvent` verifies the exact bytes; a re-serialized body fails verification, which is the single most common way this integration breaks. Follows the existing Discord pattern in server.ts, and a test asserts the handler receives a Buffer. **The exit always works.** `POST /billing/portal` gives self-serve cancellation. A user who cannot find the exit disputes the charge instead. And revoking Pro leaves `cloudAgents` untouched — separate entitlements, pinned. **UI** — a v2 Plan panel on /v2/settings reading the same `=== true` check the backend gates on, so it can never claim a tier the API would refuse. It never writes the flag; after checkout the user may briefly still read Free, and the copy says so rather than faking optimistic state. en/zh parity asserted. Ships INERT: all three of STRIPE_SECRET_KEY / STRIPE_PRICE_ID / STRIPE_WEBHOOK_SECRET must be set or every billing route returns 503 rather than half-working. Documented in .env.example; keys belong in Secret Manager. 43 new tests (21 service, 14 route, 8 panel). Backend suite compared against a same-commit baseline: identical failure set apart from one known-flaky mongodb-memory-server ordering case that passes 4/4 in isolation (#518). --- backend/.env.example | 17 +- backend/__tests__/unit/routes/billing.test.js | 189 ++++++++++++++++++ .../unit/services/billingService.test.js | 183 +++++++++++++++++ backend/models/BillingEvent.ts | 59 ++++++ backend/models/User.ts | 22 ++ backend/package-lock.json | 181 +++-------------- backend/package.json | 1 + backend/routes/billing.ts | 148 ++++++++++++++ backend/server.ts | 7 + backend/services/billingService.ts | 179 +++++++++++++++++ frontend/src/i18n/locales/en.json | 17 ++ frontend/src/i18n/locales/zh-CN.json | 17 ++ frontend/src/v2/V2App.tsx | 8 +- .../src/v2/__tests__/V2BillingPanel.test.tsx | 100 +++++++++ frontend/src/v2/components/V2BillingPanel.tsx | 97 +++++++++ frontend/src/v2/v2.css | 85 ++++++++ 16 files changed, 1154 insertions(+), 156 deletions(-) create mode 100644 backend/__tests__/unit/routes/billing.test.js create mode 100644 backend/__tests__/unit/services/billingService.test.js create mode 100644 backend/models/BillingEvent.ts create mode 100644 backend/routes/billing.ts create mode 100644 backend/services/billingService.ts create mode 100644 frontend/src/v2/__tests__/V2BillingPanel.test.tsx create mode 100644 frontend/src/v2/components/V2BillingPanel.tsx 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..6ca4a1c3 --- /dev/null +++ b/backend/routes/billing.ts @@ -0,0 +1,148 @@ +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(); + } + + 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..0e9b3a87 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1425,5 +1425,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..706a9e6a 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1421,5 +1421,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/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/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; +} From 05f0ceacb08071b6205e2d7d4ec7191a40cfaf1b Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:19:36 -0700 Subject: [PATCH 2/3] docs(billing): record why automatic_tax is absent from the Checkout session The Stripe price is now tax_behavior: inclusive, so a customer pays exactly the advertised $12 in every jurisdiction rather than $12 plus whatever their region adds. Without that setting the Australian preview billed $13.20 against a landing page promising $12. automatic_tax stays off: Stripe Tax is not activated on the account and enabling it would fail session creation. Inclusive pricing is the half that cannot be changed later without moving the sticker price, so it is set now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- backend/routes/billing.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/routes/billing.ts b/backend/routes/billing.ts index 6ca4a1c3..b2331f7d 100644 --- a/backend/routes/billing.ts +++ b/backend/routes/billing.ts @@ -54,6 +54,13 @@ router.post('/checkout', checkoutLimit, auth, async (req: any, res: any) => { 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, From 28d46e8541f452dee0701d69463abee62b0a81be Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:25:20 -0700 Subject: [PATCH 3/3] fix(landing): the Pro card promised "Free in beta" above a $12 price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The badge shipped with the pricing rewrite and survived the commit that turns real billing on, so the page currently advertises the paid tier as free in both locales ("Free in beta" / "公测期免费") directly above "$12/human/mo". Charging someone who signed up under that banner is a dispute, not a misunderstanding. Removed rather than reworded: the accent border already marks the featured tier, and every short badge available for a paid plan is either a price promise we would have to keep or a popularity claim we cannot substantiate. The absolutely-positioned rule went with it. Guarded by a test asserting no tier that shows a non-zero price describes itself as free. Scoped to the cost-describing fields only — the feature bullets legitimately say "Everything in Cloud free", which names a tier rather than making a price claim. Verified by reintroducing the badge and watching both locales fail. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XeUH4HVDsDHYPsHJthXjB8 --- frontend/src/i18n/locales/en.json | 1 - frontend/src/i18n/locales/zh-CN.json | 1 - .../__tests__/pricing-copy-invariants.test.ts | 71 +++++++++++++++++++ frontend/src/v2/landing/V2LandingPage.tsx | 6 +- frontend/src/v2/landing/v2-landing.css | 13 ---- 5 files changed, 75 insertions(+), 17 deletions(-) create mode 100644 frontend/src/v2/__tests__/pricing-copy-invariants.test.ts diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 0e9b3a87..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", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 706a9e6a..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": "/人/月", 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/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;