diff --git a/components/onboarding/Step4VatAccounting.tsx b/components/onboarding/Step4VatAccounting.tsx index 6e2ccdd7..18a9a7a7 100644 --- a/components/onboarding/Step4VatAccounting.tsx +++ b/components/onboarding/Step4VatAccounting.tsx @@ -14,6 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { InfoTooltip } from '@/components/ui/info-tooltip' import { Loader2, ArrowRight, ArrowLeft, Info } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' +import { deriveSwedishVatNumber } from '@/lib/vat/vat-number' import type { MomsPeriod, EntityType } from '@/types' const schema = z.object({ @@ -82,12 +83,15 @@ export default function Step4VatAccounting({ const vatNumber = watch('vat_number') const accountingMethod = watch('accounting_method') - // Auto-fill VAT number when vat_registered toggles on + // Auto-fill VAT number when vat_registered toggles on. Derive via the shared + // helper so a 12-digit personnummer (enskild firma) gets its century dropped — + // building SE${orgNumber}01 verbatim produced SE + 14 digits and failed + // validation on save. useEffect(() => { if (vatRegistered && !vatNumber && orgNumber) { - const cleaned = orgNumber.replace(/[-\s]/g, '') - if (cleaned.length >= 10) { - setValue('vat_number', `SE${cleaned}01`) + const derived = deriveSwedishVatNumber(orgNumber) + if (derived) { + setValue('vat_number', derived) } } }, [vatRegistered, vatNumber, orgNumber, setValue]) diff --git a/extensions/general/arcim-migration/lib/migration-orchestrator.ts b/extensions/general/arcim-migration/lib/migration-orchestrator.ts index 8e6dee3a..fa6ae005 100644 --- a/extensions/general/arcim-migration/lib/migration-orchestrator.ts +++ b/extensions/general/arcim-migration/lib/migration-orchestrator.ts @@ -22,6 +22,7 @@ import type { MigrationProgress, MigrationResults, SkipReasons } from '../types' import type { ProviderName } from '@/lib/providers/types' import type { CustomerDto, SupplierDto, SalesInvoiceDto, SupplierInvoiceDto, PartyDto } from '@/lib/providers/dto' import { resolveConsent } from '@/lib/providers/resolve-consent' +import { normalizeVatNumber, isValidSwedishVatNumber } from '@/lib/vat/vat-number' import { fetchCompanyInfoDirect, fetchCustomersDirect, @@ -109,8 +110,22 @@ export async function executeMigration(options: MigrationOptions): Promise { expect(result.success).toBe(true) }) + it('normalises vat_number (lowercase, spaces, hyphens) to the canonical SE+12 form', () => { + const result = UpdateSettingsSchema.safeParse({ + vat_registered: true, + vat_number: 'se 556123-4567 01', + moms_period: 'quarterly', + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.vat_number).toBe('SE556123456701') + } + }) + + it('rejects vat_number with 14 digits (the SE + 12-digit personnummer + 01 bug)', () => { + const result = UpdateSettingsSchema.safeParse({ + vat_registered: true, + vat_number: 'SE19900101123401', + moms_period: 'quarterly', + }) + expect(result.success).toBe(false) + if (!result.success) { + const vatError = result.error.issues.find(i => i.path.includes('vat_number')) + expect(vatError?.message).toContain('SE följt av 12 siffror') + } + }) + it('allows aktiebolag with kontantmetoden (BFL 5 kap. 2 §)', () => { const result = UpdateSettingsSchema.safeParse({ entity_type: 'aktiebolag', diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index efe6369e..df6b2a2d 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { normaliseSwish, isValidSwish } from '@/lib/payments/swish' +import { normalizeVatNumber } from '@/lib/vat/vat-number' import { isSaneDateString } from '@/lib/utils' import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute' @@ -1036,7 +1037,11 @@ export const UpdateSettingsSchema = z.object({ country: z.string().optional(), f_skatt: z.boolean().optional(), vat_registered: z.boolean().optional(), - vat_number: z.string().regex(/^SE\d{12}$/, 'Momsregistreringsnummer måste vara SE följt av 12 siffror').nullable().optional(), + vat_number: z.string() + .transform(normalizeVatNumber) + .pipe(z.string().regex(/^SE\d{12}$/, 'Momsregistreringsnummer måste vara SE följt av 12 siffror')) + .nullable() + .optional(), moms_period: MomsPeriodSchema.nullable().optional(), periodisk_sammanstallning_period: PsPeriodTypeSchema.optional(), tax_contact_name: z.string().max(200).nullable().optional(), diff --git a/lib/company/actions.ts b/lib/company/actions.ts index 3573c1e1..3e989588 100644 --- a/lib/company/actions.ts +++ b/lib/company/actions.ts @@ -4,6 +4,7 @@ import { createClient } from '@/lib/supabase/server' import { setActiveCompany, CompanyContextError } from '@/lib/company/context' import { revalidatePath } from 'next/cache' import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' +import { normalizeVatNumber, isValidSwedishVatNumber, deriveSwedishVatNumber } from '@/lib/vat/vat-number' import type { CompanyLookupResult } from '@/lib/company-lookup/types' /** @@ -191,6 +192,18 @@ async function createCompanyFromOnboardingImpl(params: { ...settingsToSave } = params.settings + // Defence in depth: this upsert bypasses UpdateSettingsSchema, so never persist + // a VAT number blind. Normalise to the canonical SE+12 form; if it isn't + // structurally valid (e.g. the legacy SE+14 personnummer derivation), re-derive + // it from the org number, falling back to null rather than storing a malformed + // momsregistreringsnummer. + if (typeof settingsToSave.vat_number === 'string' && settingsToSave.vat_number) { + const normalized = normalizeVatNumber(settingsToSave.vat_number) + settingsToSave.vat_number = isValidSwedishVatNumber(normalized) + ? normalized + : deriveSwedishVatNumber(settingsToSave.org_number as string | null | undefined) + } + const { error: settingsError } = await supabase .from('company_settings') .upsert( diff --git a/lib/vat/__tests__/vat-number.test.ts b/lib/vat/__tests__/vat-number.test.ts new file mode 100644 index 00000000..f65d43d7 --- /dev/null +++ b/lib/vat/__tests__/vat-number.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { + normalizeVatNumber, + isValidSwedishVatNumber, + deriveSwedishVatNumber, +} from '@/lib/vat/vat-number' + +describe('normalizeVatNumber', () => { + it('uppercases and strips spaces and hyphens', () => { + expect(normalizeVatNumber('se 556123-4567 01')).toBe('SE556123456701') + }) + + it('leaves an already-canonical number unchanged', () => { + expect(normalizeVatNumber('SE556123456701')).toBe('SE556123456701') + }) +}) + +describe('isValidSwedishVatNumber', () => { + it('accepts SE followed by exactly 12 digits', () => { + expect(isValidSwedishVatNumber('SE556123456701')).toBe(true) + }) + + it('rejects SE followed by 14 digits (century not dropped)', () => { + expect(isValidSwedishVatNumber('SE19900101123401')).toBe(false) + }) + + it('rejects lowercase / spaced input (must be normalised first)', () => { + expect(isValidSwedishVatNumber('se556123456701')).toBe(false) + expect(isValidSwedishVatNumber('SE 556123 4567 01')).toBe(false) + }) + + it('rejects a non-SE prefix', () => { + expect(isValidSwedishVatNumber('DE123456789')).toBe(false) + }) +}) + +describe('deriveSwedishVatNumber', () => { + it('derives from a 10-digit aktiebolag org number (used as-is + 01)', () => { + // 5561234567 is a valid-Luhn organisationsnummer + expect(deriveSwedishVatNumber('5561234567')).toBe('SE556123456701') + }) + + it('accepts hyphen-formatted org numbers', () => { + expect(deriveSwedishVatNumber('556123-4567')).toBe('SE556123456701') + }) + + it('drops the century from a 12-digit personnummer (enskild firma)', () => { + // 19850101-0006 → 10-digit form 8501010006 → SE8501010006 01 + expect(deriveSwedishVatNumber('198501010006')).toBe('SE850101000601') + }) + + it('derives from a 10-digit personnummer as-is', () => { + expect(deriveSwedishVatNumber('850101-0006')).toBe('SE850101000601') + }) + + it('returns null for a structurally invalid (bad Luhn) identity', () => { + expect(deriveSwedishVatNumber('1234567890')).toBeNull() + }) + + it('returns null for empty / nullish input', () => { + expect(deriveSwedishVatNumber('')).toBeNull() + expect(deriveSwedishVatNumber(null)).toBeNull() + expect(deriveSwedishVatNumber(undefined)).toBeNull() + }) + + it('never produces an SE+14 value from a 12-digit personnummer', () => { + const derived = deriveSwedishVatNumber('198501010006') + expect(derived).not.toBeNull() + expect(isValidSwedishVatNumber(derived as string)).toBe(true) + }) +}) diff --git a/lib/vat/vat-number.ts b/lib/vat/vat-number.ts new file mode 100644 index 00000000..b02b434b --- /dev/null +++ b/lib/vat/vat-number.ts @@ -0,0 +1,45 @@ +import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number' + +/** + * Swedish VAT registration number (momsregistreringsnummer) helpers. + * + * Canonical format per Skatteverket: "SE" + 10-digit identity + "01" = SE + * followed by exactly 12 digits, no spaces. + * - Aktiebolag: the 10-digit organisationsnummer, used as-is. + * - Enskild firma: the personnummer reduced to its 10-digit form (YYMMDD-NNNN). + * A 12-digit personnummer (YYYYMMDD-NNNN) has its birth-century prefix + * ('19'/'20') DROPPED first — including it would yield SE + 14 digits, which + * is invalid. This is the bug that shipped from the onboarding wizard. + * + * The "01" suffix is the registration serial; it is effectively always "01" for + * a single registration. + */ + +const SE_VAT_PATTERN = /^SE\d{12}$/ + +/** + * Normalise raw user/provider input to the canonical spaceless, uppercase form. + * Strips whitespace and hyphens (e.g. "se 556677-8899 01" → "SE556677889901"). + */ +export function normalizeVatNumber(raw: string): string { + return raw.replace(/[\s-]/g, '').toUpperCase() +} + +/** Structural validity check: literal "SE" followed by exactly 12 digits. */ +export function isValidSwedishVatNumber(value: string): boolean { + return SE_VAT_PATTERN.test(value) +} + +/** + * Derive a Swedish VAT number from an organisationsnummer or personnummer. + * + * Reuses {@link normalizeOrgNumber} to reach the canonical 10-digit identity + * (century dropped for 12-digit personnummer, Luhn-validated), then appends the + * "01" serial. Returns null when the input has no usable, structurally valid + * 10/12-digit identity — callers should leave the VAT number blank rather than + * persist a guess. + */ +export function deriveSwedishVatNumber(orgNumber: string | null | undefined): string | null { + const canonical = normalizeOrgNumber(orgNumber) + return canonical ? `SE${canonical}01` : null +} diff --git a/supabase/migrations/20260627130000_backfill_company_vat_number_drop_century.sql b/supabase/migrations/20260627130000_backfill_company_vat_number_drop_century.sql new file mode 100644 index 00000000..ae24423a --- /dev/null +++ b/supabase/migrations/20260627130000_backfill_company_vat_number_drop_century.sql @@ -0,0 +1,22 @@ +-- Repair malformed company VAT numbers (momsregistreringsnummer). +-- +-- The onboarding wizard (components/onboarding/Step4VatAccounting.tsx) derived +-- the VAT number as `SE` || org_number || `01`. For an enskild firma the +-- org_number is a 12-digit personnummer (YYYYMMDD-NNNN), so this produced +-- `SE` + 14 digits instead of the canonical `SE` + 10-digit identity + `01` +-- (= SE + 12 digits). Those companies could not save the tax settings page: +-- the pre-filled value is re-submitted on save and rejected by the +-- `^SE\d{12}$` validation in UpdateSettingsSchema. +-- +-- Fix: drop the 2 leading junk digits (the personnummer century, or a +-- duplicated org-number prefix) so the value matches SE + 12 digits. The +-- remaining 12 digits already carry the correct 10-digit identity + `01` +-- suffix — verified that every affected row reconciles to the org-number-based +-- canonical value. +-- +-- Idempotent and tightly scoped: only rows that are exactly `SE` followed by +-- 14 digits are touched. Already-valid SE+12 rows, NULLs and empty strings are +-- left untouched. +UPDATE public.company_settings +SET vat_number = 'SE' || substr(vat_number, 5) +WHERE vat_number ~ '^SE\d{14}$';