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:
Jakob Wennberg
2026-07-11 22:29:48 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 9d91ee0168
commit 3a2c57a167
14 changed files with 532 additions and 52 deletions
+1
View File
@@ -62,3 +62,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-10] VatBookingCard hard-disables "Skapa verifikat" while a POSTED vat_settlement exists in the period (CodeRabbit finding, accepted over the initial warn-but-allow): the proposal is not delta-aware (it re-clears the FULL period), so booking twice corrupts 26xx balances; the sanctioned redo path is annullera (storno restores the balances and re-enables the button). Already-booked detection is by source_type + entry_date within the period, so redating the entry outside the period escapes the gate: accepted v1 limitation. Card copy is hardcoded Swedish per the file's existing momsdeklaration convention (i18n.md).
[2026-07-11] Momsrapport after settlement (#984): extended the VAT-report exclusion from tag-only to shape-based. Any entry touching both a declaration account (ACCOUNT_RUTA) and a settlement net account (2650/1650) is treated as a momsredovisning and excluded from the projection (web calculateVatDeclaration + MCP computeVatReport), covering manual momsomforingar booked before #980 shipped, SIE-imported settlements, and stornos of a settlement (which would otherwise double the rutor after annullera, a latent bug in the #983 tag-only filter). Opening-balance entries are exempt from the shape rule: carried-in 26xx balances are unsettled VAT that belongs in the next declaration. Shaped POSTED entries also gate the "Skapa verifikat" button via existing_entries (the proposal re-clears the full period, so booking over a manual settlement would corrupt 26xx); stornos never gate, or annullera could not re-enable booking. Rejected the frozen-snapshot alternative the issue suggested: pure projection heals historical periods retroactively (a snapshot would not exist for them) and needs no migration.
[2026-07-11] #984 shape-rule residuals triaged and ACCEPTED (compliance-bot review): a compound verifikat mixing business VAT lines with a 2650/1650 payment/correction line in ONE entry is excluded from the rutor by the shape rule (under-reports). Kept anyway: such compound entries are rare bad practice, and the suggested direction guard (only exclude when 2650 is credited / 1650 debited) would break the storno exclusion, whose reversal carries exactly the flipped sides. Opening-balance concern verified false for app flows: SIE import and set_opening_balances both tag source_type 'opening_balance' (sie-import.ts); only a hand-booked IB verifikat shares the compound-entry residual.
[2026-07-11] Paywall conversion pass (Mobbin paywall research applied): (1) checkout now passes subscription_data.trial_end (trial grant expiry, only when >49h out per Stripe's 48h floor) so a mid-trial upgrade charges 0 kr at checkout instead of double-billing days the company already has free; the subscription starts 'trialing', which subscription-sync already treats as access-granting, and billing/status now counts 'trialing' as isPaying (card committed = manage view). (2) Trial countdown became a sidebar touchpoint (CompanyContext.trialEndsAt via getCompanyEntitlements, hidden for sandbox and once any non-trial grant is active) instead of living only inside Inställningar → Abonnemang. (3) Sell view: honest what-happens-when timeline + free-vs-paid comparison table + risk-reversal copy under the CTA. Deliberately NOT copied from the research: fake urgency, last-minute discounts, spin-the-wheel, card-required-to-trial: trust-first product, and the free tier (freeze-and-retain) is a strategic choice, not a leak. External price anchoring ("costs less than an accountant hour") skipped: unverifiable claim. Billing components stay hardcoded Swedish per the file's existing convention.
+7 -4
View File
@@ -13,7 +13,7 @@ import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider } from '@/contexts/CompanyContext'
import { getActiveCompanyId } from '@/lib/company/context'
import { getCompanyCapabilities } from '@/lib/entitlements/has-capability'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getBranding } from '@/lib/branding/service'
import { ensureSandboxAgentProfile } from '@/lib/sandbox/ensure-agent'
import { countPendingOperations, countUnbookedTransactions } from '@/lib/worklist'
@@ -90,6 +90,7 @@ export default async function DashboardLayout({
team,
isSandbox: false,
capabilities: [],
trialEndsAt: null,
}}
>
<AgentSheetProvider>
@@ -134,7 +135,7 @@ export default async function DashboardLayout({
pendingOpsCount,
{ data: agentProfileIdentity },
{ data: userProfile },
capabilities,
entitlements,
{ data: allSettingsNames },
] = await Promise.all([
supabase.from('companies').select('*').eq('id', companyId).single(),
@@ -161,7 +162,7 @@ export default async function DashboardLayout({
// popover (full_name + initial) so it's clear which user is logged
// in, distinct from the active company shown at the top.
supabase.from('profiles').select('full_name').eq('id', user.id).maybeSingle(),
getCompanyCapabilities(supabase, companyId),
getCompanyEntitlements(supabase, companyId),
// Current display names for ALL the user's companies (the switcher list).
// RLS scopes company_settings SELECT to user_company_ids(), so this bare
// select returns exactly the caller's companies, letting non-active rows
@@ -191,6 +192,7 @@ export default async function DashboardLayout({
team,
isSandbox: false,
capabilities: [],
trialEndsAt: null,
}
return (
@@ -281,7 +283,8 @@ export default async function DashboardLayout({
isTeamMember,
team,
isSandbox,
capabilities,
capabilities: entitlements.capabilities,
trialEndsAt: entitlements.trialEndsAt,
}
return (
+65 -1
View File
@@ -119,7 +119,8 @@ describe('POST /api/billing/checkout', () => {
})
it('reuses an existing Stripe customer and returns the checkout URL', async () => {
enqueue({ data: { stripe_customer_id: 'cus_existing' } })
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: null }) // no trial grant
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', {
@@ -138,10 +139,13 @@ describe('POST /api/billing/checkout', () => {
client_reference_id: 'company-1',
})
)
// No trial grant → billing starts immediately, no Stripe trial.
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
})
it('creates a Stripe customer when none exists yet', async () => {
enqueue({ data: null }) // no existing subscription row
enqueue({ data: null }) // no trial grant
enqueue({ data: null }) // upsert result
customersCreate.mockResolvedValue({ id: 'cus_new' })
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
@@ -158,4 +162,64 @@ describe('POST /api/billing/checkout', () => {
expect.objectContaining({ customer: 'cus_new' })
)
})
it('defers the first charge to the trial end when the trial has >48h left', async () => {
const trialEnd = new Date(Date.now() + 10 * 24 * 3600 * 1000).toISOString()
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: { expires_at: trialEnd } }) // active trial grant
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(sessionsCreate).toHaveBeenCalledWith(
expect.objectContaining({
subscription_data: expect.objectContaining({
metadata: { company_id: 'company-1' },
trial_end: Math.floor(new Date(trialEnd).getTime() / 1000),
}),
})
)
})
it('bills immediately when the trial is inside the 48h Stripe floor', async () => {
const trialEnd = new Date(Date.now() + 24 * 3600 * 1000).toISOString()
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: { expires_at: trialEnd } }) // trial ends tomorrow
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
})
it('bills immediately when the trial has already expired', async () => {
const trialEnd = new Date(Date.now() - 24 * 3600 * 1000).toISOString()
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: { expires_at: trialEnd } }) // lapsed trial
sessionsCreate.mockResolvedValue({ url: 'https://stripe.test/session' })
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status } = await parseJsonResponse(await POST(req, routeParams))
expect(status).toBe(200)
expect(sessionsCreate.mock.calls[0][0].subscription_data.trial_end).toBeUndefined()
})
it('fails closed (500, no Stripe session) when the trial lookup errors', async () => {
enqueue({ data: { stripe_customer_id: 'cus_existing' } }) // subscription row
enqueue({ data: null, error: { message: 'boom' } }) // trial lookup fails
const req = createMockRequest('/api/billing/checkout', { method: 'POST', body: {} })
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
await POST(req, routeParams),
)
expect(status).toBe(500)
expect(body.error.code).toBe('TRIAL_LOOKUP_FAILED')
expect(sessionsCreate).not.toHaveBeenCalled()
})
})
+110
View File
@@ -0,0 +1,110 @@
/**
* Tests for GET /api/billing/status.
*
* Focus: the isPaying classification. 'trialing' must count as paying since
* checkout defers the first charge to the trial end (the card is committed),
* while a company with no subscription stays on the upgrade path.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { parseJsonResponse } from '@/tests/helpers'
type TableResult = { data: unknown; error?: unknown }
function makeSupabase(byTable: Record<string, TableResult>) {
const chainFor = (table: string) => {
const result = byTable[table] ?? { data: null, error: null }
const chain: unknown = new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: result.data ?? null, error: result.error ?? null })
}
return () => chain
},
},
)
return chain
}
return { from: (t: string) => chainFor(t) }
}
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/sandbox/guard', () => ({
isSandboxCompany: vi.fn().mockResolvedValue(false),
}))
import { GET } from '../status/route'
interface StatusBody {
isPaying: boolean
trialEndsAt: string | null
isDemo: boolean
}
function authAs(byTable: Record<string, TableResult>) {
requireAuthMock.mockResolvedValue({
user: { id: 'user-1', is_anonymous: false },
supabase: makeSupabase(byTable),
error: null,
})
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/billing/status', () => {
it('treats a trialing subscription as paying (card committed via deferred checkout)', async () => {
authAs({
company_subscriptions: { data: { status: 'trialing' } },
capability_grants: { data: { expires_at: '2099-01-01T00:00:00Z' } },
})
const { status, body } = await parseJsonResponse<StatusBody>(await GET())
expect(status).toBe(200)
expect(body.isPaying).toBe(true)
})
it('keeps a card-less product trial on the upgrade path with its expiry', async () => {
authAs({
company_subscriptions: { data: null },
capability_grants: { data: { expires_at: '2099-01-01T00:00:00Z' } },
})
const { status, body } = await parseJsonResponse<StatusBody>(await GET())
expect(status).toBe(200)
expect(body.isPaying).toBe(false)
expect(body.trialEndsAt).toBe('2099-01-01T00:00:00Z')
})
it('treats an active subscription as paying', async () => {
authAs({
company_subscriptions: { data: { status: 'active' } },
capability_grants: { data: null },
})
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.isPaying).toBe(true)
})
it('treats a canceled subscription as not paying', async () => {
authAs({
company_subscriptions: { data: { status: 'canceled' } },
capability_grants: { data: null },
})
const { body } = await parseJsonResponse<StatusBody>(await GET())
expect(body.isPaying).toBe(false)
})
})
+52 -7
View File
@@ -55,12 +55,54 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) =>
const stripe = getStripe()
const service = createServiceClient()
// Reuse the company's Stripe customer if we already created one.
const { data: existing } = await service
.from('company_subscriptions')
.select('stripe_customer_id')
.eq('company_id', companyId)
.maybeSingle()
// Reuse the company's Stripe customer if we already created one, and read
// the trial expiry for the deferred-first-charge decision below. Independent
// reads, so one round-trip batch.
const [{ data: existing }, { data: trialGrant, error: trialGrantError }] = await Promise.all([
service
.from('company_subscriptions')
.select('stripe_customer_id')
.eq('company_id', companyId)
.maybeSingle(),
service
.from('capability_grants')
.select('expires_at')
.eq('company_id', companyId)
.eq('source', 'trial')
.order('expires_at', { ascending: false })
.limit(1)
.maybeSingle(),
])
// Fail closed on an uncertain trial state: proceeding on a lookup error
// would silently charge immediately after the UI promised "0 kr idag".
if (trialGrantError) {
return NextResponse.json(
{
error: {
code: 'TRIAL_LOOKUP_FAILED',
message: 'Kunde inte läsa din provperiod. Försök igen om en stund.',
message_en: 'Could not resolve the trial state. Try again shortly.',
},
},
{ status: 500 },
)
}
// Defer the first charge to the end of an active trial. The company already
// holds the paid capabilities free until then, so charging at checkout would
// bill for days it already has; instead the subscription starts as
// 'trialing' (which grants access via the webhook, see subscription-sync)
// and the first charge lands when the product trial ends. Stripe Checkout
// requires trial_end to be at least 48h in the future; closer than that, or
// with no active trial, billing starts immediately.
const trialExpiry = (trialGrant as { expires_at: string | null } | null)?.expires_at ?? null
const trialExpiryMs = trialExpiry ? new Date(trialExpiry).getTime() : null
const STRIPE_MIN_TRIAL_END_MS = 49 * 3600 * 1000 // Stripe's 48h floor + 1h clock margin
const trialEnd =
trialExpiryMs && trialExpiryMs - Date.now() > STRIPE_MIN_TRIAL_END_MS
? Math.floor(trialExpiryMs / 1000)
: undefined
let customerId = (existing as { stripe_customer_id: string | null } | null)?.stripe_customer_id ?? null
if (!customerId) {
@@ -81,7 +123,10 @@ export const POST = withRouteContext('billing.checkout', async (request, ctx) =>
line_items: [{ price: priceId, quantity: 1 }],
client_reference_id: companyId,
metadata: { company_id: companyId },
subscription_data: { metadata: { company_id: companyId } },
subscription_data: {
metadata: { company_id: companyId },
...(trialEnd ? { trial_end: trialEnd } : {}),
},
allow_promotion_codes: true,
success_url: `${appUrl}/settings/billing?success=1`,
cancel_url: `${appUrl}/settings/billing?canceled=1`,
+6 -3
View File
@@ -37,9 +37,12 @@ export async function GET() {
.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'
// Paying = a real subscription. Includes 'trialing': checkout defers the
// first charge to the product-trial end, so a Stripe-trialing subscription
// means the card is already committed and the user should see the manage
// view, not the upgrade pitch. Companies without a subscription (product
// trial only, no card) stay on the upgrade path.
isPaying = status === 'active' || status === 'past_due' || status === 'trialing'
const { data: trial } = await supabase
.from('capability_grants')
+40 -1
View File
@@ -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,
+26 -3
View File
@@ -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>
)
+6
View File
@@ -13,6 +13,12 @@ interface CompanyContextValue {
isSandbox: boolean
/** PAID capability keys the active company currently holds (entitled + enabled). */
capabilities: CapabilityKey[]
/**
* Trial expiry while the trial is the company's only source of paid access;
* null when paying/comped or after the trial lapsed. Drives the countdown
* touchpoint in the sidebar.
*/
trialEndsAt: string | null
}
const CompanyContext = createContext<CompanyContextValue | null>(null)
@@ -4,8 +4,9 @@ import {
hasCapability,
requireCapability,
capabilityBlockedResponse,
getCompanyEntitlements,
} from '../has-capability'
import { CAPABILITY } from '../keys'
import { CAPABILITY, PAID_CAPABILITIES } from '../keys'
/**
* Per-table mock: each table resolves to its own configured result, so a
@@ -157,6 +158,64 @@ describe('requireCapability', () => {
})
})
describe('getCompanyEntitlements', () => {
const companyId = '11111111-1111-4111-8111-111111111111'
it('reports the trial expiry while the trial is the only source of access', async () => {
const expiry = iso(10 * 24 * 3600 * 1000)
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [
{ capability_key: CAPABILITY.ai, expires_at: expiry, source: 'trial' },
{ capability_key: CAPABILITY.bank_sync, expires_at: expiry, source: 'trial' },
],
},
company_capability_config: { data: [] },
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBe(expiry)
expect(result.capabilities).toContain(CAPABILITY.ai)
expect(result.capabilities).toContain(CAPABILITY.bank_sync)
})
it('hides the trial once a non-trial grant is active (converted customer)', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [
{ capability_key: CAPABILITY.ai, expires_at: iso(10 * 24 * 3600 * 1000), source: 'trial' },
{ capability_key: CAPABILITY.ai, expires_at: null, source: 'stripe' },
],
},
company_capability_config: { data: [] },
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toContain(CAPABILITY.ai)
})
it('returns no trial and no capabilities after the trial lapsed', async () => {
const supabase = makeSupabase({
companies: { data: { team_id: null } },
capability_grants: {
data: [{ capability_key: CAPABILITY.ai, expires_at: iso(-60_000), source: 'trial' }],
},
})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toEqual([])
})
it('bypass (self-hosted) holds everything with no trial countdown', async () => {
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
const supabase = makeSupabase({})
const result = await getCompanyEntitlements(supabase, companyId)
expect(result.trialEndsAt).toBeNull()
expect(result.capabilities).toEqual([...PAID_CAPABILITIES])
})
})
describe('capabilityBlockedResponse', () => {
it('returns a bilingual 403 carrying the capability key', async () => {
const res = capabilityBlockedResponse(CAPABILITY.bank_sync)
+45 -12
View File
@@ -167,17 +167,29 @@ export async function requireCapability(
return capabilityBlockedResponse(key)
}
export interface CompanyEntitlements {
capabilities: CapabilityKey[]
/**
* Expiry of the company's trial, present only while the trial is the SOLE
* source of paid access: null once any non-trial grant (stripe/comp/team)
* is active, and null after the trial has lapsed. Drives the trial
* countdown touchpoint in the dashboard chrome.
*/
trialEndsAt: string | null
}
/**
* Resolve which PAID capabilities a company currently holds (entitled AND
* enabled), in two queries. Used to seed the client CompanyContext so the UI
* can hide/disable/upsell gated features. Self-hosted holds everything.
* enabled) plus its trial state, in two queries. Used to seed the client
* CompanyContext so the UI can hide/disable/upsell gated features.
* Self-hosted holds everything.
*/
export async function getCompanyCapabilities(
export async function getCompanyEntitlements(
supabase: SupabaseClient,
companyId: string,
): Promise<CapabilityKey[]> {
if (isPaywallBypassed()) return [...PAID_CAPABILITIES]
if (!isUuid(companyId)) return [] // fail-closed: never interpolate a non-UUID
): Promise<CompanyEntitlements> {
if (isPaywallBypassed()) return { capabilities: [...PAID_CAPABILITIES], trialEndsAt: null }
if (!isUuid(companyId)) return { capabilities: [], trialEndsAt: null } // fail-closed: never interpolate a non-UUID
// The disabled-config subtraction only needs companyId, so it runs in
// parallel with the team lookup — this function sits on the dashboard
@@ -198,24 +210,45 @@ export async function getCompanyCapabilities(
: `company_id.eq.${companyId}`
const { data: grants } = await supabase
.from('capability_grants')
.select('capability_key, expires_at')
.select('capability_key, expires_at, source')
.in('capability_key', PAID_CAPABILITIES as unknown as string[])
.or(scopeFilter)
const now = Date.now()
const entitled = new Set<string>()
let trialEndsAt: string | null = null
let hasActiveNonTrialGrant = false
for (const g of grants ?? []) {
const row = g as { capability_key: string; expires_at: string | null }
if (row.expires_at === null || new Date(row.expires_at).getTime() > now) {
entitled.add(row.capability_key)
const row = g as { capability_key: string; expires_at: string | null; source: string | null }
const active = row.expires_at === null || new Date(row.expires_at).getTime() > now
if (!active) continue
entitled.add(row.capability_key)
if (row.source === 'trial') {
// Latest trial expiry (ISO strings from the same column compare lexically).
if (row.expires_at && (!trialEndsAt || row.expires_at > trialEndsAt)) {
trialEndsAt = row.expires_at
}
} else {
hasActiveNonTrialGrant = true
}
}
if (entitled.size === 0) return []
// Paying/comped companies are not "on trial" even if the seeded trial rows
// haven't expired yet: the countdown would nag someone who already converted.
if (hasActiveNonTrialGrant) trialEndsAt = null
if (entitled.size === 0) return { capabilities: [], trialEndsAt: null }
// Subtract any explicitly-disabled (enablement axis).
for (const c of configs ?? []) {
entitled.delete((c as { capability_key: string }).capability_key)
}
return PAID_CAPABILITIES.filter((k) => entitled.has(k))
return { capabilities: PAID_CAPABILITIES.filter((k) => entitled.has(k)), trialEndsAt }
}
/** Capability list only; see getCompanyEntitlements for the full shape. */
export async function getCompanyCapabilities(
supabase: SupabaseClient,
companyId: string,
): Promise<CapabilityKey[]> {
return (await getCompanyEntitlements(supabase, companyId)).capabilities
}
+1
View File
@@ -121,6 +121,7 @@
"badge_beta": "Beta",
"needs_company_tooltip": "Add a company to enable",
"logout_sandbox": "Exit sandbox",
"trial_days_left": "Trial: {days, plural, =1 {1 day} other {# days}} left",
"ext_tic": "Company profile",
"ext_invoice_inbox": "Document inbox"
},
+1
View File
@@ -121,6 +121,7 @@
"badge_beta": "Beta",
"needs_company_tooltip": "Lägg till ett företag för att aktivera",
"logout_sandbox": "Avsluta sandbox",
"trial_days_left": "Provperiod: {days, plural, =1 {1 dag} other {# dagar}} kvar",
"ext_tic": "Företagsprofil",
"ext_invoice_inbox": "Dokumentinkorg"
},