diff --git a/DECISIONS.md b/DECISIONS.md index 026419c0..598dc73e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -52,3 +52,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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). diff --git a/app/api/billing/__tests__/checkout.test.ts b/app/api/billing/__tests__/checkout.test.ts index f0cdfbcc..1c76ef02 100644 --- a/app/api/billing/__tests__/checkout.test.ts +++ b/app/api/billing/__tests__/checkout.test.ts @@ -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() + 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', diff --git a/app/api/billing/__tests__/portal.test.ts b/app/api/billing/__tests__/portal.test.ts index 4ed9678e..a081c7b5 100644 --- a/app/api/billing/__tests__/portal.test.ts +++ b/app/api/billing/__tests__/portal.test.ts @@ -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() + 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 }) diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index a9009470..09339433 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -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, diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts index b3914220..4560d595 100644 --- a/app/api/billing/portal/route.ts +++ b/app/api/billing/portal/route.ts @@ -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 diff --git a/app/api/billing/status/route.ts b/app/api/billing/status/route.ts index 8aa2b8de..10a651da 100644 --- a/app/api/billing/status/route.ts +++ b/app/api/billing/status/route.ts @@ -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 }) } diff --git a/components/settings/sections/BillingSettingsContent.tsx b/components/settings/sections/BillingSettingsContent.tsx index ab78e14e..09159bf3 100644 --- a/components/settings/sections/BillingSettingsContent.tsx +++ b/components/settings/sections/BillingSettingsContent.tsx @@ -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 ( + + + Abonnemang + + +

+ Du provkör Accounted i en demo. Skapa ett riktigt konto för att aktivera + abonnemang, AI-assistent, bankkoppling och inlämning till Skatteverket. +

+
    + {INCLUDED.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ ) + } + // Paying company → manage view. if (view.isPaying) { return (