fix(entitlements): billing reachable in settings modal + conversion redesign (#821)

- Register BillingSettingsContent in SETTINGS_SECTIONS so the settings MODAL renders Abonnemang (it was falling back to Företag — billing was only a standalone page, never a registered section). Page is now a thin wrapper over the same component.
- Add GET /api/billing/status (isPaying / configured / trialEndsAt) so the client section gets state without server-only reads.
- Redesign for conversion: trial days-left urgency banner, reactive monthly/yearly price with a 'Spara 2 mån' badge, full-width price-bearing CTA, Stripe trust line, design-system-compliant chrome (flat Card, no shadow/rounded-xl, on-scale spacing, serif headline). Trialing companies now see the upgrade path (not the manage button).

The reported 'peach band' was not reproduced in code — no peach/salmon color exists in the app CSS and the only bottom drag-handle is in a md:hidden mobile sheet; most likely a macOS screenshot/desktop artifact.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-29 16:51:31 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 60e33c4b51
commit 0e8698f538
5 changed files with 229 additions and 108 deletions
+3 -79
View File
@@ -1,83 +1,7 @@
import { Check } from 'lucide-react'
import { createClient } from '@/lib/supabase/server'
import { getActiveCompanyId } from '@/lib/company/context'
import { statusGrantsAccess } from '@/lib/stripe/subscription-sync'
import { isStripeConfigured } from '@/lib/stripe/client'
import { BillingActions } from '@/components/settings/BillingActions'
import { BillingSettingsContent } from '@/components/settings/sections/BillingSettingsContent'
export const metadata = { title: 'Abonnemang' }
// What the paid tier unlocks (mirrors lib/entitlements PAID_CAPABILITIES).
const INCLUDED = [
'AI-assistent: chatt, kategorisering och dokumenttolkning',
'Bankkoppling och automatisk synk (PSD2)',
'Skatteverket: moms- och AGI-inlämning',
'E-postutskick av fakturor, påminnelser och lönebesked',
]
// Free for everyone — reassures users that the core ledger is never withheld.
const ALWAYS_FREE =
'All bokföring, fakturering, rapporter, SIE-export, org.nr-uppslag och momsnummerkontroll ingår alltid utan kostnad.'
/**
* Upgrade / subscription page — the destination every "Uppgradera" affordance
* points to. Reads the active company's subscription status to show either the
* Checkout CTA or the manage-subscription portal; fulfilment itself happens via
* the Stripe webhook, never this page.
*/
export default async function BillingPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
let isActive = false
if (user) {
const companyId = await getActiveCompanyId(supabase, user.id)
if (companyId) {
const { data: sub } = await supabase
.from('company_subscriptions')
.select('status')
.eq('company_id', companyId)
.maybeSingle()
isActive = statusGrantsAccess((sub as { status: string | null } | null)?.status)
}
}
const configured = isStripeConfigured()
return (
<div className="max-w-2xl">
<h1 className="font-display text-2xl tracking-tight mb-1">Abonnemang</h1>
<p className="text-muted-foreground mb-6">
{isActive
? 'Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst.'
: 'Lås upp AI-assistenten, bankkoppling, Skatteverket-inlämning och e-postutskick.'}
</p>
<div className="rounded-xl border border-border bg-card p-6 shadow-sm">
<div className="flex items-baseline gap-2">
<span className="font-display text-3xl tracking-tight">199 kr</span>
<span className="text-muted-foreground">/ månad</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
eller 1&nbsp;999 kr per år (två månader gratis).
</p>
<ul className="mt-5 space-y-2.5">
{INCLUDED.map((item) => (
<li key={item} className="flex items-start gap-2.5 text-sm">
<Check className="h-4 w-4 mt-0.5 shrink-0 text-foreground" />
<span>{item}</span>
</li>
))}
</ul>
<div className="mt-6">
<BillingActions isActive={isActive} configured={configured} />
</div>
</div>
<p className="text-xs text-muted-foreground mt-4 leading-relaxed">{ALWAYS_FREE}</p>
</div>
)
export default function BillingPage() {
return <BillingSettingsContent />
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { requireAuth } from '@/lib/auth/require-auth'
import { requireCompanyId } from '@/lib/company/context'
import { isStripeConfigured } from '@/lib/stripe/client'
/**
* Billing status for the client-rendered billing section (which lives inside the
* settings modal Dialog and can't read the DB server-side). Returns whether the
* company is paying, whether Stripe checkout is configured, and the trial expiry
* (for the days-left urgency banner). Read-only.
*/
export async function GET() {
const { user, supabase, error } = await requireAuth()
if (error) return error
let companyId: string | null = null
try {
companyId = await requireCompanyId(supabase, user.id)
} catch {
companyId = null
}
let isPaying = false
let trialEndsAt: string | null = null
if (companyId) {
const { data: sub } = await supabase
.from('company_subscriptions')
.select('status')
.eq('company_id', companyId)
.maybeSingle()
const status = (sub as { status: string | null } | null)?.status ?? null
// Paying = a real subscription. Deliberately excludes 'trialing' so a
// trialing company still sees the upgrade path (not the manage button).
isPaying = status === 'active' || status === 'past_due'
const { data: trial } = await supabase
.from('capability_grants')
.select('expires_at')
.eq('company_id', companyId)
.eq('source', 'trial')
.order('expires_at', { ascending: false })
.limit(1)
.maybeSingle()
trialEndsAt = (trial as { expires_at: string | null } | null)?.expires_at ?? null
}
return NextResponse.json({ isPaying, configured: isStripeConfigured(), trialEndsAt })
}
+56 -29
View File
@@ -1,16 +1,32 @@
'use client'
import { useState } from 'react'
import { useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import type { BillingPlan } from '@/lib/stripe/client'
const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string; cta: string }> = {
monthly: {
amount: '199 kr',
suffix: '/ mån',
sub: 'Faktureras månadsvis.',
cta: 'Aktivera abonnemang – 199 kr/mån',
},
yearly: {
amount: '166 kr',
suffix: '/ mån',
sub: '1 999 kr/år — du betalar för 10 månader.',
cta: 'Aktivera årsabonnemang – 1 999 kr/år',
},
}
/**
* Client CTA for the billing page. Active subscribers get the Stripe Customer
* Portal (manage/cancel); everyone else gets a plan toggle + Checkout. Both
* Interactive billing CTA. Paying companies get the Stripe Customer Portal
* (manage/cancel); everyone else gets a reactive plan picker + Checkout. Both
* POST to a route that returns a hosted Stripe URL we redirect to.
*/
export function BillingActions({ isActive, configured }: { isActive: boolean; configured: boolean }) {
export function BillingActions({ isPaying, configured }: { isPaying: boolean; configured: boolean }) {
const { toast } = useToast()
const [loading, setLoading] = useState(false)
const [plan, setPlan] = useState<BillingPlan>('yearly')
@@ -36,7 +52,7 @@ export function BillingActions({ isActive, configured }: { isActive: boolean; co
}
}
if (isActive) {
if (isPaying) {
return (
<Button size="lg" onClick={() => go('/api/billing/portal')} disabled={loading} className="w-full sm:w-auto">
Hantera abonnemang
@@ -46,38 +62,49 @@ export function BillingActions({ isActive, configured }: { isActive: boolean; co
if (!configured) {
return (
<Button size="lg" disabled className="w-full sm:w-auto">
<Button size="lg" disabled className="w-full">
Uppgradering öppnar snart
</Button>
)
}
const segment = (p: BillingPlan, label: ReactNode) => (
<button
type="button"
onClick={() => setPlan(p)}
className={`flex items-center rounded-md px-3 py-2 transition-colors ${
plan === p ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{label}
</button>
)
return (
<div className="space-y-3">
<div className="inline-flex rounded-lg border border-border p-1 text-sm">
{(['monthly', 'yearly'] as BillingPlan[]).map((p) => (
<button
key={p}
type="button"
onClick={() => setPlan(p)}
className={`rounded-md px-3 py-1.5 transition-colors ${
plan === p ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{p === 'monthly' ? 'Månadsvis' : 'Årsvis'}
</button>
))}
</div>
<div className="space-y-4">
<div>
<Button
size="lg"
onClick={() => go('/api/billing/checkout', { plan })}
disabled={loading}
className="w-full sm:w-auto"
>
Uppgradera
</Button>
<div className="flex items-baseline gap-2">
<span className="font-display text-3xl tracking-tight tabular-nums">{PRICE[plan].amount}</span>
<span className="text-muted-foreground">{PRICE[plan].suffix}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{PRICE[plan].sub}</p>
</div>
<div className="inline-flex rounded-lg border border-border p-1 text-sm">
{segment('monthly', 'Månadsvis')}
{segment(
'yearly',
<>
Årsvis
<Badge variant="success" className="ml-2">Spara 2 mån</Badge>
</>,
)}
</div>
<Button size="lg" onClick={() => go('/api/billing/checkout', { plan })} disabled={loading} className="w-full">
{loading ? 'Öppnar…' : PRICE[plan].cta}
</Button>
<p className="text-xs text-muted-foreground text-center">Säker betalning via Stripe · Avsluta när du vill</p>
</div>
)
}
@@ -0,0 +1,120 @@
'use client'
import { useEffect, useState } from 'react'
import { Check, Clock } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { formatDateLong } from '@/lib/utils'
import { BillingActions } from '@/components/settings/BillingActions'
// What the paid tier unlocks (mirrors lib/entitlements PAID_CAPABILITIES).
const INCLUDED = [
'AI-assistent: chatt, kategorisering och dokumenttolkning',
'Bankkoppling och automatisk synk (PSD2)',
'Skatteverket: moms- och AGI-inlämning',
'E-postutskick av fakturor, påminnelser och lönebesked',
]
const ALWAYS_FREE =
'All bokföring, fakturering, rapporter, SIE-export, org.nr-uppslag och momsnummerkontroll ingår alltid utan kostnad.'
interface BillingView {
isPaying: boolean
configured: boolean
trialEndsAt: string | null
daysLeft: number | null
}
/**
* Settings → Abonnemang. Rendered both as the full page (thin wrapper) and
* inside the settings modal (via SETTINGS_SECTIONS), so it's a client component
* that reads its state from GET /api/billing/status.
*/
export function BillingSettingsContent() {
const [view, setView] = useState<BillingView | null>(null)
useEffect(() => {
let active = true
fetch('/api/billing/status')
.then((r) => r.json())
.then((d: { isPaying: boolean; configured: boolean; trialEndsAt: string | null }) => {
if (!active) return
// Compute days-left here (effect), not during render, to keep render pure.
const daysLeft = d.trialEndsAt
? Math.max(0, Math.ceil((new Date(d.trialEndsAt).getTime() - Date.now()) / 86_400_000))
: null
setView({ ...d, daysLeft })
})
.catch(() => {
if (active) setView({ isPaying: false, configured: false, trialEndsAt: null, daysLeft: null })
})
return () => { active = false }
}, [])
if (!view) {
return (
<div className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
// Paying company → manage view.
if (view.isPaying) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Abonnemang</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst.
</p>
<BillingActions isPaying configured={view.configured} />
</CardContent>
</Card>
)
}
// Trialing / expired → sell view.
const { trialEndsAt, daysLeft } = view
return (
<div className="space-y-6">
{daysLeft !== null && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-secondary px-4 py-3 text-sm">
<Clock className="h-4 w-4 shrink-0 text-foreground" />
<span>
{daysLeft > 0
? `Din provperiod löper ut om ${daysLeft} ${daysLeft === 1 ? 'dag' : 'dagar'}${
trialEndsAt ? ` — ${formatDateLong(trialEndsAt)}` : ''
}. Lägg till betalning nu så fortsätter allt utan avbrott.`
: 'Din provperiod har löpt ut. Aktivera abonnemanget för att få tillbaka AI, bankkoppling och inlämning.'}
</span>
</div>
)}
<Card>
<CardHeader>
<CardTitle>Allt du behöver för att sköta bokföringen själv</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<BillingActions isPaying={false} configured={view.configured} />
<ul className="space-y-2">
{INCLUDED.map((item) => (
<li key={item} className="flex items-start gap-2 text-sm">
<Check className="h-4 w-4 mt-1 shrink-0 text-foreground" />
<span>{item}</span>
</li>
))}
</ul>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground leading-relaxed">
{ALWAYS_FREE} Avsluta när du vill. Ingen bindningstid.
</p>
</div>
)
}
+2
View File
@@ -9,6 +9,7 @@ import { TemplatesSettingsContent } from './TemplatesSettingsContent'
import { BankingSettingsContent } from './BankingSettingsContent'
import { AssistantSettingsContent } from './AssistantSettingsContent'
import { ApiSettingsContent } from './ApiSettingsContent'
import { BillingSettingsContent } from './BillingSettingsContent'
/**
* Single source of truth mapping a settings section id to the component that
@@ -28,6 +29,7 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
banking: BankingSettingsContent,
assistant: AssistantSettingsContent,
api: ApiSettingsContent,
billing: BillingSettingsContent,
}
export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS