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
parent 834cc4d0e8
commit c402421908
13 changed files with 476 additions and 62 deletions
+1
View File
@@ -1086,3 +1086,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount.
[2026-08-19] Import mapping step gets a bulk "Bekräfta alla föreslagna" for the VAT-treatment review gate, batching the per-row confirm semantics unchanged (defaults kept, rows marked reviewed): a Fortnox chart routinely puts 70+ class 3/4 accounts behind the gate and the one-click-per-row flow across 50-row pages was an observed live migration dead end (Boltonshield 2026-08-18, stuck at "50 kvar"). Rejected: auto-skipping review for accounts unused by the imported vouchers, because the chart rows are still created with the suggested treatment and a silently wrong default on a soon-used account is exactly what the review gate exists to catch.
[2026-08-19] Dialog overflow hardening: Dialog/Sheet titles and descriptions get break-words at the primitive; AccountCombobox's non-flat dropdown is portaled to document.body with viewport-clamped geometry (same rationale as info-tooltip's TooltipContent portal, since DialogContent's overflow-y-auto otherwise grows a horizontal scrollbar around the 34rem panel); the four rattelse-family dialog explainers are unified behind one RattelseExplainer HelpPopover (convention 7, MatchVoucherDialog precedent) instead of four near-duplicate inline paragraphs; a dialog-overflow-risk ratchet in no-new-antipatterns.mjs keeps bare-1fr tracks, dialog whitespace-nowrap and unportaled wide overlays from coming back.
[2026-08-19] Trial-expired visibility: the 2026-07-11 "no trial-expired nag" call is narrowed at the founder's direction (user-reported discoverability failure 2026-08-18): one on-entry dialog with persisted acknowledgement (user_preferences.ui_state.trial_expired_ack, per user AND company, never localStorage) plus a non-dismissable quiet chrome link (SubscriptionTouchpoint replaces the vanishing countdown pill, stays visible when the rail is collapsed, and adds the first mobile touchpoint). Free tier stays a legitimate resting state: no recurring nag, muted chrome tone, no ochre. getCompanyEntitlements also reads company_subscriptions.status inside its existing Promise.all (zero extra round-trips) so churned payers get "abonnemang" copy instead of "provperiod". Hem AttnLine variant skipped: the page already carries the otherAccountHint AttnLine and design.md allows max one per page.
+20
View File
@@ -11,6 +11,7 @@ import LazyCommandPalette from '@/components/common/LazyCommandPalette'
import { SettingsHotkey } from '@/components/settings/SettingsHotkey'
import { SessionTimeoutController } from '@/components/auth/SessionTimeoutController'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import TrialExpiredDialog from '@/components/billing/TrialExpiredDialog'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
@@ -107,6 +108,8 @@ export default async function DashboardLayout({
isSandbox: false,
capabilities: [],
trialEndsAt: null,
entitlementState: 'none' as const,
trialExpiredAt: null,
}}
>
<SessionTimeoutController />
@@ -231,6 +234,8 @@ export default async function DashboardLayout({
isSandbox: false,
capabilities: [],
trialEndsAt: null,
entitlementState: 'none' as const,
trialExpiredAt: null,
}
return (
@@ -314,6 +319,8 @@ export default async function DashboardLayout({
isSandbox,
capabilities: entitlements.capabilities,
trialEndsAt: entitlements.trialEndsAt,
entitlementState: entitlements.entitlementState,
trialExpiredAt: entitlements.trialExpiredAt,
}
return (
@@ -361,6 +368,19 @@ export default async function DashboardLayout({
<MainContainer companyId={companyId}>{children}</MainContainer>
</main>
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
{/* One-time expired-trial notice. Sandbox/anonymous demo users have
no billing (their companies carry trial grants too), so the gate
lives here where both flags are known. Acknowledgement persists
per user AND company in user_preferences.ui_state, read here
server-side so an acked dialog never flashes. */}
{!isSandbox && !user.is_anonymous && (
<TrialExpiredDialog
state={entitlements.entitlementState}
trialExpiredAt={entitlements.trialExpiredAt}
companyId={companyId}
initialAcknowledged={!!uiState.trial_expired_ack?.[companyId]}
/>
)}
<LazyCommandPalette />
<SettingsHotkey />
{settingsModal}
@@ -146,6 +146,48 @@ describe('POST /api/user/ui-state', () => {
expect(body.data.ui_state).toEqual({ nav_collapsed: true })
})
it('returns 400 when a trial_expired_ack key is not a company UUID', async () => {
const res = await POST(request({ trial_expired_ack: { 'not-a-uuid': new Date().toISOString() } }))
expect(res.status).toBe(400)
})
it('returns 400 when a trial_expired_ack value is not an ISO timestamp', async () => {
const res = await POST(
request({ trial_expired_ack: { '11111111-1111-4111-8111-111111111111': 'yesterday' } }),
)
expect(res.status).toBe(400)
})
it('merges trial_expired_ack per company instead of replacing the record', async () => {
const ackedAt = '2026-08-01T10:00:00.000Z'
enqueue({
data: {
ui_state: {
trial_expired_ack: { '11111111-1111-4111-8111-111111111111': ackedAt },
},
},
})
enqueue({ data: null })
const newAck = '2026-08-19T09:00:00.000Z'
const { status, body } = await parseJsonResponse<{
data: { ui_state: { trial_expired_ack: Record<string, string> } }
}>(
await POST(
request({
trial_expired_ack: { '22222222-2222-4222-8222-222222222222': newAck },
}),
),
)
expect(status).toBe(200)
// Acking company B never clears company A's ack.
expect(body.data.ui_state.trial_expired_ack).toEqual({
'11111111-1111-4111-8111-111111111111': ackedAt,
'22222222-2222-4222-8222-222222222222': newAck,
})
})
it('returns 500 when the upsert fails', async () => {
enqueue({ data: null })
enqueue({ data: null, error: { message: 'boom' } })
+9
View File
@@ -35,6 +35,12 @@ const BodySchema = z
})
.strict()
.optional(),
// One-time expired-trial dialog acknowledgement: companyId -> ISO
// timestamp of the ack. Merged per key like create_mode, so acking one
// company never clears another's.
trial_expired_ack: z
.record(z.string().uuid(), z.string().datetime())
.optional(),
})
.strict()
@@ -79,6 +85,9 @@ export async function POST(request: Request) {
...(patch.agent_panel
? { agent_panel: { ...current.agent_panel, ...patch.agent_panel } }
: {}),
...(patch.trial_expired_ack
? { trial_expired_ack: { ...current.trial_expired_ack, ...patch.trial_expired_ack } }
: {}),
}
const { error: upsertError } = await supabase
@@ -0,0 +1,121 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ChevronRight, Clock, Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
/**
* The chrome-level subscription touchpoint: the paywall is a lifecycle flow,
* not a settings page, so trial state stays quietly visible in the chrome
* instead of only inside Inställningar -> Abonnemang.
*
* trial -> the countdown pill ("Provperiod: N dagar kvar").
* trial_expired /
* lapsed_subscription -> a muted pill linking to /settings/billing. This is
* the navigation affordance a lapsed user could not
* find (2026-08-18 report), so it is NOT dismissable
* and, unlike the countdown, stays visible when the
* sidebar is collapsed (icon-only with aria-label).
*
* Hidden for sandbox/demo (no checkout; sandbox companies carry trial grants
* too) and for paying companies. Muted chrome tone throughout: status colors
* are data, never chrome.
*/
export function SubscriptionTouchpoint({
variant,
collapsed = false,
onNavigate,
}: {
variant: 'sidebar' | 'mobile'
collapsed?: boolean
onNavigate?: () => void
}) {
const { entitlementState, trialEndsAt, isSandbox } = useCompany()
const tNav = useTranslations('nav')
// Trial countdown. Computed in an effect (not during render) so server and
// client markup agree at hydration; an hourly tick keeps a long-lived tab
// from showing yesterday's count.
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
useEffect(() => {
if (!trialEndsAt) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTrialDaysLeft(null)
return
}
const update = () => {
const msLeft = new Date(trialEndsAt).getTime() - Date.now()
setTrialDaysLeft(msLeft > 0 ? Math.ceil(msLeft / 86_400_000) : null)
}
update()
const id = setInterval(update, 3_600_000)
return () => clearInterval(id)
}, [trialEndsAt])
if (isSandbox) return null
const lapsed =
entitlementState === 'trial_expired' || entitlementState === 'lapsed_subscription'
const showCountdown =
entitlementState === 'trial' && trialDaysLeft !== null
if (!lapsed && !showCountdown) return null
const label = lapsed
? tNav(
entitlementState === 'lapsed_subscription'
? 'subscription_lapsed_cta'
: 'trial_expired_cta',
)
: tNav('trial_days_left', { days: trialDaysLeft ?? 0 })
const Icon = lapsed ? Lock : Clock
if (variant === 'mobile') {
return (
<Link
href="/settings/billing"
onClick={onNavigate}
className="flex items-center gap-3 px-3 min-h-[44px] rounded-lg text-foreground transition-colors active:bg-muted/60"
>
<Icon className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
<span className="text-sm flex-1">{label}</span>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground/50" />
</Link>
)
}
// Sidebar. The countdown keeps its pre-existing behavior of hiding when the
// rail is collapsed; the lapsed CTA must not vanish, so it collapses to an
// icon-only link instead.
if (collapsed) {
if (!lapsed) return null
return (
<div className="flex flex-shrink-0 justify-center pb-2">
<Link
href="/settings/billing"
title={label}
aria-label={label}
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<Icon className="h-[17px] w-[17px]" />
</Link>
</div>
)
}
return (
<div className="flex-shrink-0 px-3 pb-2">
<Link
href="/settings/billing"
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Link>
</div>
)
}
export default SubscriptionTouchpoint
+92
View File
@@ -0,0 +1,92 @@
'use client'
import { useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { persistUiState } from '@/lib/ui-state/client'
import { useFormat } from '@/lib/hooks/use-format'
import type { EntitlementState } from '@/lib/entitlements/has-capability'
/**
* One-time on-entry notice that the trial (or a cancelled subscription) has
* lapsed, with the path to Abonnemang. Shown once per user AND company:
* the acknowledgement persists in user_preferences.ui_state
* (trial_expired_ack[companyId]), read server-side by the dashboard layout so
* an acked dialog never mounts (no flash). Both dismissing and clicking
* through count as acknowledged; afterwards the persistent
* SubscriptionTouchpoint in the chrome carries the CTA. The layout gates
* sandbox/anonymous users (no billing); this component additionally skips
* /settings/* so it never stacks on the routed settings modal.
*/
export function TrialExpiredDialog({
state,
trialExpiredAt,
companyId,
initialAcknowledged,
}: {
state: EntitlementState
trialExpiredAt: string | null
companyId: string
initialAcknowledged: boolean
}) {
const t = useTranslations('trial_expired_dialog')
const { formatDateLong } = useFormat()
const router = useRouter()
const pathname = usePathname()
const [open, setOpen] = useState(true)
const lapsed = state === 'trial_expired' || state === 'lapsed_subscription'
if (!lapsed || initialAcknowledged || pathname.startsWith('/settings')) {
return null
}
const acknowledge = () => {
setOpen(false)
persistUiState({
trial_expired_ack: { [companyId]: new Date().toISOString() },
})
}
const goToBilling = () => {
acknowledge()
router.push('/settings/billing')
}
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) acknowledge() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="font-display text-xl tracking-tight">
{state === 'lapsed_subscription'
? t('title_lapsed')
: trialExpiredAt
? t('title_dated', { date: formatDateLong(trialExpiredAt) })
: t('title')}
</DialogTitle>
<DialogDescription className="sr-only">{t('body_paused')}</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>{t('body_paused')}</p>
<p>{t('body_free')}</p>
</div>
<DialogFooter>
<Button variant="ghost" onClick={acknowledge}>
{t('cta_secondary')}
</Button>
<Button onClick={goToBilling}>{t('cta_primary')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default TrialExpiredDialog
+10 -43
View File
@@ -32,7 +32,6 @@ import {
Tag,
Tags,
ChevronRight,
Clock,
Sparkles,
Percent,
Landmark,
@@ -55,6 +54,7 @@ import { resetAnalyticsIdentity } from '@/lib/analytics/reset'
import { SupportLink } from '@/components/ui/support-link'
import CompanySwitcher from '@/components/dashboard/CompanySwitcher'
import UserMenu from '@/components/dashboard/UserMenu'
import SubscriptionTouchpoint from '@/components/billing/SubscriptionTouchpoint'
import AgentAvatar from '@/components/agent/AgentAvatar'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
import { useCompany } from '@/contexts/CompanyContext'
@@ -298,7 +298,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
const pathname = usePathname()
const router = useRouter()
const supabase = useRealtimeSupabase()
const { company, capabilities, trialEndsAt } = useCompany()
const { company, capabilities } = useCompany()
// Agent identity drives the "Assistent" nav icon: when the user has
// built their assistant we show its chosen avatar instead of the
// generic Sparkles glyph.
@@ -317,28 +317,6 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
pendingOperations: pendingOpsCount,
refresh: refreshBadges,
} = useWorklistBadges(company?.id)
// Trial countdown for the sidebar touchpoint. Computed in an effect (not
// during render) so server and client markup agree at hydration; an hourly
// tick keeps a long-lived tab from showing yesterday's count. The sync
// setState is that hydration strategy, not derived-state-in-effect (the
// lint only started analyzing this component once the badge-refresh loop
// that made the compiler bail was removed).
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
useEffect(() => {
if (!trialEndsAt) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTrialDaysLeft(null)
return
}
const update = () => {
const msLeft = new Date(trialEndsAt).getTime() - Date.now()
setTrialDaysLeft(msLeft > 0 ? Math.ceil(msLeft / 86_400_000) : null)
}
update()
const id = setInterval(update, 3_600_000)
return () => clearInterval(id)
}, [trialEndsAt])
const hasCompany = !!company
const ALWAYS_ENABLED = new Set(['/settings'])
const isItemEnabled = (href: string) => hasCompany || ALWAYS_ENABLED.has(href)
@@ -893,25 +871,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</nav>
</div>
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
a settings page, so trial state stays quietly visible in the
chrome instead of only inside Inställningar → Abonnemang.
Hidden for sandbox/demo (no checkout) and once any non-trial
grant is active (trialEndsAt is null then). */}
{!collapsed && !isSandbox && trialDaysLeft !== null && (
<div className="flex-shrink-0 px-3 pb-2">
<Link
href="/settings/billing"
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
>
<Clock className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{tNav('trial_days_left', { days: trialDaysLeft })}
</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
</Link>
</div>
)}
{/* Subscription touchpoint: trial countdown while the trial runs,
a persistent muted upgrade link once it (or a subscription) has
lapsed. Hides itself for sandbox/demo and paying companies. */}
<SubscriptionTouchpoint variant="sidebar" collapsed={collapsed} />
{/* Sticky user block (bottom-left): avatar, name, active company.
Opens the upward user menu with the company-switcher flyout,
@@ -1206,6 +1169,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
</div>
<div className="space-y-0.5">
{/* Subscription touchpoint: mobile had no trial surface at
all before this row (trial countdown or lapsed upgrade
link; hides itself for sandbox and paying companies). */}
<SubscriptionTouchpoint variant="mobile" onNavigate={closeMobileMenu} />
{([
{ href: '/settings', labelKey: 'settings' as NavLabelKey, icon: Settings },
{ href: '/help', labelKey: 'help' as NavLabelKey, icon: HelpCircle },
+9
View File
@@ -3,6 +3,7 @@
import { createContext, useContext } from 'react'
import type { Company, CompanyRole, Team } from '@/types'
import type { CapabilityKey } from '@/lib/entitlements/keys'
import type { EntitlementState } from '@/lib/entitlements/has-capability'
interface CompanyContextValue {
company: Company | null
@@ -19,6 +20,14 @@ interface CompanyContextValue {
* touchpoint in the sidebar.
*/
trialEndsAt: string | null
/**
* Paid-lifecycle state (trial / trial_expired / lapsed_subscription /
* paid / none). 'none' in the company:null shells. Drives the expired-trial
* touchpoint and dialog.
*/
entitlementState: EntitlementState
/** When the lapsed trial ran out; set only while entitlementState is 'trial_expired'. */
trialExpiredAt: string | null
}
const CompanyContext = createContext<CompanyContextValue | null>(null)
@@ -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')
})
})
+94 -18
View File
@@ -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. */
+11
View File
@@ -144,8 +144,19 @@
"expand_nav": "Expand menu",
"members_roles": "Members and roles",
"subscription": "Subscription",
"trial_expired_cta": "Trial ended · Upgrade",
"subscription_lapsed_cta": "Subscription ended · Reactivate",
"discord_community": "Discord community"
},
"trial_expired_dialog": {
"title": "Your trial has expired",
"title_dated": "Your trial expired {date}",
"title_lapsed": "Your subscription has ended",
"body_paused": "External connections are paused: the AI assistant, bank sync, Skatteverket and email sending.",
"body_free": "Your bookkeeping, invoices and reports are still here and cost nothing.",
"cta_primary": "View subscription",
"cta_secondary": "Continue without"
},
"agentKnowledge": {
"load_error_title": "Couldn't load the knowledge profile",
"load_error_description": "Something went wrong loading what your agent knows. Try reopening this tab.",
+11
View File
@@ -144,8 +144,19 @@
"expand_nav": "Expandera menyn",
"members_roles": "Medlemmar och roller",
"subscription": "Abonnemang",
"trial_expired_cta": "Provperioden är slut · Uppgradera",
"subscription_lapsed_cta": "Abonnemanget har avslutats · Aktivera",
"discord_community": "Discord-community"
},
"trial_expired_dialog": {
"title": "Din provperiod har löpt ut",
"title_dated": "Din provperiod löpte ut {date}",
"title_lapsed": "Ditt abonnemang har avslutats",
"body_paused": "De externa kopplingarna är pausade: AI-assistenten, bankkopplingen, Skatteverket och e-postutskick.",
"body_free": "Bokföringen, fakturorna och rapporterna finns kvar och kostar ingenting.",
"cta_primary": "Se abonnemang",
"cta_secondary": "Fortsätt utan"
},
"agentKnowledge": {
"load_error_title": "Kunde inte läsa in kunskapsprofilen",
"load_error_description": "Något gick fel när det din agent vet skulle läsas in. Försök öppna fliken igen.",
+4
View File
@@ -145,6 +145,10 @@ export interface UserUiState {
// re-clamps to the current viewport on read, so stale sizes from another
// screen are safe.
agent_panel?: AgentPanelState
// One-time expired-trial dialog acknowledgement, keyed per company
// (companyId -> ISO timestamp of the ack). Lives on the user so each
// member of a company sees the notice once.
trial_expired_ack?: Record<string, string>
}
export type AgentPanelMode = 'docked' | 'floating'