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
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}