diff --git a/DECISIONS.md b/DECISIONS.md index 92909a06..58eba0df 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -62,3 +62,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 7cd55bfb..f2dfbd7a 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -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, }} > @@ -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 ( diff --git a/app/api/billing/__tests__/checkout.test.ts b/app/api/billing/__tests__/checkout.test.ts index 1c76ef02..fd91d25d 100644 --- a/app/api/billing/__tests__/checkout.test.ts +++ b/app/api/billing/__tests__/checkout.test.ts @@ -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() + }) }) diff --git a/app/api/billing/__tests__/status.test.ts b/app/api/billing/__tests__/status.test.ts new file mode 100644 index 00000000..181831d9 --- /dev/null +++ b/app/api/billing/__tests__/status.test.ts @@ -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) { + 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) { + 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(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(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(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(await GET()) + expect(body.isPaying).toBe(false) + }) +}) diff --git a/app/api/billing/checkout/route.ts b/app/api/billing/checkout/route.ts index 09339433..7a7d5a5f 100644 --- a/app/api/billing/checkout/route.ts +++ b/app/api/billing/checkout/route.ts @@ -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`, diff --git a/app/api/billing/status/route.ts b/app/api/billing/status/route.ts index 10a651da..b116e57c 100644 --- a/app/api/billing/status/route.ts +++ b/app/api/billing/status/route.ts @@ -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') diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 34d0b58e..6a1fc886 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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(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 + {/* 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 && ( +
+ + + + {tNav('trial_days_left', { days: trialDaysLeft })} + + + +
+ )} + {/* 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, diff --git a/components/settings/BillingActions.tsx b/components/settings/BillingActions.tsx index 822ec3bc..b3984cc3 100644 --- a/components/settings/BillingActions.tsx +++ b/components/settings/BillingActions.tsx @@ -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 = { @@ -24,8 +26,20 @@ const PRICE: Record('yearly') @@ -105,9 +119,18 @@ export function BillingActions({ isPaying, configured }: { isPaying: boolean; co -

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

+ {firstChargeAt && ( +

+ Första debiteringen sker {formatDateLong(firstChargeAt)}, när provperioden slutar. Avslutar du innan dess + kostar det ingenting. +

+ )} +

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

) } diff --git a/components/settings/sections/BillingSettingsContent.tsx b/components/settings/sections/BillingSettingsContent.tsx index 09159bf3..25c499a1 100644 --- a/components/settings/sections/BillingSettingsContent.tsx +++ b/components/settings/sections/BillingSettingsContent.tsx @@ -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 ( + + + Abonnemang + + +

+ + Klart! Ditt abonnemang är aktiverat och alla funktioner låses upp inom någon minut. +

+

Ladda om sidan om du inte ser ändringen.

+
+
+ ) + } + // Trialing / expired → sell view. const { trialEndsAt, daysLeft } = view + const deferredTo = view.chargeDeferred ? trialEndsAt : null return (
@@ -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.'}
@@ -127,20 +179,60 @@ export function BillingSettingsContent() { Allt du behöver för att sköta bokföringen själv - -
    - {INCLUDED.map((item) => ( -
  • - - {item} -
  • - ))} -
+ + + {deferredTo && ( +
+

Så funkar det

+
    +
  1. + Idag + Du lägger till ditt kort. Inget dras nu. +
  2. +
  3. + {formatDateLong(deferredTo)} + Provperioden slutar och den första debiteringen sker. +
  4. +
  5. + När som helst + Avsluta direkt via Stripe. Före {formatDateLong(deferredTo)} kostar det ingenting. +
  6. +
+
+ )} + + + + + Funktion + Gratis + Abonnemang + + + + {FEATURE_MATRIX.map((f) => ( + + {f.label} + + {f.free ? ( + + ) : ( + + )} + + + + + + ))} + +

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

) diff --git a/contexts/CompanyContext.tsx b/contexts/CompanyContext.tsx index f02060f5..e06ccd17 100644 --- a/contexts/CompanyContext.tsx +++ b/contexts/CompanyContext.tsx @@ -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(null) diff --git a/lib/entitlements/__tests__/has-capability.test.ts b/lib/entitlements/__tests__/has-capability.test.ts index 0691dce7..941ea6ed 100644 --- a/lib/entitlements/__tests__/has-capability.test.ts +++ b/lib/entitlements/__tests__/has-capability.test.ts @@ -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) diff --git a/lib/entitlements/has-capability.ts b/lib/entitlements/has-capability.ts index 62cc8d38..208f83e4 100644 --- a/lib/entitlements/has-capability.ts +++ b/lib/entitlements/has-capability.ts @@ -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 { - if (isPaywallBypassed()) return [...PAID_CAPABILITIES] - if (!isUuid(companyId)) return [] // fail-closed: never interpolate a non-UUID +): Promise { + 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() + 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 { + return (await getCompanyEntitlements(supabase, companyId)).capabilities } diff --git a/messages/en.json b/messages/en.json index b781b981..ebff3c2e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" }, diff --git a/messages/sv.json b/messages/sv.json index 41083d37..24662736 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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" },