From 0e8698f53834d6ae20908c0309135776d6322013 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:51:31 +0200 Subject: [PATCH] fix(entitlements): billing reachable in settings modal + conversion redesign (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- app/(dashboard)/settings/billing/page.tsx | 82 +----------- app/api/billing/status/route.ts | 48 +++++++ components/settings/BillingActions.tsx | 85 ++++++++----- .../sections/BillingSettingsContent.tsx | 120 ++++++++++++++++++ components/settings/sections/index.ts | 2 + 5 files changed, 229 insertions(+), 108 deletions(-) create mode 100644 app/api/billing/status/route.ts create mode 100644 components/settings/sections/BillingSettingsContent.tsx diff --git a/app/(dashboard)/settings/billing/page.tsx b/app/(dashboard)/settings/billing/page.tsx index 196f9a7c..d0101be5 100644 --- a/app/(dashboard)/settings/billing/page.tsx +++ b/app/(dashboard)/settings/billing/page.tsx @@ -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 ( -
-

Abonnemang

-

- {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.'} -

- -
-
- 199 kr - / månad -
-

- eller 1 999 kr per år (två månader gratis). -

- -
    - {INCLUDED.map((item) => ( -
  • - - {item} -
  • - ))} -
- -
- -
-
- -

{ALWAYS_FREE}

-
- ) +export default function BillingPage() { + return } diff --git a/app/api/billing/status/route.ts b/app/api/billing/status/route.ts new file mode 100644 index 00000000..8aa2b8de --- /dev/null +++ b/app/api/billing/status/route.ts @@ -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 }) +} diff --git a/components/settings/BillingActions.tsx b/components/settings/BillingActions.tsx index e8a737e0..9d159c6d 100644 --- a/components/settings/BillingActions.tsx +++ b/components/settings/BillingActions.tsx @@ -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 = { + 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('yearly') @@ -36,7 +52,7 @@ export function BillingActions({ isActive, configured }: { isActive: boolean; co } } - if (isActive) { + if (isPaying) { return ( ) } + const segment = (p: BillingPlan, label: ReactNode) => ( + + ) + return ( -
-
- {(['monthly', 'yearly'] as BillingPlan[]).map((p) => ( - - ))} -
+
- +
+ {PRICE[plan].amount} + {PRICE[plan].suffix} +
+

{PRICE[plan].sub}

+ +
+ {segment('monthly', 'Månadsvis')} + {segment( + 'yearly', + <> + Årsvis + Spara 2 mån + , + )} +
+ + +

Säker betalning via Stripe · Avsluta när du vill

) } diff --git a/components/settings/sections/BillingSettingsContent.tsx b/components/settings/sections/BillingSettingsContent.tsx new file mode 100644 index 00000000..c69d0478 --- /dev/null +++ b/components/settings/sections/BillingSettingsContent.tsx @@ -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(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 ( +
+ + +
+ ) + } + + // Paying company → manage view. + if (view.isPaying) { + return ( + + + Abonnemang + + +

+ Ditt abonnemang är aktivt. Du kan hantera eller avsluta det när som helst. +

+ +
+
+ ) + } + + // Trialing / expired → sell view. + const { trialEndsAt, daysLeft } = view + + return ( +
+ {daysLeft !== null && ( +
+ + + {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.'} + +
+ )} + + + + Allt du behöver för att sköta bokföringen själv + + + +
    + {INCLUDED.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ +

+ {ALWAYS_FREE} Avsluta när du vill. Ingen bindningstid. +

+
+ ) +} diff --git a/components/settings/sections/index.ts b/components/settings/sections/index.ts index c8a17385..c8ed7183 100644 --- a/components/settings/sections/index.ts +++ b/components/settings/sections/index.ts @@ -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 = { banking: BankingSettingsContent, assistant: AssistantSettingsContent, api: ApiSettingsContent, + billing: BillingSettingsContent, } export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS