fix(billing): block demo/sandbox accounts from Stripe checkout (#948)

* fix(billing): block demo/sandbox accounts from Stripe checkout

An anonymous demo user on a sandbox company reached POST /api/billing/checkout
and created a live Stripe customer. Neither the checkout nor the portal route
checked is_anonymous or is_sandbox, and withRouteContext lets anonymous users
through (they are authenticated, just anonymously).

Guard both routes on both conditions before any Stripe call: refuse anonymous
users (identity truth, cheap in-memory check) and sandbox companies (matches the
existing lib/sandbox/guard.ts "never charge a token" doctrine). Surface isDemo
on GET /api/billing/status so the client hides the upgrade CTA instead of
showing a button that 403s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(billing): redact tenant/customer IDs from incident note (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-09 22:16:25 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 0626bb6326
commit c9d9c5fe99
7 changed files with 148 additions and 8 deletions
+46 -1
View File
@@ -24,6 +24,14 @@ vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
// Keep sandboxBlockedResponse real; stub only the DB-backed guardSandbox so the
// route's company_settings read doesn't need a live supabase mock.
const guardSandboxMock = vi.fn()
vi.mock('@/lib/sandbox/guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/sandbox/guard')>()
return { ...actual, guardSandbox: (...args: unknown[]) => guardSandboxMock(...args) }
})
const customersCreate = vi.fn()
const sessionsCreate = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
@@ -41,8 +49,9 @@ const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
guardSandboxMock.mockResolvedValue(null)
requireAuthMock.mockResolvedValue({
user: { id: 'user-1', email: 'u@example.com' },
user: { id: 'user-1', email: 'u@example.com', is_anonymous: false },
supabase: {},
error: null,
})
@@ -61,6 +70,42 @@ describe('POST /api/billing/checkout', () => {
expect(res.status).toBe(401)
})
it('blocks an anonymous (demo) user with 403 and never touches Stripe', async () => {
requireAuthMock.mockResolvedValue({
user: { id: 'anon-1', email: null, is_anonymous: true },
supabase: {},
error: null,
})
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
await POST(req, routeParams),
)
expect(status).toBe(403)
expect(body.sandbox_blocked).toBe(true)
expect(customersCreate).not.toHaveBeenCalled()
expect(sessionsCreate).not.toHaveBeenCalled()
// The cheap identity check short-circuits before the DB-backed guard.
expect(guardSandboxMock).not.toHaveBeenCalled()
})
it('blocks a sandbox company with 403 and never touches Stripe', async () => {
const { sandboxBlockedResponse } = await import('@/lib/sandbox/guard')
guardSandboxMock.mockResolvedValue(sandboxBlockedResponse())
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: { plan: 'monthly' } })
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
await POST(req, routeParams),
)
expect(status).toBe(403)
expect(body.sandbox_blocked).toBe(true)
expect(guardSandboxMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
expect(customersCreate).not.toHaveBeenCalled()
expect(sessionsCreate).not.toHaveBeenCalled()
})
it('rejects an unknown plan with 400', async () => {
const req = createMockRequest('/api/billing/checkout', {
method: 'POST',
+42 -1
View File
@@ -21,6 +21,13 @@ vi.mock('@/lib/supabase/server', () => ({
createServiceClient: () => serviceSupabase,
}))
// Keep sandboxBlockedResponse real; stub only the DB-backed guardSandbox.
const guardSandboxMock = vi.fn()
vi.mock('@/lib/sandbox/guard', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/sandbox/guard')>()
return { ...actual, guardSandbox: (...args: unknown[]) => guardSandboxMock(...args) }
})
const portalCreate = vi.fn()
vi.mock('@/lib/stripe/client', () => ({
getStripe: () => ({
@@ -35,7 +42,8 @@ const routeParams = { params: Promise.resolve({}) }
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
guardSandboxMock.mockResolvedValue(null)
requireAuthMock.mockResolvedValue({ user: { id: 'user-1', is_anonymous: false }, supabase: {}, error: null })
})
describe('POST /api/billing/portal', () => {
@@ -51,6 +59,39 @@ describe('POST /api/billing/portal', () => {
expect(res.status).toBe(401)
})
it('blocks an anonymous (demo) user with 403 and never touches Stripe', async () => {
requireAuthMock.mockResolvedValue({
user: { id: 'anon-1', is_anonymous: true },
supabase: {},
error: null,
})
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
await POST(req, routeParams),
)
expect(status).toBe(403)
expect(body.sandbox_blocked).toBe(true)
expect(portalCreate).not.toHaveBeenCalled()
expect(guardSandboxMock).not.toHaveBeenCalled()
})
it('blocks a sandbox company with 403 and never touches Stripe', async () => {
const { sandboxBlockedResponse } = await import('@/lib/sandbox/guard')
guardSandboxMock.mockResolvedValue(sandboxBlockedResponse())
const req = createMockRequest('/api/billing/portal', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ sandbox_blocked?: boolean }>(
await POST(req, routeParams),
)
expect(status).toBe(403)
expect(body.sandbox_blocked).toBe(true)
expect(guardSandboxMock).toHaveBeenCalledWith(expect.anything(), 'company-1')
expect(portalCreate).not.toHaveBeenCalled()
})
it('returns 400 with NO_SUBSCRIPTION when the company has no Stripe customer', async () => {
enqueue({ data: null })
+11 -1
View File
@@ -4,6 +4,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { createServiceClient } from '@/lib/supabase/server'
import { getStripe, priceIdForPlan } from '@/lib/stripe/client'
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
const CheckoutSchema = z.object({
plan: z.enum(['monthly', 'yearly']).default('monthly'),
@@ -19,7 +20,16 @@ const CheckoutSchema = z.object({
* still filters by the membership-validated companyId.
*/
export const POST = withRouteContext('billing.checkout', async (request, ctx) => {
const { user, companyId, log } = ctx
const { user, supabase, companyId, log } = ctx
// Demo accounts must never reach Stripe. An anonymous user has no real
// identity to bill, and a sandbox company must never charge a token (same
// doctrine as lib/sandbox/guard.ts: no real external side effects). Both
// checks run before any Stripe call: this is the gap that let an anonymous
// demo user create a live Stripe customer.
if (user.is_anonymous) return sandboxBlockedResponse()
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const validation = await validateBody(request, CheckoutSchema, {
log,
+9 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { createServiceClient } from '@/lib/supabase/server'
import { getStripe } from '@/lib/stripe/client'
import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard'
/**
* Create a Stripe Billing Customer Portal session so the user can manage,
@@ -13,7 +14,14 @@ import { getStripe } from '@/lib/stripe/client'
* by the membership-validated companyId.
*/
export const POST = withRouteContext('billing.portal', async (_request, ctx) => {
const { companyId } = ctx
const { user, supabase, companyId } = ctx
// Demo accounts must never reach Stripe (see billing/checkout for the full
// rationale). Defense in depth: a demo tenant should never own a portal
// session even if a stray customer row exists.
if (user.is_anonymous) return sandboxBlockedResponse()
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
const service = createServiceClient()
const { data: sub } = await service
+9 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { requireCompanyId } from '@/lib/company/context'
import { isStripeConfigured } from '@/lib/stripe/client'
import { isSandboxCompany } from '@/lib/sandbox/guard'
/**
* Billing status for the client-rendered billing section (which lives inside the
@@ -20,6 +21,13 @@ export async function GET() {
companyId = null
}
// Demo accounts (anonymous user or sandbox company) can't check out, so the
// client hides the upgrade CTA rather than showing a button that only errors.
let isDemo = user.is_anonymous === true
if (companyId && !isDemo) {
isDemo = await isSandboxCompany(supabase, companyId)
}
let isPaying = false
let trialEndsAt: string | null = null
if (companyId) {
@@ -44,5 +52,5 @@ export async function GET() {
trialEndsAt = (trial as { expires_at: string | null } | null)?.expires_at ?? null
}
return NextResponse.json({ isPaying, configured: isStripeConfigured(), trialEndsAt })
return NextResponse.json({ isPaying, configured: isStripeConfigured(), trialEndsAt, isDemo })
}