From c40242190860422607c4e8d9027671c8f8ffb8f5 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 10:05:39 +0200 Subject: [PATCH] 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 --- DECISIONS.md | 1 + app/(dashboard)/layout.tsx | 20 +++ app/api/user/ui-state/__tests__/route.test.ts | 42 ++++++ app/api/user/ui-state/route.ts | 9 ++ components/billing/SubscriptionTouchpoint.tsx | 121 ++++++++++++++++++ components/billing/TrialExpiredDialog.tsx | 92 +++++++++++++ components/dashboard/DashboardNav.tsx | 53 ++------ contexts/CompanyContext.tsx | 9 ++ .../__tests__/has-capability.test.ts | 53 +++++++- lib/entitlements/has-capability.ts | 112 +++++++++++++--- messages/en.json | 11 ++ messages/sv.json | 11 ++ types/index.ts | 4 + 13 files changed, 476 insertions(+), 62 deletions(-) create mode 100644 components/billing/SubscriptionTouchpoint.tsx create mode 100644 components/billing/TrialExpiredDialog.tsx diff --git a/DECISIONS.md b/DECISIONS.md index a6e878d8..c2c510ee 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1086,3 +1086,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 87fe97ae..f7e3ca60 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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, }} > @@ -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({ {children}