diff --git a/app/(auth)/auth/callback/route.ts b/app/(auth)/auth/callback/route.ts index 58f279c1..26a819b0 100644 --- a/app/(auth)/auth/callback/route.ts +++ b/app/(auth)/auth/callback/route.ts @@ -136,27 +136,37 @@ export async function GET(request: NextRequest) { } } - // Check if user has completed onboarding (for any company they belong to) - const { data: membership } = await supabase - .from('company_members') - .select('company_id') + // Ensure user has a silent team (for new signups and existing users without one) + const { data: teamMembership } = await supabase + .from('team_members') + .select('team_id') .eq('user_id', user.id) .limit(1) - .single() + .maybeSingle() - if (membership?.company_id) { - const { data: settings } = await supabase - .from('company_settings') - .select('onboarding_complete') - .eq('company_id', membership.company_id) - .single() + if (!teamMembership) { + // Create team via service client (RPC requires auth.uid() which isn't available here) + const serviceClient = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { cookies: { getAll: () => [], setAll: () => {} } } + ) - if (!settings?.onboarding_complete) { - redirectPath = '/onboarding' - } - } else { - redirectPath = '/onboarding' + const teamId = crypto.randomUUID() + await serviceClient.from('teams').insert({ + id: teamId, + name: 'Personal', + created_by: user.id, + }) + await serviceClient.from('team_members').insert({ + team_id: teamId, + user_id: user.id, + role: 'owner', + }) } + + // Always redirect to dashboard — it handles zero-company and incomplete states + redirectPath = '/' } // Create redirect and explicitly set auth cookies on the response diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index e871e529..b287d9ac 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -127,7 +127,7 @@ function RegisterPageContent() { return } - router.push('/onboarding') + router.push('/') router.refresh() } catch (error) { console.error('[register] BankID signup error', error) diff --git a/app/(dashboard)/customers/page.tsx b/app/(dashboard)/customers/page.tsx index 68fcb00e..3d32b0ad 100644 --- a/app/(dashboard)/customers/page.tsx +++ b/app/(dashboard)/customers/page.tsx @@ -12,6 +12,7 @@ import { Plus, Search, Users } from 'lucide-react' import CustomerForm from '@/components/customers/CustomerForm' import { EmptyCustomers } from '@/components/ui/empty-state' import Link from 'next/link' +import { useCompany } from '@/contexts/CompanyContext' import type { Customer, CustomerType, CreateCustomerInput } from '@/types' const customerTypeLabels: Record = { @@ -31,6 +32,7 @@ function getInitials(name: string): string { } export default function CustomersPage() { + const { company } = useCompany() const [customers, setCustomers] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -40,10 +42,12 @@ export default function CustomersPage() { const supabase = createClient() async function fetchCustomers() { + if (!company) return setIsLoading(true) const { data, error } = await supabase .from('customers') .select('*') + .eq('company_id', company.id) .order('name', { ascending: true }) if (error) { diff --git a/app/(dashboard)/deadlines/page.tsx b/app/(dashboard)/deadlines/page.tsx index 8b98f9c7..6230c340 100644 --- a/app/(dashboard)/deadlines/page.tsx +++ b/app/(dashboard)/deadlines/page.tsx @@ -8,11 +8,13 @@ import { ToastAction } from '@/components/ui/toast' import { DeadlineList } from '@/components/deadlines/DeadlineList' import { PageHeader } from '@/components/ui/page-header' import { AlertTriangle, ArrowRight } from 'lucide-react' +import { useCompany } from '@/contexts/CompanyContext' import type { Deadline } from '@/types' const supabase = createClient() export default function DeadlinesPage() { + const { company } = useCompany() const [deadlines, setDeadlines] = useState([]) const [customers, setCustomers] = useState<{ id: string; name: string }[]>([]) const [overdueInvoices, setOverdueInvoices] = useState<{ count: number; total: number }>({ count: 0, total: 0 }) @@ -20,15 +22,16 @@ export default function DeadlinesPage() { const { toast } = useToast() const fetchData = useCallback(async () => { + if (!company) return setIsLoading(true) try { const today = new Date().toISOString().split('T')[0] const [deadlinesRes, customersRes, overdueRes] = await Promise.all([ - supabase.from('deadlines').select('*, customer:customers(name)').order('due_date', { ascending: true }), - supabase.from('customers').select('id, name').order('name', { ascending: true }), - supabase.from('invoices').select('total_sek, total').in('status', ['sent', 'unpaid']).lt('due_date', today), + supabase.from('deadlines').select('*, customer:customers(name)').eq('company_id', company.id).order('due_date', { ascending: true }), + supabase.from('customers').select('id, name').eq('company_id', company.id).order('name', { ascending: true }), + supabase.from('invoices').select('total_sek, total').eq('company_id', company.id).in('status', ['sent', 'unpaid']).lt('due_date', today), ]) if (deadlinesRes.error) throw deadlinesRes.error diff --git a/app/(dashboard)/expenses/page.tsx b/app/(dashboard)/expenses/page.tsx index 72dadde2..22588bf3 100644 --- a/app/(dashboard)/expenses/page.tsx +++ b/app/(dashboard)/expenses/page.tsx @@ -13,6 +13,7 @@ import { EmptyState } from '@/components/ui/empty-state' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { Plus, Search, Wallet, Clock, AlertCircle } from 'lucide-react' +import { useCompany } from '@/contexts/CompanyContext' import type { SupplierInvoice } from '@/types' type ExpenseInvoice = SupplierInvoice & { supplier?: { id: string; name: string } } @@ -60,6 +61,7 @@ function getRelativeTimeLabel(dueDateStr: string, status: string): { text: strin } export default function ExpensesPage() { + const { company } = useCompany() const [invoices, setInvoices] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -68,10 +70,12 @@ export default function ExpensesPage() { const supabase = createClient() async function fetchExpenses() { + if (!company) return setIsLoading(true) const { data, error } = await supabase .from('supplier_invoices') .select('*, supplier:suppliers(id, name)') + .eq('company_id', company.id) .order('due_date', { ascending: true }) if (error) { diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 0c9390d0..8de1dd51 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -15,6 +15,7 @@ import { formatCurrency, formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' import { Plus, Search, Receipt } from 'lucide-react' import { EmptyInvoices } from '@/components/ui/empty-state' +import { useCompany } from '@/contexts/CompanyContext' import type { Invoice, InvoiceStatus } from '@/types' const statusConfig: Record = { @@ -49,6 +50,7 @@ function getRelativeTimeLabel(dueDateStr: string, status: InvoiceStatus): { text } export default function InvoicesPage() { + const { company } = useCompany() const [invoices, setInvoices] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -57,10 +59,12 @@ export default function InvoicesPage() { const supabase = createClient() async function fetchInvoices() { + if (!company) return setIsLoading(true) const { data, error } = await supabase .from('invoices') .select('*, customer:customers(name)') + .eq('company_id', company.id) .order('invoice_date', { ascending: false }) if (error) { diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index b3e4a5d7..299900dd 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -47,45 +47,8 @@ export default async function DashboardLayout({ const isTeamMember = !!teamMembership - // Consultant with team but no companies — show dashboard with empty state + // No companies — redirect to onboarding if (!companyId) { - if (isTeamMember) { - const companyContextValue = { - company: null, - role: null, - companies: [], - isTeamMember: true, - team, - } - - return ( - -
- - Hoppa till innehåll - - -
-
- {children} -
-
- -
-
- ) - } - redirect('/onboarding') } @@ -102,42 +65,38 @@ export default async function DashboardLayout({ if (!companyRow || !memberRow) { // Stale cookie pointing to a deleted/inaccessible company. - // If the user is a team member, render the empty-state dashboard - // instead of redirecting to onboarding (which would cause a loop). - if (isTeamMember) { - const companyContextValue = { - company: null, - role: null, - companies: (allMemberships || []).filter(m => m.companies).map((m) => ({ - company: m.companies as unknown as import('@/types').Company, - role: m.role as CompanyRole, - })), - isTeamMember: true, - team, - } - - return ( - -
- -
-
- {children} -
-
- -
-
- ) + // Render the empty-state dashboard so user can switch or create a company. + const companyContextValue = { + company: null, + role: null, + companies: (allMemberships || []).filter(m => m.companies).map((m) => ({ + company: m.companies as unknown as import('@/types').Company, + role: m.role as CompanyRole, + })), + isTeamMember, + team, } - redirect('/onboarding') + + return ( + +
+ +
+
+ {children} +
+
+ +
+
+ ) } const [{ data: settings }, { count: uncategorizedCount }, { count: pendingOpsCount }] = await Promise.all([ @@ -158,12 +117,11 @@ export default async function DashboardLayout({ .eq('status', 'pending'), ]) - if (!settings?.onboarding_complete) { - redirect('/onboarding') - } + // If onboarding incomplete, still render the dashboard — the page component + // will show the inline onboarding card instead of the normal dashboard content. // Use company_name from settings as the display name (companies.name may be stale) - const displayName = settings.company_name || companyRow.name + const displayName = settings?.company_name || companyRow.name const companyWithName = { ...companyRow, name: displayName } const companyContextValue = { @@ -181,9 +139,9 @@ export default async function DashboardLayout({ team, } - const entityType = (settings.entity_type as EntityType) || 'enskild_firma' + const entityType = (settings?.entity_type as EntityType) || 'enskild_firma' - const isSandbox = settings.is_sandbox === true + const isSandbox = settings?.is_sandbox === true return ( @@ -197,7 +155,7 @@ export default async function DashboardLayout({ {isSandbox && } )} diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 5c687d23..cb66a13d 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -2,7 +2,6 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { cookies } from 'next/headers' import DashboardContent from '@/components/dashboard/DashboardContent' -import ConsultantEmptyState from '@/components/dashboard/ConsultantEmptyState' import { getActiveCompanyId } from '@/lib/company/context' import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' @@ -34,24 +33,6 @@ export default async function DashboardPage() { } if (!companyId) { - // Consultants (team members) see an empty state; solo users go to onboarding - const { data: teamMembership } = await supabase - .from('team_members') - .select('team_id') - .eq('user_id', user.id) - .limit(1) - .maybeSingle() - - if (teamMembership) { - const { data: profile } = await supabase - .from('profiles') - .select('full_name') - .eq('id', user.id) - .single() - const firstName = profile?.full_name?.split(' ')[0] || null - return - } - redirect('/onboarding') } @@ -122,6 +103,11 @@ export default async function DashboardPage() { const firstName = profile?.full_name?.split(' ')[0] || null + // If onboarding is not complete, redirect to onboarding + if (!settings?.onboarding_complete) { + redirect('/onboarding') + } + const onboardingProgress: OnboardingProgress = { hasCustomers: (customerCount || 0) > 0, hasInvoices: (invoiceCount || 0) > 0, diff --git a/app/(dashboard)/suppliers/page.tsx b/app/(dashboard)/suppliers/page.tsx index edf82cb1..37350712 100644 --- a/app/(dashboard)/suppliers/page.tsx +++ b/app/(dashboard)/suppliers/page.tsx @@ -11,6 +11,7 @@ import { useToast } from '@/components/ui/use-toast' import { Plus, Search, Building2, Globe } from 'lucide-react' import SupplierForm from '@/components/suppliers/SupplierForm' import Link from 'next/link' +import { useCompany } from '@/contexts/CompanyContext' import type { Supplier, SupplierType, CreateSupplierInput } from '@/types' const supplierTypeLabels: Record = { @@ -26,6 +27,7 @@ const supplierTypeIcons: Record = { } export default function SuppliersPage() { + const { company } = useCompany() const [suppliers, setSuppliers] = useState([]) const [isLoading, setIsLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') @@ -35,10 +37,12 @@ export default function SuppliersPage() { const supabase = createClient() async function fetchSuppliers() { + if (!company) return setIsLoading(true) const { data, error } = await supabase .from('suppliers') .select('*') + .eq('company_id', company.id) .order('name', { ascending: true }) if (error) { diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 1745bd14..a567c9a4 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -104,10 +104,12 @@ export default function TransactionsPage() { const PAGE_SIZE = 200 async function fetchTransactions() { + if (!company) return setIsLoading(true) const { data: txData, error: txError } = await supabase .from('transactions') .select('*') + .eq('company_id', company.id) .order('date', { ascending: false }) .limit(PAGE_SIZE) @@ -165,11 +167,13 @@ export default function TransactionsPage() { } async function loadMoreTransactions() { + if (!company) return setIsLoadingMore(true) const offset = transactions.length const { data: txData, error: txError } = await supabase .from('transactions') .select('*') + .eq('company_id', company.id) .order('date', { ascending: false }) .range(offset, offset + PAGE_SIZE - 1) diff --git a/app/(onboarding)/layout.tsx b/app/(onboarding)/layout.tsx index 82588029..b61069a0 100644 --- a/app/(onboarding)/layout.tsx +++ b/app/(onboarding)/layout.tsx @@ -10,9 +10,11 @@ export default async function OnboardingLayout({ const { data: { user } } = await supabase.auth.getUser() return ( - <> - {children} +
+
+ {children} +
{user && } - +
) } diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index a10ae02e..c0cc42d5 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -1,808 +1,46 @@ -'use client' +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import WelcomeOnboarding from '@/components/dashboard/WelcomeOnboarding' -import { useState, useEffect, Suspense } from 'react' -import { useRouter } from 'next/navigation' -import Image from 'next/image' -import * as Sentry from '@sentry/nextjs' -import { createClient } from '@/lib/supabase/client' -import { useToast } from '@/components/ui/use-toast' -import { Loader2 } from 'lucide-react' -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' -import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' -import type { CompanyLookupResult } from '@/lib/company-lookup/types' -import type { CompanySettings, EntityType, MomsPeriod } from '@/types' -import type { CompanyRole } from '@/extensions/general/tic/lib/bankid-types' +export const dynamic = 'force-dynamic' -import Step1EntityType from '@/components/onboarding/Step1EntityType' -import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' -import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' -import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting' +export default async function OnboardingPage() { + const supabase = await createClient() -const STEP_INFO = [ - { title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' }, - { title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' }, - { title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.', label: 'Skatt' }, - { title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.', label: 'Moms' }, -] + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + redirect('/login') + } -/** Map TIC legalEntityType to gnubok EntityType */ -function mapEntityType(ticType: string): EntityType | null { - const lower = ticType.toLowerCase() - if (lower === 'ab' || lower.includes('aktiebolag')) return 'aktiebolag' - if (lower === 'ef' || lower.includes('enskild firma') || lower.includes('enskild')) return 'enskild_firma' - return null -} - -function translatePeriodError(msg: string): string { - if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.' - if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.' - if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.' - if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).' - return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.' -} - -export default function OnboardingPage() { - return ( - - - - }> - - - ) -} - -const LOG = '[onboarding]' - -/** Log to browser console, Vercel server logs (via API), and Sentry. */ -function logError(message: string, extra?: Record) { - console.error(LOG, message, extra ?? '') - - // Send to server so it appears in Vercel Logs - fetch('/api/log', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message, extra }), - }).catch(() => {}) // fire-and-forget, never block the UI - - Sentry.captureMessage(`onboarding: ${message}`, { - level: 'error', - extra: { ...extra, component: 'onboarding' }, - }) -} - -function OnboardingPageContent() { - const router = useRouter() - const { toast } = useToast() - const supabase = createClient() - - const [isLoading, setIsLoading] = useState(true) - const [isSaving, setIsSaving] = useState(false) - const [currentStep, setCurrentStep] = useState(1) - const [settings, setSettings] = useState>({}) - const [companyId, setCompanyId] = useState(null) - const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') - const [ticLookup, setTicLookup] = useState(null) - const [enrichmentCompanies, setEnrichmentCompanies] = useState([]) - const [orgNumberLocked, setOrgNumberLocked] = useState(false) - - const totalSteps = 4 - - // Detect stuck state: onboarding marked complete but still on this page - useEffect(() => { - if (settings.onboarding_complete && !isLoading) { - const timeout = setTimeout(() => { - logError('still on onboarding page after onboarding_complete=true — redirect may have failed') - }, 3000) - return () => clearTimeout(timeout) - } - }, [settings.onboarding_complete, isLoading]) - - // Load existing settings on mount - useEffect(() => { - async function loadSettings() { - const { data: { user }, error: authError } = await supabase.auth.getUser() - - if (authError) { - logError('auth.getUser() failed on mount', { message: authError.message }) - } - - if (!user) { - logError('no authenticated user on mount, redirecting to login') - router.push('/login') - return - } - - // Check for unprocessed invite token (fallback if auth callback didn't process it) - const cookieMatch = document.cookie.match(/gnubok-invite-token=([^;]+)/) - const inviteToken = cookieMatch?.[1] - - if (inviteToken) { - try { - const res = await fetch('/api/team/accept', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: inviteToken }), - }) - - if (res.ok) { - // Clear the cookie - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' - console.log(LOG, 'invite accepted via fallback — redirecting to dashboard') - router.push('/') - return - } - - // Log the failure to help diagnose - const errBody = await res.json().catch(() => ({})) - console.error(LOG, 'fallback invite acceptance returned non-ok', { - status: res.status, - error: errBody.error, - }) - } catch (err) { - console.error(LOG, 'fallback invite acceptance failed:', err) - } - // Clear cookie regardless to avoid retry loops - document.cookie = 'gnubok-invite-token=; path=/; max-age=0' - } - - // Check if user is already in a team (consultant) — skip onboarding - const { data: teamMember } = await supabase - .from('team_members') - .select('id') - .eq('user_id', user.id) - .limit(1) - .maybeSingle() - - if (teamMember) { - console.log(LOG, 'user already in a team — redirecting to dashboard') - window.location.href = '/' - return - } - - // Check if user already has a company via company_members - const { data: membership } = await supabase - .from('company_members') - .select('company_id') - .eq('user_id', user.id) - .order('created_at', { ascending: true }) - .limit(1) - .single() - - if (membership?.company_id) { - const { data, error } = await supabase - .from('company_settings') - .select('*') - .eq('company_id', membership.company_id) - .single() - - if (error && error.code !== 'PGRST116') { - logError('failed to load settings', { message: error.message, code: error.code }) - } - - // If this company is already onboarded (invited user joining existing company), - // skip onboarding entirely and go to dashboard - if (data?.onboarding_complete) { - console.log(LOG, 'company already onboarded — redirecting to dashboard') - router.push('/') - return - } - - setCompanyId(membership.company_id) - - if (data) { - const step = data.onboarding_step || 1 - const clampedStep = step > totalSteps ? totalSteps : step - if (step > totalSteps) { - logError('onboarding_step exceeds totalSteps — clamped', { step, totalSteps }) - } - // Important milestone: where we resume - console.log(LOG, 'resuming at step', clampedStep, { entity_type: data.entity_type }) - setSettings(data) - setCurrentStep(clampedStep) - } - } - - // Load BankID enrichment data if available (one-time use) - try { - const { data: enrichmentRow } = await supabase - .from('extension_data') - .select('id, value') - .eq('user_id', user.id) - .eq('extension_id', 'tic') - .eq('key', 'bankid_enrichment') - .maybeSingle() - - if (enrichmentRow?.value) { - const enrichment = enrichmentRow.value as { spar?: Record; companyRoles?: CompanyRole[] } - - // Extract active companies - const activeCompanies = (enrichment.companyRoles ?? []).filter( - (c: CompanyRole) => c.companyStatus === 'Aktivt' && c.positionEnd === null - ) - if (activeCompanies.length > 0) { - setEnrichmentCompanies(activeCompanies) - } - - // Pre-fill SPAR address if not already set - if (enrichment.spar && !settings.address_line1) { - const spar = enrichment.spar - setSettings((prev) => ({ - ...prev, - address_line1: spar.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 || prev.address_line1, - postal_code: spar.Folkbokforingsadress_SvenskAdress_PostNr || prev.postal_code, - city: spar.Folkbokforingsadress_SvenskAdress_Postort || prev.city, - })) - } - - // Delete enrichment data (one-time use) - await supabase - .from('extension_data') - .delete() - .eq('id', enrichmentRow.id) - } - } catch (err) { - console.warn(LOG, 'enrichment loading failed (non-blocking)', err) - } - - setIsLoading(false) - } - - loadSettings() - }, [supabase, router, toast]) - - const saveSettings = async (updates: Partial, nextStep?: number) => { - const targetStep = nextStep ?? currentStep - setIsSaving(true) - - try { - const { data: { user }, error: authError } = await supabase.auth.getUser() - - if (authError) { - logError('auth.getUser() failed during save', { message: authError.message, step: targetStep }) - } - - if (!user) { - logError('save aborted: no authenticated user', { step: targetStep }) - router.push('/login') - return false - } - - const updatedSettings = { - ...settings, - ...updates, - onboarding_step: targetStep, - } - - if (!companyId) { - logError('save aborted: no companyId', { step: targetStep }) - return false - } - - // Remove read-only and transient fields before updating - const { - id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record - - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' }) - - if (error) { - logError('save failed', { message: error.message, step: targetStep, code: error.code, details: error.details }) - toast({ - title: 'Fel', - description: error.message || 'Kunde inte spara. Försök igen.', - variant: 'destructive', - }) - return false - } - - setSettings(updatedSettings) - return true - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logError('saveSettings threw unexpectedly', { message, step: targetStep }) - Sentry.captureException(err) - toast({ - title: 'Fel', - description: 'Ett oväntat fel uppstod. Försök igen.', - variant: 'destructive', - }) - return false - } finally { - setIsSaving(false) - } - } - - const handleNext = async (stepData: Partial) => { - // Fix org number bug: clear dependent fields when entity type changes - if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { - console.warn(LOG, 'entity type changed from', settings.entity_type, 'to', stepData.entity_type, '— clearing dependent fields') - stepData = { ...stepData, org_number: '', company_name: '' } - setTicLookup(null) - } - - // Step 1: Create company + membership + user_preferences if no companyId yet - let activeCompanyId = companyId - - if (currentStep === 1 && !activeCompanyId) { - try { - const { data: { user }, error: authError } = await supabase.auth.getUser() - if (authError) { - logError('auth.getUser() failed before company creation', { message: authError.message }) - } - if (!user) { - logError('company creation skipped: no user') - router.push('/login') - return - } - - // Atomically create company + owner membership + set active - const { data: newCompanyId, error: rpcError } = await supabase.rpc('create_company_with_owner', { - p_name: 'Mitt företag', - p_entity_type: stepData.entity_type, - }) - - if (rpcError || !newCompanyId) { - logError('company creation failed', { message: rpcError?.message, code: rpcError?.code }) - toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) - return - } - - activeCompanyId = newCompanyId - setCompanyId(activeCompanyId) - console.log(LOG, 'created company', activeCompanyId) - } catch (err) { - logError('company creation threw', { error: String(err) }) - Sentry.captureException(err) - toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) - return - } - } - - if (!activeCompanyId) { - logError('handleNext aborted: no companyId', { step: currentStep }) - return - } - - const nextStep = currentStep + 1 - - // For step 1, companyId state may not be updated yet (React batching). - // Save settings directly with activeCompanyId to avoid the race condition. - const needsDirectSave = currentStep === 1 && !companyId - const success = needsDirectSave - ? await (async () => { - setIsSaving(true) - try { - const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep } - const { - id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, - is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, - ...settingsToSave - } = updatedSettings as Record - - const { error } = await supabase - .from('company_settings') - .upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' }) - - if (error) { - logError('save failed', { message: error.message, step: nextStep, code: error.code }) - toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) - return false - } - - setSettings(updatedSettings) - return true - } catch (err) { - logError('saveSettings threw', { message: String(err), step: nextStep }) - Sentry.captureException(err) - return false - } finally { - setIsSaving(false) - } - })() - : await saveSettings(stepData, nextStep) - - if (!success) { - logError('handleNext aborted: saveSettings failed', { step: currentStep }) - return - } - - // After step 2 (company details): sync company name to companies table - if (currentStep === 2 && stepData.company_name && activeCompanyId) { - const { error: nameError } = await supabase - .from('companies') - .update({ name: stepData.company_name }) - .eq('id', activeCompanyId) - - if (nameError) { - logError('failed to sync company name to companies table', { - message: nameError.message, - code: nameError.code, - }) - } - } - - // After step 1 (entity type selection): seed chart of accounts - if (currentStep === 1 && stepData.entity_type) { - try { - const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { - p_company_id: activeCompanyId, - p_entity_type: stepData.entity_type, - }) - if (rpcError) { - logError('chart of accounts seeding failed', { - entity_type: stepData.entity_type, - message: rpcError.message, - code: rpcError.code, - details: rpcError.details, - }) - } - } catch (err) { - logError('chart of accounts seeding threw', { error: String(err) }) - Sentry.captureException(err) - } - } - - // After step 3 (tax registration): create initial fiscal period - if (currentStep === 3 && companyId) { - try { - const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined - const firstYearStart = stepData.first_year_start as string | undefined - const firstYearEnd = stepData.first_year_end as string | undefined - - let startStr: string - let endStr: string - let periodName: string - - if (isFirstYear && firstYearStart && firstYearEnd) { - // First fiscal year: use exact dates provided - startStr = firstYearStart - endStr = firstYearEnd - - const startYear = new Date(firstYearStart).getFullYear() - const endYear = new Date(firstYearEnd).getFullYear() - periodName = startYear === endYear - ? `Första räkenskapsåret ${startYear}` - : `Första räkenskapsåret ${startYear}/${endYear}` - } else { - // Ongoing: compute 12-month period from fiscal_year_start_month - let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 - - // For enskild firma: force calendar year - if (settings.entity_type === 'enskild_firma') { - startMonth = 1 - } - - const currentYear = new Date().getFullYear() - startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` - - let endYear: number - let endMonth: number - if (startMonth === 1) { - endYear = currentYear - endMonth = 12 - } else { - endYear = currentYear + 1 - endMonth = startMonth - 1 - } - const lastDay = new Date(endYear, endMonth, 0).getDate() - endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` - - periodName = startMonth === 1 - ? `Räkenskapsår ${currentYear}` - : `Räkenskapsår ${currentYear}/${currentYear + 1}` - } - - // Validate period duration - const validationError = validatePeriodDuration(startStr, endStr) - if (validationError) { - logError('fiscal period validation failed', { - validationError, startStr, endStr, isFirstYear, entity_type: settings.entity_type, - }) - toast({ - title: 'Ogiltigt räkenskapsår', - description: translatePeriodError(validationError), - variant: 'destructive', - }) - setCurrentStep(3) - return - } - - // Delete any existing fiscal periods that have no journal entries, - // so re-running onboarding with different dates doesn't create - // overlapping periods (DB exclusion constraint would reject it). - const { data: existingPeriods, error: fetchPeriodsError } = await supabase - .from('fiscal_periods') - .select('id') - .eq('company_id', companyId) - - if (fetchPeriodsError) { - logError('failed to fetch existing fiscal periods', { - message: fetchPeriodsError.message, code: fetchPeriodsError.code, - }) - } - - if (existingPeriods && existingPeriods.length > 0) { - for (const ep of existingPeriods) { - const { count, error: countError } = await supabase - .from('journal_entries') - .select('id', { count: 'exact', head: true }) - .eq('fiscal_period_id', ep.id) - - if (countError) { - logError('failed to count journal entries for period', { periodId: ep.id, message: countError.message }) - continue - } - - if (count === 0) { - const { error: deleteError } = await supabase - .from('fiscal_periods') - .delete() - .eq('id', ep.id) - - if (deleteError) { - logError('failed to delete empty fiscal period', { periodId: ep.id, message: deleteError.message }) - } - } - } - } - - const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ - company_id: companyId, - name: periodName, - period_start: startStr, - period_end: endStr, - }, { - onConflict: 'company_id,period_start,period_end', - }) - - if (upsertError) { - logError('fiscal period upsert failed', { - message: upsertError.message, startStr, endStr, code: upsertError.code, details: upsertError.details, - }) - } - } catch (err) { - logError('fiscal period creation threw', { error: String(err) }) - Sentry.captureException(err) - toast({ - title: 'Kunde inte skapa räkenskapsår', - description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.', - variant: 'destructive', - }) - } - } - - if (nextStep > totalSteps) { - const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) - if (!finalSuccess) { - logError('failed to set onboarding_complete after all steps') - return - } - // Important milestone - console.log(LOG, 'onboarding completed') - toast({ - title: 'Välkommen!', - description: 'Din profil är nu redo.', - }) - router.push('/') - } else { - setCurrentStep(nextStep) - } - } - - const handleBack = () => { - if (currentStep > 1) { - setCurrentStep(currentStep - 1) - } - } - - const handleSkip = async () => { - const nextStep = currentStep + 1 - const success = await saveSettings({}, nextStep) - - if (!success) { - logError('skip failed: saveSettings returned false', { step: currentStep }) - return - } - setCurrentStep(nextStep) - } - - /** Handle selecting a company from BankID enrichment */ - const handleEnrichmentSelect = (company: CompanyRole) => { - const entityType = mapEntityType(company.legalEntityType) - if (!entityType) return - - // Auto-set entity type, org number, and company name - setSettings((prev) => ({ - ...prev, - entity_type: entityType, - org_number: company.companyRegistrationNumber, - company_name: company.legalName, - })) - setOrgNumberLocked(true) - setEnrichmentCompanies([]) // Dismiss picker - } - - if (isLoading) { - return ( -
- -
- ) - } - - const stepInfo = STEP_INFO[currentStep - 1] - - const renderSteps = () => ( - <> - {currentStep === 1 && enrichmentCompanies.length > 0 && ( -
-

Vi hittade dessa foretag kopplade till ditt BankID:

- {enrichmentCompanies.map((company) => { - const entityType = mapEntityType(company.legalEntityType) - return ( - - ) - })} -
-
-
-
-
- eller valj foretagsform manuellt -
-
-
- )} - - {currentStep === 1 && ( - handleNext(data)} - isSaving={isSaving} - /> - )} - - {currentStep === 2 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - orgNumberLocked={orgNumberLocked} - /> - )} - - {currentStep === 3 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - )} - - {currentStep === 4 && ( - handleNext(data)} - onBack={handleBack} - isSaving={isSaving} - /> - )} - - ) - - // ── Steps 1–4 ── - return ( -
- {/* ── Branded Header ── */} -
- {/* Decorative elements */} -
-
- - {String(currentStep).padStart(2, '0')} - -
- -
- {/* Top row: Logo + step indicator + counter */} -
-
- Gnubok - gnubok -
- {/* Step indicator — inline with logo row */} -
- {STEP_INFO.map((_, i) => { - const num = i + 1 - return ( -
currentStep && 'w-4 bg-white/[0.1]', - )} - /> - ) - })} -
- - {currentStep} / {totalSteps} - -
- - {/* Step title — compact */} -
-

