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
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) {
return (
<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"
>
<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')}>
<Link
href="/onboarding"
href="/select-company"
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"
>
+17 -86
View File
@@ -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<Partial<CompanySettings>>({})
const ticEnabled = ENABLED_EXTENSION_IDS.has('tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(null)
const [enrichmentCompanies, setEnrichmentCompanies] = useState<EnrichmentCompanyRole[]>([])
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<string, string>; 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<string, string> } | 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<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) {
return (
<div className="flex items-center justify-center py-20">
@@ -324,35 +285,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
{/* Form content */}
<div className="px-6 py-6">
<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 && (
<Step1EntityType
initialData={{ entity_type: settings.entity_type as EntityType }}
@@ -377,7 +309,6 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE
onNext={(data) => handleNext(data)}
onBack={handleBack}
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 { 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<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)
const bankIdStartCooldowns = new Map<string, number>()
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: {
@@ -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 { 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<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.
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