fix(entitlements): show paid features as gated upsells instead of dead ends (#913)

* fix(entitlements): show paid features as gated upsells instead of dead ends

Post-cutover, non-payers still saw fully interactive UI for paid features
(bank picker, agent-build hero, SKV VAT submission) that silently failed or
403'd on the server gate. Every surface now stays visible as a conversion
surface but is explicitly gated:

- new shared components/billing/UpgradeNote (lock icon + billing link)
- agent-build hero (dashboard + new-user checklist): routes to
  /settings/billing with upgrade copy when the ai capability is missing
- bank connect: BankingSettingsPanel and the import-page PSD2 wizard swap
  the bank list for an upgrade note; the import selection card swaps the
  "Rekommenderat" chip for "Kräver abonnemang"
- VAT report SkatteverketPanel: gated state renders before the
  connection check, so trial-connected companies see the upsell instead
  of action buttons that would 403; manual-filing framing kept
- SkatteverketConnectPanel: skahmst consent note hidden while gated
  (irrelevant until the consent page is reachable)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(ne-bilaga): fix time-of-day flake in SRU field-code assertion

The bare substrings '7310'/'7350' also match the #SKAPAD HHMMSS timestamp
when CI runs at 07:31/07:35, so the assertion now requires the full
'#UPPGIFT <code>' prefix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-07 10:01:44 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent fca75df3e2
commit 27b88426e2
10 changed files with 141 additions and 31 deletions
+28 -10
View File
@@ -12,7 +12,9 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeftRight, ArrowRightLeft, FileText, ArrowLeft, Landmark, Loader2, Info, ChevronRight, FileSpreadsheet, Download, AlertTriangle } from 'lucide-react'
import { cn, formatDate } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
@@ -1910,6 +1912,7 @@ function PSD2ConnectWizard() {
const supabase = createClient()
const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
const hasBankSync = useCapability(CAPABILITY.bank_sync)
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
@@ -2069,7 +2072,8 @@ function PSD2ConnectWizard() {
</Card>
)}
{/* Connect new bank */}
{/* Connect new bank. Non-payers see the card but the bank list is
replaced by an upgrade note: the server gate would 403 the connect. */}
<Card>
<CardHeader>
<CardTitle>Anslut din bank</CardTitle>
@@ -2078,11 +2082,18 @@ function PSD2ConnectWizard() {
</CardDescription>
</CardHeader>
<CardContent>
<BankSelector
onConnect={handleConnectBank}
isConnecting={isConnecting}
connectingBankName={connectingBankName}
/>
{!hasBankSync ? (
<UpgradeNote>
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
transaktioner manuellt via bankfiler nedan.
</UpgradeNote>
) : (
<BankSelector
onConnect={handleConnectBank}
isConnecting={isConnecting}
connectingBankName={connectingBankName}
/>
)}
</CardContent>
</Card>
</div>
@@ -2106,6 +2117,7 @@ export default function ImportPage() {
const t = useTranslations('import')
const router = useRouter()
const hasCloudBackup = ENABLED_EXTENSION_IDS.has('cloud-backup')
const hasBankSync = useCapability(CAPABILITY.bank_sync)
// Fetch authenticated user ID and sandbox status
useEffect(() => {
@@ -2225,9 +2237,15 @@ export default function ImportPage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5">
<h3 className="text-[15px] font-semibold leading-tight">{t('psd2_title')}</h3>
<span className="text-[11px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full leading-none">
{t('psd2_recommended')}
</span>
{hasBankSync ? (
<span className="text-[11px] font-medium text-success bg-success/10 px-2 py-0.5 rounded-full leading-none">
{t('psd2_recommended')}
</span>
) : (
<span className="text-[11px] font-medium text-muted-foreground bg-secondary px-2 py-0.5 rounded-full leading-none">
{t('psd2_requires_subscription')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed max-w-lg">
{t('psd2_description')}
+28
View File
@@ -0,0 +1,28 @@
import Link from 'next/link'
import { Lock } from 'lucide-react'
import { cn } from '@/lib/utils'
/**
* Inline paywall note for a feature that is visible but not entitled.
* The feature stays on screen (conversion surface: never hide, disable
* with an upsell), this note explains why it's disabled and links to
* /settings/billing. Copy mirrors CAPABILITY_BLOCKED_MESSAGE_SV.
*/
export function UpgradeNote({ children, className }: { children?: React.ReactNode; className?: string }) {
return (
<div
className={cn(
'flex items-start gap-2.5 rounded-lg border border-border bg-secondary/40 px-3 py-2.5 text-sm text-muted-foreground',
className,
)}
>
<Lock className="h-4 w-4 mt-0.5 shrink-0" />
<span>
{children ?? 'Den här funktionen kräver ett abonnemang.'}{' '}
<Link href="/settings/billing" className="underline underline-offset-2 text-foreground">
Uppgradera
</Link>
</span>
</div>
)
}
+10 -3
View File
@@ -8,6 +8,8 @@ import { Badge } from '@/components/ui/badge'
import { cn, formatCurrency } from '@/lib/utils'
import { UpcomingDeadlinesWidget } from '@/components/deadlines/UpcomingDeadlinesWidget'
import { TaxTodoWidget } from '@/components/deadlines/TaxTodoWidget'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import NewUserChecklist from '@/components/onboarding/NewUserChecklist'
import AttGoraSection from '@/components/dashboard/AttGoraSection'
import {
@@ -51,6 +53,7 @@ interface DashboardContentProps {
export default function DashboardContent({ companyId, summary, worklist, suggestedMatches, onboardingProgress, agentBuilt = true }: DashboardContentProps) {
const t = useTranslations('dashboard')
const hasAi = useCapability(CAPABILITY.ai)
// The setup gate exists to nudge brand-new users into a data-import step
// before they hit the dashboard. Once the assistant is built we treat the
@@ -121,7 +124,9 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
has a single CTA surface instead of two that point at the same work. */}
{!agentBuilt && (
<section>
<Link href="/onboarding/agent" className="block group">
{/* Non-payers keep seeing the hero (conversion surface) but it
routes to billing instead of a build flow that would 403. */}
<Link href={hasAi ? '/onboarding/agent' : '/settings/billing'} className="block group">
<Card className="transition-colors hover:border-primary/50">
<CardContent className="p-6 flex items-center gap-4">
<div className="flex-shrink-0 h-10 w-10 rounded-lg flex items-center justify-center bg-foreground text-background">
@@ -133,11 +138,13 @@ export default function DashboardContent({ companyId, summary, worklist, suggest
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.
{hasAi
? 'Några frågor om din verksamhet kalibrerar en assistent som föreslår bokföring åt dig.'
: 'Ingår i abonnemanget: en assistent som föreslår bokföring åt dig.'}
</p>
</div>
<div className="hidden sm:flex items-center gap-1.5 text-sm font-medium text-foreground group-hover:translate-x-0.5 transition-transform">
<span>Kom igång</span>
<span>{hasAi ? 'Kom igång' : 'Uppgradera'}</span>
<ArrowRight className="h-4 w-4" />
</div>
</CardContent>
+10 -3
View File
@@ -17,6 +17,8 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { getBranding } from '@/lib/branding/service'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
const branding = getBranding()
@@ -46,6 +48,7 @@ export default function NewUserChecklist({
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
const hasBanking = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
const hasAi = useCapability(CAPABILITY.ai)
return (
<div className={cn('min-h-[75vh] flex flex-col items-center justify-center px-4 sm:px-0 stagger-enter', className)}>
@@ -304,7 +307,7 @@ export default function NewUserChecklist({
</div>
) : (
<Link
href="/onboarding/agent"
href={hasAi ? '/onboarding/agent' : '/settings/billing'}
className="group block p-4 sm:p-6 rounded-lg border border-border hover:border-primary/40 hover:bg-primary/[0.02] transition-colors duration-150"
>
<div className="flex items-start gap-3 sm:gap-4">
@@ -316,10 +319,14 @@ export default function NewUserChecklist({
<p className="font-medium group-hover:text-primary transition-colors text-sm sm:text-base">
Bygg din bokföringsassistent
</p>
<Badge variant="secondary" className="uppercase tracking-wider">Beta</Badge>
<Badge variant="secondary" className="uppercase tracking-wider">
{hasAi ? 'Beta' : 'Abonnemang'}
</Badge>
</div>
<p className="text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-2 leading-relaxed">
Några frågor om din verksamhet kalibrerar tonalitet, signatur och vad assistenten kan. Ju mer du delar, desto bättre förstår den dig.
{hasAi
? 'Några frågor om din verksamhet kalibrerar tonalitet, signatur och vad assistenten kan. Ju mer du delar, desto bättre förstår den dig.'
: 'Ingår i abonnemanget: en assistent som föreslår bokföring åt dig. Uppgradera för att komma igång.'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-primary/60 mt-1 flex-shrink-0 transition-colors" />
+31
View File
@@ -29,6 +29,9 @@ import {
} from '@/lib/reports/vat-declaration-checks'
import type { RcBasisGap } from '@/lib/reports/rc-basis-gaps'
import { formatDate } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
interface SkatteverketStatus {
connected: boolean
@@ -88,6 +91,7 @@ export function SkatteverketPanel(props: SkatteverketPanelProps) {
}
function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: SkatteverketPanelProps) {
const hasSkvCapability = useCapability(CAPABILITY.skatteverket)
const [status, setStatus] = useState<SkatteverketStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
@@ -517,6 +521,33 @@ function SkatteverketPanelInner({ periodType, year, period, hasData, rutor }: Sk
)
}
// Paywall: direct API submission is the paid convenience; manual filing at
// skatteverket.se stays free and is described in the card above this panel.
// Rendered BEFORE the connected check so a company that connected during
// trial sees the upsell instead of action buttons that would 403.
if (!hasSkvCapability) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileCheck className="h-5 w-5" />
Skicka direkt till Skatteverket (valfritt)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Deklarationen är redan klar att lämnas in manuellt hos Skatteverket
(se ovan). Med ett abonnemang kan du ansluta med BankID och skicka
den direkt härifrån, samt validera, spara utkast och signera.
</p>
<UpgradeNote>
Direktinlämning till Skatteverket kräver ett abonnemang.
</UpgradeNote>
</CardContent>
</Card>
)
}
// Not connected. The momsdeklaration is already complete and can be filed
// manually at skatteverket.se with no connection (see the manual-filing card
// rendered above this panel). Connecting is an optional convenience for
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { CheckCircle2, ExternalLink, ShieldOff, FlaskConical, ShieldAlert } from 'lucide-react'
type Environment = 'test' | 'prod'
@@ -118,19 +119,18 @@ export function SkatteverketConnectPanel() {
<p className="text-sm text-muted-foreground">
{t('connect_intro')}
</p>
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</div>
{!hasSkatteverket && (
<div className="rounded-lg border border-border bg-secondary/40 px-3 py-2.5 text-sm text-muted-foreground">
Anslutning till Skatteverket kräver ett abonnemang.{' '}
<a href="/settings/billing" className="underline underline-offset-2">
Uppgradera
</a>
{/* The skahmst consent-page note only matters when the user can
actually reach that page: hidden while the feature is gated. */}
{hasSkatteverket && (
<div className="rounded-md border border-border bg-secondary/40 p-3 text-xs text-muted-foreground">
{t.rich('skahmst_note', {
code: (chunks) => <span className="font-mono">{chunks}</span>,
})}
</div>
)}
{!hasSkatteverket && (
<UpgradeNote>Anslutning till Skatteverket kräver ett abonnemang.</UpgradeNote>
)}
<Button
onClick={startConnect}
disabled={status?.disabled || !hasSkatteverket}
@@ -9,7 +9,9 @@ import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui
import { AlertTriangle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus'
import { AccountPickerDialog } from './AccountPickerDialog'
@@ -26,6 +28,7 @@ export default function BankingSettingsPanel() {
const { dialogProps, confirm } = useDestructiveConfirm()
const { company } = useCompany()
const hasBankSync = useCapability(CAPABILITY.bank_sync)
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [syncingConnectionId, setSyncingConnectionId] = useState<string | null>(null)
@@ -464,7 +467,9 @@ export default function BankingSettingsPanel() {
</Card>
)}
{/* Connect new bank */}
{/* Connect new bank. Non-payers keep seeing the card (conversion
surface) but the bank list is replaced by an upgrade note: the
server gate would 403 the connect anyway. */}
<Card>
<CardHeader>
<CardTitle>Anslut ny bank</CardTitle>
@@ -472,6 +477,14 @@ export default function BankingSettingsPanel() {
Välj din bank nedan för att koppla ditt konto via PSD2.
</CardDescription>
</CardHeader>
{!hasBankSync ? (
<CardContent>
<UpgradeNote>
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
transaktioner manuellt via bankfiler på importsidan.
</UpgradeNote>
</CardContent>
) : (
<CardContent className="space-y-4">
{/* Account type selector */}
<div className="flex items-center gap-3">
@@ -515,6 +528,7 @@ export default function BankingSettingsPanel() {
connectingBankName={connectingBankName}
/>
</CardContent>
)}
</Card>
{/* Info about PSD2 */}
@@ -106,8 +106,11 @@ describe('NE-bilaga SRU generator', () => {
it('does not use the old (wrong) 73xx field codes', () => {
const { blanketterSru } = generateNESRUSubmission(makeDeclaration())
expect(blanketterSru).not.toContain('7310')
expect(blanketterSru).not.toContain('7350')
// Match the full #UPPGIFT prefix, not a bare substring: the #SKAPAD
// HHMMSS timestamp contains "7310"/"7350" when the clock reads
// 07:31:0x / 07:35:0x, which made this assertion time-of-day flaky.
expect(blanketterSru).not.toContain('#UPPGIFT 7310')
expect(blanketterSru).not.toContain('#UPPGIFT 7350')
expect(blanketterSru).not.toContain('#UPPGIFT 7000')
})
+1
View File
@@ -5002,6 +5002,7 @@
"back_to_choices": "Back to choices",
"psd2_title": "Connect bank",
"psd2_recommended": "Recommended",
"psd2_requires_subscription": "Requires subscription",
"psd2_description": "Connect your bank account directly and sync transactions automatically via PSD2.",
"migration_title": "Import from another system",
"migration_description": "Nothing changes in your existing system.",
+1
View File
@@ -5002,6 +5002,7 @@
"back_to_choices": "Tillbaka till val",
"psd2_title": "Koppla bank",
"psd2_recommended": "Rekommenderat",
"psd2_requires_subscription": "Kräver abonnemang",
"psd2_description": "Anslut ditt bankkonto direkt och synka transaktioner automatiskt via PSD2.",
"migration_title": "Hämta från annat system",
"migration_description": "Inget ändras i ditt befintliga system.",