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:
co-authored by
Claude Opus 4.7
parent
adf58a51c0
commit
f3a3d07ed3
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user