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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
834cc4d0e8
commit
c402421908
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user