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 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-07 13:49:16 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 0016732232
commit 9879fb53e9
5 changed files with 145 additions and 8 deletions
+1
View File
@@ -1636,3 +1636,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+68 -1
View File
@@ -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',
}),
)
})
})
})
+59 -1
View File
@@ -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' }),
)
})
})
})
+10 -3
View File
@@ -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 })
+7 -3
View File
@@ -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 })