feat: one-click company setup from BankID directorships (#309)

* feat: one-click company setup from BankID directorships

After BankID auth, surface Bolagsverket companies where the user is a
director and provision a fully-configured gnubok company with one click
instead of walking the 4-step wizard. Also exposed via CompanySwitcher's
"Lägg till företag" for returning users.

- New /select-company route merges gnubok memberships with TIC
  CompanyRoles; cards flag already-registered org numbers.
- createCompanyFromTicRole server action derives entity_type, f-skatt,
  VAT, moms_period, and SPAR address defaults, then delegates to
  createCompanyFromOnboarding for consistent provisioning.
- TIC /bankid/complete now requests enrichment on login too, so
  returning users see fresh CompanyRoles in the picker.
- Middleware routes zero-membership users to /select-company when
  enrichment is available, /onboarding otherwise.
- Inline enrichment picker removed from WelcomeOnboarding (wizard is
  now the manual fallback); SPAR address pre-fill preserved.
- Unit tests for mapEntityType helper and createCompanyFromTicRole
  defaults (VAT-AB, non-VAT EF, unmappable, unauth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR review feedback on BankID company picker

Greptile P1 + swedish-compliance bot findings:

- Move enrichment row cleanup out of createCompanyFromOnboarding and
  into createCompanyFromTicRole. The manual wizard also goes through
  createCompanyFromOnboarding, and was wiping the enrichment row before
  the returning-user "Lägg till företag" flow could use it.
- Refuse to provision when TIC /lookup is missing. Silently defaulting
  vat_registered to false for a momsregistrerat bolag would create a
  company that issues invoices without moms (ML 17 kap violation). The
  picker now routes to the manual wizard with org_number pre-filled
  when the lookup fails, so the user confirms VAT/F-skatt manually.
- Default accounting_method by entity type: enskild firma → cash
  (K1/kontantmetoden per BFNAR 2013:2), aktiebolag → accrual (K2/K3).
- Document that moms_period='quarterly' is a provisional middle-tier
  default; Skatteverket's assigned period depends on turnover and the
  user can correct it in /settings/tax.
- Fix the misleading "re-fetch from BankID" comment — /select-company
  only reads the cached enrichment row; it's refreshed only on the next
  BankID auth.
- Extend test coverage: lookup-missing refusal, EF kontantmetoden default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: tighten entity-type mapping and clarify K1 threshold

Second round of PR review fixes (swedish-compliance bot):

- mapEntityType now uses explicit allow-lists instead of substring
  matches. "Enskild stiftelse" / "Enskild näringsverksamhet utan firma"
  no longer false-match as enskild_firma (would have provisioned with
  K1/kontantmetoden — ML/BFL risk). Regression guard test added.
- Publikt aktiebolag explicitly included (same K2/K3 regime as private
  AB); Bankaktiebolag / Försäkringsaktiebolag excluded (FFFS regime).
- Remove misleading claim that onboarding UI flags moms_period as
  provisional — no such UI exists by design (approved one-click UX).
- Expand accounting_method comment to cite the 3 MSEK K1→K3 threshold
  (BFNAR 2013:2 vs 2017:3) so the EF→cash default is honest about its
  scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-22 13:21:55 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent adf58a51c0
commit f3a3d07ed3
10 changed files with 1081 additions and 114 deletions
+160
View File
@@ -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<string>()
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 (
<BankIdCompanyPicker
firstName={firstName}
teamId={teamId}
memberCompanies={memberCompanies}
ticCompanies={ticCompanies}
enrichmentStale={enrichmentStale}
/>
)
}
+2 -2
View File
@@ -110,7 +110,7 @@ export default function CompanySwitcher() {
if (!company && companies.length === 0) { if (!company && companies.length === 0) {
return ( return (
<Link <Link
href="/onboarding" href="/select-company"
className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150" className="flex items-center gap-2 w-full text-left rounded-lg border border-dashed border-border/60 hover:border-foreground/30 hover:bg-muted/40 -mx-1 px-2 py-1.5 transition-all duration-150"
> >
<Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" /> <Plus className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
@@ -189,7 +189,7 @@ export default function CompanySwitcher() {
<div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}> <div className={cn(companies.length > 0 && 'border-t border-border/40 mt-1 pt-1', 'px-1')}>
<Link <Link
href="/onboarding" href="/select-company"
onClick={() => setOpen(false)} onClick={() => 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" 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"
> >
+17 -86
View File
@@ -6,10 +6,10 @@ import { createClient } from '@/lib/supabase/client'
import { createCompanyFromOnboarding } from '@/lib/company/actions' import { createCompanyFromOnboarding } from '@/lib/company/actions'
import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period' import { computeFiscalPeriod } from '@/lib/company/compute-fiscal-period'
import { useToast } from '@/components/ui/use-toast' 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 { cn } from '@/lib/utils'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions' 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 type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step1EntityType from '@/components/onboarding/Step1EntityType' import Step1EntityType from '@/components/onboarding/Step1EntityType'
@@ -24,14 +24,6 @@ const STEP_INFO = [
{ title: 'Moms & bokföring', subtitle: 'Momsregistrering och bokföringsmetod.' }, { 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 { function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.' 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('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<Partial<CompanySettings>>({}) const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null) const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
const [enrichmentCompanies, setEnrichmentCompanies] = useState<EnrichmentCompanyRole[]>([])
const [orgNumberLocked, setOrgNumberLocked] = useState(false)
const totalSteps = 4 const totalSteps = 4
const hour = new Date().getHours() 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 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(() => { useEffect(() => {
async function loadEnrichment() { async function loadSparAddress() {
const { data: { user } } = await supabase.auth.getUser() const { data: { user } } = await supabase.auth.getUser()
if (!user) { if (!user) {
router.push('/login') router.push('/login')
@@ -90,37 +83,20 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
try { try {
const { data: enrichmentRow } = await supabase const { data: enrichmentRow } = await supabase
.from('extension_data') .from('extension_data')
.select('id, value') .select('value')
.eq('user_id', user.id) .eq('user_id', user.id)
.eq('extension_id', 'tic') .eq('extension_id', 'tic')
.eq('key', 'bankid_enrichment') .eq('key', 'bankid_enrichment')
.maybeSingle() .maybeSingle()
if (enrichmentRow?.value) { const spar = (enrichmentRow?.value as { spar?: Record<string, string> } | null)?.spar
const enrichment = enrichmentRow.value as { spar?: Record<string, string>; companyRoles?: EnrichmentCompanyRole[] } if (spar) {
setSettings((prev) => ({
const activeCompanies = (enrichment.companyRoles ?? []).filter( ...prev,
(c: EnrichmentCompanyRole) => c.companyStatus === 'Aktivt' && c.positionEnd === null address_line1: spar.Folkbokforingsadress_SvenskAdress_Utdelningsadress1 || prev.address_line1,
) postal_code: spar.Folkbokforingsadress_SvenskAdress_PostNr || prev.postal_code,
if (activeCompanies.length > 0) { city: spar.Folkbokforingsadress_SvenskAdress_Postort || prev.city,
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) { } catch (err) {
console.warn(LOG, 'enrichment loading failed (non-blocking)', err) console.warn(LOG, 'enrichment loading failed (non-blocking)', err)
@@ -129,7 +105,7 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
setIsLoading(false) setIsLoading(false)
} }
loadEnrichment() loadSparAddress()
}, [supabase, router]) }, [supabase, router])
const handleNext = async (stepData: Partial<CompanySettings>) => { const handleNext = async (stepData: Partial<CompanySettings>) => {
@@ -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) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
@@ -324,35 +285,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
{/* Form content */} {/* Form content */}
<div className="px-6 py-6"> <div className="px-6 py-6">
<div key={`step-${currentStep}`} className="animate-slide-up"> <div key={`step-${currentStep}`} className="animate-slide-up">
{currentStep === 1 && enrichmentCompanies.length > 0 && (
<div className="mb-6 space-y-3">
<p className="text-sm font-medium">Vi hittade dessa företag kopplade till ditt BankID:</p>
{enrichmentCompanies.map((company) => {
const entityType = mapEntityType(company.legalEntityType)
return (
<button
key={company.companyRegistrationNumber}
onClick={() => handleEnrichmentSelect(company)}
className="w-full rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-primary/[0.02]"
>
<p className="font-medium text-sm">{company.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{company.companyRegistrationNumber} · {entityType === 'aktiebolag' ? 'Aktiebolag' : entityType === 'enskild_firma' ? 'Enskild firma' : company.legalEntityType}
</p>
</button>
)
})}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">eller välj företagsform manuellt</span>
</div>
</div>
</div>
)}
{currentStep === 1 && ( {currentStep === 1 && (
<Step1EntityType <Step1EntityType
initialData={{ entity_type: settings.entity_type as EntityType }} initialData={{ entity_type: settings.entity_type as EntityType }}
@@ -377,7 +309,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
onNext={(data) => handleNext(data)} onNext={(data) => handleNext(data)}
onBack={handleBack} onBack={handleBack}
isSaving={isSaving} isSaving={isSaving}
orgNumberLocked={orgNumberLocked}
/> />
)} )}
@@ -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<SetupState>({ 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 (
<div className="stagger-enter">
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">Sätter upp ditt företag…</p>
</header>
<div className="max-w-lg rounded-xl border bg-card p-6" style={{ boxShadow: 'var(--shadow-md)' }}>
<div className="flex items-start gap-3">
<Building2 className="h-5 w-5 text-muted-foreground mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Org.nr {setup.orgNumber}</p>
<ul className="mt-3 space-y-2 text-sm">
<li className="flex items-center gap-2">
{lookupDone
? <Check className="h-4 w-4 text-sage" />
: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
<span className={cn(!lookupDone && 'text-muted-foreground')}>
Hämtar uppgifter från Bolagsverket
</span>
</li>
<li className="flex items-center gap-2">
{lookupDone
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <span className="h-4 w-4 inline-block rounded-full border border-border" />}
<span className={cn(!lookupDone && 'text-muted-foreground/50')}>
Skapar företag och kontoplan
</span>
</li>
</ul>
</div>
</div>
</div>
</div>
)
}
return (
<div className="stagger-enter">
<header className="mb-10">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="text-muted-foreground text-sm mt-1.5">
Välj ett företag att öppna eller lägg till ett nytt.
</p>
</header>
<div className="max-w-lg space-y-8">
{enrichmentStale && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/60 dark:bg-amber-950/30">
<div className="flex items-start gap-2.5">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
Uppgifterna från BankID är äldre än en vecka. Logga in med BankID igen för att uppdatera listan.
</p>
</div>
</div>
)}
{memberCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
Dina företag i gnubok
</h2>
<ul className="space-y-2">
{memberCompanies.map((c) => {
const isOpening = setup.kind === 'opening' && setup.companyId === c.id
return (
<li key={c.id}>
<button
type="button"
onClick={() => handleOpenMember(c.id)}
disabled={busy}
className={cn(
'w-full rounded-lg border bg-card p-4 text-left transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'opacity-60 cursor-not-allowed',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{c.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{c.orgNumber ? `${c.orgNumber} · ` : ''}
{humanEntityType(c.entityType)}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{c.role !== 'owner' && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/80">
{c.role}
</span>
)}
{isOpening
? <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
: <ArrowRight className="h-4 w-4 text-muted-foreground" />}
</div>
</div>
</button>
</li>
)
})}
</ul>
</section>
)}
{ticCompanies.length > 0 && (
<section>
<h2 className="text-xs uppercase tracking-[0.08em] text-muted-foreground mb-3">
Företag kopplade till ditt BankID
</h2>
<ul className="space-y-2">
{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 (
<li key={cleaned}>
<div className="w-full rounded-lg border bg-muted/20 p-4 text-left opacity-70">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
Finns redan i gnubok
</span>
</div>
<p className="text-xs text-muted-foreground/70 mt-2">
Be en befintlig administratör att bjuda in dig.
</p>
</div>
</li>
)
}
if (!mappable) {
return (
<li key={cleaned}>
<Link
href={`/onboarding?org_number=${encodeURIComponent(cleaned)}`}
className={cn(
'block w-full rounded-lg border bg-card p-4 text-left transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'pointer-events-none opacity-60',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
{position ? ` · ${position}` : ''}
</p>
</div>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground flex-shrink-0">
Sätts upp manuellt
</span>
</div>
</Link>
</li>
)
}
return (
<li key={cleaned}>
<button
type="button"
onClick={() => handleCreateFromTic(role)}
disabled={busy}
className={cn(
'w-full rounded-lg border bg-card p-4 text-left transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'opacity-60 cursor-not-allowed',
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{role.legalName}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{cleaned} · {entityLabel}
{position ? ` · ${position}` : ''}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
</button>
</li>
)
})}
</ul>
</section>
)}
{memberCompanies.length === 0 && ticCompanies.length === 0 && (
<p className="text-sm text-muted-foreground">
Inga företag hittades. Lägg till ditt första företag nedan.
</p>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden>
<div className="w-full border-t border-border/60" />
</div>
<div className="relative flex justify-center">
<span className="bg-background px-3 text-xs uppercase tracking-[0.08em] text-muted-foreground">
eller
</span>
</div>
</div>
<Link
href="/onboarding"
className={cn(
'flex items-center justify-center gap-2 w-full rounded-lg border border-dashed p-4 text-sm font-medium transition-colors',
!busy && 'hover:border-foreground/30 hover:bg-muted/30',
busy && 'pointer-events-none opacity-60',
)}
>
<Plus className="h-4 w-4" />
Lägg till företag manuellt
</Link>
</div>
</div>
)
}
+40 -24
View File
@@ -24,10 +24,45 @@ import type { CompanyLookupResult } from '@/lib/company-lookup/types'
import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid' import { hashPersonalNumber, encryptPersonalNumber } from '@/lib/auth/bankid'
import { createServiceClient } from '@/lib/supabase/server' import { createServiceClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger' import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import crypto from 'crypto' import crypto from 'crypto'
const log = createLogger('tic/bankid') 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<void> {
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) // Server-side per-IP rate limit for /bankid/start (each call = billable TIC session)
const bankIdStartCooldowns = new Map<string, number>() const bankIdStartCooldowns = new Map<string, number>()
const BANKID_START_COOLDOWN_MS = 5_000 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({ return NextResponse.json({
data: { data: {
tokenHash: link.properties.hashed_token, tokenHash: link.properties.hashed_token,
@@ -655,30 +693,8 @@ export const ticExtension: Extension = {
) )
} }
// Attempt enrichment and store for onboarding pre-fill // Enrichment (SPAR + CompanyRoles) — pre-fills /select-company picker.
try { await fetchAndStoreEnrichment(sessionId, userId, supabase)
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)
}
return NextResponse.json({ return NextResponse.json({
data: { data: {
@@ -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()
})
})
+34
View File
@@ -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<string>([
'ab',
'aktiebolag',
'publikt aktiebolag',
])
const ENSKILD_FIRMA_VALUES = new Set<string>([
'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
}
+235
View File
@@ -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<string, Record<string, { data?: unknown; error?: unknown }>>
rpcResults?: Record<string, { data?: unknown; error?: unknown }>
}) {
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<string, unknown> = {}
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<string, unknown>)
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<string, unknown>
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')
})
})
+127
View File
@@ -3,6 +3,9 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { setActiveCompany } from '@/lib/company/context' import { setActiveCompany } from '@/lib/company/context'
import { revalidatePath } from 'next/cache' 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 }> { export async function switchCompany(companyId: string): Promise<{ error?: string }> {
const supabase = await createClient() const supabase = await createClient()
@@ -146,3 +149,127 @@ export async function createCompanyFromOnboarding(params: {
revalidatePath('/') revalidatePath('/')
return { companyId: newCompanyId } 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<string, string | undefined> } | 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<string, unknown> = {
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 }
}
+15 -2
View File
@@ -138,16 +138,29 @@ export async function updateSession(request: NextRequest) {
// their account without being trapped on /onboarding forever. // their account without being trapped on /onboarding forever.
const isNoCompanyAllowed = const isNoCompanyAllowed =
pathname.startsWith('/onboarding') || pathname.startsWith('/onboarding') ||
pathname.startsWith('/select-company') ||
pathname.startsWith('/settings/account') || pathname.startsWith('/settings/account') ||
pathname.startsWith('/api/account/') || pathname.startsWith('/api/account/') ||
pathname.startsWith('/api/company') 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 (!companyId) {
if (isNoCompanyAllowed) { if (isNoCompanyAllowed) {
return supabaseResponse 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 // Set company cookie on the response so downstream requests have it