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:
@@ -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.
|
||||
|
||||
@@ -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' } })
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user