diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index c0cc42d5..d50ab6fc 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -4,7 +4,11 @@ import WelcomeOnboarding from '@/components/dashboard/WelcomeOnboarding' export const dynamic = 'force-dynamic' -export default async function OnboardingPage() { +export default async function OnboardingPage({ + searchParams, +}: { + searchParams: Promise<{ org_number?: string }> +}) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -42,5 +46,19 @@ export default async function OnboardingPage() { const firstName = profile?.full_name?.split(' ')[0] || null - return + // The BankID picker routes here with ?org_number=… when TIC /lookup fails + // or the entity type isn't one-click-provisionable. Strip formatting so + // whatever Step2 displays matches what the rest of the flow will store. + const { org_number: rawOrgNumber } = await searchParams + const initialOrgNumber = rawOrgNumber ? rawOrgNumber.replace(/[\s-]/g, '') : undefined + + return ( + + ) } diff --git a/app/(onboarding)/select-company/page.tsx b/app/(onboarding)/select-company/page.tsx index 8dbe50b0..a6e46e50 100644 --- a/app/(onboarding)/select-company/page.tsx +++ b/app/(onboarding)/select-company/page.tsx @@ -105,8 +105,22 @@ export default async function SelectCompanyPage() { companyRoles?: EnrichmentCompanyRole[] } | null + // "Currently a director" = no position end date. We deliberately do NOT + // also require companyStatus === 'Aktivt': real TIC payloads have been + // observed with other values (locale/tenant variants), and filtering too + // strictly silently hides the user's real directorships. + // + // Ceased/struck-off companies would still render here, but two later + // guards block provisioning: + // 1. BankIdCompanyPicker calls TIC /lookup before provisioning and + // short-circuits with a toast when isCeased=true. + // 2. createCompanyFromTicRole refuses to provision when lookup.isCeased. + // Both guards are required — don't remove one without removing both. + // + // Loose `== null` on purpose: TIC payloads have been observed returning + // `undefined` for open-ended positions, which `=== null` would miss. const activeRoles = (enrichmentValue?.companyRoles ?? []).filter( - (r) => r.companyStatus === 'Aktivt' && r.positionEnd === null, + (r) => r.positionEnd == null, ) // Drop TIC roles that already appear in the user's gnubok memberships — diff --git a/app/api/company/check-org-number/__tests__/route.test.ts b/app/api/company/check-org-number/__tests__/route.test.ts new file mode 100644 index 00000000..e6cbfc7d --- /dev/null +++ b/app/api/company/check-org-number/__tests__/route.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createMockRequest, parseJsonResponse } from '@/tests/helpers' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), + createServiceClient: vi.fn(), +})) + +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { GET } from '../route' + +const mockCreateClient = vi.mocked(createClient) +const mockCreateServiceClient = vi.mocked(createServiceClient) + +function mockAuth(user: { id: string } | null) { + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user } }) }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +/** + * Service-role mock. Returns a match only if the incoming `.eq('org_number', X)` + * value matches `existing`. Anything else (or empty `existing`) returns null. + */ +function mockService(existing?: string) { + let lastOrgNumber: string | null = null + const chain: Record = {} + const methods = ['select', 'eq', 'is', 'limit', 'maybeSingle'] + for (const m of methods) { + chain[m] = (...args: unknown[]) => { + if (m === 'eq' && args[0] === 'org_number') { + lastOrgNumber = String(args[1]) + } + if (m === 'maybeSingle') { + return Promise.resolve({ + data: existing && lastOrgNumber === existing ? { id: 'other' } : null, + error: null, + }) + } + return chain + } + } + mockCreateServiceClient.mockReturnValue({ + from: vi.fn().mockReturnValue(chain), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/company/check-org-number', () => { + it('returns 401 when unauthenticated', async () => { + mockAuth(null) + mockService() + const req = createMockRequest('/api/company/check-org-number?org_number=5560125790') + const { status } = await parseJsonResponse(await GET(req)) + expect(status).toBe(401) + }) + + it('returns 400 when org_number is missing', async () => { + mockAuth({ id: 'user-1' }) + mockService() + const req = createMockRequest('/api/company/check-org-number') + const { status } = await parseJsonResponse(await GET(req)) + expect(status).toBe(400) + }) + + it('returns exists=false when the org number is not registered', async () => { + mockAuth({ id: 'user-1' }) + mockService(undefined) // no existing match + const req = createMockRequest('/api/company/check-org-number?org_number=5560125790') + const { status, body } = await parseJsonResponse(await GET(req)) + expect(status).toBe(200) + expect((body as { data: { exists: boolean } }).data.exists).toBe(false) + }) + + it('returns exists=true when the org number is already registered', async () => { + mockAuth({ id: 'user-1' }) + mockService('5560125790') + const req = createMockRequest('/api/company/check-org-number?org_number=5560125790') + const { status, body } = await parseJsonResponse(await GET(req)) + expect(status).toBe(200) + expect((body as { data: { exists: boolean } }).data.exists).toBe(true) + }) + + it('normalizes formatted org numbers before lookup (strips hyphens/spaces)', async () => { + mockAuth({ id: 'user-1' }) + mockService('5560125790') + const req = createMockRequest('/api/company/check-org-number?org_number=556012-5790') + const { status, body } = await parseJsonResponse(await GET(req)) + expect(status).toBe(200) + expect((body as { data: { exists: boolean } }).data.exists).toBe(true) + }) + + it('normalizes 12-digit input to 10-digit canonical before lookup', async () => { + // Stored form is 10-digit canonical (8001011231); user types 12-digit + // personnummer with century prefix. + mockAuth({ id: 'user-1' }) + mockService('8001011231') + const req = createMockRequest('/api/company/check-org-number?org_number=198001011231') + const { status, body } = await parseJsonResponse(await GET(req)) + expect(status).toBe(200) + expect((body as { data: { exists: boolean } }).data.exists).toBe(true) + }) + + it('returns exists=false for Luhn-invalid input (not a duplicate of anything)', async () => { + // The submit-time server action will reject this as org_number_invalid; + // here we just confirm the check endpoint doesn't produce a misleading + // "exists=true" result by accidentally matching an invalid number. + mockAuth({ id: 'user-1' }) + mockService('5560125790') // a real registered number + const req = createMockRequest('/api/company/check-org-number?org_number=5560125791') + const { status, body } = await parseJsonResponse(await GET(req)) + expect(status).toBe(200) + expect((body as { data: { exists: boolean } }).data.exists).toBe(false) + }) +}) diff --git a/app/api/company/check-org-number/route.ts b/app/api/company/check-org-number/route.ts new file mode 100644 index 00000000..4419b561 --- /dev/null +++ b/app/api/company/check-org-number/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from 'next/server' +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' + +/** + * GET /api/company/check-org-number?org_number=XXXXXXXXXX + * + * Returns `{ data: { exists: boolean } }` indicating whether the given + * organisation number is already registered in any non-archived gnubok + * company. Used by the onboarding wizard to warn users before they try to + * create a duplicate. + * + * Normalizes the input with the same rule as the server action + * (`normalizeOrgNumber`) so that a 12-digit form typed in the UI still + * matches a 10-digit stored canonical. Returns `exists: false` for + * malformed input — the submit-time server action will reject it with + * `org_number_invalid`, which is the right place to surface the error. + * + * Requires authentication so the endpoint can't be used to enumerate the + * full set of org numbers on the platform. Uses the service role internally + * because RLS hides rows the caller isn't a member of — which is exactly + * what we need to detect ("owned by someone else"). + */ +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const url = new URL(request.url) + const raw = url.searchParams.get('org_number') ?? '' + if (!raw) { + return NextResponse.json({ error: 'org_number is required' }, { status: 400 }) + } + + const canonical = normalizeOrgNumber(raw) + if (!canonical) { + // Invalid format/Luhn — not a duplicate of anything by definition. + return NextResponse.json({ data: { exists: false } }) + } + + const service = createServiceClient() + const { data, error } = await service + .from('companies') + .select('id') + .eq('org_number', canonical) + .is('archived_at', null) + .limit(1) + .maybeSingle() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: { exists: !!data } }) +} diff --git a/components/dashboard/WelcomeOnboarding.tsx b/components/dashboard/WelcomeOnboarding.tsx index 4ef175e2..3a93e3ec 100644 --- a/components/dashboard/WelcomeOnboarding.tsx +++ b/components/dashboard/WelcomeOnboarding.tsx @@ -48,9 +48,17 @@ interface WelcomeOnboardingProps { teamId: string skipWelcome?: boolean hasExistingCompanies?: boolean + /** Pre-fill Step 2 org_number when the picker routed here via ?org_number=. */ + initialOrgNumber?: string } -export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasExistingCompanies }: WelcomeOnboardingProps) { +export default function WelcomeOnboarding({ + firstName, + teamId, + skipWelcome, + hasExistingCompanies, + initialOrgNumber, +}: WelcomeOnboardingProps) { const router = useRouter() const { toast } = useToast() const supabase = createClient() @@ -59,7 +67,9 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) const [currentStep, setCurrentStep] = useState(1) - const [settings, setSettings] = useState>({}) + const [settings, setSettings] = useState>( + initialOrgNumber ? { org_number: initialOrgNumber } : {}, + ) const ticEnabled = ENABLED_EXTENSION_IDS.has('tic') const [ticLookup, setTicLookup] = useState(null) @@ -109,7 +119,15 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE }, [supabase, router]) const handleNext = async (stepData: Partial) => { - if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { + // Reset org_number/company_name only on a genuine change (user going back + // and picking a different entity type). First-time selection must not + // wipe a pre-fill (e.g. ?org_number= deep-link from /select-company). + if ( + currentStep === 1 && + stepData.entity_type && + settings.entity_type && + stepData.entity_type !== settings.entity_type + ) { stepData = { ...stepData, org_number: '', company_name: '' } setTicLookup(null) } @@ -163,11 +181,27 @@ export default function WelcomeOnboarding({ firstName, teamId, skipWelcome, hasE if (result.error || !result.companyId) { logError('create company action failed', { error: result.error }) + let title = 'Fel' + let description: string = result.error || 'Kunde inte skapa företag. Försök igen.' + let backToStep2 = false + if (result.error === 'org_number_exists') { + title = 'Företaget finns redan' + description = 'Det här företaget finns redan i gnubok. Be en befintlig administratör att bjuda in dig.' + backToStep2 = true + } else if (result.error === 'org_number_invalid') { + title = 'Ogiltigt organisationsnummer' + description = 'Kontrollera att du angett ett giltigt 10- eller 12-siffrigt organisationsnummer.' + backToStep2 = true + } toast({ - title: 'Fel', - description: result.error || 'Kunde inte skapa företag. Försök igen.', + title, + description, variant: 'destructive', }) + // Back user up to step 2 so they can correct the org number. + if (backToStep2) { + setCurrentStep(2) + } return } diff --git a/components/onboarding/BankIdCompanyPicker.tsx b/components/onboarding/BankIdCompanyPicker.tsx index ea83e2ca..fccae3f2 100644 --- a/components/onboarding/BankIdCompanyPicker.tsx +++ b/components/onboarding/BankIdCompanyPicker.tsx @@ -132,6 +132,18 @@ export default function BankIdCompanyPicker({ return } + // Block provisioning for companies that are avregistrerade/likviderade. + // Under BFL 2 kap, bokföringsskyldighet ends when a company is struck off. + if (lookup.isCeased) { + toast({ + title: 'Företaget är avregistrerat', + description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.', + variant: 'destructive', + }) + setSetup({ kind: 'idle' }) + return + } + setSetup({ kind: 'creating', orgNumber, step: 'provision' }) startTransition(async () => { @@ -151,6 +163,40 @@ export default function BankIdCompanyPicker({ return } + if (result.error === 'org_number_exists') { + toast({ + title: 'Företaget finns redan', + description: 'Be en befintlig administratör att bjuda in dig.', + variant: 'destructive', + }) + setSetup({ kind: 'idle' }) + return + } + + if (result.error === 'company_ceased') { + // Belt-and-suspenders: we already check lookup.isCeased client-side + // above, but the server-side guard catches any race where TIC's + // cached result differs between the two calls. + toast({ + title: 'Företaget är avregistrerat', + description: 'Det går inte att sätta upp bokföring för ett avregistrerat företag.', + variant: 'destructive', + }) + setSetup({ kind: 'idle' }) + return + } + + if (result.error === 'org_number_invalid') { + toast({ + title: 'Ogiltigt organisationsnummer', + description: 'Fortsätt med manuell uppsättning.', + variant: 'destructive', + }) + setSetup({ kind: 'idle' }) + router.push(`/onboarding?org_number=${encodeURIComponent(orgNumber)}`) + return + } + if (result.error || !result.companyId) { toast({ title: 'Kunde inte skapa företag', diff --git a/components/onboarding/Step2CompanyDetails.tsx b/components/onboarding/Step2CompanyDetails.tsx index 5dbdde5a..cb36f89c 100644 --- a/components/onboarding/Step2CompanyDetails.tsx +++ b/components/onboarding/Step2CompanyDetails.tsx @@ -68,10 +68,43 @@ export default function Step2CompanyDetails({ const [isLooking, setIsLooking] = useState(false) const [lookupError, setLookupError] = useState(null) const [lookupDone, setLookupDone] = useState(null) + const [orgNumberExists, setOrgNumberExists] = useState(false) const abortRef = useRef(null) + const dupAbortRef = useRef(null) const orgNumber = watch('org_number') + // Debounced duplicate check against gnubok's own companies table. Runs in + // parallel with the TIC lookup — they don't conflict. On match, the submit + // button is disabled; the server action would also reject ('org_number_exists') + // but blocking client-side avoids a wasted roundtrip. + useEffect(() => { + if (!orgNumber || !ORG_NUMBER_REGEX.test(orgNumber)) { + setOrgNumberExists(false) + return + } + const timer = setTimeout(() => { + dupAbortRef.current?.abort() + const controller = new AbortController() + dupAbortRef.current = controller + fetch(`/api/company/check-org-number?org_number=${encodeURIComponent(orgNumber)}`, { + signal: controller.signal, + }) + .then(async (res) => { + if (controller.signal.aborted || !res.ok) return + const { data } = await res.json() + setOrgNumberExists(!!data?.exists) + }) + .catch(() => { + // Network failure is non-fatal — the server action will re-check. + }) + }, 500) + return () => { + clearTimeout(timer) + dupAbortRef.current?.abort() + } + }, [orgNumber]) + useEffect(() => { if (!ticEnabled || !orgNumber || !ORG_NUMBER_REGEX.test(orgNumber)) { return @@ -201,6 +234,14 @@ export default function Step2CompanyDetails({ {ticEnabled && lookupError && ( {lookupError} )} + {orgNumberExists && ( + + + + Det här företaget finns redan i gnubok. Be en befintlig administratör att bjuda in dig. + + + )} @@ -262,7 +303,11 @@ export default function Step2CompanyDetails({ Tillbaka - + {isSaving ? ( <> diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts index 2a560e74..878aed6b 100644 --- a/extensions/general/tic/index.ts +++ b/extensions/general/tic/index.ts @@ -42,22 +42,45 @@ async function fetchAndStoreEnrichment( ): Promise { 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, - }) + log.info('enrichment request returned', { + status: enrichment.status, + requestedTypes: enrichment.requestedTypes, + completedTypes: enrichment.completedTypes, + hasSecureUrl: !!enrichment.secureUrl, + }) - await supabase - .from('extension_data') - .upsert({ - user_id: userId, - extension_id: 'tic', - key: 'bankid_enrichment', - value: enrichmentData, - }, { onConflict: 'user_id,extension_id,key' }) - } + // Accept both fully and partially completed runs — if the tenant only has + // SPAR enabled (not CompanyRoles), we still want the address data. + const usable = (enrichment.status === 'Completed' || enrichment.status === 'PartiallyCompleted') + && enrichment.secureUrl + if (!usable) return + + const enrichmentData = await fetchEnrichmentData(enrichment.secureUrl) + + // Log a PII-free snapshot so we can debug the role filter in production. + // Raw personnummer/names are deliberately omitted. + const firstRole = enrichmentData.companyRoles?.[0] + log.info('enrichment data shape', { + hasSpar: !!enrichmentData.spar, + companyCount: enrichmentData.companyRoles?.length ?? 0, + firstRoleStatuses: firstRole + ? { + companyStatus: firstRole.companyStatus, + positionEndIsNull: firstRole.positionEnd === null, + positionTypes: firstRole.positionTypes, + legalEntityType: firstRole.legalEntityType, + } + : null, + }) + + 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) } diff --git a/lib/company-lookup/__tests__/normalize-org-number.test.ts b/lib/company-lookup/__tests__/normalize-org-number.test.ts new file mode 100644 index 00000000..8358346f --- /dev/null +++ b/lib/company-lookup/__tests__/normalize-org-number.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { normalizeOrgNumber } from '../normalize-org-number' + +describe('normalizeOrgNumber', () => { + it('accepts valid 10-digit AB org numbers (real Volvo)', () => { + expect(normalizeOrgNumber('5560125790')).toBe('5560125790') + expect(normalizeOrgNumber('556012-5790')).toBe('5560125790') + expect(normalizeOrgNumber(' 556012-5790 ')).toBe('5560125790') + }) + + it('strips the century prefix from 12-digit input and returns the 10-digit canonical', () => { + expect(normalizeOrgNumber('165560125790')).toBe('5560125790') // 16 = AB prefix + expect(normalizeOrgNumber('198001011231')).toBe('8001011231') // 19 = pre-2000 personnummer + expect(normalizeOrgNumber('19800101-1231')).toBe('8001011231') + }) + + it('accepts valid 10-digit personnummer-style org numbers', () => { + expect(normalizeOrgNumber('8001011231')).toBe('8001011231') + expect(normalizeOrgNumber('800101-1231')).toBe('8001011231') + }) + + it('rejects Luhn-invalid org numbers (blocks garbage at the boundary)', () => { + // 5560125791 has the last digit flipped from the real Volvo number + // 5560125790 — Luhn check fails. + expect(normalizeOrgNumber('5560125791')).toBeNull() + expect(normalizeOrgNumber('556012-5791')).toBeNull() + // Typo: swapping adjacent digits in the payload breaks the Luhn check. + expect(normalizeOrgNumber('5506125790')).toBeNull() + }) + + it('rejects wrong lengths and non-digit content', () => { + expect(normalizeOrgNumber('')).toBeNull() + expect(normalizeOrgNumber('abc123')).toBeNull() + expect(normalizeOrgNumber('12345')).toBeNull() // too short + expect(normalizeOrgNumber('12345678901')).toBeNull() // 11 digits + expect(normalizeOrgNumber('1234567890123')).toBeNull() // 13 digits + }) + + it('returns null for nullish input', () => { + expect(normalizeOrgNumber(null)).toBeNull() + expect(normalizeOrgNumber(undefined)).toBeNull() + }) +}) diff --git a/lib/company-lookup/normalize-org-number.ts b/lib/company-lookup/normalize-org-number.ts new file mode 100644 index 00000000..f6cbefa9 --- /dev/null +++ b/lib/company-lookup/normalize-org-number.ts @@ -0,0 +1,33 @@ +import { luhnValidate } from '@/lib/bankgiro/luhn' + +/** + * Normalize an org number to gnubok's canonical 10-digit storage form. + * + * Accepts hyphen/space-formatted input in either of the two shapes Swedish + * users commonly type: + * - 10 digits (5560125790 or 8001011231) — stored as-is + * - 12 digits (198001011231) — century prefix stripped + * + * Returns null for any other length, non-digit content, or invalid Luhn + * check digit (the structural rule Bolagsverket and personnummer share). + * Storing a structurally invalid org number would later be caught by + * Skatteverket SRU and any receiving SIE4 system — refusing at the boundary + * keeps gnubok's bookkeeping from accumulating under an unusable identifier. + * + * 10-digit storage matches the rest of the codebase — see + * `lib/skatteverket/format.ts`, which converts 10→12 at export time by + * prefixing with '16' (AB) or '19'/'20' (EF personnummer). + */ +export function normalizeOrgNumber(raw: string | null | undefined): string | null { + if (!raw) return null + const cleaned = raw.replace(/[\s-]/g, '') + let canonical: string + if (/^\d{10}$/.test(cleaned)) { + canonical = cleaned + } else if (/^\d{12}$/.test(cleaned)) { + canonical = cleaned.substring(2) + } else { + return null + } + return luhnValidate(canonical) ? canonical : null +} diff --git a/lib/company/__tests__/actions.test.ts b/lib/company/__tests__/actions.test.ts index ff8350ae..2a74c20a 100644 --- a/lib/company/__tests__/actions.test.ts +++ b/lib/company/__tests__/actions.test.ts @@ -6,17 +6,45 @@ vi.mock('next/cache', () => ({ vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), + createServiceClient: vi.fn(), })) vi.mock('@/lib/company/context', () => ({ setActiveCompany: vi.fn().mockResolvedValue(undefined), })) -import { createClient } from '@/lib/supabase/server' -import { createCompanyFromTicRole } from '../actions' +import { createClient, createServiceClient } from '@/lib/supabase/server' +import { createCompanyFromTicRole, createCompanyFromOnboarding } from '../actions' import type { CompanyLookupResult } from '@/lib/company-lookup/types' const mockCreateClient = vi.mocked(createClient) +const mockCreateServiceClient = vi.mocked(createServiceClient) + +/** + * Build a service-role client mock. Seed `existingOrgNumber` when you want + * the duplicate-org guard in createCompanyFromOnboarding to find a match. + * Any other service-role query resolves to `{ data: null, error: null }`. + */ +function mockServiceClientForOrgNumber(existingOrgNumber?: string) { + const serviceFrom = vi.fn().mockImplementation(() => { + const chain: Record = {} + const methods = ['select', 'eq', 'is', 'in', 'order', 'limit', 'maybeSingle'] + for (const m of methods) { + chain[m] = () => { + if (m === 'maybeSingle') { + return Promise.resolve({ + data: existingOrgNumber ? { id: 'other-company', name: 'Other AB' } : null, + error: null, + }) + } + return chain + } + } + chain.then = (resolve: (v: unknown) => void) => resolve({ data: null, error: null }) + return chain + }) + mockCreateServiceClient.mockReturnValue({ from: serviceFrom } as never) +} type CapturedCall = { table: string; method: string; args: unknown[] } @@ -79,6 +107,9 @@ function buildSupabase(opts: { beforeEach(() => { vi.clearAllMocks() + // Default: no existing company with this org_number. Individual tests can + // override by calling mockServiceClientForOrgNumber('...') inside the test. + mockServiceClientForOrgNumber(undefined) }) describe('createCompanyFromTicRole', () => { @@ -88,7 +119,7 @@ describe('createCompanyFromTicRole', () => { const result = await createCompanyFromTicRole({ teamId: 'team-1', - orgNumber: '5566778899', + orgNumber: '5560125790', legalName: 'Acme AB', legalEntityType: 'AB', lookup: null, @@ -121,7 +152,7 @@ describe('createCompanyFromTicRole', () => { const result = await createCompanyFromTicRole({ teamId: 'team-1', - orgNumber: '5566778899', + orgNumber: '5560125790', legalName: 'Acme AB', legalEntityType: 'AB', lookup: null, @@ -163,7 +194,7 @@ describe('createCompanyFromTicRole', () => { const result = await createCompanyFromTicRole({ teamId: 'team-1', - orgNumber: '5566778899', + orgNumber: '5560125790', legalName: 'Acme Konsult AB', legalEntityType: 'AB', lookup, @@ -178,7 +209,7 @@ describe('createCompanyFromTicRole', () => { const settings = (settingsUpsert!.args[0] as Record) expect(settings.entity_type).toBe('aktiebolag') expect(settings.company_name).toBe('Acme Konsult AB') - expect(settings.org_number).toBe('5566778899') + expect(settings.org_number).toBe('5560125790') expect(settings.f_skatt).toBe(true) expect(settings.vat_registered).toBe(true) expect(settings.moms_period).toBe('quarterly') @@ -218,7 +249,7 @@ describe('createCompanyFromTicRole', () => { await createCompanyFromTicRole({ teamId: 'team-1', - orgNumber: '8001011234', + orgNumber: '8001011231', legalName: 'Liten EF', legalEntityType: 'Enskild firma', lookup, @@ -233,3 +264,227 @@ describe('createCompanyFromTicRole', () => { expect(settings.accounting_method).toBe('cash') }) }) + +describe('createCompanyFromOnboarding — duplicate org_number guard', () => { + it('refuses to create a company when the org number already exists', async () => { + const { supabase, calls } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { + create_company_with_owner: { data: 'should-not-be-called' }, + }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockServiceClientForOrgNumber('5560125790') // pretend this org is already taken + + const result = await createCompanyFromOnboarding({ + teamId: 'team-1', + settings: { + entity_type: 'aktiebolag', + company_name: 'Acme AB', + org_number: '5560125790', + }, + fiscalPeriod: { + startDate: '2026-01-01', + endDate: '2026-12-31', + name: 'Räkenskapsår 2026', + }, + }) + + expect(result.error).toBe('org_number_exists') + expect(result.companyId).toBeUndefined() + + // Guard must short-circuit before the create RPC runs — otherwise we'd + // leave a ghost company behind when the duplicate is detected. + const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner') + expect(rpcCreate).toBeUndefined() + // And no company_settings upsert should have happened. + expect(calls.find((c) => c.table === 'company_settings' && c.method === 'upsert')).toBeUndefined() + }) + + it('tolerates formatted org_numbers when detecting duplicates (hyphens/spaces stripped)', async () => { + const { supabase } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { create_company_with_owner: { data: 'x' } }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockServiceClientForOrgNumber('5560125790') + + const result = await createCompanyFromOnboarding({ + teamId: 'team-1', + settings: { + entity_type: 'aktiebolag', + company_name: 'Acme AB', + // User-typed format — the guard should still catch this as a duplicate. + org_number: '556677-8899', + }, + fiscalPeriod: { + startDate: '2026-01-01', + endDate: '2026-12-31', + name: 'Räkenskapsår 2026', + }, + }) + + expect(result.error).toBe('org_number_exists') + }) + + it('normalizes 12-digit personnummer input down to the 10-digit canonical form', async () => { + const { supabase } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { create_company_with_owner: { data: 'x' } }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + // The existing company is stored as the 10-digit canonical form. + mockServiceClientForOrgNumber('8001011231') + + const result = await createCompanyFromOnboarding({ + teamId: 'team-1', + settings: { + entity_type: 'enskild_firma', + company_name: 'Anna EF', + // User types full 12-digit personnummer with century prefix. + org_number: '19800101-1231', + }, + fiscalPeriod: { + startDate: '2026-01-01', + endDate: '2026-12-31', + name: 'Räkenskapsår 2026', + }, + }) + + // Should detect the duplicate despite the 12-digit input. + expect(result.error).toBe('org_number_exists') + }) + + it('rejects malformed org_numbers at the guard boundary', async () => { + const { supabase } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { create_company_with_owner: { data: 'x' } }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromOnboarding({ + teamId: 'team-1', + settings: { + entity_type: 'aktiebolag', + company_name: 'Broken AB', + org_number: 'abc123', // not a 10- or 12-digit number + }, + fiscalPeriod: { + startDate: '2026-01-01', + endDate: '2026-12-31', + name: 'Räkenskapsår 2026', + }, + }) + + expect(result.error).toBe('org_number_invalid') + // Must NOT have reached the create RPC — otherwise we'd save a malformed + // org_number and poison SIE/SRU exports. + const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner') + expect(rpcCreate).toBeUndefined() + }) + + it('rejects right-length org_numbers with invalid Luhn check digit', async () => { + const { supabase } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { create_company_with_owner: { data: 'x' } }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromOnboarding({ + teamId: 'team-1', + settings: { + entity_type: 'aktiebolag', + company_name: 'Fake AB', + // 10 digits but Luhn check digit is wrong (real Volvo is 5560125790; + // the trailing 1 is an intentional off-by-one). Skatteverket SRU + // validators and receiving SIE4 consumers would reject this, so we + // refuse at the boundary. + org_number: '5560125791', + }, + fiscalPeriod: { + startDate: '2026-01-01', + endDate: '2026-12-31', + name: 'Räkenskapsår 2026', + }, + }) + + expect(result.error).toBe('org_number_invalid') + const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner') + expect(rpcCreate).toBeUndefined() + }) + + it('fails closed when the duplicate lookup errors out (does not silently allow duplicates)', async () => { + const { supabase } = buildSupabase({ + user: { id: 'user-1' }, + rpcResults: { create_company_with_owner: { data: 'x' } }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + // Seed a service client that errors on maybeSingle — simulating a DB + // outage or RLS misconfiguration. + mockCreateServiceClient.mockReturnValue({ + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ + data: null, + error: { message: 'connection lost' }, + }), + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + const result = await createCompanyFromOnboarding({ + teamId: 'team-1', + settings: { + entity_type: 'aktiebolag', + company_name: 'Acme AB', + org_number: '5560125790', + }, + fiscalPeriod: { + startDate: '2026-01-01', + endDate: '2026-12-31', + name: 'Räkenskapsår 2026', + }, + }) + + // Must return a user-facing error, NOT silently proceed with creation. + expect(result.companyId).toBeUndefined() + expect(result.error).toBeTruthy() + // And the create RPC must not have been called. + const rpcCreate = supabase.rpc.mock.calls.find(([name]) => name === 'create_company_with_owner') + expect(rpcCreate).toBeUndefined() + }) +}) + +describe('createCompanyFromTicRole — ceased companies', () => { + it('refuses to provision when TIC lookup reports the company is ceased', async () => { + const lookup: CompanyLookupResult = { + companyName: 'Avregistrerat AB', + isCeased: true, // <- key field + address: null, + registration: { fTax: false, vat: false }, + bankAccounts: [], + email: null, + phone: null, + sniCodes: [], + } + + const { supabase, calls } = buildSupabase({ user: { id: 'user-1' } }) + mockCreateClient.mockResolvedValue(supabase as never) + + const result = await createCompanyFromTicRole({ + teamId: 'team-1', + orgNumber: '5560125790', + legalName: 'Avregistrerat AB', + legalEntityType: 'AB', + lookup, + }) + + expect(result.error).toBe('company_ceased') + const writes = calls.filter((c) => ['insert', 'upsert', 'delete', 'update'].includes(c.method)) + expect(writes).toEqual([]) + }) +}) diff --git a/lib/company/actions.ts b/lib/company/actions.ts index b7a6d702..144249cb 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -1,12 +1,43 @@ 'use server' -import { createClient } from '@/lib/supabase/server' +import { createClient, createServiceClient } 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 { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' import type { CompanyLookupResult } from '@/lib/company-lookup/types' +/** + * Check whether an org number is already registered in any non-archived + * gnubok company. Uses the service role because RLS hides rows the caller + * isn't a member of — and "other users' duplicates" is exactly what we + * need to detect. Returns null when `orgNumber` is empty/malformed. Throws + * if the underlying query fails — callers must not silently treat that as + * "no duplicate," or the whole guard gets bypassed on transient DB errors. + */ +async function findExistingCompanyByOrgNumber( + orgNumber: string | null | undefined, +): Promise<{ id: string; name: string } | null> { + const cleaned = normalizeOrgNumber(orgNumber) + if (!cleaned) return null + + const service = createServiceClient() + const { data, error } = await service + .from('companies') + .select('id, name') + .eq('org_number', cleaned) + .is('archived_at', null) + .limit(1) + .maybeSingle() + + if (error) { + throw new Error(`Duplicate-org lookup failed: ${error.message}`) + } + + return data ?? null +} + export async function switchCompany(companyId: string): Promise<{ error?: string }> { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() @@ -60,6 +91,34 @@ export async function createCompanyFromOnboarding(params: { const companyName = (params.settings.company_name as string | undefined) || 'Mitt företag' + // Duplicate-org guard. We don't have a DB unique constraint on + // companies.org_number (can't add one safely without cleaning up any + // existing duplicates first), so enforce uniqueness at the application + // boundary. Must run before the create RPC so we don't leave a ghost + // company if the duplicate is detected mid-flow. + // + // normalizeOrgNumber returns null for malformed input — we refuse rather + // than storing a value that would break SIE/SRU exports later. + const rawOrgNumber = params.settings.org_number as string | undefined + const cleanedOrgNumber = normalizeOrgNumber(rawOrgNumber) + if (rawOrgNumber && rawOrgNumber.trim() && !cleanedOrgNumber) { + return { error: 'org_number_invalid' } + } + if (cleanedOrgNumber) { + try { + const existing = await findExistingCompanyByOrgNumber(cleanedOrgNumber) + if (existing) { + return { error: 'org_number_exists' } + } + } catch (err) { + // Guard must fail closed: if we can't confirm uniqueness, don't create + // a company. A silent pass-through would let transient DB errors + // through as duplicates (exactly the bug Greptile flagged). + console.error('[createCompanyFromOnboarding] duplicate-org lookup failed', err) + return { error: 'Kunde inte verifiera organisationsnummer. Försök igen.' } + } + } + // 1. Create company + owner membership atomically via RPC const { data: newCompanyId, error: companyError } = await supabase.rpc('create_company_with_owner', { p_name: companyName, @@ -82,6 +141,22 @@ export async function createCompanyFromOnboarding(params: { await supabase.from('companies').delete().eq('id', newCompanyId) } + // Mirror the normalized org_number onto the companies row so future + // duplicate checks and cross-references are reliable. MUST be error-checked + // and rolled back on failure — otherwise the freshly-created company would + // exist without an org_number and the duplicate guard would never match it + // for any future user (the very guard this code is enforcing). + if (cleanedOrgNumber) { + const { error: orgUpdateError } = await supabase + .from('companies') + .update({ org_number: cleanedOrgNumber }) + .eq('id', newCompanyId) + if (orgUpdateError) { + await rollback('org_number update failed', orgUpdateError) + return { error: 'Kunde inte spara organisationsnummer. Försök igen.' } + } + } + // 2. Seed chart of accounts const { error: coaError } = await supabase.rpc('seed_chart_of_accounts', { p_company_id: newCompanyId, @@ -194,6 +269,14 @@ export async function createCompanyFromTicRole(params: { return { error: 'lookup_missing' } } + // Ceased/struck-off companies must not be provisioned. Under BFL 2 kap, + // bokföringsskyldighet ends when a company is avregistrerad; creating a + // new gnubok accounting entity for a non-existent legal entity would let + // users file momsdeklarationer or årsredovisning for it. + if (params.lookup.isCeased) { + return { error: 'company_ceased' } + } + // SPAR address fallback if the TIC lookup didn't include one. const { data: enrichmentRow } = await supabase .from('extension_data')
{lookupError}