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
+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}
/>
)}