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:
Jakob Wennberg
2026-07-11 22:29:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 9d91ee0168
commit 3a2c57a167
14 changed files with 532 additions and 52 deletions
+65 -1
View File
@@ -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()
})
})
+110
View File
@@ -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)
})
})