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:
co-authored by
Claude Opus 4.8
parent
0626bb6326
commit
c9d9c5fe99
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user