- {stepInfo.title} -

-

- {stepInfo.subtitle} -

-
-
-
- - {/* ── Form Content ── */} -
-
-
- {renderSteps()} -
-
-
-
- ) + // Check if user already has companies (adding another vs first-time) + const { data: existingMembership } = await supabase + .from('company_members') + .select('company_id') + .eq('user_id', user.id) + .limit(1) + .maybeSingle() + + const hasCompanies = !!existingMembership + + // Fetch profile and team + const [{ data: profile }, { data: teamMembership }] = await Promise.all([ + supabase.from('profiles').select('full_name').eq('id', user.id).single(), + supabase.from('team_members').select('team_id').eq('user_id', user.id).limit(1).maybeSingle(), + ]) + + let teamId = teamMembership?.team_id + + // Ensure user has a team (fallback for edge cases) + if (!teamId) { + const { data: newTeamId } = await supabase.rpc('ensure_user_team') + teamId = newTeamId + } + + if (!teamId) { + redirect('/login') + } + + const firstName = profile?.full_name?.split(' ')[0] || null + + return } diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index c5cea534..a33316d2 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -170,9 +170,7 @@ export async function GET(request: Request) { .eq('company_id', companyId) .single() - const redirectTarget = userSettings?.onboarding_complete - ? `/settings/banking?bank_connected=true&connection_id=${connectionId}` - : `/onboarding?bank_connected=true&connection_id=${connectionId}` + const redirectTarget = `/settings/banking?bank_connected=true&connection_id=${connectionId}` return NextResponse.redirect(`${baseUrl}${redirectTarget}`) } catch (error) { diff --git a/app/companies/new/page.tsx b/app/companies/new/page.tsx index f52861e7..ccc83aa3 100644 --- a/app/companies/new/page.tsx +++ b/app/companies/new/page.tsx @@ -77,7 +77,9 @@ function NewCompanyContent() { const totalSteps = 4 - // Just verify auth on mount + const [teamId, setTeamId] = useState(null) + + // Verify auth and fetch team_id on mount useEffect(() => { async function checkAuth() { const { data: { user } } = await supabase.auth.getUser() @@ -85,6 +87,23 @@ function NewCompanyContent() { router.push('/login') return } + + // Fetch user's team_id + const { data: teamMembership } = await supabase + .from('team_members') + .select('team_id') + .eq('user_id', user.id) + .limit(1) + .maybeSingle() + + if (teamMembership?.team_id) { + setTeamId(teamMembership.team_id) + } else { + // Ensure user has a team (fallback) + const { data: newTeamId } = await supabase.rpc('ensure_user_team') + setTeamId(newTeamId) + } + setIsLoading(false) } checkAuth() @@ -162,6 +181,7 @@ function NewCompanyContent() { const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', { p_name: 'Nytt företag', p_entity_type: stepData.entity_type, + p_team_id: teamId, }) if (companyError || !newCompanyId) { diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx index d350eea3..e7aacfee 100644 --- a/components/dashboard/CompanySwitcher.tsx +++ b/components/dashboard/CompanySwitcher.tsx @@ -10,7 +10,7 @@ import { switchCompany } from '@/lib/company/actions' import { Check, ChevronsUpDown, Plus, Loader2 } from 'lucide-react' export default function CompanySwitcher() { - const { company, companies, isTeamMember, team } = useCompany() + const { company, companies } = useCompany() const router = useRouter() const [open, setOpen] = useState(false) const [isPending, startTransition] = useTransition() @@ -43,7 +43,6 @@ export default function CompanySwitcher() { // Update position when opening (run twice: once to render, once to measure) useEffect(() => { if (!open) return - // First frame: portal mounts, second frame: we can measure it const raf = requestAnimationFrame(() => updatePosition()) return () => cancelAnimationFrame(raf) }, [open, updatePosition]) @@ -88,124 +87,31 @@ export default function CompanySwitcher() { }) } - const canOpen = companies.length > 0 || isTeamMember - - // For team members: show team name as header, active company below - // For self-service: just show company name - if (team) { - return ( -
- - - {open && createPortal( -
- {companies.length > 0 && ( - <> -
-

- Företag -

-
- -
- {companies.map(({ company: c, role }) => ( - - ))} -
- - )} - -
0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> - setOpen(false)} - className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap" - > - - Lägg till företag - -
-
, - document.body - )} -
- ) - } - - // Self-service user (no team) — simple company display + // Always allow opening the dropdown (to show "Lägg till företag") const hasMultiple = companies.length > 1 + const canOpen = companies.length > 0 + + // No companies yet — hide the switcher entirely + if (!company && companies.length === 0) { + return null + } return (
{open && createPortal( @@ -214,36 +120,59 @@ export default function CompanySwitcher() { className="fixed min-w-56 w-max max-w-[calc(100vw-1rem)] bg-card border border-border/60 rounded-lg shadow-lg z-[60] py-1 animate-in fade-in slide-in-from-top-1 duration-150" style={{ top: dropdownPos.top, left: dropdownPos.left }} > -
- {companies.map(({ company: c, role }) => ( - - ))} + {companies.length > 0 && ( + <> + {hasMultiple && ( +
+

+ Företag +

+
+ )} + +
+ {companies.map(({ company: c, role }) => ( + + ))} +
+ + )} + +
0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> + setOpen(false)} + className="flex items-center gap-2 px-2.5 py-2 text-[13px] text-muted-foreground hover:text-foreground hover:bg-muted/40 rounded-md transition-colors md:whitespace-nowrap" + > + + Lägg till företag +
, document.body diff --git a/components/dashboard/ConsultantEmptyState.tsx b/components/dashboard/ConsultantEmptyState.tsx index cae7acd2..1cd7862b 100644 --- a/components/dashboard/ConsultantEmptyState.tsx +++ b/components/dashboard/ConsultantEmptyState.tsx @@ -32,7 +32,7 @@ export default function ConsultantEmptyState({ firstName }: ConsultantEmptyState

diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 312d926b..3e68c301 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -21,7 +21,6 @@ import { } from 'lucide-react' import { getAllExtensions } from '@/lib/extensions/sectors' import { resolveIcon } from '@/lib/extensions/icon-resolver' -import { useCompany } from '@/contexts/CompanyContext' import type { QuickActionDefinition } from '@/lib/extensions/types' import type { CompanySettings, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' @@ -51,15 +50,19 @@ interface DashboardContentProps { } export default function DashboardContent({ firstName, settings, summary, onboardingProgress }: DashboardContentProps) { - const { isTeamMember } = useCompany() const [showAllAlerts, setShowAllAlerts] = useState(false) const [showMore, setShowMore] = useState(false) + const [greeting, setGreeting] = useState('Hej') // Setup gate — blocks dashboard until user imports data or chooses fresh start - // Consultants (team members) skip this — they go straight to the dashboard - const needsSetup = !isTeamMember && onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport + const needsSetup = onboardingProgress && !onboardingProgress.hasBankConnected && !onboardingProgress.hasSIEImport const [setupGateActive, setSetupGateActive] = useState(!!needsSetup) + useEffect(() => { + const hour = new Date().getHours() + setGreeting(hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll') + }, []) + useEffect(() => { if (!needsSetup) { setSetupGateActive(false) @@ -252,9 +255,6 @@ export default function DashboardContent({ firstName, settings, summary, onboard { href: '/transactions', icon: ArrowLeftRight, label: 'Transaktioner', desc: 'Bokför' }, ] - const hour = new Date().getHours() - const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll' - const passedDeadlinesCount = summary.deadlines.filter(d => !d.is_completed && new Date(d.due_date) <= new Date()).length const pendingReceiptsCount = summary.receiptQueue ? summary.receiptQueue.pending_review_count + summary.receiptQueue.unmatched_receipts_count diff --git a/components/dashboard/WelcomeOnboarding.tsx b/components/dashboard/WelcomeOnboarding.tsx new file mode 100644 index 00000000..b69c1c1e --- /dev/null +++ b/components/dashboard/WelcomeOnboarding.tsx @@ -0,0 +1,634 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import * as Sentry from '@sentry/nextjs' +import { createClient } from '@/lib/supabase/client' +import { switchCompany } from '@/lib/company/actions' +import { useToast } from '@/components/ui/use-toast' +import { Loader2, Building2, Plus } from 'lucide-react' +import { cn } from '@/lib/utils' +import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' +import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' +import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types' +import type { CompanySettings, EntityType, MomsPeriod } from '@/types' + +import Step1EntityType from '@/components/onboarding/Step1EntityType' +import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' +import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' +import Step4VatAccounting from '@/components/onboarding/Step4VatAccounting' + +const STEP_INFO = [ + { title: 'Företagsform', subtitle: 'Välj din företagsform för att komma igång.' }, + { title: 'Uppgifter', subtitle: 'Uppgifterna visas på fakturor och dokument.' }, + { title: 'F-skatt & räkenskapsår', subtitle: 'Ange din skatteregistrering och räkenskapsår.' }, + { title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.' }, +] + +/** Map TIC legalEntityType to gnubok EntityType */ +function mapEntityType(ticType: string): EntityType | null { + const lower = ticType.toLowerCase() + if (lower === 'ab' || lower.includes('aktiebolag')) return 'aktiebolag' + if (lower === 'ef' || lower.includes('enskild firma') || lower.includes('enskild')) return 'enskild_firma' + return null +} + +function translatePeriodError(msg: string): string { + if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.' + if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.' + if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.' + if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).' + return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.' +} + +const LOG = '[welcome-onboarding]' + +function logError(message: string, extra?: Record) { + console.error(LOG, message, extra ?? '') + fetch('/api/log', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: `welcome-onboarding: ${message}`, extra }), + }).catch(() => {}) + Sentry.captureMessage(`welcome-onboarding: ${message}`, { + level: 'error', + extra: { ...extra, component: 'welcome-onboarding' }, + }) +} + +interface WelcomeOnboardingProps { + firstName?: string | null + teamId: string + skipWelcome?: boolean + hasExistingCompanies?: boolean +} + +export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasExistingCompanies }: WelcomeOnboardingProps) { + const router = useRouter() + const { toast } = useToast() + const supabase = createClient() + + const [started, setStarted] = useState(skipWelcome ?? false) + const [isLoading, setIsLoading] = useState(true) + const [isSaving, setIsSaving] = useState(false) + const [currentStep, setCurrentStep] = useState(1) + const [settings, setSettings] = useState>({}) + const [companyId, setCompanyId] = useState(null) + const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') + const [ticLookup, setTicLookup] = useState(null) + const [enrichmentCompanies, setEnrichmentCompanies] = useState([]) + const [orgNumberLocked, setOrgNumberLocked] = useState(false) + + const totalSteps = 4 + + const hour = new Date().getHours() + const greeting = hour < 5 ? 'God natt' : hour < 10 ? 'Godmorgon' : hour < 14 ? 'Hej' : hour < 18 ? 'God eftermiddag' : 'God kväll' + + // Load BankID enrichment data on mount + useEffect(() => { + async function loadEnrichment() { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return + } + + try { + const { data: enrichmentRow } = await supabase + .from('extension_data') + .select('id, value') + .eq('user_id', user.id) + .eq('extension_id', 'tic') + .eq('key', 'bankid_enrichment') + .maybeSingle() + + if (enrichmentRow?.value) { + const enrichment = enrichmentRow.value as { spar?: Record; companyRoles?: EnrichmentCompanyRole[] } + + const activeCompanies = (enrichment.companyRoles ?? []).filter( + (c: EnrichmentCompanyRole) => c.companyStatus === 'Aktivt' && c.positionEnd === null + ) + if (activeCompanies.length > 0) { + setEnrichmentCompanies(activeCompanies) + } + + if (enrichment.spar) { + const spar = enrichment.spar + setSettings((prev) => ({ + ...prev, + address_line1: spar.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 || prev.address_line1, + postal_code: spar.Folkbokforingsadress_SvenskAdress_PostNr || prev.postal_code, + city: spar.Folkbokforingsadress_SvenskAdress_Postort || prev.city, + })) + } + + // Delete enrichment data (one-time use) + await supabase + .from('extension_data') + .delete() + .eq('id', enrichmentRow.id) + } + } catch (err) { + console.warn(LOG, 'enrichment loading failed (non-blocking)', err) + } + + setIsLoading(false) + } + + loadEnrichment() + }, [supabase, router]) + + const saveSettings = async (updates: Partial, nextStep?: number) => { + const targetStep = nextStep ?? currentStep + setIsSaving(true) + + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return false + } + + const updatedSettings = { + ...settings, + ...updates, + onboarding_step: targetStep, + } + + if (!companyId) { + logError('save aborted: no companyId', { step: targetStep }) + return false + } + + const { + id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, + is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, + ...settingsToSave + } = updatedSettings as Record + + const { error } = await supabase + .from('company_settings') + .upsert({ ...settingsToSave, company_id: companyId }, { onConflict: 'company_id' }) + + if (error) { + logError('save failed', { message: error.message, step: targetStep, code: error.code }) + toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) + return false + } + + setSettings(updatedSettings) + return true + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logError('saveSettings threw', { message, step: targetStep }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Ett oväntat fel uppstod. Försök igen.', variant: 'destructive' }) + return false + } finally { + setIsSaving(false) + } + } + + const handleNext = async (stepData: Partial) => { + if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { + stepData = { ...stepData, org_number: '', company_name: '' } + setTicLookup(null) + } + + let activeCompanyId = companyId + + if (currentStep === 1 && !activeCompanyId) { + try { + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + router.push('/login') + return + } + + const { data: newCompanyId, error: rpcError } = await supabase.rpc('create_company_with_owner', { + p_name: 'Mitt företag', + p_entity_type: stepData.entity_type, + p_team_id: teamId, + }) + + if (rpcError || !newCompanyId) { + logError('company creation failed', { message: rpcError?.message, code: rpcError?.code }) + toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) + return + } + + activeCompanyId = newCompanyId + setCompanyId(activeCompanyId) + console.log(LOG, 'created company', activeCompanyId) + } catch (err) { + logError('company creation threw', { error: String(err) }) + Sentry.captureException(err) + toast({ title: 'Fel', description: 'Kunde inte skapa företag. Försök igen.', variant: 'destructive' }) + return + } + } + + if (!activeCompanyId) { + logError('handleNext aborted: no companyId', { step: currentStep }) + return + } + + const nextStep = currentStep + 1 + + const needsDirectSave = currentStep === 1 && !companyId + const success = needsDirectSave + ? await (async () => { + setIsSaving(true) + try { + const updatedSettings = { ...settings, ...stepData, onboarding_step: nextStep } + const { + id: _id, user_id: _uid, company_id: _cid, created_at: _ca, updated_at: _ua, + is_first_fiscal_year: _ify, first_year_start: _fys, first_year_end: _fye, + ...settingsToSave + } = updatedSettings as Record + + const { error } = await supabase + .from('company_settings') + .upsert({ ...settingsToSave, company_id: activeCompanyId }, { onConflict: 'company_id' }) + + if (error) { + logError('save failed', { message: error.message, step: nextStep, code: error.code }) + toast({ title: 'Fel', description: error.message || 'Kunde inte spara. Försök igen.', variant: 'destructive' }) + return false + } + + setSettings(updatedSettings) + return true + } catch (err) { + logError('saveSettings threw', { message: String(err), step: nextStep }) + Sentry.captureException(err) + return false + } finally { + setIsSaving(false) + } + })() + : await saveSettings(stepData, nextStep) + + if (!success) { + logError('handleNext aborted: saveSettings failed', { step: currentStep }) + return + } + + // After step 2: sync company name to companies table + if (currentStep === 2 && stepData.company_name && activeCompanyId) { + const { error: nameError } = await supabase + .from('companies') + .update({ name: stepData.company_name }) + .eq('id', activeCompanyId) + + if (nameError) { + logError('failed to sync company name', { message: nameError.message }) + } + } + + // After step 1: seed chart of accounts + if (currentStep === 1 && stepData.entity_type) { + try { + const { error: rpcError } = await supabase.rpc('seed_chart_of_accounts', { + p_company_id: activeCompanyId, + p_entity_type: stepData.entity_type, + }) + if (rpcError) { + logError('COA seeding failed', { entity_type: stepData.entity_type, message: rpcError.message }) + } + } catch (err) { + logError('COA seeding threw', { error: String(err) }) + Sentry.captureException(err) + } + } + + // After step 3: create fiscal period + if (currentStep === 3 && activeCompanyId) { + try { + const isFirstYear = stepData.is_first_fiscal_year as boolean | undefined + const firstYearStart = stepData.first_year_start as string | undefined + const firstYearEnd = stepData.first_year_end as string | undefined + + let startStr: string + let endStr: string + let periodName: string + + if (isFirstYear && firstYearStart && firstYearEnd) { + startStr = firstYearStart + endStr = firstYearEnd + const startYear = new Date(firstYearStart).getFullYear() + const endYear = new Date(firstYearEnd).getFullYear() + periodName = startYear === endYear + ? `Första räkenskapsåret ${startYear}` + : `Första räkenskapsåret ${startYear}/${endYear}` + } else { + let startMonth = stepData.fiscal_year_start_month || settings.fiscal_year_start_month || 1 + if (settings.entity_type === 'enskild_firma') startMonth = 1 + + const currentYear = new Date().getFullYear() + startStr = `${currentYear}-${String(startMonth).padStart(2, '0')}-01` + + let endYear: number + let endMonth: number + if (startMonth === 1) { + endYear = currentYear + endMonth = 12 + } else { + endYear = currentYear + 1 + endMonth = startMonth - 1 + } + const lastDay = new Date(endYear, endMonth, 0).getDate() + endStr = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}` + + periodName = startMonth === 1 + ? `Räkenskapsår ${currentYear}` + : `Räkenskapsår ${currentYear}/${currentYear + 1}` + } + + const validationError = validatePeriodDuration(startStr, endStr) + if (validationError) { + logError('fiscal period validation failed', { validationError, startStr, endStr }) + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(validationError), + variant: 'destructive', + }) + setCurrentStep(3) + return + } + + // Clean up empty fiscal periods + const { data: existingPeriods } = await supabase + .from('fiscal_periods') + .select('id') + .eq('company_id', activeCompanyId) + + if (existingPeriods && existingPeriods.length > 0) { + for (const ep of existingPeriods) { + const { count } = await supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('fiscal_period_id', ep.id) + + if (count === 0) { + await supabase.from('fiscal_periods').delete().eq('id', ep.id) + } + } + } + + const { error: upsertError } = await supabase.from('fiscal_periods').upsert({ + company_id: activeCompanyId, + name: periodName, + period_start: startStr, + period_end: endStr, + }, { onConflict: 'company_id,period_start,period_end' }) + + if (upsertError) { + logError('fiscal period upsert failed', { message: upsertError.message, startStr, endStr }) + } + } catch (err) { + logError('fiscal period creation threw', { error: String(err) }) + Sentry.captureException(err) + toast({ + title: 'Kunde inte skapa räkenskapsår', + description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.', + variant: 'destructive', + }) + } + } + + if (nextStep > totalSteps) { + const finalSuccess = await saveSettings({ onboarding_complete: true }, totalSteps) + if (!finalSuccess) { + logError('failed to set onboarding_complete after all steps') + return + } + + // Update company name + if (settings.company_name || stepData.company_name) { + await supabase + .from('companies') + .update({ name: settings.company_name || stepData.company_name }) + .eq('id', activeCompanyId) + } + + // Switch to the new company + await switchCompany(activeCompanyId) + + console.log(LOG, 'onboarding completed') + toast({ + title: 'Välkommen!', + description: 'Ditt företag är nu redo.', + }) + router.push('/') + } else { + setCurrentStep(nextStep) + } + } + + const handleBack = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1) + } + } + + /** Handle selecting a company from BankID enrichment */ + const handleEnrichmentSelect = (company: EnrichmentCompanyRole) => { + const entityType = mapEntityType(company.legalEntityType) + if (!entityType) return + + setSettings((prev) => ({ + ...prev, + entity_type: entityType, + org_number: company.companyRegistrationNumber, + company_name: company.legalName, + })) + setOrgNumberLocked(true) + setEnrichmentCompanies([]) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + const stepInfo = STEP_INFO[currentStep - 1] + + // Welcome screen — show before user clicks "Lägg till ditt första företag" + if (!started) { + return ( +
+

