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:
co-authored by
Claude Fable 5.1
parent
0016732232
commit
9879fb53e9
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user