diff --git a/app/(onboarding)/select-company/page.tsx b/app/(onboarding)/select-company/page.tsx new file mode 100644 index 00000000..8dbe50b0 --- /dev/null +++ b/app/(onboarding)/select-company/page.tsx @@ -0,0 +1,160 @@ +import { redirect } from 'next/navigation' +import { createClient, createServiceClient } from '@/lib/supabase/server' +import type { EnrichmentCompanyRole } from '@/lib/company-lookup/types' +import BankIdCompanyPicker, { + type MemberCompany, + type TicPickerCompany, +} from '@/components/onboarding/BankIdCompanyPicker' + +export const dynamic = 'force-dynamic' + +const ENRICHMENT_TTL_DAYS = 7 + +export default async function SelectCompanyPage() { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + redirect('/login') + } + + // Existing gnubok memberships. + const { data: memberships } = await supabase + .from('company_members') + .select(` + role, + company:company_id ( + id, + name, + org_number, + entity_type, + archived_at + ) + `) + .eq('user_id', user.id) + .order('joined_at', { ascending: true }) + + type CompanyRow = { + id: string + name: string + org_number: string | null + entity_type: string | null + archived_at: string | null + } + + const memberCompanies: MemberCompany[] = ((memberships ?? []) as unknown as Array<{ + role: string + // Supabase's generated types can express this as either a single object + // or an array depending on the relationship graph; handle both shapes. + company: CompanyRow | CompanyRow[] | null + }>) + .map((m) => ({ + role: m.role, + company: Array.isArray(m.company) ? m.company[0] ?? null : m.company, + })) + .filter((m): m is { role: string; company: CompanyRow } => !!m.company && !m.company.archived_at) + .map((m) => ({ + id: m.company.id, + name: m.company.name, + orgNumber: m.company.org_number, + entityType: m.company.entity_type, + role: m.role, + })) + + const memberOrgNumbers = new Set( + memberCompanies + .map((c) => (c.orgNumber ? c.orgNumber.replace(/[\s-]/g, '') : null)) + .filter((n): n is string => !!n), + ) + + // Ensure the user has a team (same pattern as /onboarding). + const { data: teamMembership } = await supabase + .from('team_members') + .select('team_id') + .eq('user_id', user.id) + .limit(1) + .maybeSingle() + + let teamId = teamMembership?.team_id + if (!teamId) { + const { data: ensured } = await supabase.rpc('ensure_user_team') + teamId = ensured ?? null + } + if (!teamId) { + redirect('/login') + } + + // Greeting name. + const { data: profile } = await supabase + .from('profiles') + .select('full_name') + .eq('id', user.id) + .single() + const firstName = profile?.full_name?.split(' ')[0] ?? null + + // TIC enrichment (SPAR + CompanyRoles). + const { data: enrichmentRow } = await supabase + .from('extension_data') + .select('value, created_at, updated_at') + .eq('user_id', user.id) + .eq('extension_id', 'tic') + .eq('key', 'bankid_enrichment') + .maybeSingle() + + const enrichmentValue = enrichmentRow?.value as { + companyRoles?: EnrichmentCompanyRole[] + } | null + + const activeRoles = (enrichmentValue?.companyRoles ?? []).filter( + (r) => r.companyStatus === 'Aktivt' && r.positionEnd === null, + ) + + // Drop TIC roles that already appear in the user's gnubok memberships — + // those render via the "Your gnubok companies" section above instead. + const rolesNotAlreadyMine = activeRoles.filter( + (r) => !memberOrgNumbers.has(r.companyRegistrationNumber.replace(/[\s-]/g, '')), + ) + + // Cross-reference remaining TIC org numbers against the global companies + // table to detect "exists in gnubok, user not a member" cases. Use the + // service client — RLS filters out companies the user isn't a member of, + // which is exactly the data we need. Scoped to the specific org numbers. + let externallyOwnedOrgs = new Set() + if (rolesNotAlreadyMine.length > 0) { + const service = createServiceClient() + const orgNumbers = rolesNotAlreadyMine.map((r) => + r.companyRegistrationNumber.replace(/[\s-]/g, ''), + ) + const { data: rows } = await service + .from('companies') + .select('org_number') + .in('org_number', orgNumbers) + .is('archived_at', null) + externallyOwnedOrgs = new Set( + (rows ?? []).map((r: { org_number: string | null }) => r.org_number ?? '').filter(Boolean), + ) + } + + const ticCompanies: TicPickerCompany[] = rolesNotAlreadyMine.map((role) => { + const cleaned = role.companyRegistrationNumber.replace(/[\s-]/g, '') + return { + role, + status: externallyOwnedOrgs.has(cleaned) ? 'exists' : 'new', + } + }) + + const enrichmentTimestamp = enrichmentRow?.updated_at ?? enrichmentRow?.created_at ?? null + const enrichmentStale = enrichmentTimestamp + ? Date.now() - new Date(enrichmentTimestamp).getTime() > ENRICHMENT_TTL_DAYS * 24 * 60 * 60 * 1000 + : false + + return ( + + ) +} diff --git a/components/dashboard/CompanySwitcher.tsx b/components/dashboard/CompanySwitcher.tsx index 208ac308..41626899 100644 --- a/components/dashboard/CompanySwitcher.tsx +++ b/components/dashboard/CompanySwitcher.tsx @@ -110,7 +110,7 @@ export default function CompanySwitcher() { if (!company && companies.length === 0) { return ( @@ -189,7 +189,7 @@ export default function CompanySwitcher() {
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" > diff --git a/components/dashboard/WelcomeOnboarding.tsx b/components/dashboard/WelcomeOnboarding.tsx index 5601a5f9..4ef175e2 100644 --- a/components/dashboard/WelcomeOnboarding.tsx +++ b/components/dashboard/WelcomeOnboarding.tsx @@ -6,10 +6,10 @@ import { createClient } from '@/lib/supabase/client' import { createCompanyFromOnboarding } from '@/lib/company/actions' import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period' import { useToast } from '@/components/ui/use-toast' -import { Loader2, Building2, Plus } from 'lucide-react' +import { Loader2, Building2 } from 'lucide-react' import { cn } from '@/lib/utils' import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' -import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' import type { CompanySettings, EntityType, MomsPeriod } from '@/types' import Step1EntityType from '@/components/onboarding/Step1EntityType' @@ -24,14 +24,6 @@ const STEP_INFO = [ { 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.' @@ -70,17 +62,18 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE const [settings, setSettings] = useState>({}) 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 + // Pre-fill the user's folkbokföringsadress from BankID/SPAR enrichment when + // available. The row is persisted (not consumed here) so /select-company and + // repeat /onboarding visits still see it; it's deleted only once a company + // is successfully created (see createCompanyFromOnboarding). useEffect(() => { - async function loadEnrichment() { + async function loadSparAddress() { const { data: { user } } = await supabase.auth.getUser() if (!user) { router.push('/login') @@ -90,37 +83,20 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE try { const { data: enrichmentRow } = await supabase .from('extension_data') - .select('id, value') + .select('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) + const spar = (enrichmentRow?.value as { spar?: Record } | null)?.spar + if (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, + })) } } catch (err) { console.warn(LOG, 'enrichment loading failed (non-blocking)', err) @@ -129,7 +105,7 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE setIsLoading(false) } - loadEnrichment() + loadSparAddress() }, [supabase, router]) const handleNext = async (stepData: Partial) => { @@ -216,21 +192,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE } } - /** 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 (
@@ -324,35 +285,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE {/* 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)} onBack={handleBack} isSaving={isSaving} - orgNumberLocked={orgNumberLocked} /> )} diff --git a/components/onboarding/BankIdCompanyPicker.tsx b/components/onboarding/BankIdCompanyPicker.tsx new file mode 100644 index 00000000..ea83e2ca --- /dev/null +++ b/components/onboarding/BankIdCompanyPicker.tsx @@ -0,0 +1,405 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { Building2, ArrowRight, Loader2, Plus, Check, AlertTriangle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useToast } from '@/components/ui/use-toast' +import { switchCompany, createCompanyFromTicRole } from '@/lib/company/actions' +import { mapEntityType } from '@/lib/company-lookup/entity-type-map' +import type { CompanyLookupResult, EnrichmentCompanyRole } from '@/lib/company-lookup/types' + +export interface MemberCompany { + id: string + name: string + orgNumber: string | null + entityType: string | null + role: string +} + +export interface TicPickerCompany { + role: EnrichmentCompanyRole + /** 'new' = not in gnubok, can set up. 'exists' = in gnubok but user is not a member. */ + status: 'new' | 'exists' +} + +interface BankIdCompanyPickerProps { + firstName: string | null + teamId: string + memberCompanies: MemberCompany[] + ticCompanies: TicPickerCompany[] + enrichmentStale: boolean +} + +type SetupState = + | { kind: 'idle' } + | { kind: 'opening'; companyId: string } + | { kind: 'creating'; orgNumber: string; step: 'lookup' | 'provision' } + +function humanEntityType(t: string | null | undefined): string { + if (!t) return '' + if (t === 'aktiebolag') return 'Aktiebolag' + if (t === 'enskild_firma') return 'Enskild firma' + // TIC legalEntityType strings ('AB', 'HB', etc.) fall through to this + return t +} + +function humanTicEntityType(t: string): string { + const mapped = mapEntityType(t) + if (mapped === 'aktiebolag') return 'Aktiebolag' + if (mapped === 'enskild_firma') return 'Enskild firma' + if (t.toLowerCase().includes('handelsbolag') || t.toLowerCase() === 'hb') return 'Handelsbolag' + if (t.toLowerCase().includes('kommanditbolag') || t.toLowerCase() === 'kb') return 'Kommanditbolag' + return t +} + +function positionLabel(role: EnrichmentCompanyRole): string { + const descs = role.positionDescriptions?.filter(Boolean) + if (descs && descs.length > 0) return descs.join(' · ') + const types = role.positionTypes?.filter(Boolean) + if (types && types.length > 0) return types.join(' · ') + return '' +} + +export default function BankIdCompanyPicker({ + firstName, + teamId, + memberCompanies, + ticCompanies, + enrichmentStale, +}: BankIdCompanyPickerProps) { + const router = useRouter() + const { toast } = useToast() + const [setup, setSetup] = useState({ kind: 'idle' }) + const [isPending, startTransition] = useTransition() + + 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 busy = setup.kind !== 'idle' || isPending + + async function handleOpenMember(companyId: string) { + if (busy) return + setSetup({ kind: 'opening', companyId }) + const result = await switchCompany(companyId) + if (result.error) { + toast({ title: 'Fel', description: result.error, variant: 'destructive' }) + setSetup({ kind: 'idle' }) + return + } + window.location.assign('/') + } + + async function handleCreateFromTic(role: EnrichmentCompanyRole) { + if (busy) return + const orgNumber = role.companyRegistrationNumber.replace(/[\s-]/g, '') + const mapped = mapEntityType(role.legalEntityType) + if (!mapped) { + // Entity type isn't supported end-to-end — route to manual wizard with + // the org number pre-filled. + router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`) + return + } + + setSetup({ kind: 'creating', orgNumber, step: 'lookup' }) + + let lookup: CompanyLookupResult | null = null + try { + const res = await fetch( + `/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`, + { method: 'GET' }, + ) + if (res.ok) { + const json = await res.json() + lookup = (json?.data as CompanyLookupResult | undefined) ?? null + } + } catch { + // Network/parse failure — fall through to the lookup-missing branch below. + } + + // Without a lookup we don't know the company's VAT/F-skatt status. + // Defaulting those to false for a momsregistrerat bolag would silently + // create a company that issues invoices without moms — ML 17 kap violation. + // Route to the manual wizard with the known fields pre-filled instead. + if (!lookup) { + toast({ + title: 'Kunde inte hämta företagsuppgifter', + description: 'Fyll i resterande uppgifter manuellt.', + }) + setSetup({ kind: 'idle' }) + router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`) + return + } + + setSetup({ kind: 'creating', orgNumber, step: 'provision' }) + + startTransition(async () => { + const result = await createCompanyFromTicRole({ + teamId, + orgNumber, + legalName: role.legalName, + legalEntityType: role.legalEntityType, + lookup, + }) + + if (result.error === 'lookup_missing') { + // Extremely unlikely (we just verified lookup above) but if it happens, + // the same fallback applies. + setSetup({ kind: 'idle' }) + router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`) + return + } + + if (result.error || !result.companyId) { + toast({ + title: 'Kunde inte skapa företag', + description: result.error ?? 'Försök igen eller lägg till manuellt.', + variant: 'destructive', + }) + setSetup({ kind: 'idle' }) + return + } + + toast({ title: 'Välkommen!', description: 'Ditt företag är nu redo.' }) + window.location.assign('/') + }) + } + + // Progress card while creating + if (setup.kind === 'creating') { + const lookupDone = setup.step === 'provision' + return ( +
+
+

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

+

Sätter upp ditt företag…

+
+ +
+
+ +
+

Org.nr {setup.orgNumber}

+
    +
  • + {lookupDone + ? + : } + + Hämtar uppgifter från Bolagsverket + +
  • +
  • + {lookupDone + ? + : } + + Skapar företag och kontoplan + +
  • +
+
+
+
+
+ ) + } + + return ( +
+
+

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

+

+ Välj ett företag att öppna eller lägg till ett nytt. +

+
+ +
+ {enrichmentStale && ( +
+
+ +

+ Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan. +

+
+
+ )} + + {memberCompanies.length > 0 && ( +
+

+ Dina företag i gnubok +

+
    + {memberCompanies.map((c) => { + const isOpening = setup.kind === 'opening' && setup.companyId === c.id + return ( +
  • + +
  • + ) + })} +
+
+ )} + + {ticCompanies.length > 0 && ( +
+

+ Företag kopplade till ditt BankID +

+
    + {ticCompanies.map(({ role, status }) => { + const cleaned = role.companyRegistrationNumber.replace(/[\s-]/g, '') + const position = positionLabel(role) + const entityLabel = humanTicEntityType(role.legalEntityType) + const mappable = mapEntityType(role.legalEntityType) !== null + + if (status === 'exists') { + return ( +
  • +
    +
    +
    +

    {role.legalName}

    +

    + {cleaned} · {entityLabel} +

    +
    + + Finns redan i gnubok + +
    +

    + Be en befintlig administratör att bjuda in dig. +

    +
    +
  • + ) + } + + if (!mappable) { + return ( +
  • + +
    +
    +

    {role.legalName}

    +

    + {cleaned} · {entityLabel} + {position ? ` · ${position}` : ''} +

    +
    + + Sätts upp manuellt + +
    + +
  • + ) + } + + return ( +
  • + +
  • + ) + })} +
+
+ )} + + {memberCompanies.length === 0 && ticCompanies.length === 0 && ( +

+ Inga företag hittades. Lägg till ditt första företag nedan. +

+ )} + +
+
+
+
+
+ + eller + +
+
+ + + + Lägg till företag manuellt + +
+
+ ) +} diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 60a4b183..2a560e74 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -24,10 +24,45 @@ import type { CompanyLookupResult } from '@/lib/company-lookup/types' import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid' import { createServiceClient } from '@/lib/supabase/server' import { createLogger } from '@/lib/logger' +import type { SupabaseClient } from '@supabase/supabase-js' import crypto from 'crypto' const log = createLogger('tic/bankid') +/** + * Request SPAR + CompanyRoles enrichment for a completed BankID session and + * cache the result in `extension_data` so /select-company and the onboarding + * wizard can pre-fill from it. Non-blocking: any failure is logged and + * swallowed — BankID auth must still succeed even if enrichment is down. + */ +async function fetchAndStoreEnrichment( + sessionId: string, + userId: string, + supabase: SupabaseClient, +): Promise { + try { + const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles']) + if (enrichment.status === 'Completed' && enrichment.secureUrl) { + const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl) + log.info('enrichment success', { + hasSpar: !!enrichmentData.spar, + companyCount: enrichmentData.companyRoles?.length ?? 0, + }) + + await supabase + .from('extension_data') + .upsert({ + user_id: userId, + extension_id: 'tic', + key: 'bankid_enrichment', + value: enrichmentData, + }, { onConflict: 'user_id,extension_id,key' }) + } + } catch (enrichError) { + log.warn('enrichment failed (non-blocking)', enrichError) + } +} + // Server-side per-IP rate limit for /bankid/start (each call = billable TIC session) const bankIdStartCooldowns = new Map() const BANKID_START_COOLDOWN_MS = 5_000 @@ -560,6 +595,9 @@ export const ticExtension: Extension = { ) } + // Refresh enrichment so /select-company sees current Bolagsverket roles. + await fetchAndStoreEnrichment(sessionId, existing.user_id, supabase) + return NextResponse.json({ data: { tokenHash: link.properties.hashed_token, @@ -655,30 +693,8 @@ export const ticExtension: Extension = { ) } - // Attempt enrichment and store for onboarding pre-fill - try { - const enrichment = await requestEnrichment(sessionId, ['SPAR', 'CompanyRoles']) - if (enrichment.status === 'Completed' && enrichment.secureUrl) { - const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl) - log.info('enrichment success', { - hasSpar: !!enrichmentData.spar, - companyCount: enrichmentData.companyRoles?.length ?? 0, - }) - - // Store enrichment data in extension_data for the onboarding page to read - await supabase - .from('extension_data') - .upsert({ - user_id: userId, - extension_id: 'tic', - key: 'bankid_enrichment', - value: enrichmentData, - }, { onConflict: 'user_id,extension_id,key' }) - } - } catch (enrichError) { - // Enrichment is optional — don't fail signup - log.warn('enrichment failed (non-blocking)', enrichError) - } + // Enrichment (SPAR + CompanyRoles) — pre-fills /select-company picker. + await fetchAndStoreEnrichment(sessionId, userId, supabase) return NextResponse.json({ data: { diff --git a/lib/company-lookup/__tests__/entity-type-map.test.ts b/lib/company-lookup/__tests__/entity-type-map.test.ts new file mode 100644 index 00000000..e7650d62 --- /dev/null +++ b/lib/company-lookup/__tests__/entity-type-map.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { mapEntityType } from '../entity-type-map' + +describe('mapEntityType', () => { + it('maps the exact AB codes and labels to aktiebolag', () => { + expect(mapEntityType('AB')).toBe('aktiebolag') + expect(mapEntityType('ab')).toBe('aktiebolag') + expect(mapEntityType('Aktiebolag')).toBe('aktiebolag') + expect(mapEntityType('Publikt aktiebolag')).toBe('aktiebolag') // same K2/K3 regime + expect(mapEntityType(' Aktiebolag ')).toBe('aktiebolag') // whitespace tolerant + }) + + it('maps the exact EF codes and labels to enskild_firma', () => { + expect(mapEntityType('EF')).toBe('enskild_firma') + expect(mapEntityType('ef')).toBe('enskild_firma') + expect(mapEntityType('Enskild firma')).toBe('enskild_firma') + expect(mapEntityType('Enskild näringsidkare')).toBe('enskild_firma') + }) + + it('returns null for unsupported entity types', () => { + expect(mapEntityType('HB')).toBeNull() + expect(mapEntityType('Handelsbolag')).toBeNull() + expect(mapEntityType('KB')).toBeNull() + expect(mapEntityType('Kommanditbolag')).toBeNull() + expect(mapEntityType('Stiftelse')).toBeNull() + expect(mapEntityType('Ekonomisk förening')).toBeNull() + expect(mapEntityType('Bostadsrättsförening')).toBeNull() + }) + + it('does not false-match strings that merely contain "enskild" or "aktiebolag"', () => { + // Regression guard: a loose substring match would misclassify these and + // provision them with K1/kontantmetoden defaults (ML/BFL risk). + expect(mapEntityType('Enskild stiftelse')).toBeNull() + expect(mapEntityType('Enskild näringsverksamhet utan firma')).toBeNull() + // Bank- and försäkringsaktiebolag follow FFFS, not K2/K3 — not a safe + // one-click provision. + expect(mapEntityType('Försäkringsaktiebolag')).toBeNull() + expect(mapEntityType('Bankaktiebolag')).toBeNull() + }) + + it('returns null for empty or nullish input', () => { + expect(mapEntityType('')).toBeNull() + expect(mapEntityType(null)).toBeNull() + expect(mapEntityType(undefined)).toBeNull() + }) +}) diff --git a/lib/company-lookup/entity-type-map.ts b/lib/company-lookup/entity-type-map.ts new file mode 100644 index 00000000..16bd94c6 --- /dev/null +++ b/lib/company-lookup/entity-type-map.ts @@ -0,0 +1,34 @@ +import type { EntityType } from '@/types' + +/** + * Explicit allow-lists for TIC/Bolagsverket `legalEntityType` → gnubok + * EntityType. Strict (not substring) matching avoids misclassifications like + * "Enskild stiftelse" → enskild_firma, which would provision with K1/ + * kontantmetoden defaults — an ML/BFL correctness risk. + * + * Publikt aktiebolag is included because the bookkeeping regime (K2/K3) and + * VAT treatment are identical to a privat AB. Specialized AB forms + * (Bankaktiebolag, Försäkringsaktiebolag) are deliberately excluded — they + * follow FFFS and need manual setup. + * + * Extend only with values whose bookkeeping regime is known to match. + */ +const AKTIEBOLAG_VALUES = new Set([ + 'ab', + 'aktiebolag', + 'publikt aktiebolag', +]) + +const ENSKILD_FIRMA_VALUES = new Set([ + 'ef', + 'enskild firma', + 'enskild näringsidkare', +]) + +export function mapEntityType(ticType: string | null | undefined): EntityType | null { + if (!ticType) return null + const normalized = ticType.trim().toLowerCase() + if (AKTIEBOLAG_VALUES.has(normalized)) return 'aktiebolag' + if (ENSKILD_FIRMA_VALUES.has(normalized)) return 'enskild_firma' + return null +} diff --git a/lib/company/__tests__/actions.test.ts b/lib/company/__tests__/actions.test.ts new file mode 100644 index 00000000..ff8350ae --- /dev/null +++ b/lib/company/__tests__/actions.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ + revalidatePath: vi.fn(), +})) + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/company/context', () => ({ + setActiveCompany: vi.fn().mockResolvedValue(undefined), +})) + +import { createClient } from '@/lib/supabase/server' +import { createCompanyFromTicRole } from '../actions' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' + +const mockCreateClient = vi.mocked(createClient) + +type CapturedCall = { table: string; method: string; args: unknown[] } + +/** + * Builds a chainable Supabase mock that records every method call, allows + * per-table result seeding, and returns a capture log the test can assert on. + * + * - `results[table][method]` (optional) is returned when the chain ends on + * that method. Chains otherwise resolve to `{ data: null, error: null }`. + * - Unknown methods on the chain no-op and return the chain so callers can + * keep chaining freely. + */ +function buildSupabase(opts: { + user: { id: string } | null + results?: Record> + rpcResults?: Record +}) { + const calls: CapturedCall[] = [] + const { user, results = {}, rpcResults = {} } = opts + + function makeChain(table: string) { + const record = (method: string, args: unknown[]) => { + calls.push({ table, method, args }) + } + const chain: Record = {} + const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle', 'single', 'insert', 'upsert', 'delete', 'update'] + for (const m of methods) { + chain[m] = (...args: unknown[]) => { + record(m, args) + const canTerminate = results[table]?.[m] + if (canTerminate) { + return Promise.resolve({ + data: canTerminate.data ?? null, + error: canTerminate.error ?? null, + }) + } + return chain + } + } + chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null }) + return chain + } + + const supabase = { + auth: { + getUser: vi.fn().mockResolvedValue({ data: { user } }), + }, + from: vi.fn().mockImplementation((table: string) => makeChain(table)), + rpc: vi.fn().mockImplementation((name: string) => { + const result = rpcResults[name] + if (result) { + return Promise.resolve({ data: result.data ?? null, error: result.error ?? null }) + } + return Promise.resolve({ data: null, error: null }) + }), + } + + return { supabase, calls } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createCompanyFromTicRole', () => { + it('returns Unauthorized when no user session', async () => { + const { supabase } = buildSupabase({ user: null }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromTicRole({ + teamId: 'team-1', + orgNumber: '5566778899', + legalName: 'Acme AB', + legalEntityType: 'AB', + lookup: null, + }) + + expect(result.error).toBe('Unauthorized') + }) + + it('rejects unmappable entity types before any DB work', async () => { + const { supabase, calls } = buildSupabase({ user: { id: 'user-1' } }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromTicRole({ + teamId: 'team-1', + orgNumber: '969696-1212', + legalName: 'Beta HB', + legalEntityType: 'Handelsbolag', + lookup: null, + }) + + expect(result.error).toMatch(/manuellt/i) + // Entity-type rejection should short-circuit — no table writes. + const writes = calls.filter((c) => ['insert', 'upsert', 'delete', 'update'].includes(c.method)) + expect(writes).toEqual([]) + }) + + it('refuses to guess when TIC lookup is missing (prevents silent ML 17 kap violation)', async () => { + const { supabase, calls } = buildSupabase({ user: { id: 'user-1' } }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromTicRole({ + teamId: 'team-1', + orgNumber: '5566778899', + legalName: 'Acme AB', + legalEntityType: 'AB', + lookup: null, + }) + + expect(result.error).toBe('lookup_missing') + // Must not have provisioned anything with a guessed VAT status. + const writes = calls.filter((c) => ['insert', 'upsert', 'delete', 'update'].includes(c.method)) + expect(writes).toEqual([]) + }) + + it('provisions with sensible defaults for a VAT-registered aktiebolag', async () => { + const lookup: CompanyLookupResult = { + companyName: 'Acme Konsult AB', + isCeased: false, + address: { street: 'Storgatan 1', postalCode: '11122', city: 'Stockholm' }, + registration: { fTax: true, vat: true }, + bankAccounts: [], + email: null, + phone: null, + sniCodes: [], + } + + const { supabase, calls } = buildSupabase({ + user: { id: 'user-1' }, + results: { + // Seed an enrichment row so the cleanup branch runs and the test + // can verify it fires. + extension_data: { + maybeSingle: { data: { id: 'enrichment-1', value: {} } }, + }, + }, + rpcResults: { + create_company_with_owner: { data: 'new-company-id' }, + seed_chart_of_accounts: { data: null }, + }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromTicRole({ + teamId: 'team-1', + orgNumber: '5566778899', + legalName: 'Acme Konsult AB', + legalEntityType: 'AB', + lookup, + }) + + expect(result.companyId).toBe('new-company-id') + expect(result.error).toBeUndefined() + + // The settings upsert on company_settings should reflect our derived defaults. + const settingsUpsert = calls.find((c) => c.table === 'company_settings' && c.method === 'upsert') + expect(settingsUpsert).toBeDefined() + const settings = (settingsUpsert!.args[0] as Record) + expect(settings.entity_type).toBe('aktiebolag') + expect(settings.company_name).toBe('Acme Konsult AB') + expect(settings.org_number).toBe('5566778899') + expect(settings.f_skatt).toBe(true) + expect(settings.vat_registered).toBe(true) + expect(settings.moms_period).toBe('quarterly') + expect(settings.accounting_method).toBe('accrual') + expect(settings.address_line1).toBe('Storgatan 1') + expect(settings.postal_code).toBe('11122') + expect(settings.city).toBe('Stockholm') + + // The enrichment row must be cleaned up by the one-click path so the + // picker doesn't re-offer this company on a return visit. + const enrichmentDelete = calls.find( + (c) => c.table === 'extension_data' && c.method === 'delete', + ) + expect(enrichmentDelete).toBeDefined() + }) + + it('defaults enskild firma to kontantmetoden (K1), leaves moms_period null when non-VAT', async () => { + const lookup: CompanyLookupResult = { + companyName: 'Liten EF', + isCeased: false, + address: null, + registration: { fTax: true, vat: false }, + bankAccounts: [], + email: null, + phone: null, + sniCodes: [], + } + + const { supabase, calls } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { + create_company_with_owner: { data: 'new-company-id' }, + seed_chart_of_accounts: { data: null }, + }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + await createCompanyFromTicRole({ + teamId: 'team-1', + orgNumber: '8001011234', + legalName: 'Liten EF', + legalEntityType: 'Enskild firma', + lookup, + }) + + const settingsUpsert = calls.find((c) => c.table === 'company_settings' && c.method === 'upsert') + const settings = settingsUpsert!.args[0] as Record + expect(settings.entity_type).toBe('enskild_firma') + expect(settings.vat_registered).toBe(false) + expect(settings.moms_period).toBeNull() + // EF entities default to cash per K1/BFNAR 2013:2; AB must use accrual (K2/K3). + expect(settings.accounting_method).toBe('cash') + }) +}) diff --git a/lib/company/actions.ts b/lib/company/actions.ts index 2e961ae4..b7a6d702 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -3,6 +3,9 @@ import { createClient } from '@/lib/supabase/server' import { setActiveCompany } from '@/lib/company/context' import { revalidatePath } from 'next/cache' +import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period' +import { mapEntityType } from '@/lib/company-lookup/entity-type-map' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' export async function switchCompany(companyId: string): Promise<{ error?: string }> { const supabase = await createClient() @@ -146,3 +149,127 @@ export async function createCompanyFromOnboarding(params: { revalidatePath('/') return { companyId: newCompanyId } } + +/** + * One-click company setup from a TIC/Bolagsverket company role. + * + * The picker page at /select-company passes a `CompanyLookupResult` already + * fetched from `/api/extensions/ext/tic/lookup`, plus the `EnrichmentCompanyRole` + * minimums (org number, legal name, legal entity type). This action derives + * sensible defaults (accrual, quarterly moms for VAT-registered, Jan-Dec + * fiscal year), reads SPAR address from `extension_data` as a fallback, and + * then delegates to `createCompanyFromOnboarding` so the provisioning path is + * identical to the manual wizard. On success it clears the enrichment row + * consumed by this path — the manual wizard leaves it intact so a returning + * BankID user can still reach `/select-company` and pick another directorship. + * + * Requires `lookup` to be non-null: if TIC `/lookup` is unreachable, the client + * must route to the manual wizard instead. Silently defaulting `vat_registered` + * to false for a momsregistrerat bolag would violate ML 17 kap (invoices + * without moms), so we refuse to guess. + */ +export async function createCompanyFromTicRole(params: { + teamId: string + orgNumber: string + legalName: string + legalEntityType: string + lookup: CompanyLookupResult | null +}): Promise<{ companyId?: string; error?: string }> { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return { error: 'Unauthorized' } + } + + const entityType = mapEntityType(params.legalEntityType) + if (!entityType) { + return { error: 'Den här företagsformen måste sättas upp manuellt.' } + } + + // If the TIC lookup failed we don't know the company's VAT/F-skatt status. + // Refuse to silently guess — the caller routes to the manual wizard so the + // user can confirm these fields themselves. + if (!params.lookup) { + return { error: 'lookup_missing' } + } + + // SPAR address fallback if the TIC lookup didn't include one. + 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() + + const spar = (enrichmentRow?.value as { spar?: Record } | null)?.spar + const sparStreet = spar?.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 + const sparPostal = spar?.Folkbokforingsadress_SvenskAdress_PostNr + const sparCity = spar?.Folkbokforingsadress_SvenskAdress_Postort + + const addressStreet = params.lookup.address?.street ?? sparStreet ?? null + const addressPostal = params.lookup.address?.postalCode ?? sparPostal ?? null + const addressCity = params.lookup.address?.city ?? sparCity ?? null + + const fTax = params.lookup.registration.fTax + const vatRegistered = params.lookup.registration.vat + + // moms_period: Skatteverket assigns the actual reporting period from + // annual beskattningsunderlag (≤1 MSEK → yearly, ≤40 MSEK → quarterly, + // >40 MSEK → monthly). TIC /lookup doesn't expose turnover, so we pick the + // middle-tier default. The user must verify it matches their Skatteverket + // assignment in /settings/tax — a mismatch causes late-filing penalties + // under SFL. + const momsPeriod = vatRegistered ? 'quarterly' : null + + // EF ≤3 MSEK may use kontantmetoden under K1/BFNAR 2013:2; above that + // threshold, BFNAR 2017:3 requires bokföringsmässiga grunder. We default + // to cash because the vast majority of EF users are small; users above + // the threshold can switch in /settings/bookkeeping. Aktiebolag must use + // accrual under K2/K3. + const accountingMethod = entityType === 'enskild_firma' ? 'cash' : 'accrual' + + const settings: Record = { + entity_type: entityType, + company_name: params.legalName, + org_number: params.orgNumber.replace(/[\s-]/g, ''), + f_skatt: fTax, + vat_registered: vatRegistered, + moms_period: momsPeriod, + accounting_method: accountingMethod, + fiscal_year_start_month: 1, + address_line1: addressStreet, + postal_code: addressPostal, + city: addressCity, + } + + const periodResult = computeFiscalPeriod(settings) + if (periodResult.error) { + return { error: 'Kunde inte beräkna räkenskapsår.' } + } + + const result = await createCompanyFromOnboarding({ + teamId: params.teamId, + settings, + fiscalPeriod: { + startDate: periodResult.startStr, + endDate: periodResult.endStr, + name: periodResult.periodName, + }, + }) + + if (result.error || !result.companyId) { + return { error: result.error ?? 'Kunde inte skapa företag. Försök igen.' } + } + + // One-time use: drop the enrichment row now that the user has committed to + // a TIC-suggested company. The manual wizard intentionally does NOT do this + // so a user with multiple directorships can still reach /select-company + // afterwards and provision another one. + if (enrichmentRow?.id) { + await supabase.from('extension_data').delete().eq('id', enrichmentRow.id) + } + + return { companyId: result.companyId } +} diff --git a/lib/supabase/middleware.ts b/lib/supabase/middleware.ts index f0c30924..1604c629 100644 --- a/lib/supabase/middleware.ts +++ b/lib/supabase/middleware.ts @@ -138,16 +138,29 @@ export async function updateSession(request: NextRequest) { // their account without being trapped on /onboarding forever. const isNoCompanyAllowed = pathname.startsWith('/onboarding') || + pathname.startsWith('/select-company') || pathname.startsWith('/settings/account') || pathname.startsWith('/api/account/') || pathname.startsWith('/api/company') - // No companies — redirect to onboarding, but allow the escape-hatch routes + // No companies — redirect to the picker if we have BankID enrichment for + // this user, otherwise the manual wizard. Either way, allow the escape-hatch + // routes to pass through. if (!companyId) { if (isNoCompanyAllowed) { return supabaseResponse } - return NextResponse.redirect(new URL('/onboarding', request.url)) + + const { data: enrichmentRow } = await supabase + .from('extension_data') + .select('id') + .eq('user_id', user.id) + .eq('extension_id', 'tic') + .eq('key', 'bankid_enrichment') + .maybeSingle() + + const destination = enrichmentRow ? '/select-company' : '/onboarding' + return NextResponse.redirect(new URL(destination, request.url)) } // Set company cookie on the response so downstream requests have it