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
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user