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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -276,6 +276,25 @@ export async function requireCapability(
|
||||
return capabilityBlockedResponse(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the company sits in the paid lifecycle, derived from the same grant
|
||||
* rows that produce `capabilities`:
|
||||
* 'paid' : an active non-trial grant (stripe/comp/manual/team).
|
||||
* 'trial' : the trial is the sole source of paid access.
|
||||
* 'lapsed_subscription' : no active grants, but a company_subscriptions row
|
||||
* in a non-paying status: a churned payer, so copy
|
||||
* says "abonnemang", not "provperiod".
|
||||
* 'trial_expired' : no active grants, only expired trial rows.
|
||||
* 'none' : no grant rows at all (effectively unreachable on
|
||||
* hosted: every company is seeded with trial rows).
|
||||
*/
|
||||
export type EntitlementState =
|
||||
| 'trial'
|
||||
| 'trial_expired'
|
||||
| 'lapsed_subscription'
|
||||
| 'paid'
|
||||
| 'none'
|
||||
|
||||
export interface CompanyEntitlements {
|
||||
capabilities: CapabilityKey[]
|
||||
/**
|
||||
@@ -285,8 +304,17 @@ export interface CompanyEntitlements {
|
||||
* countdown touchpoint in the dashboard chrome.
|
||||
*/
|
||||
trialEndsAt: string | null
|
||||
entitlementState: EntitlementState
|
||||
/**
|
||||
* When the lapsed trial ran out (latest trial expires_at), set only while
|
||||
* entitlementState is 'trial_expired'. Drives the expired-trial notice.
|
||||
*/
|
||||
trialExpiredAt: string | null
|
||||
}
|
||||
|
||||
/** company_subscriptions.status values that count as a live subscription. */
|
||||
const PAYING_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due']
|
||||
|
||||
/**
|
||||
* Resolve which PAID capabilities a company currently holds (entitled AND
|
||||
* enabled) plus its trial state, in two queries. Used to seed the client
|
||||
@@ -297,19 +325,38 @@ export async function getCompanyEntitlements(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<CompanyEntitlements> {
|
||||
if (isPaywallBypassed()) return { capabilities: [...PAID_CAPABILITIES], trialEndsAt: null }
|
||||
if (!isUuid(companyId)) return { capabilities: [], trialEndsAt: null } // fail-closed: never interpolate a non-UUID
|
||||
if (isPaywallBypassed()) {
|
||||
return {
|
||||
capabilities: [...PAID_CAPABILITIES],
|
||||
trialEndsAt: null,
|
||||
entitlementState: 'paid',
|
||||
trialExpiredAt: null,
|
||||
}
|
||||
}
|
||||
// Fail-closed: never interpolate a non-UUID.
|
||||
if (!isUuid(companyId)) {
|
||||
return { capabilities: [], trialEndsAt: null, entitlementState: 'none', trialExpiredAt: null }
|
||||
}
|
||||
|
||||
// The disabled-config subtraction only needs companyId, so it runs in
|
||||
// parallel with the team lookup — this function sits on the dashboard
|
||||
// layout's critical path, where each serialized round-trip is latency.
|
||||
const [{ data: company }, { data: configs }] = await Promise.all([
|
||||
// The disabled-config subtraction and the subscription-status read only
|
||||
// need companyId, so they run in parallel with the team lookup: this
|
||||
// function sits on the dashboard layout's critical path, where each
|
||||
// serialized round-trip is latency. The subscription row (members-readable
|
||||
// per RLS) distinguishes a churned payer from an expired trial: cancelled
|
||||
// subscriptions have their stripe grants deleted, so the grants alone
|
||||
// cannot tell the two apart.
|
||||
const [{ data: company }, { data: configs }, { data: subscription }] = await Promise.all([
|
||||
supabase.from('companies').select('team_id').eq('id', companyId).maybeSingle(),
|
||||
supabase
|
||||
.from('company_capability_config')
|
||||
.select('capability_key, enabled')
|
||||
.eq('company_id', companyId)
|
||||
.eq('enabled', false),
|
||||
supabase
|
||||
.from('company_subscriptions')
|
||||
.select('status')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
const rawTeamId = (company as { team_id: string | null } | null)?.team_id ?? null
|
||||
const teamId = rawTeamId && isUuid(rawTeamId) ? rawTeamId : null
|
||||
@@ -325,33 +372,62 @@ export async function getCompanyEntitlements(
|
||||
|
||||
const now = Date.now()
|
||||
const entitled = new Set<string>()
|
||||
let trialEndsAt: string | null = null
|
||||
// Latest trial expiry across ALL trial rows, expired ones included: this is
|
||||
// what tells the UI the trial ENDED (ISO strings from the same column
|
||||
// compare lexically).
|
||||
let latestTrialExpiry: string | null = null
|
||||
let hasActiveNonTrialGrant = false
|
||||
for (const g of grants ?? []) {
|
||||
const row = g as { capability_key: string; expires_at: string | null; source: string | null }
|
||||
if (
|
||||
row.source === 'trial' &&
|
||||
row.expires_at &&
|
||||
(!latestTrialExpiry || row.expires_at > latestTrialExpiry)
|
||||
) {
|
||||
latestTrialExpiry = row.expires_at
|
||||
}
|
||||
const active = row.expires_at === null || new Date(row.expires_at).getTime() > now
|
||||
if (!active) continue
|
||||
entitled.add(row.capability_key)
|
||||
if (row.source === 'trial') {
|
||||
// Latest trial expiry (ISO strings from the same column compare lexically).
|
||||
if (row.expires_at && (!trialEndsAt || row.expires_at > trialEndsAt)) {
|
||||
trialEndsAt = row.expires_at
|
||||
}
|
||||
} else {
|
||||
hasActiveNonTrialGrant = true
|
||||
}
|
||||
if (row.source !== 'trial') hasActiveNonTrialGrant = true
|
||||
}
|
||||
// Paying/comped companies are not "on trial" even if the seeded trial rows
|
||||
// haven't expired yet: the countdown would nag someone who already converted.
|
||||
if (hasActiveNonTrialGrant) trialEndsAt = null
|
||||
if (entitled.size === 0) return { capabilities: [], trialEndsAt: null }
|
||||
const trialIsActive =
|
||||
latestTrialExpiry !== null && new Date(latestTrialExpiry).getTime() > now
|
||||
const trialEndsAt = !hasActiveNonTrialGrant && trialIsActive ? latestTrialExpiry : null
|
||||
|
||||
const subscriptionStatus = (subscription as { status: string | null } | null)?.status ?? null
|
||||
let entitlementState: EntitlementState
|
||||
let trialExpiredAt: string | null = null
|
||||
if (hasActiveNonTrialGrant) {
|
||||
entitlementState = 'paid'
|
||||
} else if (trialEndsAt) {
|
||||
entitlementState = 'trial'
|
||||
} else if (subscriptionStatus && !PAYING_SUBSCRIPTION_STATUSES.includes(subscriptionStatus)) {
|
||||
entitlementState = 'lapsed_subscription'
|
||||
} else if (latestTrialExpiry) {
|
||||
entitlementState = 'trial_expired'
|
||||
trialExpiredAt = latestTrialExpiry
|
||||
} else {
|
||||
entitlementState = 'none'
|
||||
}
|
||||
|
||||
if (entitled.size === 0) {
|
||||
return { capabilities: [], trialEndsAt: null, entitlementState, trialExpiredAt }
|
||||
}
|
||||
|
||||
// Subtract any explicitly-disabled (enablement axis).
|
||||
for (const c of configs ?? []) {
|
||||
entitled.delete((c as { capability_key: string }).capability_key)
|
||||
}
|
||||
|
||||
return { capabilities: PAID_CAPABILITIES.filter((k) => entitled.has(k)), trialEndsAt }
|
||||
return {
|
||||
capabilities: PAID_CAPABILITIES.filter((k) => entitled.has(k)),
|
||||
trialEndsAt,
|
||||
entitlementState,
|
||||
trialExpiredAt,
|
||||
}
|
||||
}
|
||||
|
||||
/** Capability list only; see getCompanyEntitlements for the full shape. */
|
||||
|
||||
Reference in New Issue
Block a user