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
+1
View File
@@ -52,3 +52,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-09] Issue #916 (disconnect orphans ledger accounts): release claims by demoting cash_accounts rows to manual (bank_connection_id = null), never deleting: transactions.cash_account_id and ledger history reference the rows, and upsertFromPsd2 promotes a manual holder in place on reconnect so the bank lands back on its original BAS slot. Orphans predating the fix self-heal via a revoked-status filter in the allocator + collision guard (not data repair). When a promote collides with a duplicate row for the same connection+uid (callback mirrored onto an overflow slot pre-fix), the duplicate is deleted only if it has zero linked transactions, otherwise demoted: preserves FK links while freeing the slot. Picker-save rejections now render inline in the picker instead of routing to the sync-progress modal, whose parent-unmount-on-close made every save outcome invisible.
[2026-07-09] #917 fix scoped to the current-year suggestion: "Sedan räkenskapsårets början" now resolves from the fiscal_periods row containing today, but the "Föregående räkenskapsårets start" custom option still derives from the recurring fiscal_year_start_month: the issue only covers the current-year date and a first-year company has no previous period row to resolve against.
[2026-07-09] Issue #919 (duplicate guard should steer to matching): the match action lives INSIDE DuplicateBookingDialog (fetch to /api/reconciliation/bank/link + account resolution via /api/cash-accounts + resolveAccount, exactly the MatchVoucherDialog path) rather than in each call site or a new endpoint: both call sites (transactions page runCategorize + TransactionBookingDialog/JournalEntryForm) share one implementation and pass only the transaction context + an onMatched callback mirroring onLinked. Match is primary ONLY for ledger-only candidates (transaction_id null, the SIE-import case); sibling-transaction candidates keep "Bokför ändå" primary since N:1 matching is the edge case. No lib change: the candidate already carries the transaction_id discriminator, covered by existing tests.
[2026-07-09] Demo/sandbox users could reach Stripe: an anonymous user on a sandbox company hit POST /api/billing/checkout and created a live Stripe customer (no subscription = no charge; exact tenant/customer IDs kept out of source control, see the incident PR). Root cause: neither billing/checkout nor billing/portal checked is_anonymous or is_sandbox, and withRouteContext lets anonymous users through (they are authenticated, just anonymously). Fix guards BOTH conditions in both routes (is_anonymous is the identity truth; guardSandbox matches the existing lib/sandbox/guard.ts "never charge a token" doctrine), belt-and-suspenders since anon and sandbox happen to co-occur today but are orthogonal. Anon check runs first (in-memory, no DB round trip). Also surfaced isDemo on GET /api/billing/status so the client hides the upgrade CTA instead of showing a button that 403s. Blast radius = exactly one company (no other sandbox/anon tenant had a stripe_customer_id). Left the stray company_subscriptions row + orphan Stripe customer for manual cleanup (prod write / external destructive action, not done unilaterally).
+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 })
}
@@ -23,6 +23,7 @@ interface BillingView {
configured: boolean
trialEndsAt: string | null
daysLeft: number | null
isDemo: boolean
}
/**
@@ -37,16 +38,16 @@ export function BillingSettingsContent() {
let active = true
fetch('/api/billing/status')
.then((r) => r.json())
.then((d: { isPaying: boolean; configured: boolean; trialEndsAt: string | null }) => {
.then((d: { isPaying: boolean; configured: boolean; trialEndsAt: string | null; isDemo?: boolean }) => {
if (!active) return
// Compute days-left here (effect), not during render, to keep render pure.
const daysLeft = d.trialEndsAt
? Math.max(0, Math.ceil((new Date(d.trialEndsAt).getTime() - Date.now()) / 86_400_000))
: null
setView({ ...d, daysLeft })
setView({ ...d, daysLeft, isDemo: d.isDemo ?? false })
})
.catch(() => {
if (active) setView({ isPaying: false, configured: false, trialEndsAt: null, daysLeft: null })
if (active) setView({ isPaying: false, configured: false, trialEndsAt: null, daysLeft: null, isDemo: false })
})
return () => { active = false }
}, [])
@@ -60,6 +61,32 @@ export function BillingSettingsContent() {
)
}
// Demo / sandbox account → can't check out. Show the value prop but point
// them to creating a real account instead of a pay button that would 403.
if (view.isDemo) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Abonnemang</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera
abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket.
</p>
<ul className="space-y-2">
{INCLUDED.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 mt-1 shrink-0 text-foreground" />
<span>{item}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
}
// Paying company → manage view.
if (view.isPaying) {
return (