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
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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`,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user