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
@@ -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>
)
}