feat(billing): make the expired-trial state visible with a clear upgrade path (#1725)

getCompanyEntitlements now derives an entitlementState (trial /
trial_expired / lapsed_subscription / paid / none) plus trialExpiredAt
from the grants it already fetches, reading company_subscriptions.status
inside the existing Promise.all so churned payers get 'abonnemang' copy
instead of 'provperiod'. The state threads through CompanyContext and the
dashboard layout.

Two new surfaces, both hidden in sandbox:
- SubscriptionTouchpoint replaces the sidebar trial pill: countdown while
  the trial runs, a persistent muted upgrade link to /settings/billing
  once it lapses (visible even collapsed, icon-only with aria-label), and
  the first mobile bottom-sheet touchpoint.
- TrialExpiredDialog: one-time on-entry notice with 'Se abonnemang' and a
  ghost dismiss; acknowledgement persists per user+company in
  user_preferences.ui_state.trial_expired_ack (read server-side, no
  flash), set on dismiss and click-through alike.

Narrows the 2026-07-11 'no trial-expired nag' decision at the founder's
direction after a user could not find the upgrade path at all; see
DECISIONS.md.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:05:39 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 834cc4d0e8
commit c402421908
13 changed files with 476 additions and 62 deletions
@@ -240,6 +240,8 @@ describe('getCompanyEntitlements', () => {
expect(result.trialEndsAt).toBe(expiry)
expect(result.capabilities).toContain(CAPABILITY.ai)
expect(result.capabilities).toContain(CAPABILITY.bank_sync)
expect(result.entitlementState).toBe('trial')
expect(result.trialExpiredAt).toBeNull()
})
it('hides the trial once a non-trial grant is active (converted customer)', async () => {
@@ -256,26 +258,75 @@ describe('getCompanyEntitlements', () => {
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toContain(CAPABILITY.ai)
expect(result.entitlementState).toBe('paid')
expect(result.trialExpiredAt).toBeNull()
})
it('returns no trial and no capabilities after the trial lapsed', async () => {
it('reports trial_expired with the lapsed expiry after the trial lapsed', async () => {
const expiredEarlier = iso(-120_000)
const expiredLatest = iso(-60_000)
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [
{ capability_key: CAPABILITY.ai, expires_at: expiredLatest, source: 'trial' },
{ capability_key: CAPABILITY.bank_sync, expires_at: expiredEarlier, source: 'trial' },
],
},
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toEqual([])
expect(result.entitlementState).toBe('trial_expired')
// Latest expiry across the trial rows, even though all are expired.
expect(result.trialExpiredAt).toBe(expiredLatest)
})
it('reports lapsed_subscription for a churned payer (cancelled subscription, expired trial rows)', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [{ capability_key: CAPABILITY.ai, expires_at: iso(-60_000), source: 'trial' }],
},
company_subscriptions: { data: { status: 'canceled' } },
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.entitlementState).toBe('lapsed_subscription')
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toEqual([])
})
it('a live subscription status never marks a company lapsed', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [{ capability_key: CAPABILITY.ai, expires_at: iso(-60_000), source: 'trial' }],
},
company_subscriptions: { data: { status: 'active' } },
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.entitlementState).toBe('trial_expired')
})
it('reports none when no grant rows exist at all', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: { data: [] },
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.entitlementState).toBe('none')
expect(result.trialEndsAt).toBeNull()
expect(result.trialExpiredAt).toBeNull()
expect(result.capabilities).toEqual([])
})
it('bypass (self-hosted) holds everything with no trial countdown', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toEqual([...PAID_CAPABILITIES])
expect(result.entitlementState).toBe('paid')
})
})