{greeting}

+

+ Välkommen till Gnubok +

+ +
+ ) + } + + return ( +
+ {/* Greeting header */} +
+

+ {greeting}{firstName ? `, ${firstName}` : ''} +

+

+ {hasExistingCompanies ? 'Lägg till ett företag.' : 'Lägg till ditt första företag för att komma igång.'} +

+
+ + {/* Onboarding card */} +
+
+ {/* Card header with step info */} +
+
+
+
+ +
+
+
+ + Nytt företag +
+
+ {STEP_INFO.map((_, i) => { + const num = i + 1 + return ( +
currentStep && 'w-3 bg-white/[0.1]', + )} + /> + ) + })} + + {currentStep}/{totalSteps} + +
+
+ +

+ {stepInfo.title} +

+

+ {stepInfo.subtitle} +

+
+
+ + {/* Form content */} +
+
+ {currentStep === 1 && enrichmentCompanies.length > 0 && ( +
+

Vi hittade dessa företag kopplade till ditt BankID:

+ {enrichmentCompanies.map((company) => { + const entityType = mapEntityType(company.legalEntityType) + return ( + + ) + })} +
+
+
+
+
+ eller välj företagsform manuellt +
+
+
+ )} + + {currentStep === 1 && ( + handleNext(data)} + isSaving={isSaving} + /> + )} + + {currentStep === 2 && ( + handleNext(data)} + onBack={handleBack} + isSaving={isSaving} + orgNumberLocked={orgNumberLocked} + /> + )} + + {currentStep === 3 && ( + handleNext(data)} + onBack={handleBack} + isSaving={isSaving} + /> + )} + + {currentStep === 4 && ( + handleNext(data)} + onBack={handleBack} + isSaving={isSaving} + /> + )} +
+
+
+
+
+ ) +} diff --git a/components/settings/SettingsSidebar.tsx b/components/settings/SettingsSidebar.tsx index f84f2973..94c1beda 100644 --- a/components/settings/SettingsSidebar.tsx +++ b/components/settings/SettingsSidebar.tsx @@ -16,7 +16,7 @@ interface NavItem { export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) { const pathname = usePathname() const router = useRouter() - const { company, isTeamMember } = useCompany() + const { company } = useCompany() const hasCompany = !!company const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking') @@ -27,7 +27,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) { { href: '/settings/invoicing', label: 'Fakturering', show: hasCompany }, { href: '/settings/bookkeeping', label: 'Bokföring', show: hasCompany }, { href: '/settings/tax', label: 'Skatt', show: hasCompany }, - { href: '/settings/team', label: 'Lag', show: isTeamMember }, + { href: '/settings/team', label: 'Lag', show: false }, { href: '/settings/banking', label: 'Bank (PSD2)', show: hasCompany && !isSandbox && hasBankingExtension }, { href: '/settings/templates', label: 'Mallar', show: hasCompany }, { href: '/settings/account', label: 'Konto', show: true }, diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 84d6d9fd..9a70e5ec 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -22,7 +22,7 @@ import type { TICCompanyProfile } from './lib/tic-types' import type { BankIdCompleteRequest } from './lib/bankid-types' import type { CompanyLookupResult } from '@/lib/company-lookup/types' import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid' -import { createServiceClientNoCookies } from '@/lib/auth/api-keys' +import { createServiceClient } from '@/lib/supabase/server' import crypto from 'crypto' // Server-side per-IP rate limit for /bankid/start (each call = billable TIC session) @@ -450,7 +450,9 @@ export const ticExtension: Extension = { ) } - if (mode === 'signup' && !email) { + const trimmedEmail = email?.trim().toLowerCase() + + if (mode === 'signup' && !trimmedEmail) { return NextResponse.json( { error: 'email is required for signup' }, { status: 400 } @@ -468,7 +470,7 @@ export const ticExtension: Extension = { const { personalNumber, givenName, surname, name } = session.user const pnrHash = hashPersonalNumber(personalNumber) - const supabase = createServiceClientNoCookies() + const supabase = createServiceClient() // Look up existing BankID identity const { data: existing } = await supabase @@ -525,30 +527,51 @@ export const ticExtension: Extension = { ) } - // Create new Supabase user - const randomPassword = crypto.randomBytes(32).toString('base64url') - const { data: newUser, error: createError } = await supabase.auth.admin.createUser({ - email: email!, - email_confirm: true, - password: randomPassword, - user_metadata: { full_name: name }, - }) + // Check if email is already taken by a non-BankID user + const { data: existingByEmail } = await supabase + .from('profiles') + .select('id') + .eq('email', trimmedEmail!) + .single() - if (createError || !newUser?.user) { - console.error('[tic/bankid] createUser failed', createError) - return NextResponse.json( - { error: 'Failed to create account', message: createError?.message }, - { status: 500 } - ) + let userId: string + let isNewUser = true + + if (existingByEmail) { + // Email already exists — link BankID to existing account + userId = existingByEmail.id + isNewUser = false + + await supabase.auth.admin.updateUserById(userId, { + app_metadata: { bankid_linked: true }, + user_metadata: { full_name: name }, + }) + } else { + // Create new Supabase user + const randomPassword = crypto.randomBytes(32).toString('base64url') + const { data: newUser, error: createError } = await supabase.auth.admin.createUser({ + email: trimmedEmail!, + email_confirm: true, + password: randomPassword, + user_metadata: { full_name: name }, + }) + + if (createError || !newUser?.user) { + console.error('[tic/bankid] createUser failed', { email: trimmedEmail, status: createError?.status, code: (createError as any)?.code, message: createError?.message }) + return NextResponse.json( + { error: 'Failed to create account', message: createError?.message }, + { status: 500 } + ) + } + + userId = newUser.user.id + + // Mark user as BankID-linked (skips TOTP MFA) + await supabase.auth.admin.updateUserById(userId, { + app_metadata: { bankid_linked: true }, + }) } - const userId = newUser.user.id - - // Mark user as BankID-linked (skips TOTP MFA) - await supabase.auth.admin.updateUserById(userId, { - app_metadata: { bankid_linked: true }, - }) - // Store BankID identity const { error: insertError } = await supabase .from('bankid_identities') @@ -562,8 +585,6 @@ export const ticExtension: Extension = { if (insertError) { console.error('[tic/bankid] insert bankid_identities failed', insertError) - // Clean up the created user if identity linking fails - await supabase.auth.admin.deleteUser(userId) return NextResponse.json( { error: 'Failed to link BankID identity' }, { status: 500 } @@ -573,7 +594,7 @@ export const ticExtension: Extension = { // Generate magic link for session const { data: link, error: linkError } = await supabase.auth.admin.generateLink({ type: 'magiclink', - email: email!, + email: trimmedEmail!, }) if (linkError || !link?.properties?.hashed_token) { @@ -613,7 +634,7 @@ export const ticExtension: Extension = { data: { tokenHash: link.properties.hashed_token, type: 'magiclink', - isNewUser: true, + isNewUser, }, }) } catch (error) { @@ -681,7 +702,7 @@ export const ticExtension: Extension = { const { personalNumber, givenName, surname } = session.user const pnrHash = hashPersonalNumber(personalNumber) - const supabase = createServiceClientNoCookies() + const supabase = createServiceClient() // Check personnummer not already linked to another user const { data: existing } = await supabase @@ -746,7 +767,7 @@ export const ticExtension: Extension = { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const supabase = createServiceClientNoCookies() + const supabase = createServiceClient() // Delete bankid_identities row const { error: deleteError } = await supabase diff --git a/lib/company-lookup/types.ts b/lib/company-lookup/types.ts index 52185317..5b89f700 100644 --- a/lib/company-lookup/types.ts +++ b/lib/company-lookup/types.ts @@ -1,3 +1,21 @@ +/** + * A person's role/position in a Swedish company, from BankID enrichment. + * Defined in core so onboarding components can import it without + * violating the CI constraint (no core → @/extensions/ imports). + */ +export interface EnrichmentCompanyRole { + companyId: number + companyRegistrationNumber: string + legalName: string + legalEntityType: string + positionTypes: string[] + positionDescriptions: string[] + positionStart: string + positionEnd: string | null + companyStatus: string + signatureDescription?: string +} + /** * Generic company lookup result — provider-agnostic. * Defined in core so onboarding components can import it without diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index 0c38622b..fdb7a361 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -58,23 +58,8 @@ export async function updateSession(request: NextRequest) { pathname.startsWith('/sandbox') || pathname.startsWith('/invite') ) { - // If user is logged in and trying to access auth pages, redirect to dashboard or onboarding + // If user is logged in and trying to access auth pages, redirect to dashboard if (user) { - const companyId = await resolveCompanyForMiddleware(supabase, user.id, request) - if (!companyId) { - return NextResponse.redirect(new URL('/onboarding', request.url)) - } - - const { data: settings } = await supabase - .from('company_settings') - .select('onboarding_complete') - .eq('company_id', companyId) - .single() - - if (!settings?.onboarding_complete) { - return NextResponse.redirect(new URL('/onboarding', request.url)) - } - return NextResponse.redirect(new URL('/', request.url)) } return supabaseResponse @@ -102,8 +87,9 @@ export async function updateSession(request: NextRequest) { } // MFA required but user has no factor enrolled yet → force enrollment - // (skip during onboarding — let them finish setup first) - if (!pathname.startsWith('/onboarding')) { + // Skip for users with no companies (still setting up) + const companyIdForMfa = await resolveCompanyForMiddleware(supabase, user.id, request) + if (companyIdForMfa) { const { data: factors } = await supabase.auth.mfa.listFactors() const hasVerifiedFactor = factors?.totp?.some(f => f.status === 'verified') @@ -116,27 +102,8 @@ export async function updateSession(request: NextRequest) { // Company context resolution const companyId = await resolveCompanyForMiddleware(supabase, user.id, request) - // No companies at all + // No companies — only allow /onboarding, redirect everything else there if (!companyId) { - // Check if user is in a team (consultant without companies yet). - // Team members should reach the dashboard (which shows an empty state) - // rather than being forced through company onboarding. - const { data: teamMember } = await supabase - .from('team_members') - .select('id') - .eq('user_id', user.id) - .limit(1) - .maybeSingle() - - if (teamMember) { - // Consultant with a team but no companies — allow dashboard access - if (pathname.startsWith('/onboarding')) { - return NextResponse.redirect(new URL('/', request.url)) - } - return supabaseResponse - } - - // No team, no companies → redirect to onboarding if (pathname.startsWith('/onboarding')) { return supabaseResponse } @@ -152,37 +119,11 @@ export async function updateSession(request: NextRequest) { maxAge: 60 * 60 * 24 * 365, }) - // Select-company page — allow access (for switching companies) - if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new')) { + // Allow access to onboarding (for adding new companies), select-company, and companies/new + if (pathname.startsWith('/select-company') || pathname.startsWith('/companies/new') || pathname.startsWith('/onboarding')) { return supabaseResponse } - // Onboarding route - only accessible if not complete - if (pathname.startsWith('/onboarding')) { - const { data: settings } = await supabase - .from('company_settings') - .select('onboarding_complete') - .eq('company_id', companyId) - .single() - - if (settings?.onboarding_complete) { - return NextResponse.redirect(new URL('/', request.url)) - } - - return supabaseResponse - } - - // Dashboard routes - require completed onboarding for active company - const { data: settings } = await supabase - .from('company_settings') - .select('onboarding_complete') - .eq('company_id', companyId) - .single() - - if (!settings?.onboarding_complete) { - return NextResponse.redirect(new URL('/onboarding', request.url)) - } - return supabaseResponse } diff --git a/supabase/migrations/20260408130000_sie_files_storage_bucket.sql b/supabase/migrations/20260408130000_sie_files_storage_bucket.sql index 34efc296..646d7b0c 100644 --- a/supabase/migrations/20260408130000_sie_files_storage_bucket.sql +++ b/supabase/migrations/20260408130000_sie_files_storage_bucket.sql @@ -16,7 +16,6 @@ VALUES ( ARRAY['text/plain'] ) ON CONFLICT (id) DO NOTHING; -ON CONFLICT (id) DO NOTHING; -- ============================================================================= -- 2. INSERT policy: Users can upload to companies they belong to diff --git a/supabase/migrations/20260408140000_silent_teams_for_all_users.sql b/supabase/migrations/20260408140000_silent_teams_for_all_users.sql new file mode 100644 index 00000000..8f2f54b2 --- /dev/null +++ b/supabase/migrations/20260408140000_silent_teams_for_all_users.sql @@ -0,0 +1,151 @@ +-- Migration: Silent teams for all users +-- +-- Every user now gets a silent team at signup. This migration: +-- 1. Creates an ensure_user_team() RPC for idempotent team creation +-- 2. Backfills: creates teams for existing users who don't have one +-- 3. Assigns orphaned companies (team_id IS NULL) to their owner's team +-- 4. Deletes incomplete companies (mid-onboarding, no journal entries) + +-- ============================================================================= +-- 1. NEW RPC: ensure_user_team() +-- ============================================================================= +-- Idempotently ensures the calling user has a team. +-- Returns the team_id (existing or newly created). + +CREATE OR REPLACE FUNCTION public.ensure_user_team() +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_user_id uuid; + v_team_id uuid; +BEGIN + v_user_id := auth.uid(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Check if user already has a team + SELECT team_id INTO v_team_id + FROM public.team_members + WHERE user_id = v_user_id + LIMIT 1; + + IF v_team_id IS NOT NULL THEN + RETURN v_team_id; + END IF; + + -- Create a new team (name doesn't matter — hidden from UI) + INSERT INTO public.teams (name, created_by) + VALUES ('Personal', v_user_id) + RETURNING id INTO v_team_id; + + -- Add user as team owner + INSERT INTO public.team_members (team_id, user_id, role) + VALUES (v_team_id, v_user_id, 'owner'); + + RETURN v_team_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.ensure_user_team() TO authenticated; + +-- ============================================================================= +-- 2. BACKFILL: Create teams for existing users without one +-- ============================================================================= +-- Find all users who have company_members rows but no team_members rows. +-- Create a team for each and add them as owner. + +DO $$ +DECLARE + rec RECORD; + v_team_id uuid; +BEGIN + FOR rec IN + SELECT DISTINCT cm.user_id + FROM public.company_members cm + WHERE NOT EXISTS ( + SELECT 1 FROM public.team_members tm WHERE tm.user_id = cm.user_id + ) + LOOP + -- Create team + INSERT INTO public.teams (name, created_by) + VALUES ('Personal', rec.user_id) + RETURNING id INTO v_team_id; + + -- Add as owner + INSERT INTO public.team_members (team_id, user_id, role) + VALUES (v_team_id, rec.user_id, 'owner'); + + -- Assign all companies owned by this user to the new team + UPDATE public.companies + SET team_id = v_team_id + WHERE created_by = rec.user_id + AND team_id IS NULL; + END LOOP; +END; +$$; + +-- ============================================================================= +-- 3. Assign any remaining orphaned companies to their creator's team +-- ============================================================================= +-- Edge case: companies where team_id IS NULL but the creator already has a team +-- (e.g., they were a team member but also had solo companies). + +UPDATE public.companies c +SET team_id = ( + SELECT tm.team_id + FROM public.team_members tm + WHERE tm.user_id = c.created_by + LIMIT 1 +) +WHERE c.team_id IS NULL + AND EXISTS ( + SELECT 1 FROM public.team_members tm WHERE tm.user_id = c.created_by + ); + +-- ============================================================================= +-- 4. Delete incomplete companies (mid-onboarding cleanup) +-- ============================================================================= +-- Only delete companies where: +-- - onboarding_complete is false or no settings row exists +-- - There are zero journal entries +-- - There are zero transactions +-- This is safe because no real bookkeeping data exists. + +DO $$ +DECLARE + rec RECORD; + v_je_count int; + v_tx_count int; +BEGIN + FOR rec IN + SELECT c.id AS company_id + FROM public.companies c + LEFT JOIN public.company_settings cs ON cs.company_id = c.id + WHERE (cs.onboarding_complete IS NULL OR cs.onboarding_complete = false) + LOOP + -- Check for journal entries + SELECT count(*) INTO v_je_count + FROM public.journal_entries + WHERE company_id = rec.company_id; + + -- Check for transactions + SELECT count(*) INTO v_tx_count + FROM public.transactions + WHERE company_id = rec.company_id; + + -- Only delete if truly empty + IF v_je_count = 0 AND v_tx_count = 0 THEN + -- Delete dependent rows first (order matters for FK constraints) + DELETE FROM public.company_settings WHERE company_id = rec.company_id; + DELETE FROM public.fiscal_periods WHERE company_id = rec.company_id; + DELETE FROM public.chart_of_accounts WHERE company_id = rec.company_id; + DELETE FROM public.company_members WHERE company_id = rec.company_id; + DELETE FROM public.companies WHERE id = rec.company_id; + END IF; + END LOOP; +END; +$$;