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:
co-authored by
Claude Opus 4.8
parent
60e33c4b51
commit
0e8698f538
@@ -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 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 />
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user