feat(billing): paywall conversion pass (deferred first charge, trial touchpoint, sell-view upgrade) (#991)
* feat(billing): paywall conversion pass: deferred first charge, trial touchpoint, sell-view upgrade - checkout passes subscription_data.trial_end (trial grant expiry, 49h floor) so a mid-trial upgrade costs 0 kr today instead of double-billing days the company already has free; billing/status counts 'trialing' as paying - trial countdown pill in the sidebar (CompanyContext.trialEndsAt via getCompanyEntitlements); hidden for sandbox, dev bypass, and once any non-trial grant is active - sell view: what-happens-when timeline, free-vs-paid comparison table, risk-reversal copy + chevron CTA, post-checkout confirmation state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(billing): review triage: fail-closed trial lookup, hourly countdown refresh, BFL retention note - checkout returns 500 (no Stripe session) when the trial-grant lookup errors, instead of silently charging immediately after the UI promised 0 kr idag - sidebar trial countdown recomputes hourly so a long-lived tab stays honest - sell-view retention copy states BFL 7-year retention explicitly (compliance-bot suggestion) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI (pull_request event delivery stuck) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9d91ee0168
commit
3a2c57a167
@@ -31,7 +31,9 @@ import {
|
||||
Package,
|
||||
Tag,
|
||||
Tags,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Percent,
|
||||
Landmark,
|
||||
@@ -230,7 +232,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = useRealtimeSupabase()
|
||||
const { company, capabilities } = useCompany()
|
||||
const { company, capabilities, trialEndsAt } = useCompany()
|
||||
// Agent identity drives the "Assistent" nav icon: when the user has
|
||||
// built their assistant we show its chosen avatar instead of the
|
||||
// generic Sparkles glyph.
|
||||
@@ -245,6 +247,23 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
)
|
||||
const refreshInFlightRef = useRef(false)
|
||||
const refreshQueuedRef = useRef(false)
|
||||
// Trial countdown for the sidebar touchpoint. Computed in an effect (not
|
||||
// during render) so server and client markup agree at hydration; an hourly
|
||||
// tick keeps a long-lived tab from showing yesterday's count.
|
||||
const [trialDaysLeft, setTrialDaysLeft] = useState<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!trialEndsAt) {
|
||||
setTrialDaysLeft(null)
|
||||
return
|
||||
}
|
||||
const update = () => {
|
||||
const msLeft = new Date(trialEndsAt).getTime() - Date.now()
|
||||
setTrialDaysLeft(msLeft > 0 ? Math.ceil(msLeft / 86_400_000) : null)
|
||||
}
|
||||
update()
|
||||
const id = setInterval(update, 3_600_000)
|
||||
return () => clearInterval(id)
|
||||
}, [trialEndsAt])
|
||||
|
||||
const hasCompany = !!company
|
||||
const ALWAYS_ENABLED = new Set(['/settings'])
|
||||
@@ -665,6 +684,26 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Trial countdown touchpoint: the paywall is a lifecycle flow, not
|
||||
a settings page, so trial state stays quietly visible in the
|
||||
chrome instead of only inside Inställningar → Abonnemang.
|
||||
Hidden for sandbox/demo (no checkout) and once any non-trial
|
||||
grant is active (trialEndsAt is null then). */}
|
||||
{!isSandbox && trialDaysLeft !== null && (
|
||||
<div className="flex-shrink-0 px-3 pb-2">
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-secondary/60 hover:text-foreground transition-colors duration-150"
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">
|
||||
{tNav('trial_days_left', { days: trialDaysLeft })}
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account popover (bottom-left). Triggered by the signed-in
|
||||
user's name + initial. Holds Inställningar, Hjälp, Support,
|
||||
Logga ut. CompanySwitcher lives at the top of the sidebar,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import type { BillingPlan } from '@/lib/stripe/client'
|
||||
|
||||
const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string; cta: string }> = {
|
||||
@@ -24,8 +26,20 @@ const PRICE: Record<BillingPlan, { amount: string; suffix: string; sub: string;
|
||||
* 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.
|
||||
*
|
||||
* `firstChargeAt`: when the checkout route will defer the first charge to the
|
||||
* trial's end (see billing/checkout), the date it lands. Shifts the CTA from
|
||||
* "pay now" to "0 kr idag": the strongest risk-reversal we can make truthfully.
|
||||
*/
|
||||
export function BillingActions({ isPaying, configured }: { isPaying: boolean; configured: boolean }) {
|
||||
export function BillingActions({
|
||||
isPaying,
|
||||
configured,
|
||||
firstChargeAt = null,
|
||||
}: {
|
||||
isPaying: boolean
|
||||
configured: boolean
|
||||
firstChargeAt?: string | null
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [plan, setPlan] = useState<BillingPlan>('yearly')
|
||||
@@ -105,9 +119,18 @@ export function BillingActions({ isPaying, configured }: { isPaying: boolean; co
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={() => go('/api/billing/checkout', { plan })} disabled={loading} className="w-full">
|
||||
{loading ? 'Öppnar…' : PRICE[plan].cta}
|
||||
{loading ? 'Öppnar…' : firstChargeAt ? 'Starta abonnemanget: 0 kr idag' : PRICE[plan].cta}
|
||||
{!loading && <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">Säker betalning via Stripe · Avsluta när du vill</p>
|
||||
{firstChargeAt && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess
|
||||
kostar det ingenting.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Ingen bindningstid · Avsluta när du vill · Säker betalning via Stripe
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, Clock } from 'lucide-react'
|
||||
import { Check, Clock, Minus } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { formatDateLong } from '@/lib/utils'
|
||||
import { BillingActions } from '@/components/settings/BillingActions'
|
||||
|
||||
@@ -15,14 +16,29 @@ const INCLUDED = [
|
||||
'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.'
|
||||
// Free vs paid, shown as a comparison table: what the paid tier adds reads
|
||||
// strongest next to what stays free forever (freeze-and-retain, nothing is
|
||||
// taken away). Free rows mirror the old ALWAYS_FREE copy.
|
||||
const FEATURE_MATRIX: { label: string; free: boolean }[] = [
|
||||
{ label: 'Bokföring och rapporter', free: true },
|
||||
{ label: 'Fakturering', free: true },
|
||||
{ label: 'SIE-export', free: true },
|
||||
{ label: 'Org.nr-uppslag och momsnummerkontroll', free: true },
|
||||
...INCLUDED.map((label) => ({ label, free: false })),
|
||||
]
|
||||
|
||||
// Mirrors the checkout route's deferred-first-charge condition (Stripe's 48h
|
||||
// trial_end floor plus clock margin). Above this, checkout collects the card
|
||||
// but the first charge lands when the trial ends.
|
||||
const DEFER_THRESHOLD_MS = 49 * 3600 * 1000
|
||||
|
||||
interface BillingView {
|
||||
isPaying: boolean
|
||||
configured: boolean
|
||||
trialEndsAt: string | null
|
||||
daysLeft: number | null
|
||||
chargeDeferred: boolean
|
||||
paidJustNow: boolean
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
@@ -40,14 +56,26 @@ export function BillingSettingsContent() {
|
||||
.then((r) => r.json())
|
||||
.then((d: { isPaying: boolean; configured: boolean; trialEndsAt: string | null; isDemo?: boolean }) => {
|
||||
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, isDemo: d.isDemo ?? false })
|
||||
// Compute time-derived state here (effect), not during render, to keep render pure.
|
||||
const msLeft = d.trialEndsAt ? new Date(d.trialEndsAt).getTime() - Date.now() : null
|
||||
const daysLeft = msLeft !== null ? Math.max(0, Math.ceil(msLeft / 86_400_000)) : null
|
||||
const chargeDeferred = msLeft !== null && msLeft > DEFER_THRESHOLD_MS
|
||||
// Set by the checkout success redirect. Provisioning happens via the
|
||||
// Stripe webhook, so isPaying can lag the redirect by a few seconds.
|
||||
const paidJustNow = new URLSearchParams(window.location.search).get('success') === '1'
|
||||
setView({ ...d, daysLeft, chargeDeferred, paidJustNow, isDemo: d.isDemo ?? false })
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setView({ isPaying: false, configured: false, trialEndsAt: null, daysLeft: null, isDemo: false })
|
||||
if (active)
|
||||
setView({
|
||||
isPaying: false,
|
||||
configured: false,
|
||||
trialEndsAt: null,
|
||||
daysLeft: null,
|
||||
chargeDeferred: false,
|
||||
paidJustNow: false,
|
||||
isDemo: false,
|
||||
})
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
@@ -104,8 +132,28 @@ export function BillingSettingsContent() {
|
||||
)
|
||||
}
|
||||
|
||||
// Just returned from checkout but the webhook hasn't flipped isPaying yet →
|
||||
// confirm instead of re-showing the sell pitch to someone who already paid.
|
||||
if (view.paidJustNow) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Abonnemang</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 mt-0.5 shrink-0 text-foreground" />
|
||||
<span>Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut.</span>
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Ladda om sidan om du inte ser ändringen.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Trialing / expired → sell view.
|
||||
const { trialEndsAt, daysLeft } = view
|
||||
const deferredTo = view.chargeDeferred ? trialEndsAt : null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -116,7 +164,11 @@ export function BillingSettingsContent() {
|
||||
{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.`
|
||||
}. ${
|
||||
deferredTo
|
||||
? 'Lägg till ditt kort nu: inget dras förrän provperioden är slut.'
|
||||
: '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>
|
||||
@@ -127,20 +179,60 @@ export function BillingSettingsContent() {
|
||||
<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>
|
||||
<BillingActions isPaying={false} configured={view.configured} firstChargeAt={deferredTo} />
|
||||
|
||||
{deferredTo && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">Så funkar det</h3>
|
||||
<ol className="space-y-2 text-sm">
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">Idag</span>
|
||||
<span>Du lägger till ditt kort. Inget dras nu.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground tabular-nums">{formatDateLong(deferredTo)}</span>
|
||||
<span>Provperioden slutar och den första debiteringen sker.</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">När som helst</span>
|
||||
<span>Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting.</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-full">Funktion</TableHead>
|
||||
<TableHead className="text-center">Gratis</TableHead>
|
||||
<TableHead className="text-center">Abonnemang</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{FEATURE_MATRIX.map((f) => (
|
||||
<TableRow key={f.label}>
|
||||
<TableCell className="text-sm">{f.label}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.free ? (
|
||||
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
|
||||
) : (
|
||||
<Minus role="img" aria-label="Ingår inte" className="h-4 w-4 mx-auto text-muted-foreground/50" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Check role="img" aria-label="Ingår" className="h-4 w-4 mx-auto text-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ALWAYS_FREE} Avsluta när du vill. Ingen bindningstid.
|
||||
Utan abonnemang behåller du bokföringen, fakturorna, rapporterna och all din data utan kostnad. Ingenting
|
||||
raderas: räkenskapsinformation bevaras i sju år enligt bokföringslagen, oavsett abonnemang.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user