From 9879fb53e9eebbe1d721f6c841452a0b5e51e5c9 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:49:16 +0200 Subject: [PATCH] fix(billing): return from Stripe to the brand domain the user started on (#2370) Checkout success/cancel URLs and the customer-portal return URL were built from NEXT_PUBLIC_APP_URL, so a user on a white-label host came back to the canonical app, where they hold no session, and saw a foreign-branded login. Both routes now resolve the request host through resolveRequestAppOrigin: a registered white-label host stays on its brand, anything else falls back to the canonical app. Return paths stay fixed literals. Claude-Session: https://claude.ai/code/session_01DAGcgQDEAGmhGNSeMbgsn2 Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/api/billing/__tests__/checkout.test.ts | 69 +++++++++++++++++++++- app/api/billing/__tests__/portal.test.ts | 60 ++++++++++++++++++- app/api/billing/checkout/route.ts | 13 +++- app/api/billing/portal/route.ts | 10 +++- 5 files changed, 145 insertions(+), 8 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 85bc1366..d7b72b75 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1636,3 +1636,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-06] Bundled SKV ROT/RUT payout books ONE voucher (one 1513 leg per begäran) and the set is suggested at read time with no hint column: one bank row = one verifikat (match-batch precedent) and a uuid[] hint would need six clear paths and go stale; N vouchers + the 1:N reconciliation split was rejected because its half-failure state has no UI exit, and begäran, not the invoice, is the unit under fakturamodellen. [2026-09-06] Utlägg via lön settles claims with an idempotent RPC after the salary verifikat is posted (pre-checked before posting), not with a trigger on salary_runs -> booked: a raise inside that trigger after the entries exist would leave a paid run with posted verifikat and a retry would double-post; the RPC path fails to "booked, claims still open, re-runnable". [2026-09-06] A privately paid supplier invoice is booked through registerExpenseClaim (verifikat + expense_claims row, source_type expense_claim) with the invoice's kontering as custom lines, and a person-paid inbox document goes to the core route with inbox_item_id instead of the extension's convert endpoint: the form's switch, the second entry generator and the convert bypass were three write paths for one fact, so one writer wins over adding a claims insert beside the old generator (the issue's shape) or copying the branch into the convert handler. +[2026-09-07] Stripe checkout and portal return URLs resolve through the existing resolveRequestAppOrigin allowlist (NEXT_PUBLIC_WHITELABEL_DOMAINS), not a DB brand lookup: it is the same trust boundary invites and email-change links already use, so one allowlist governs every host we redirect a browser to. Return paths stay fixed literals; no caller-supplied URL is accepted. Session-expiry and company-switch handling were left alone: the middleware already bounces to /login on the same host with the path preserved, and the webhook keys on company_id metadata. diff --git a/app/api/billing/__tests__/checkout.test.ts b/app/api/billing/__tests__/checkout.test.ts index 476dc15e..7eefca16 100644 --- a/app/api/billing/__tests__/checkout.test.ts +++ b/app/api/billing/__tests__/checkout.test.ts @@ -4,7 +4,7 @@ * Exercises the route through the real withRouteContext wrapper, mocking * auth/company, the Stripe client, and the service-role Supabase client. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' @@ -46,9 +46,14 @@ import { POST } from '../checkout/route' const routeParams = { params: Promise.resolve({}) } +const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL +const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + beforeEach(() => { vi.clearAllMocks() reset() + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS guardSandboxMock.mockResolvedValue(null) requireAuthMock.mockResolvedValue({ user: { id: 'user-1', email: 'u@example.com', is_anonymous: false }, @@ -57,6 +62,13 @@ beforeEach(() => { }) }) +afterEach(() => { + if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL + else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl + if (originalWhiteLabelDomains === undefined) delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + else process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains +}) + describe('POST /api/billing/checkout', () => { it('returns 401 when not authenticated', async () => { requireAuthMock.mockResolvedValue({ @@ -240,4 +252,59 @@ describe('POST /api/billing/checkout', () => { expect(body.error.code).toBe('TRIAL_LOOKUP_FAILED') expect(sessionsCreate).not.toHaveBeenCalled() }) + + describe('return URLs', () => { + function checkoutFrom(url: string) { + enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row + enqueue({ data: null }) // no trial grant + sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' }) + return POST(createMockRequest(url, { method: 'POST', body: {} }), routeParams) + } + + it('returns to the canonical app when checkout starts there', async () => { + const { status } = await parseJsonResponse( + await checkoutFrom('https://app.accounted.test/api/billing/checkout'), + ) + + expect(status).toBe(200) + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + success_url: 'https://app.accounted.test/settings/billing?success=1', + cancel_url: 'https://app.accounted.test/settings/billing?canceled=1', + }), + ) + }) + + it('returns to a registered white-label host when checkout starts there', async () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + + const { status } = await parseJsonResponse( + await checkoutFrom('https://portal.brand.test/api/billing/checkout'), + ) + + expect(status).toBe(200) + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + success_url: 'https://portal.brand.test/settings/billing?success=1', + cancel_url: 'https://portal.brand.test/settings/billing?canceled=1', + }), + ) + }) + + it('falls back to the canonical app for an unregistered or spoofed host', async () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + + const { status } = await parseJsonResponse( + await checkoutFrom('https://portal.brand.test.attacker.test/api/billing/checkout'), + ) + + expect(status).toBe(200) + expect(sessionsCreate).toHaveBeenCalledWith( + expect.objectContaining({ + success_url: 'https://app.accounted.test/settings/billing?success=1', + cancel_url: 'https://app.accounted.test/settings/billing?canceled=1', + }), + ) + }) + }) }) diff --git a/app/api/billing/__tests__/portal.test.ts b/app/api/billing/__tests__/portal.test.ts index a081c7b5..3082e395 100644 --- a/app/api/billing/__tests__/portal.test.ts +++ b/app/api/billing/__tests__/portal.test.ts @@ -1,7 +1,7 @@ /** * Tests for POST /api/billing/portal. */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { NextResponse } from 'next/server' import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' @@ -39,13 +39,25 @@ import { POST } from '../portal/route' const routeParams = { params: Promise.resolve({}) } +const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL +const originalWhiteLabelDomains = process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + beforeEach(() => { vi.clearAllMocks() reset() + process.env.NEXT_PUBLIC_APP_URL = 'https://app.accounted.test' + delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS guardSandboxMock.mockResolvedValue(null) requireAuthMock.mockResolvedValue({ user: { id: 'user-1', is_anonymous: false }, supabase: {}, error: null }) }) +afterEach(() => { + if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL + else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl + if (originalWhiteLabelDomains === undefined) delete process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS + else process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = originalWhiteLabelDomains +}) + describe('POST /api/billing/portal', () => { it('returns 401 when not authenticated', async () => { requireAuthMock.mockResolvedValue({ @@ -118,4 +130,50 @@ describe('POST /api/billing/portal', () => { expect.objectContaining({ customer: 'cus_1' }) ) }) + + describe('return URL', () => { + function portalFrom(url: string) { + enqueue({ data: { stripe_customer_id: 'cus_1' } }) + portalCreate.mockResolvedValue({ url: 'https://stripe.test/portal' }) + return POST(createMockRequest(url, { method: 'POST' }), routeParams) + } + + it('comes back to the canonical app when opened there', async () => { + const { status } = await parseJsonResponse( + await portalFrom('https://app.accounted.test/api/billing/portal'), + ) + + expect(status).toBe(200) + expect(portalCreate).toHaveBeenCalledWith({ + customer: 'cus_1', + return_url: 'https://app.accounted.test/settings/billing', + }) + }) + + it('comes back to a registered white-label host when opened there', async () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + + const { status } = await parseJsonResponse( + await portalFrom('https://portal.brand.test/api/billing/portal'), + ) + + expect(status).toBe(200) + expect(portalCreate).toHaveBeenCalledWith( + expect.objectContaining({ return_url: 'https://portal.brand.test/settings/billing' }), + ) + }) + + it('falls back to the canonical app for an unregistered or spoofed host', async () => { + process.env.NEXT_PUBLIC_WHITELABEL_DOMAINS = 'portal.brand.test' + + const { status } = await parseJsonResponse( + await portalFrom('https://portal.brand.test.attacker.test/api/billing/portal'), + ) + + expect(status).toBe(200) + expect(portalCreate).toHaveBeenCalledWith( + expect.objectContaining({ return_url: 'https://app.accounted.test/settings/billing' }), + ) + }) + }) }) diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 9ea43482..3c3c589d 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -5,6 +5,7 @@ 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' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' const CheckoutSchema = z.object({ plan: z.enum(['monthly', 'yearly']).default('monthly'), @@ -116,7 +117,13 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) => .upsert({ company_id: companyId, stripe_customer_id: customerId }, { onConflict: 'company_id' }) } - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '' + // Return the user to the host they started on. Sessions are per domain, so + // sending a white-label user back to the canonical app would land them on a + // foreign-branded login with no session. The origin is resolved against the + // registered host allowlist; an unknown or spoofed host falls back to the + // canonical app URL. The paths stay fixed: never accept a caller-supplied + // return URL here. + const appOrigin = resolveRequestAppOrigin(request) const session = await stripe.checkout.sessions.create({ mode: 'subscription', customer: customerId, @@ -139,8 +146,8 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) => ...(trialEnd ? { trial_end: trialEnd } : {}), }, allow_promotion_codes: true, - success_url: `${appUrl}/settings/billing?success=1`, - cancel_url: `${appUrl}/settings/billing?canceled=1`, + success_url: `${appOrigin}/settings/billing?success=1`, + cancel_url: `${appOrigin}/settings/billing?canceled=1`, }) return NextResponse.json({ url: session.url }) diff --git a/app/api/billing/portal/route.ts b/app/api/billing/portal/route.ts index 4560d595..3fcc97f4 100644 --- a/app/api/billing/portal/route.ts +++ b/app/api/billing/portal/route.ts @@ -3,6 +3,7 @@ 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' +import { resolveRequestAppOrigin } from '@/lib/domains/trusted-app-origin' /** * Create a Stripe Billing Customer Portal session so the user can manage, @@ -13,7 +14,7 @@ import { guardSandbox, sandboxBlockedResponse } from '@/lib/sandbox/guard' * is webhook-owned and not member-readable under RLS; the query still filters * by the membership-validated companyId. */ -export const POST = withRouteContext('billing.portal', async (_request, ctx) => { +export const POST = withRouteContext('billing.portal', async (request, ctx) => { const { user, supabase, companyId } = ctx // Demo accounts must never reach Stripe (see billing/checkout for the full @@ -44,10 +45,13 @@ export const POST = withRouteContext('billing.portal', async (_request, ctx) => ) } - const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? '' + // Same host the user started on (see billing/checkout): a registered + // white-label host stays on its brand, anything else returns to the + // canonical app. The path is fixed. + const appOrigin = resolveRequestAppOrigin(request) const portal = await getStripe().billingPortal.sessions.create({ customer: customerId, - return_url: `${appUrl}/settings/billing`, + return_url: `${appOrigin}/settings/billing`, }) return NextResponse.json({ url: portal.url })