feat(billing): paywall conversion pass (deferred first charge, trial touchpoint, sell-view upgrade) (#991)
* feat(billing): paywall conversion pass: deferred first charge, trial touchpoint, sell-view upgrade - checkout passes subscription_data.trial_end (trial grant expiry, 49h floor) so a mid-trial upgrade costs 0 kr today instead of double-billing days the company already has free; billing/status counts 'trialing' as paying - trial countdown pill in the sidebar (CompanyContext.trialEndsAt via getCompanyEntitlements); hidden for sandbox, dev bypass, and once any non-trial grant is active - sell view: what-happens-when timeline, free-vs-paid comparison table, risk-reversal copy + chevron CTA, post-checkout confirmation state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(billing): review triage: fail-closed trial lookup, hourly countdown refresh, BFL retention note - checkout returns 500 (no Stripe session) when the trial-grant lookup errors, instead of silently charging immediately after the UI promised 0 kr idag - sidebar trial countdown recomputes hourly so a long-lived tab stays honest - sell-view retention copy states BFL 7-year retention explicitly (compliance-bot suggestion) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI (pull_request event delivery stuck) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9d91ee0168
commit
3a2c57a167
@@ -119,7 +119,8 @@ describe('POST /api/billing/checkout', () => {
|
||||
})
|
||||
|
||||
it('reuses an existing Stripe customer and returns the checkout URL', async () => {
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } })
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
|
||||
enqueue({ data: null }) // no trial grant
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', {
|
||||
@@ -138,10 +139,13 @@ describe('POST /api/billing/checkout', () => {
|
||||
client_reference_id: 'company-1',
|
||||
})
|
||||
)
|
||||
// No trial grant → billing starts immediately, no Stripe trial.
|
||||
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates a Stripe customer when none exists yet', async () => {
|
||||
enqueue({ data: null }) // no existing subscription row
|
||||
enqueue({ data: null }) // no trial grant
|
||||
enqueue({ data: null }) // upsert result
|
||||
customersCreate.mockResolvedValue({ id: 'cus_new' })
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
@@ -158,4 +162,64 @@ describe('POST /api/billing/checkout', () => {
|
||||
expect.objectContaining({ customer: 'cus_new' })
|
||||
)
|
||||
})
|
||||
|
||||
it('defers the first charge to the trial end when the trial has >48h left', async () => {
|
||||
const trialEnd = new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString()
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
|
||||
enqueue({ data: { expires_at: trialEnd } }) // active trial grant
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(sessionsCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
subscription_data: expect.objectContaining({
|
||||
metadata: { company_id: 'company-1' },
|
||||
trial_end: Math.floor(new Date(trialEnd).getTime() / 1000),
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('bills immediately when the trial is inside the 48h Stripe floor', async () => {
|
||||
const trialEnd = new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
|
||||
enqueue({ data: { expires_at: trialEnd } }) // trial ends tomorrow
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bills immediately when the trial has already expired', async () => {
|
||||
const trialEnd = new Date(Date.now() - 24 * 3600 * 1000).toISOString()
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
|
||||
enqueue({ data: { expires_at: trialEnd } }) // lapsed trial
|
||||
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const { status } = await parseJsonResponse(await POST(req, routeParams))
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
|
||||
})
|
||||
|
||||
it('fails closed (500, no Stripe session) when the trial lookup errors', async () => {
|
||||
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
|
||||
enqueue({ data: null, error: { message: 'boom' } }) // trial lookup fails
|
||||
|
||||
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await POST(req, routeParams),
|
||||
)
|
||||
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('TRIAL_LOOKUP_FAILED')
|
||||
expect(sessionsCreate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Tests for GET /api/billing/status.
|
||||
*
|
||||
* Focus: the isPaying classification. 'trialing' must count as paying since
|
||||
* checkout defers the first charge to the trial end (the card is committed),
|
||||
* while a company with no subscription stays on the upgrade path.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
type TableResult = { data: unknown; error?: unknown }
|
||||
function makeSupabase(byTable: Record<string, TableResult>) {
|
||||
const chainFor = (table: string) => {
|
||||
const result = byTable[table] ?? { data: null, error: null }
|
||||
const chain: unknown = new Proxy(
|
||||
{},
|
||||
{
|
||||
get(_t, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) =>
|
||||
resolve({ data: result.data ?? null, error: result.error ?? null })
|
||||
}
|
||||
return () => chain
|
||||
},
|
||||
},
|
||||
)
|
||||
return chain
|
||||
}
|
||||
return { from: (t: string) => chainFor(t) }
|
||||
}
|
||||
|
||||
const requireAuthMock = vi.fn()
|
||||
vi.mock('@/lib/auth/require-auth', () => ({
|
||||
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/sandbox/guard', () => ({
|
||||
isSandboxCompany: vi.fn().mockResolvedValue(false),
|
||||
}))
|
||||
|
||||
import { GET } from '../status/route'
|
||||
|
||||
interface StatusBody {
|
||||
isPaying: boolean
|
||||
trialEndsAt: string | null
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
function authAs(byTable: Record<string, TableResult>) {
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1', is_anonymous: false },
|
||||
supabase: makeSupabase(byTable),
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/billing/status', () => {
|
||||
it('treats a trialing subscription as paying (card committed via deferred checkout)', async () => {
|
||||
authAs({
|
||||
company_subscriptions: { data: { status: 'trialing' } },
|
||||
capability_grants: { data: { expires_at: '2099-01-01T00:00:00Z' } },
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<StatusBody>(await GET())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.isPaying).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a card-less product trial on the upgrade path with its expiry', async () => {
|
||||
authAs({
|
||||
company_subscriptions: { data: null },
|
||||
capability_grants: { data: { expires_at: '2099-01-01T00:00:00Z' } },
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<StatusBody>(await GET())
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.isPaying).toBe(false)
|
||||
expect(body.trialEndsAt).toBe('2099-01-01T00:00:00Z')
|
||||
})
|
||||
|
||||
it('treats an active subscription as paying', async () => {
|
||||
authAs({
|
||||
company_subscriptions: { data: { status: 'active' } },
|
||||
capability_grants: { data: null },
|
||||
})
|
||||
|
||||
const { body } = await parseJsonResponse<StatusBody>(await GET())
|
||||
expect(body.isPaying).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a canceled subscription as not paying', async () => {
|
||||
authAs({
|
||||
company_subscriptions: { data: { status: 'canceled' } },
|
||||
capability_grants: { data: null },
|
||||
})
|
||||
|
||||
const { body } = await parseJsonResponse<StatusBody>(await GET())
|
||||
expect(body.isPaying).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -55,12 +55,54 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) =>
|
||||
const stripe = getStripe()
|
||||
const service = createServiceClient()
|
||||
|
||||
// Reuse the company's Stripe customer if we already created one.
|
||||
const { data: existing } = await service
|
||||
.from('company_subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
// Reuse the company's Stripe customer if we already created one, and read
|
||||
// the trial expiry for the deferred-first-charge decision below. Independent
|
||||
// reads, so one round-trip batch.
|
||||
const [{ data: existing }, { data: trialGrant, error: trialGrantError }] = await Promise.all([
|
||||
service
|
||||
.from('company_subscriptions')
|
||||
.select('stripe_customer_id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
service
|
||||
.from('capability_grants')
|
||||
.select('expires_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('source', 'trial')
|
||||
.order('expires_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
// Fail closed on an uncertain trial state: proceeding on a lookup error
|
||||
// would silently charge immediately after the UI promised "0 kr idag".
|
||||
if (trialGrantError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: 'TRIAL_LOOKUP_FAILED',
|
||||
message: 'Kunde inte läsa din provperiod. Försök igen om en stund.',
|
||||
message_en: 'Could not resolve the trial state. Try again shortly.',
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
|
||||
// Defer the first charge to the end of an active trial. The company already
|
||||
// holds the paid capabilities free until then, so charging at checkout would
|
||||
// bill for days it already has; instead the subscription starts as
|
||||
// 'trialing' (which grants access via the webhook, see subscription-sync)
|
||||
// and the first charge lands when the product trial ends. Stripe Checkout
|
||||
// requires trial_end to be at least 48h in the future; closer than that, or
|
||||
// with no active trial, billing starts immediately.
|
||||
const trialExpiry = (trialGrant as { expires_at: string | null } | null)?.expires_at ?? null
|
||||
const trialExpiryMs = trialExpiry ? new Date(trialExpiry).getTime() : null
|
||||
const STRIPE_MIN_TRIAL_END_MS = 49 * 3600 * 1000 // Stripe's 48h floor + 1h clock margin
|
||||
const trialEnd =
|
||||
trialExpiryMs && trialExpiryMs - Date.now() > STRIPE_MIN_TRIAL_END_MS
|
||||
? Math.floor(trialExpiryMs / 1000)
|
||||
: undefined
|
||||
|
||||
let customerId = (existing as { stripe_customer_id: string | null } | null)?.stripe_customer_id ?? null
|
||||
if (!customerId) {
|
||||
@@ -81,7 +123,10 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) =>
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
client_reference_id: companyId,
|
||||
metadata: { company_id: companyId },
|
||||
subscription_data: { metadata: { company_id: companyId } },
|
||||
subscription_data: {
|
||||
metadata: { company_id: companyId },
|
||||
...(trialEnd ? { trial_end: trialEnd } : {}),
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${appUrl}/settings/billing?success=1`,
|
||||
cancel_url: `${appUrl}/settings/billing?canceled=1`,
|
||||
|
||||
@@ -37,9 +37,12 @@ export async function GET() {
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
const status = (sub as { status: string | null } | null)?.status ?? null
|
||||
// Paying = a real subscription. Deliberately excludes 'trialing' so a
|
||||
// trialing company still sees the upgrade path (not the manage button).
|
||||
isPaying = status === 'active' || status === 'past_due'
|
||||
// Paying = a real subscription. Includes 'trialing': checkout defers the
|
||||
// first charge to the product-trial end, so a Stripe-trialing subscription
|
||||
// means the card is already committed and the user should see the manage
|
||||
// view, not the upgrade pitch. Companies without a subscription (product
|
||||
// trial only, no card) stay on the upgrade path.
|
||||
isPaying = status === 'active' || status === 'past_due' || status === 'trialing'
|
||||
|
||||
const { data: trial } = await supabase
|
||||
.from('capability_grants')
|
||||
|
||||
Reference in New Issue
Block a user