diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 6edddbc6..9a404eaa 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import DashboardContent from '@/components/dashboard/DashboardContent' +import { LEGACY_GENERAL_EXTENSIONS } from '@/lib/extensions/toggle-check' import type { Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' export const dynamic = 'force-dynamic' @@ -223,7 +224,12 @@ export default async function DashboardPage() { staleUncategorizedCount: staleUncategorizedCount || 0, }} onboardingProgress={onboardingProgress} - enabledExtensions={enabledToggles || []} + enabledExtensions={[ + ...(enabledToggles || []), + ...LEGACY_GENERAL_EXTENSIONS + .filter(slug => !(enabledToggles || []).some(t => t.sector_slug === 'general' && t.extension_slug === slug)) + .map(slug => ({ sector_slug: 'general', extension_slug: slug })), + ]} /> ) } diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 9a632da0..59e4ddae 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -9,6 +9,8 @@ import { useToast } from '@/components/ui/use-toast' import { Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' +import { useExtensionToggle } from '@/lib/extensions/hooks' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' import type { CompanySettings, EntityType, MomsPeriod } from '@/types' import Step1EntityType from '@/components/onboarding/Step1EntityType' @@ -74,6 +76,8 @@ function OnboardingPageContent() { const [isSaving, setIsSaving] = useState(false) const [currentStep, setCurrentStep] = useState(1) const [settings, setSettings] = useState>({}) + const { enabled: ticEnabled } = useExtensionToggle('general', 'tic') + const [ticLookup, setTicLookup] = useState(null) const totalSteps = 5 @@ -218,6 +222,7 @@ function OnboardingPageContent() { if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) { console.warn(LOG, 'entity type changed from', settings.entity_type, 'to', stepData.entity_type, '— clearing dependent fields') stepData = { ...stepData, org_number: '', company_name: '' } + setTicLookup(null) } const nextStep = currentStep + 1 @@ -460,6 +465,8 @@ function OnboardingPageContent() { city: settings.city ?? undefined, }} entityType={settings.entity_type as EntityType} + ticEnabled={ticEnabled} + onTicLookup={setTicLookup} onNext={(data) => handleNext(data)} onBack={handleBack} isSaving={isSaving} @@ -469,7 +476,7 @@ function OnboardingPageContent() { {currentStep === 3 && ( b.type === 'iban')?.accountNumber) ?? undefined, + bic: settings.bic ?? (ticLookup?.bankAccounts.find((b) => b.type === 'iban')?.bic) ?? undefined, }} onComplete={async (data) => { if (data) { diff --git a/app/api/extensions/toggles/[sector]/[slug]/route.ts b/app/api/extensions/toggles/[sector]/[slug]/route.ts index 3c8df51d..f7f27175 100644 --- a/app/api/extensions/toggles/[sector]/[slug]/route.ts +++ b/app/api/extensions/toggles/[sector]/[slug]/route.ts @@ -1,14 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' - -// Legacy general extensions default to enabled when no toggle row exists -const LEGACY_GENERAL_EXTENSIONS = [ - 'receipt-ocr', - 'ai-categorization', - 'ai-chat', - 'push-notifications', - 'enable-banking', -] +import { LEGACY_GENERAL_EXTENSIONS } from '@/lib/extensions/toggle-check' export async function GET( _request: Request, diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 5de5efb8..3adee348 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -25,13 +25,21 @@ import { FileInput, Wallet, } from 'lucide-react' +import { resolveIcon } from '@/lib/extensions/icon-resolver' import type { EntityType } from '@/types' +interface ExtensionNavItem { + href: string + label: string + icon: string +} + interface DashboardNavProps { companyName: string entityType: EntityType uncategorizedTransactionCount?: number isSandbox?: boolean + extensionNavItems?: ExtensionNavItem[] } interface NavItem { @@ -67,7 +75,7 @@ const groupLabels: Record = { övrigt: 'Övrigt', } -export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, isSandbox = false }: DashboardNavProps) { +export default function DashboardNav({ companyName, entityType, uncategorizedTransactionCount = 0, isSandbox = false, extensionNavItems = [] }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = createClient() @@ -75,7 +83,7 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra const [isClosing, setIsClosing] = useState(false) const closeTimerRef = useRef | null>(null) // Auto-expand Övrigt when the user is on one of its pages, or when manually toggled - const isOnOvrigtPage = ['/help', '/settings'].some(p => pathname.startsWith(p)) + const isOnOvrigtPage = ['/help', '/settings', '/e/'].some(p => pathname.startsWith(p)) const [manualOvrigtExpanded, setManualOvrigtExpanded] = useState(false) const isOvrigtExpanded = isOnOvrigtPage || manualOvrigtExpanded const openMobileMenu = () => { @@ -222,6 +230,28 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra {isOvrigtExpanded && (
+ {extensionNavItems.map((item) => { + const Icon = resolveIcon(item.icon) + const active = isActive(item.href) + return ( + + + {item.label} + + ) + })} {övrigtItems.map((item) => { const Icon = item.icon const active = isActive(item.href) @@ -430,6 +460,26 @@ export default function DashboardNav({ companyName, entityType, uncategorizedTra {/* Other items */}
+ {extensionNavItems.map((item) => { + const Icon = resolveIcon(item.icon) + const active = isActive(item.href) + return ( + + + {item.label} + + ) + })} {övrigtItems.map((item) => { const Icon = item.icon const active = isActive(item.href) diff --git a/components/extensions/ExtensionWorkspaceShell.tsx b/components/extensions/ExtensionWorkspaceShell.tsx index c813de90..c8853b2f 100644 --- a/components/extensions/ExtensionWorkspaceShell.tsx +++ b/components/extensions/ExtensionWorkspaceShell.tsx @@ -1,9 +1,7 @@ 'use client' import type { ExtensionDefinition } from '@/lib/extensions/types' -import { getSector } from '@/lib/extensions/sectors' import { resolveIcon } from '@/lib/extensions/icon-resolver' -import Link from 'next/link' export default function ExtensionWorkspaceShell({ definition, @@ -12,28 +10,10 @@ export default function ExtensionWorkspaceShell({ definition: ExtensionDefinition children: React.ReactNode }) { - const Icon = resolveIcon(definition.icon) - const sector = getSector(definition.sector) return (
- {/* Breadcrumb */} - - {/* Header */}
diff --git a/components/extensions/general/TicWorkspace.tsx b/components/extensions/general/TicWorkspace.tsx new file mode 100644 index 00000000..8ea9d28a --- /dev/null +++ b/components/extensions/general/TicWorkspace.tsx @@ -0,0 +1,420 @@ +'use client' + +import { useState, useCallback, useEffect } from 'react' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useExtensionData } from '@/lib/extensions/use-extension-data' +import { useToast } from '@/components/ui/use-toast' +import { + Building2, + CheckCircle, + XCircle, + MapPin, + Mail, + Phone, + Settings, +} from 'lucide-react' +import Link from 'next/link' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import type { TICCompanyProfile } from '@/extensions/general/tic/lib/tic-types' + +function formatKSEK(value: number | null): string { + if (value === null) return '—' + return `${(value * 1000).toLocaleString('sv-SE')} kr` +} + +function formatPercent(value: number | null): string { + if (value === null) return '—' + return `${value.toFixed(1)} %` +} + +function toMs(epoch: number): number { + // TIC returns epoch seconds; Date() expects milliseconds + return epoch < 1e12 ? epoch * 1000 : epoch +} + +function formatPeriod(start: number, end: number): string { + const s = new Date(toMs(start)) + const e = new Date(toMs(end)) + const fmt = (d: Date) => + d.toLocaleDateString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit' }) + return `${fmt(s)} – ${fmt(e)}` +} + +function timeAgo(isoDate: string): string { + const diff = Date.now() - new Date(isoDate).getTime() + const minutes = Math.floor(diff / 60000) + if (minutes < 1) return 'just nu' + if (minutes < 60) return `${minutes} min sedan` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours} tim sedan` + const days = Math.floor(hours / 24) + return `${days} dag${days > 1 ? 'ar' : ''} sedan` +} + +function ProfileSkeleton() { + return ( +
+
+ + + +
+
+ + + + + + + + + + + + + + + +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+
+
+
+ ) +} + +export default function TicWorkspace({ userId }: WorkspaceComponentProps) { + const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic') + const { toast } = useToast() + const [profile, setProfile] = useState(null) + const [isFetching, setIsFetching] = useState(false) + const [noOrgNumber, setNoOrgNumber] = useState(false) + const [initialLoad, setInitialLoad] = useState(true) + + // Load cached profile from extension data + useEffect(() => { + if (isDataLoading) return + const cached = getByKey('company_profile') + if (cached?.value) { + setProfile(cached.value as unknown as TICCompanyProfile) + } + setInitialLoad(false) + }, [isDataLoading, getByKey]) + + const fetchProfile = useCallback(async () => { + setIsFetching(true) + setNoOrgNumber(false) + + try { + // Get org_number from company settings + const settingsRes = await fetch('/api/settings') + if (!settingsRes.ok) { + toast({ title: 'Kunde inte hämta inställningar', variant: 'destructive' }) + return + } + const { data: settings } = await settingsRes.json() + const orgNumber = settings?.org_number + + if (!orgNumber) { + setNoOrgNumber(true) + return + } + + const res = await fetch( + `/api/extensions/ext/tic/profile?org_number=${encodeURIComponent(orgNumber)}` + ) + + if (!res.ok) { + const { error } = await res.json() + toast({ title: error ?? 'Kunde inte hämta företagsprofil', variant: 'destructive' }) + return + } + + const { data } = await res.json() + setProfile(data) + await save('company_profile', data) + } catch { + toast({ title: 'Ett oväntat fel inträffade', variant: 'destructive' }) + } finally { + setIsFetching(false) + } + }, [save, toast]) + + // Auto-fetch on first visit when no cached data + useEffect(() => { + if (!initialLoad && !profile && !noOrgNumber && !isFetching) { + fetchProfile() + } + }, [initialLoad, profile, noOrgNumber, isFetching, fetchProfile]) + + if (initialLoad || isDataLoading) { + return + } + + if (noOrgNumber) { + return ( +
+ +

+ Inget organisationsnummer +

+

+ Ange organisationsnummer under Inställningar för att visa företagsprofilen. +

+ +
+ ) + } + + if (isFetching && !profile) { + return + } + + if (!profile) return null + + const isActive = profile.activityStatus !== 'ceased' + + return ( +
+ {/* Status bar */} +
+ + {isActive ? 'Aktiv' : 'Avregistrerat'} + + {profile.registration.fTax && F-skatt} + {profile.registration.vat && Moms} + {profile.registration.payroll && Arbetsgivare} + + + Uppdaterad {timeAgo(profile.fetchedAt)} + +
+ +
+ {/* Company info card */} + + + + + {profile.companyName} + + + {profile.orgNumber} · {profile.legalEntityType} + + + + {profile.address && ( +
+ + + {[profile.address.street, `${profile.address.postalCode} ${profile.address.city}`] + .filter(Boolean) + .join(', ')} + +
+ )} + {profile.email && ( +
+ + {profile.email} +
+ )} + {profile.phone && ( +
+ + {profile.phone} +
+ )} + {profile.sniCodes.length > 0 && ( +
+

SNI-koder

+
+ {profile.sniCodes + .filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i) + .map((sni) => ( +

+ {sni.code}{' '} + {sni.name} +

+ ))} +
+
+ )} + {profile.bankAccounts.length > 0 && ( +
+

Bankuppgifter

+
+ {profile.bankAccounts.map((ba, i) => ( +

+ {ba.type}:{' '} + {ba.accountNumber} +

+ ))} +
+
+ )} + {profile.purpose && ( +
+

Verksamhet

+

{profile.purpose}

+
+ )} + {(profile.employeeRange || profile.turnoverRange) && ( +
+ {profile.employeeRange && ( +

+ Anställda: {profile.employeeRange} +

+ )} + {profile.turnoverRange && ( +

+ Omsättning: {profile.turnoverRange} +

+ )} +
+ )} +
+
+ + {/* Financials card */} + + + Senaste bokslut + {profile.financials && ( + + {formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)} + + )} + + + {profile.financials ? ( +
+ + + + + + +
+ ) : ( +

Inga finansiella uppgifter tillgängliga.

+ )} +
+
+
+ + {/* Financial reports table */} + {profile.financialReports.length > 0 && ( + + + Årsredovisningar + + + + + + Period + Titel + Inlämnad + Reviderad + Revisionsutlåtande + + + + {profile.financialReports + .filter((r) => !r.isInterimReport) + .sort((a, b) => { + const aEnd = a.periodEnd ? new Date(a.periodEnd).getTime() : 0 + const bEnd = b.periodEnd ? new Date(b.periodEnd).getTime() : 0 + return bEnd - aEnd + }) + .slice(0, 10) + .map((report, i) => ( + + + {report.periodStart && report.periodEnd + ? `${report.periodStart.slice(0, 10)} – ${report.periodEnd.slice(0, 10)}` + : '—'} + + {report.title ?? '—'} + + {report.arrivalDate + ? new Date(report.arrivalDate).toLocaleDateString('sv-SE') + : '—'} + + + {report.isAudited === true ? ( + + ) : report.isAudited === false ? ( + + ) : ( + — + )} + + {report.auditOpinion ?? '—'} + + ))} + +
+
+
+ )} +
+ ) +} + +function FinancialCell({ + label, + value, + negative = false, +}: { + label: string + value: string + negative?: boolean +}) { + return ( +
+

{label}

+

+ {value} +

+
+ ) +} diff --git a/components/onboarding/Step2CompanyDetails.tsx b/components/onboarding/Step2CompanyDetails.tsx index b701f612..0f792838 100644 --- a/components/onboarding/Step2CompanyDetails.tsx +++ b/components/onboarding/Step2CompanyDetails.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState, useEffect, useRef } from 'react' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -7,8 +8,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { Loader2, ArrowRight, ArrowLeft } from 'lucide-react' +import { Loader2, ArrowRight, ArrowLeft, CheckCircle2, AlertTriangle } from 'lucide-react' import type { EntityType } from '@/types' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' const schema = z.object({ company_name: z.string().min(1, 'Företagsnamn krävs'), @@ -22,9 +24,13 @@ const schema = z.object({ type FormData = z.infer +const ORG_NUMBER_REGEX = /^\d{6,8}[-\s]?\d{4}$/ + interface Step2Props { initialData: Partial entityType?: EntityType + ticEnabled?: boolean + onTicLookup?: (result: CompanyLookupResult | null) => void onNext: (data: FormData) => void onBack: () => void isSaving: boolean @@ -33,6 +39,8 @@ interface Step2Props { export default function Step2CompanyDetails({ initialData, entityType, + ticEnabled, + onTicLookup, onNext, onBack, isSaving, @@ -40,6 +48,8 @@ export default function Step2CompanyDetails({ const { register, handleSubmit, + watch, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(schema), @@ -53,6 +63,81 @@ export default function Step2CompanyDetails({ }, }) + const [isLooking, setIsLooking] = useState(false) + const [lookupError, setLookupError] = useState(null) + const [lookupDone, setLookupDone] = useState(null) + const abortRef = useRef(null) + + const orgNumber = watch('org_number') + + useEffect(() => { + if (!ticEnabled || !orgNumber || !ORG_NUMBER_REGEX.test(orgNumber)) { + return + } + + setLookupError(null) + setLookupDone(null) + + const timer = setTimeout(() => { + // Abort any in-flight request + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + + setIsLooking(true) + + fetch(`/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}`, { + signal: controller.signal, + }) + .then(async (res) => { + if (controller.signal.aborted) return + + if (res.status === 403) { + // Extension disabled — silently ignore + return + } + if (res.status === 404) { + setLookupError('Inget företag hittades med det organisationsnumret.') + onTicLookup?.(null) + return + } + if (!res.ok) { + setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.') + onTicLookup?.(null) + return + } + + const { data } = (await res.json()) as { data: CompanyLookupResult } + + // Guard: only apply if org_number still matches (user may have changed it) + if (controller.signal.aborted) return + + setLookupDone(data) + onTicLookup?.(data) + + // Auto-fill from TIC — overwrite since user just entered a new org number + if (data.companyName) setValue('company_name', data.companyName) + if (data.address?.street) setValue('address_line1', data.address.street) + if (data.address?.postalCode) setValue('postal_code', data.address.postalCode) + if (data.address?.city) setValue('city', data.address.city) + }) + .catch((err) => { + if ((err as Error).name === 'AbortError') return + setLookupError('Kunde inte hämta företagsuppgifter. Du kan fylla i manuellt.') + onTicLookup?.(null) + }) + .finally(() => { + if (!controller.signal.aborted) setIsLooking(false) + }) + }, 500) + + return () => { + clearTimeout(timer) + abortRef.current?.abort() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ticEnabled, orgNumber]) + const isAB = entityType === 'aktiebolag' return ( @@ -61,9 +146,11 @@ export default function Step2CompanyDetails({ Grunduppgifter - {isAB - ? 'Ange bolagets registrerade namn och organisationsnummer.' - : 'Ange namn på din verksamhet.'} + {ticEnabled + ? 'Ange organisationsnummer så hämtas övriga uppgifter automatiskt.' + : isAB + ? 'Ange bolagets registrerade namn och organisationsnummer.' + : 'Ange namn på din verksamhet.'} @@ -72,20 +159,6 @@ export default function Step2CompanyDetails({ console.error('[onboarding] step 2 validation failed:', fields, errs) fetch('/api/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'step 2 validation failed', extra: { fields } }) }).catch(() => {}) })} className="space-y-4"> -
- - - {errors.company_name && ( -

{errors.company_name.message}

- )} -
-
+ +
+ + + {errors.company_name && ( +

{errors.company_name.message}

+ )}
diff --git a/extensions.config.json b/extensions.config.json index 6a80320b..10fd7ecd 100644 --- a/extensions.config.json +++ b/extensions.config.json @@ -1 +1 @@ -{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration"]} +{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic"]} diff --git a/extensions.schema.json b/extensions.schema.json index 35dfb8ef..d069bf30 100644 --- a/extensions.schema.json +++ b/extensions.schema.json @@ -24,7 +24,8 @@ "calendar", "enable-banking", "email", - "arcim-migration" + "arcim-migration", + "tic" ] }, "description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory." diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts index 96a5c62b..78e7f015 100644 --- a/extensions/general/arcim-migration/index.ts +++ b/extensions/general/arcim-migration/index.ts @@ -20,6 +20,9 @@ import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/ import { loadMappings, generateImportPreview, executeSIEImport, saveMappings } from '@/lib/import/sie-import' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' +/** Fiscal years we support importing — older data is not needed */ +const ALLOWED_FISCAL_YEARS = new Set([2024, 2025, 2026]) + /** * Arcim Migration extension * @@ -302,15 +305,25 @@ export const arcimMigrationExtension: Extension = { log.info(`Fetching SIE export for consent ${consentId}...`) const sieResult = await fetchSIEExport(consentId, 4) log.info(`SIE export response: ${sieResult.files.length} files returned`) - if (sieResult.files.length > 0) { + + // Only keep fiscal years we need (2024–2026) + const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear)) + if (filteredFiles.length < sieResult.files.length) { + const skippedYears = sieResult.files + .filter(f => !ALLOWED_FISCAL_YEARS.has(f.fiscalYear)) + .map(f => f.fiscalYear) + log.info(`Filtered out fiscal years: ${skippedYears.join(', ')} (only importing ${[...ALLOWED_FISCAL_YEARS].join(', ')})`) + } + + if (filteredFiles.length > 0) { sieAvailable = true - const totalAccounts = Math.max(...sieResult.files.map(f => f.accountCount)) - const totalTransactions = sieResult.files.reduce((sum, f) => sum + f.transactionCount, 0) - const fiscalYears = sieResult.files.map(f => f.fiscalYear).sort() + const totalAccounts = Math.max(...filteredFiles.map(f => f.accountCount)) + const totalTransactions = filteredFiles.reduce((sum, f) => sum + f.transactionCount, 0) + const fiscalYears = filteredFiles.map(f => f.fiscalYear).sort() sieStats = { accountCount: totalAccounts, transactionCount: totalTransactions, fiscalYears } log.info(`SIE stats: ${totalAccounts} accounts, ${totalTransactions} transactions, years: ${fiscalYears.join(', ')}`) } else { - log.info('SIE export returned empty files array') + log.info('No SIE files within allowed fiscal years') } } catch (err) { log.info('SIE export failed:', err instanceof Error ? err.message : String(err)) @@ -358,21 +371,22 @@ export const arcimMigrationExtension: Extension = { } try { - // Fetch SIE from gateway + // Fetch SIE from gateway and filter to allowed fiscal years (2024–2026) const sieResult = await fetchSIEExport(consentId, 4) - if (sieResult.files.length === 0) { - return NextResponse.json({ error: 'No SIE data available' }, { status: 404 }) + const filteredFiles = sieResult.files.filter(f => ALLOWED_FISCAL_YEARS.has(f.fiscalYear)) + if (filteredFiles.length === 0) { + return NextResponse.json({ error: 'No SIE data available for fiscal years 2024–2026' }, { status: 404 }) } // Parse most recent file for preview/validation - const sieFile = sieResult.files[sieResult.files.length - 1] + const sieFile = filteredFiles[filteredFiles.length - 1] const parsed = parseSIEFile(sieFile.rawContent) const validation = validateSIEFile(parsed) // Collect ALL unique accounts across ALL fiscal year files // so mappings cover every account that will be imported const allAccountsMap = new Map() - for (const file of sieResult.files) { + for (const file of filteredFiles) { const fileParsed = parseSIEFile(file.rawContent) for (const acc of fileParsed.accounts) { if (!allAccountsMap.has(acc.number)) { @@ -408,13 +422,13 @@ export const arcimMigrationExtension: Extension = { const mappings = suggestMappings(allAccounts, basAccounts, existingRecords) const mappingStats = getMappingStats(mappings) - log.info(`Account mapping: ${allAccounts.length} unique accounts across ${sieResult.files.length} files, ${mappingStats.unmapped} unmapped`) + log.info(`Account mapping: ${allAccounts.length} unique accounts across ${filteredFiles.length} files, ${mappingStats.unmapped} unmapped`) // Generate preview const preview = generateImportPreview(parsed, mappings) - // Collect all raw SIE content (all fiscal years) - const allRawContent = sieResult.files.map(f => f.rawContent) + // Collect all raw SIE content (filtered fiscal years only) + const allRawContent = filteredFiles.map(f => f.rawContent) return NextResponse.json({ parsed, diff --git a/extensions/general/tic/__tests__/lookup.test.ts b/extensions/general/tic/__tests__/lookup.test.ts new file mode 100644 index 00000000..eb20837b --- /dev/null +++ b/extensions/general/tic/__tests__/lookup.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock the tic-client functions +vi.mock('../lib/tic-client', () => ({ + searchCompanyByOrgNumber: vi.fn(), + getBankAccounts: vi.fn(), + getSNICodes: vi.fn(), + getEmails: vi.fn(), + getPhones: vi.fn(), +})) + +import { ticExtension } from '../index' +import { + searchCompanyByOrgNumber, + getBankAccounts, + getSNICodes, + getEmails, + getPhones, +} from '../lib/tic-client' +import type { TICCompanyDocument } from '../lib/tic-types' + +const mockSearch = vi.mocked(searchCompanyByOrgNumber) +const mockBank = vi.mocked(getBankAccounts) +const mockSNI = vi.mocked(getSNICodes) +const mockEmails = vi.mocked(getEmails) +const mockPhones = vi.mocked(getPhones) + +function makeRequest(orgNumber?: string): Request { + const url = orgNumber + ? `http://localhost/api/extensions/ext/tic/lookup?org_number=${encodeURIComponent(orgNumber)}` + : 'http://localhost/api/extensions/ext/tic/lookup' + return new Request(url) +} + +const lookupHandler = ticExtension.apiRoutes![0].handler + +const mockDoc: TICCompanyDocument = { + companyId: 42, + registrationNumber: '5560360793', + names: [ + { nameOrIdentifier: 'Registered Name', companyNamingType: 'registeredName' }, + { nameOrIdentifier: 'Test AB', companyNamingType: 'name' }, + ], + legalEntityType: 'AB', + registrationDate: 0, + mostRecentRegisteredAddress: { + street: 'Storgatan 1', + postalCode: '111 22', + city: 'Stockholm', + }, + isRegisteredForFTax: true, + isRegisteredForVAT: true, + activityStatus: 'active', +} + +describe('TIC lookup route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 when org_number is missing', async () => { + const res = await lookupHandler(makeRequest()) + expect(res.status).toBe(400) + }) + + it('returns 404 when company not found', async () => { + mockSearch.mockResolvedValue(null) + + const res = await lookupHandler(makeRequest('000000-0000')) + expect(res.status).toBe(404) + }) + + it('returns full lookup result on happy path', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockBank.mockResolvedValue([ + { bankAccountType: 1, accountNumber: '123-456', swift_BIC: null }, + ]) + mockSNI.mockResolvedValue([ + { sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }, + ]) + mockEmails.mockResolvedValue([{ emailAddress: 'info@test.se' }]) + mockPhones.mockResolvedValue([{ phoneNumber: '08-1234567' }]) + + const res = await lookupHandler(makeRequest('556036-0793')) + expect(res.status).toBe(200) + + const { data } = await res.json() + expect(data.companyName).toBe('Test AB') + expect(data.isCeased).toBe(false) + expect(data.address).toEqual({ + street: 'Storgatan 1', + postalCode: '111 22', + city: 'Stockholm', + }) + expect(data.registration).toEqual({ fTax: true, vat: true }) + expect(data.bankAccounts).toEqual([ + { type: 'bankgiro', accountNumber: '123-456', bic: null }, + ]) + expect(data.sniCodes).toEqual([{ code: '62010', name: 'Dataprogrammering' }]) + expect(data.email).toBe('info@test.se') + expect(data.phone).toBe('08-1234567') + }) + + it('prefers name type over other naming types', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockBank.mockResolvedValue(null) + mockSNI.mockResolvedValue(null) + mockEmails.mockResolvedValue(null) + mockPhones.mockResolvedValue(null) + + const res = await lookupHandler(makeRequest('556036-0793')) + const { data } = await res.json() + expect(data.companyName).toBe('Test AB') + }) + + it('handles partial Phase 2 failures gracefully', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockBank.mockRejectedValue(new Error('timeout')) + mockSNI.mockResolvedValue([{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }]) + mockEmails.mockRejectedValue(new Error('timeout')) + mockPhones.mockResolvedValue(null) + + const res = await lookupHandler(makeRequest('556036-0793')) + expect(res.status).toBe(200) + + const { data } = await res.json() + expect(data.companyName).toBe('Test AB') + expect(data.bankAccounts).toEqual([]) + expect(data.sniCodes).toHaveLength(1) + expect(data.email).toBeNull() + expect(data.phone).toBeNull() + }) + + it('detects ceased companies', async () => { + mockSearch.mockResolvedValue({ ...mockDoc, activityStatus: 'ceased' }) + mockBank.mockResolvedValue(null) + mockSNI.mockResolvedValue(null) + mockEmails.mockResolvedValue(null) + mockPhones.mockResolvedValue(null) + + const res = await lookupHandler(makeRequest('556036-0793')) + const { data } = await res.json() + expect(data.isCeased).toBe(true) + }) +}) diff --git a/extensions/general/tic/__tests__/profile.test.ts b/extensions/general/tic/__tests__/profile.test.ts new file mode 100644 index 00000000..f5284e36 --- /dev/null +++ b/extensions/general/tic/__tests__/profile.test.ts @@ -0,0 +1,248 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../lib/tic-client', () => ({ + searchCompanyByOrgNumber: vi.fn(), + getBankAccounts: vi.fn(), + getSNICodes: vi.fn(), + getEmails: vi.fn(), + getPhones: vi.fn(), + getCompanyPurpose: vi.fn(), + getFinancialReportSummaries: vi.fn(), +})) + +import { ticExtension } from '../index' +import { + searchCompanyByOrgNumber, + getBankAccounts, + getSNICodes, + getEmails, + getPhones, + getCompanyPurpose, + getFinancialReportSummaries, +} from '../lib/tic-client' +import type { TICCompanyDocument } from '../lib/tic-types' + +const mockSearch = vi.mocked(searchCompanyByOrgNumber) +const mockBank = vi.mocked(getBankAccounts) +const mockSNI = vi.mocked(getSNICodes) +const mockEmails = vi.mocked(getEmails) +const mockPhones = vi.mocked(getPhones) +const mockPurpose = vi.mocked(getCompanyPurpose) +const mockReports = vi.mocked(getFinancialReportSummaries) + +function makeRequest(orgNumber?: string): Request { + const url = orgNumber + ? `http://localhost/api/extensions/ext/tic/profile?org_number=${encodeURIComponent(orgNumber)}` + : 'http://localhost/api/extensions/ext/tic/profile' + return new Request(url) +} + +const profileHandler = ticExtension.apiRoutes![1].handler + +const mockDoc: TICCompanyDocument = { + companyId: 42, + registrationNumber: '5560360793', + names: [ + { nameOrIdentifier: 'Registered Name', companyNamingType: 'registeredName' }, + { nameOrIdentifier: 'Test AB', companyNamingType: 'name' }, + ], + legalEntityType: 'AB', + registrationDate: 946684800000, + mostRecentPurpose: 'Software development', + mostRecentRegisteredAddress: { + street: 'Storgatan 1', + postalCode: '111 22', + city: 'Stockholm', + }, + isRegisteredForFTax: true, + isRegisteredForVAT: true, + isRegisteredForPayroll: false, + activityStatus: 'active', + cSector: { categoryCode: 1, categoryCodeDescription: 'Privat sektor' }, + cNbrEmployeesInterval: { categoryCode: 3, categoryCodeDescription: '10-49' }, + cTurnoverInterval: { categoryCode: 5, categoryCodeDescription: '10-50 MSEK' }, + mostRecentFinancialSummary: { + periodStart: 1672531200000, + periodEnd: 1704067200000, + isAudited: true, + rs_NetSalesK: 15000, + rs_OperatingProfitOrLossK: 2500, + bs_TotalAssetsK: 8000, + fn_NumberOfEmployees: 12, + km_OperatingMargin: 16.7, + km_NetProfitMargin: 12.3, + km_EquityAssetsRatio: 45.2, + }, +} + +function mockAllSupplementary() { + mockBank.mockResolvedValue([ + { bankAccountType: 1, accountNumber: '123-456', swift_BIC: null }, + ]) + mockSNI.mockResolvedValue([ + { sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }, + ]) + mockEmails.mockResolvedValue([{ emailAddress: 'info@test.se' }]) + mockPhones.mockResolvedValue([{ phoneNumber: '08-1234567' }]) + mockPurpose.mockResolvedValue([ + { companyPurposeId: 1, purpose: 'Försäljning av drycker' }, + ]) + mockReports.mockResolvedValue([ + { + financialReportSummaryId: 1, + title: 'Årsredovisning 2023', + arrivalDate: '2024-06-15', + periodStart: '2023-01-01', + periodEnd: '2023-12-31', + isAudited: true, + auditOpinion: 'Ren', + }, + ]) +} + +describe('TIC profile route', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns 400 when org_number is missing', async () => { + const res = await profileHandler(makeRequest()) + expect(res.status).toBe(400) + }) + + it('returns 404 when company not found', async () => { + mockSearch.mockResolvedValue(null) + const res = await profileHandler(makeRequest('000000-0000')) + expect(res.status).toBe(404) + }) + + it('returns full profile on happy path', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockAllSupplementary() + + const res = await profileHandler(makeRequest('556036-0793')) + expect(res.status).toBe(200) + + const { data } = await res.json() + expect(data.companyId).toBe(42) + expect(data.orgNumber).toBe('5560360793') + expect(data.companyName).toBe('Test AB') + expect(data.legalEntityType).toBe('AB') + expect(data.activityStatus).toBe('active') + expect(data.purpose).toBe('Försäljning av drycker') + expect(data.address).toEqual({ + street: 'Storgatan 1', + postalCode: '111 22', + city: 'Stockholm', + }) + expect(data.registration).toEqual({ fTax: true, vat: true, payroll: false }) + expect(data.sector).toEqual({ code: 1, description: 'Privat sektor' }) + expect(data.employeeRange).toBe('10-49') + expect(data.turnoverRange).toBe('10-50 MSEK') + expect(data.email).toBe('info@test.se') + expect(data.phone).toBe('08-1234567') + expect(data.sniCodes).toEqual([{ code: '62010', name: 'Dataprogrammering' }]) + expect(data.bankAccounts).toEqual([ + { type: 'bankgiro', accountNumber: '123-456', bic: null }, + ]) + expect(data.fetchedAt).toBeDefined() + }) + + it('includes financial summary from company document', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockAllSupplementary() + + const res = await profileHandler(makeRequest('556036-0793')) + const { data } = await res.json() + + expect(data.financials).toEqual({ + periodStart: 1672531200000, + periodEnd: 1704067200000, + netSalesK: 15000, + operatingProfitK: 2500, + totalAssetsK: 8000, + numberOfEmployees: 12, + operatingMargin: 16.7, + netProfitMargin: 12.3, + equityAssetsRatio: 45.2, + }) + }) + + it('includes financial report summaries', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockAllSupplementary() + + const res = await profileHandler(makeRequest('556036-0793')) + const { data } = await res.json() + + expect(data.financialReports).toHaveLength(1) + expect(data.financialReports[0]).toMatchObject({ + title: 'Årsredovisning 2023', + isAudited: true, + auditOpinion: 'Ren', + }) + }) + + it('handles missing financial summary gracefully', async () => { + const docWithoutFinancials = { ...mockDoc, mostRecentFinancialSummary: undefined } + mockSearch.mockResolvedValue(docWithoutFinancials) + mockAllSupplementary() + + const res = await profileHandler(makeRequest('556036-0793')) + const { data } = await res.json() + + expect(data.financials).toBeNull() + }) + + it('handles partial Phase 2 failures gracefully', async () => { + mockSearch.mockResolvedValue(mockDoc) + mockBank.mockRejectedValue(new Error('timeout')) + mockSNI.mockResolvedValue([{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }]) + mockEmails.mockRejectedValue(new Error('timeout')) + mockPhones.mockResolvedValue(null) + mockPurpose.mockRejectedValue(new Error('timeout')) + mockReports.mockRejectedValue(new Error('timeout')) + + const res = await profileHandler(makeRequest('556036-0793')) + expect(res.status).toBe(200) + + const { data } = await res.json() + expect(data.companyName).toBe('Test AB') + expect(data.bankAccounts).toEqual([]) + expect(data.sniCodes).toHaveLength(1) + expect(data.email).toBeNull() + expect(data.phone).toBeNull() + expect(data.purpose).toBe('Software development') // falls back to mostRecentPurpose + expect(data.financialReports).toEqual([]) + }) + + it('sets financials to null when no optional fields present', async () => { + const minimalDoc: TICCompanyDocument = { + companyId: 99, + registrationNumber: '1234567890', + names: [{ nameOrIdentifier: 'Minimal AB', companyNamingType: 'name' }], + legalEntityType: 'AB', + registrationDate: 0, + } + mockSearch.mockResolvedValue(minimalDoc) + mockBank.mockResolvedValue(null) + mockSNI.mockResolvedValue(null) + mockEmails.mockResolvedValue(null) + mockPhones.mockResolvedValue(null) + mockPurpose.mockResolvedValue(null) + mockReports.mockResolvedValue(null) + + const res = await profileHandler(makeRequest('1234567890')) + const { data } = await res.json() + + expect(data.companyName).toBe('Minimal AB') + expect(data.activityStatus).toBeNull() + expect(data.address).toBeNull() + expect(data.registration).toEqual({ fTax: false, vat: false, payroll: false }) + expect(data.sector).toBeNull() + expect(data.employeeRange).toBeNull() + expect(data.turnoverRange).toBeNull() + expect(data.financials).toBeNull() + expect(data.financialReports).toEqual([]) + }) +}) diff --git a/extensions/general/tic/__tests__/tic-client.test.ts b/extensions/general/tic/__tests__/tic-client.test.ts new file mode 100644 index 00000000..b6058207 --- /dev/null +++ b/extensions/general/tic/__tests__/tic-client.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ticApiFetch, searchCompanyByOrgNumber, getBankAccounts, getSNICodes } from '../lib/tic-client' +import { TICAPIError } from '../lib/tic-types' + +const PROXY_URL = 'https://proxy.example.com/api/tic/proxy' + +describe('tic-client', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + vi.stubEnv('TIC_API_PROXY_URL', PROXY_URL) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.unstubAllGlobals() + }) + + describe('ticApiFetch', () => { + it('constructs correct proxy URL with encoded endpoint', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValue(new Response(JSON.stringify({ data: 'test' }), { status: 200 })) + + await ticApiFetch('/search/companies?q=5560360793&query_by=registrationNumber') + + expect(mockFetch).toHaveBeenCalledWith( + `${PROXY_URL}?endpoint=${encodeURIComponent('/search/companies?q=5560360793&query_by=registrationNumber')}`, + expect.objectContaining({ + headers: { Accept: 'application/json' }, + }) + ) + }) + + it('returns null on 404', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('Not found', { status: 404 })) + + const result = await ticApiFetch('/test') + expect(result).toBeNull() + }) + + it('throws TICAPIError on 429', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('Too many requests', { status: 429 })) + + await expect(ticApiFetch('/test')).rejects.toThrow(TICAPIError) + await expect(ticApiFetch('/test')).rejects.toMatchObject({ + statusCode: 429, + code: 'RATE_LIMIT_EXCEEDED', + }) + }) + + it('throws TICAPIError on 500', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response('Internal Server Error', { status: 500, statusText: 'Internal Server Error' }) + ) + + await expect(ticApiFetch('/test')).rejects.toThrow(TICAPIError) + }) + + it('throws NOT_CONFIGURED when TIC_API_PROXY_URL is missing', async () => { + vi.stubEnv('TIC_API_PROXY_URL', '') + + await expect(ticApiFetch('/test')).rejects.toMatchObject({ + code: 'NOT_CONFIGURED', + }) + }) + + it('wraps fetch errors in TICAPIError', async () => { + vi.mocked(fetch).mockRejectedValue(new Error('network failure')) + + await expect(ticApiFetch('/test')).rejects.toThrow(TICAPIError) + await expect(ticApiFetch('/test')).rejects.toThrow(/network failure/) + }) + }) + + describe('searchCompanyByOrgNumber', () => { + it('returns company document on match', async () => { + const doc = { + companyId: 123, + registrationNumber: '5560360793', + names: [{ nameOrIdentifier: 'Test AB', companyNamingType: 'name' }], + legalEntityType: 'AB', + registrationDate: 0, + } + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ found: 1, hits: [{ document: doc }], facet_counts: [] })) + ) + + const result = await searchCompanyByOrgNumber('556036-0793') + expect(result).toEqual(doc) + }) + + it('strips dashes and spaces from org number', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValue(new Response(JSON.stringify({ found: 0, hits: [] }))) + + await searchCompanyByOrgNumber('556036-0793') + + const calledUrl = mockFetch.mock.calls[0][0] as string + expect(calledUrl).toContain('q%3D5560360793') + }) + + it('returns null when no hits', async () => { + vi.mocked(fetch).mockResolvedValue( + new Response(JSON.stringify({ found: 0, hits: [], facet_counts: [] })) + ) + + const result = await searchCompanyByOrgNumber('000000-0000') + expect(result).toBeNull() + }) + }) + + describe('getBankAccounts', () => { + it('fetches bank accounts for company ID', async () => { + const accounts = [{ bankAccountType: 1, accountNumber: '123-456' }] + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(accounts))) + + const result = await getBankAccounts(123) + expect(result).toEqual(accounts) + }) + }) + + describe('getSNICodes', () => { + it('fetches SNI codes for company ID', async () => { + const sni = [{ sni_2007Code: '62010', sni_2007Name: 'Dataprogrammering' }] + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(sni))) + + const result = await getSNICodes(123) + expect(result).toEqual(sni) + }) + }) +}) diff --git a/extensions/general/tic/index.ts b/extensions/general/tic/index.ts new file mode 100644 index 00000000..cfb025b7 --- /dev/null +++ b/extensions/general/tic/index.ts @@ -0,0 +1,346 @@ +import type { Extension } from '@/lib/extensions/types' +import { NextResponse } from 'next/server' +import { + searchCompanyByOrgNumber, + getBankAccounts, + getSNICodes, + getEmails, + getPhones, + getCompanyPurpose, + getFinancialReportSummaries, +} from './lib/tic-client' +import { TICAPIError } from './lib/tic-types' +import type { TICCompanyProfile } from './lib/tic-types' +import type { CompanyLookupResult } from '@/lib/company-lookup/types' + +/** Map TIC bankAccountType enum to human-readable string */ +function bankAccountTypeLabel(type?: number): string { + switch (type) { + case 0: return 'bankkonto' + case 1: return 'bankgiro' + case 2: return 'plusgiro' + case 3: return 'iban' + default: return 'bankkonto' + } +} + +export const ticExtension: Extension = { + id: 'tic', + name: 'Bolagsuppgifter', + version: '1.0.0', + sector: 'general', + + apiRoutes: [ + { + method: 'GET', + path: '/lookup', + handler: async (request: Request, ctx?) => { + const log = ctx?.log ?? console + const url = new URL(request.url) + const orgNumber = url.searchParams.get('org_number') + + if (!orgNumber) { + return NextResponse.json( + { error: 'org_number query parameter is required' }, + { status: 400 } + ) + } + + try { + // Phase 1: Search — returns name, address, registration flags + const doc = await searchCompanyByOrgNumber(orgNumber) + + if (!doc) { + return NextResponse.json( + { error: 'Company not found' }, + { status: 404 } + ) + } + + // Extract company name (prefer 'name' type over other naming types) + const nameEntry = + doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0] + const companyName = nameEntry?.nameOrIdentifier ?? '' + + const isCeased = doc.activityStatus === 'ceased' + + const address = doc.mostRecentRegisteredAddress + ? { + street: doc.mostRecentRegisteredAddress.streetAddress + ?? doc.mostRecentRegisteredAddress.street + ?? null, + postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null, + city: doc.mostRecentRegisteredAddress.city ?? null, + } + : null + + const registration = { + fTax: doc.isRegisteredForFTax ?? false, + vat: doc.isRegisteredForVAT ?? false, + } + + // Phase 2: Supplementary data (non-blocking) + const companyId = doc.companyId + const [bankResult, sniResult, emailResult, phoneResult] = + await Promise.allSettled([ + getBankAccounts(companyId), + getSNICodes(companyId), + getEmails(companyId), + getPhones(companyId), + ]) + + const bankAccounts = + bankResult.status === 'fulfilled' && bankResult.value + ? bankResult.value.map((ba) => ({ + type: bankAccountTypeLabel(ba.bankAccountType), + accountNumber: ba.accountNumber ?? '', + bic: ba.swift_BIC ?? null, + })) + : [] + + const sniCodes = + sniResult.status === 'fulfilled' && sniResult.value + ? sniResult.value.map((s) => ({ + code: s.sni_2007Code ?? '', + name: s.sni_2007Name ?? '', + })) + : [] + + const email = + emailResult.status === 'fulfilled' && emailResult.value?.[0]?.emailAddress + ? emailResult.value[0].emailAddress + : null + + const phone = + phoneResult.status === 'fulfilled' && phoneResult.value?.[0]?.phoneNumber + ? phoneResult.value[0].phoneNumber + : null + + // Log Phase 2 failures for debugging + if (bankResult.status === 'rejected') { + log.warn('[tic] bank accounts fetch failed', { reason: String(bankResult.reason) }) + } + if (sniResult.status === 'rejected') { + log.warn('[tic] SNI codes fetch failed', { reason: String(sniResult.reason) }) + } + + const result: CompanyLookupResult = { + companyName, + isCeased, + address, + registration, + bankAccounts, + email, + phone, + sniCodes, + } + + return NextResponse.json({ data: result }) + } catch (error) { + if (error instanceof TICAPIError) { + log.error('[tic] lookup failed', { + message: error.message, + statusCode: error.statusCode, + code: error.code, + }) + + if (error.code === 'NOT_CONFIGURED') { + return NextResponse.json( + { error: 'TIC is not configured' }, + { status: 503 } + ) + } + + if (error.code === 'RATE_LIMIT_EXCEEDED') { + return NextResponse.json( + { error: 'Rate limit exceeded, try again later' }, + { status: 429 } + ) + } + } + + log.error('[tic] unexpected error', { error: String(error) }) + return NextResponse.json( + { error: 'Failed to look up company' }, + { status: 500 } + ) + } + }, + }, + { + method: 'GET', + path: '/profile', + handler: async (request: Request, ctx?) => { + const log = ctx?.log ?? console + const url = new URL(request.url) + const orgNumber = url.searchParams.get('org_number') + + if (!orgNumber) { + return NextResponse.json( + { error: 'org_number query parameter is required' }, + { status: 400 } + ) + } + + try { + const doc = await searchCompanyByOrgNumber(orgNumber) + + if (!doc) { + return NextResponse.json( + { error: 'Company not found' }, + { status: 404 } + ) + } + + const nameEntry = + doc.names.find((n) => n.companyNamingType === 'name') ?? doc.names[0] + const companyName = nameEntry?.nameOrIdentifier ?? '' + const companyId = doc.companyId + + // Phase 2: Supplementary data (non-blocking) + const [bankResult, sniResult, emailResult, phoneResult, purposeResult, reportsResult] = + await Promise.allSettled([ + getBankAccounts(companyId), + getSNICodes(companyId), + getEmails(companyId), + getPhones(companyId), + getCompanyPurpose(companyId), + getFinancialReportSummaries(companyId), + ]) + + const bankAccounts = + bankResult.status === 'fulfilled' && bankResult.value + ? bankResult.value.map((ba) => ({ + type: bankAccountTypeLabel(ba.bankAccountType), + accountNumber: ba.accountNumber ?? '', + bic: ba.swift_BIC ?? null, + })) + : [] + + const sniCodes = + sniResult.status === 'fulfilled' && sniResult.value + ? sniResult.value.map((s) => ({ + code: s.sni_2007Code ?? '', + name: s.sni_2007Name ?? '', + })) + : [] + + const email = + emailResult.status === 'fulfilled' && emailResult.value?.[0]?.emailAddress + ? emailResult.value[0].emailAddress + : null + + const phone = + phoneResult.status === 'fulfilled' && phoneResult.value?.[0]?.phoneNumber + ? phoneResult.value[0].phoneNumber + : null + + const financialReports = + reportsResult.status === 'fulfilled' && reportsResult.value + ? reportsResult.value + : [] + + // Use dedicated purpose endpoint, fall back to search result + const purpose = + purposeResult.status === 'fulfilled' && purposeResult.value?.[0]?.purpose + ? purposeResult.value[0].purpose + : doc.mostRecentPurpose ?? null + + // Log Phase 2 failures + if (bankResult.status === 'rejected') { + log.warn('[tic] profile: bank accounts fetch failed', { reason: String(bankResult.reason) }) + } + if (sniResult.status === 'rejected') { + log.warn('[tic] profile: SNI codes fetch failed', { reason: String(sniResult.reason) }) + } + if (reportsResult.status === 'rejected') { + log.warn('[tic] profile: financial reports fetch failed', { reason: String(reportsResult.reason) }) + } + + const fin = doc.mostRecentFinancialSummary + const financials = fin + ? { + periodStart: fin.periodStart, + periodEnd: fin.periodEnd, + netSalesK: fin.rs_NetSalesK ?? null, + operatingProfitK: fin.rs_OperatingProfitOrLossK ?? null, + totalAssetsK: fin.bs_TotalAssetsK ?? null, + numberOfEmployees: fin.fn_NumberOfEmployees ?? null, + operatingMargin: fin.km_OperatingMargin ?? null, + netProfitMargin: fin.km_NetProfitMargin ?? null, + equityAssetsRatio: fin.km_EquityAssetsRatio ?? null, + } + : null + + const profile: TICCompanyProfile = { + companyId, + orgNumber: doc.registrationNumber, + companyName, + legalEntityType: doc.legalEntityType, + registrationDate: doc.registrationDate, + activityStatus: doc.activityStatus ?? null, + purpose, + address: doc.mostRecentRegisteredAddress + ? { + street: doc.mostRecentRegisteredAddress.streetAddress + ?? doc.mostRecentRegisteredAddress.street + ?? null, + postalCode: doc.mostRecentRegisteredAddress.postalCode ?? null, + city: doc.mostRecentRegisteredAddress.city ?? null, + } + : null, + registration: { + fTax: doc.isRegisteredForFTax ?? false, + vat: doc.isRegisteredForVAT ?? false, + payroll: doc.isRegisteredForPayroll ?? false, + }, + sector: doc.cSector + ? { code: doc.cSector.categoryCode, description: doc.cSector.categoryCodeDescription } + : null, + employeeRange: doc.cNbrEmployeesInterval?.categoryCodeDescription ?? null, + turnoverRange: doc.cTurnoverInterval?.categoryCodeDescription ?? null, + email, + phone, + sniCodes, + bankAccounts, + financials, + financialReports, + fetchedAt: new Date().toISOString(), + } + + return NextResponse.json({ data: profile }) + } catch (error) { + if (error instanceof TICAPIError) { + log.error('[tic] profile failed', { + message: error.message, + statusCode: error.statusCode, + code: error.code, + }) + + if (error.code === 'NOT_CONFIGURED') { + return NextResponse.json( + { error: 'TIC is not configured' }, + { status: 503 } + ) + } + + if (error.code === 'RATE_LIMIT_EXCEEDED') { + return NextResponse.json( + { error: 'Rate limit exceeded, try again later' }, + { status: 429 } + ) + } + } + + log.error('[tic] profile unexpected error', { error: String(error) }) + return NextResponse.json( + { error: 'Failed to fetch company profile' }, + { status: 500 } + ) + } + }, + }, + ], + + eventHandlers: [], +} diff --git a/extensions/general/tic/lib/tic-client.ts b/extensions/general/tic/lib/tic-client.ts new file mode 100644 index 00000000..51267d82 --- /dev/null +++ b/extensions/general/tic/lib/tic-client.ts @@ -0,0 +1,102 @@ +import type { + TICCompanyResponse, + TICCompanyDocument, + TICBankAccount, + TICSNICode, + TICEmail, + TICPhone, + TICCompanyPurpose, + TICFinancialReportSummary, +} from './tic-types' +import { TICAPIError } from './tic-types' + +const TIC_API_TIMEOUT = 15_000 + +/** + * Generic TIC API fetch helper. + * Routes through the proxy at TIC_API_PROXY_URL (no API key needed). + */ +export async function ticApiFetch(endpoint: string): Promise { + const proxyUrl = process.env.TIC_API_PROXY_URL + if (!proxyUrl) { + throw new TICAPIError('TIC_API_PROXY_URL is not configured', undefined, 'NOT_CONFIGURED') + } + + const url = `${proxyUrl}?endpoint=${encodeURIComponent(endpoint)}` + + try { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(TIC_API_TIMEOUT), + }) + + if (response.status === 404) { + return null + } + + if (response.status === 429) { + throw new TICAPIError('Rate limit exceeded', 429, 'RATE_LIMIT_EXCEEDED') + } + + if (!response.ok) { + throw new TICAPIError(`TIC API error: ${response.statusText}`, response.status) + } + + return await response.json() + } catch (error: unknown) { + if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) { + throw new TICAPIError('Request timeout', undefined, 'TIMEOUT') + } + if (error instanceof TICAPIError) { + throw error + } + const message = error instanceof Error ? error.message : String(error) + throw new TICAPIError(`Failed to fetch from TIC: ${message}`) + } +} + +/** Search for a company by org number. Returns the first matching document or null. */ +export async function searchCompanyByOrgNumber( + orgNumber: string +): Promise { + const cleaned = orgNumber.replace(/[\s-]/g, '') + const data = await ticApiFetch( + `/search/companies?q=${cleaned}&query_by=registrationNumber` + ) + + if (!data || data.found === 0 || !data.hits?.[0]) { + return null + } + + return data.hits[0].document +} + +/** Get bank accounts for a company. */ +export async function getBankAccounts(companyId: number): Promise { + return ticApiFetch(`/datasets/companies/${companyId}/bank-accounts`) +} + +/** Get SNI codes for a company. */ +export async function getSNICodes(companyId: number): Promise { + return ticApiFetch(`/datasets/companies/${companyId}/se/sni`) +} + +/** Get email addresses for a company. */ +export async function getEmails(companyId: number): Promise { + return ticApiFetch(`/datasets/companies/${companyId}/email-addresses`) +} + +/** Get phone numbers for a company. */ +export async function getPhones(companyId: number): Promise { + return ticApiFetch(`/datasets/companies/${companyId}/phone-numbers`) +} + +/** Get company purpose / verksamhetsbeskrivning. */ +export async function getCompanyPurpose(companyId: number): Promise { + return ticApiFetch(`/datasets/companies/${companyId}/purpose`) +} + +/** Get financial report summaries for a company. */ +export async function getFinancialReportSummaries(companyId: number): Promise { + return ticApiFetch(`/datasets/companies/${companyId}/financial-report-summaries`) +} diff --git a/extensions/general/tic/lib/tic-types.ts b/extensions/general/tic/lib/tic-types.ts new file mode 100644 index 00000000..fbe9098c --- /dev/null +++ b/extensions/general/tic/lib/tic-types.ts @@ -0,0 +1,159 @@ +/** Search response wrapper */ +export interface TICCompanyResponse { + facet_counts: unknown[] + found: number + hits: Array<{ + document: TICCompanyDocument + }> +} + +/** Full company document from TIC search */ +export interface TICCompanyDocument { + companyId: number + registrationNumber: string + names: Array<{ + nameOrIdentifier: string + companyNamingType: string + companyNameDecidedAt?: number + firstSeenAt?: number + }> + legalEntityType: string + registrationDate: number + mostRecentPurpose?: string + mostRecentRegisteredAddress?: { + street?: string + streetAddress?: string + postalCode?: string + city?: string + countryCodeAlpha3?: string + } + isRegisteredForVAT?: boolean + isRegisteredForFTax?: boolean + isRegisteredForPayroll?: boolean + activityStatus?: string + cSector?: { + categoryCode: number + categoryCodeDescription: string + } + cOwnership?: { + categoryCode: number + categoryCodeDescription: string + } + cNbrEmployeesInterval?: { + categoryCode: number + categoryCodeDescription: string + } + cTurnoverInterval?: { + categoryCode: number + categoryCodeDescription: string + } + mostRecentFinancialSummary?: { + periodStart: number + periodEnd: number + isAudited?: boolean + rs_NetSalesK?: number + rs_OperatingProfitOrLossK?: number + bs_TotalAssetsK?: number + fn_NumberOfEmployees?: number + km_OperatingMargin?: number + km_NetProfitMargin?: number + km_EquityAssetsRatio?: number + } +} + +/** Bank account from /bank-accounts endpoint */ +export interface TICBankAccount { + bankAccountType?: number // 0=Unknown, 1=Bankgiro, 2=Plusgiro, 3=IBAN, etc. + accountNumber?: string + swift_BIC?: string + firstSeenAtUtc?: string + lastSeenAtUtc?: string +} + +/** SNI code from /se/sni endpoint */ +export interface TICSNICode { + sni_2007Code?: string + sni_2007Name?: string + sni_2007Section?: string + isPrimary?: boolean +} + +/** Email address from /email-addresses endpoint */ +export interface TICEmail { + emailAddress?: string + firstSeenAtUtc?: string + lastSeenAtUtc?: string +} + +/** Phone number from /phone-numbers endpoint */ +export interface TICPhone { + phoneNumber?: string + firstSeenAtUtc?: string + lastSeenAtUtc?: string +} + +/** Company purpose from /purpose endpoint */ +export interface TICCompanyPurpose { + companyPurposeId?: number + purpose?: string + firstSeenAtUtc?: string + lastUpdatedAtUtc?: string +} + +/** Financial report summary from /financial-report-summaries endpoint */ +export interface TICFinancialReportSummary { + financialReportSummaryId?: number + title?: string + arrivalDate?: string + registrationDate?: string + periodStart?: string + periodEnd?: string + isInterimReport?: boolean + isConsolidatedAccounts?: boolean + isAudited?: boolean + auditOpinion?: string +} + +/** Normalized company profile for workspace display */ +export interface TICCompanyProfile { + companyId: number + orgNumber: string + companyName: string + legalEntityType: string + registrationDate: number + activityStatus: string | null + purpose: string | null + address: { street: string | null; postalCode: string | null; city: string | null } | null + registration: { fTax: boolean; vat: boolean; payroll: boolean } + sector: { code: number; description: string } | null + employeeRange: string | null + turnoverRange: string | null + email: string | null + phone: string | null + sniCodes: { code: string; name: string }[] + bankAccounts: { type: string; accountNumber: string; bic: string | null }[] + financials: { + periodStart: number + periodEnd: number + netSalesK: number | null + operatingProfitK: number | null + totalAssetsK: number | null + numberOfEmployees: number | null + operatingMargin: number | null + netProfitMargin: number | null + equityAssetsRatio: number | null + } | null + financialReports: TICFinancialReportSummary[] + fetchedAt: string +} + +export class TICAPIError extends Error { + constructor( + message: string, + public statusCode?: number, + public code?: string + ) { + super(message) + this.name = 'TICAPIError' + } +} diff --git a/extensions/general/tic/manifest.json b/extensions/general/tic/manifest.json new file mode 100644 index 00000000..aaacc478 --- /dev/null +++ b/extensions/general/tic/manifest.json @@ -0,0 +1,26 @@ +{ + "id": "tic", + "sector": "general", + "exportName": "ticExtension", + "entryPoint": "@/extensions/general/tic", + "workspace": "@/components/extensions/general/TicWorkspace", + "requiredEnvVars": ["TIC_API_PROXY_URL"], + "optionalEnvVars": [], + "npmDependencies": [], + "definition": { + "name": "Bolagsuppgifter", + "category": "import", + "icon": "Building2", + "dataPattern": "manual", + "hasOwnData": true, + "description": "Hämta företagsinformation automatiskt vid registrering", + "longDescription": "Fyll i företagsuppgifter automatiskt genom att ange organisationsnummer. Hämtar adress, momsregistrering, F-skattestatus och bankuppgifter från offentliga register via TIC.", + "quickAction": { + "label": "Företagsprofil", + "description": "Visa offentliga uppgifter", + "icon": "Building2", + "href": "/e/general/tic", + "order": 10 + } + } +} diff --git a/lib/company-lookup/types.ts b/lib/company-lookup/types.ts new file mode 100644 index 00000000..52185317 --- /dev/null +++ b/lib/company-lookup/types.ts @@ -0,0 +1,15 @@ +/** + * Generic company lookup result — provider-agnostic. + * Defined in core so onboarding components can import it without + * violating the CI constraint (no core → @/extensions/ imports). + */ +export interface CompanyLookupResult { + companyName: string + isCeased: boolean + address: { street: string | null; postalCode: string | null; city: string | null } | null + registration: { fTax: boolean; vat: boolean } + bankAccounts: { type: string; accountNumber: string; bic: string | null }[] + email: string | null + phone: string | null + sniCodes: { code: string; name: string }[] +} diff --git a/lib/extensions/__tests__/sectors.test.ts b/lib/extensions/__tests__/sectors.test.ts index f6584d56..5ddabe75 100644 --- a/lib/extensions/__tests__/sectors.test.ts +++ b/lib/extensions/__tests__/sectors.test.ts @@ -49,7 +49,7 @@ describe('sectors registry', () => { }) it('should have 8 total extensions', () => { - expect(getAllExtensions().length).toBe(9) + expect(getAllExtensions().length).toBe(10) }) it('should have unique slugs within each sector', () => { @@ -94,7 +94,7 @@ describe('sectors registry', () => { it('getExtensionsBySector returns extensions for a sector', () => { const extensions = getExtensionsBySector('general') - expect(extensions.length).toBe(9) + expect(extensions.length).toBe(10) }) it('all extensions have required fields', () => { diff --git a/lib/extensions/_generated/enabled-extensions.ts b/lib/extensions/_generated/enabled-extensions.ts index d307d1d1..4fdf262b 100644 --- a/lib/extensions/_generated/enabled-extensions.ts +++ b/lib/extensions/_generated/enabled-extensions.ts @@ -4,4 +4,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet = new Set([ 'enable-banking', 'email', 'arcim-migration', + 'tic', ]) diff --git a/lib/extensions/_generated/extension-list.ts b/lib/extensions/_generated/extension-list.ts index 15ba41ab..ea8ab576 100644 --- a/lib/extensions/_generated/extension-list.ts +++ b/lib/extensions/_generated/extension-list.ts @@ -3,9 +3,11 @@ import type { Extension } from '../types' import { enableBankingExtension } from '@/extensions/general/enable-banking' import { emailExtension } from '@/extensions/general/email' import { arcimMigrationExtension } from '@/extensions/general/arcim-migration' +import { ticExtension } from '@/extensions/general/tic' export const FIRST_PARTY_EXTENSIONS: Extension[] = [ enableBankingExtension, emailExtension, arcimMigrationExtension, + ticExtension, ] diff --git a/lib/extensions/_generated/sector-definitions.ts b/lib/extensions/_generated/sector-definitions.ts index 8524ab68..d009998d 100644 --- a/lib/extensions/_generated/sector-definitions.ts +++ b/lib/extensions/_generated/sector-definitions.ts @@ -40,5 +40,23 @@ export const EXTENSION_DEFINITIONS: Record = { "description": "Migrera bokföring från Fortnox, Visma, Bokio, Björn Lundén eller Briox", "longDescription": "Flytta all bokföringsdata från ditt gamla system till gnubok. Importerar kontoplan, verifikationer, kunder, leverantörer och öppna fakturor automatiskt via säker API-integration." }, + { + "slug": "tic", + "name": "Bolagsuppgifter", + "sector": "general", + "category": "import", + "icon": "Building2", + "dataPattern": "manual", + "description": "Hämta företagsinformation automatiskt vid registrering", + "longDescription": "Fyll i företagsuppgifter automatiskt genom att ange organisationsnummer. Hämtar adress, momsregistrering, F-skattestatus och bankuppgifter från offentliga register via TIC.", + "hasOwnData": true, + "quickAction": { + "label": "Företagsprofil", + "description": "Visa offentliga uppgifter", + "icon": "Building2", + "href": "/e/general/tic", + "order": 10 + } + }, ], } diff --git a/lib/extensions/_generated/workspace-map.tsx b/lib/extensions/_generated/workspace-map.tsx index 50681548..886570bf 100644 --- a/lib/extensions/_generated/workspace-map.tsx +++ b/lib/extensions/_generated/workspace-map.tsx @@ -6,4 +6,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry' export const WORKSPACES: Record> = { 'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')), 'general/arcim-migration': dynamic(() => import('@/components/extensions/general/ArcimMigrationWorkspace')), + 'general/tic': dynamic(() => import('@/components/extensions/general/TicWorkspace')), } diff --git a/lib/extensions/icon-resolver.tsx b/lib/extensions/icon-resolver.tsx index 1252e67d..d66e0f5b 100644 --- a/lib/extensions/icon-resolver.tsx +++ b/lib/extensions/icon-resolver.tsx @@ -28,6 +28,9 @@ import { Ship, FileText, Shield, + Building2, + ArrowRightLeft, + Mail, type LucideIcon, } from 'lucide-react' @@ -61,6 +64,9 @@ const ICON_MAP: Record = { Ship, FileText, Shield, + Building2, + ArrowRightLeft, + Mail, } export function resolveIcon(name: string): LucideIcon { diff --git a/lib/extensions/sectors.ts b/lib/extensions/sectors.ts index dcd72e44..b8eb378a 100644 --- a/lib/extensions/sectors.ts +++ b/lib/extensions/sectors.ts @@ -1,5 +1,6 @@ import type { Sector, SectorSlug, ExtensionDefinition } from './types' import { EXTENSION_DEFINITIONS } from './_generated/sector-definitions' +import { WORKSPACES } from './_generated/workspace-map' // ============================================================ // Sector & Extension Registry @@ -46,3 +47,27 @@ export function getAllExtensions(): ExtensionDefinition[] { export function getExtensionsBySector(slug: SectorSlug): ExtensionDefinition[] { return getSector(slug)?.extensions ?? [] } + +/** Extensions with a workspace and a quickAction href — for sidebar nav. + * Filters against the user's enabled extensions when provided. */ +export function getExtensionNavItems( + enabledExtensions?: { sector_slug: string; extension_slug: string }[] +): { href: string; label: string; icon: string }[] { + return getAllExtensions() + .filter(e => { + const key = `${e.sector}/${e.slug}` + if (!(key in WORKSPACES) || !e.quickAction?.href) return false + if (enabledExtensions) { + return enabledExtensions.some( + t => t.sector_slug === e.sector && t.extension_slug === e.slug + ) + } + return true + }) + .sort((a, b) => (a.quickAction!.order ?? 0) - (b.quickAction!.order ?? 0)) + .map(e => ({ + href: e.quickAction!.href!, + label: e.quickAction!.label, + icon: e.quickAction!.icon, + })) +} diff --git a/lib/extensions/toggle-check.ts b/lib/extensions/toggle-check.ts index bcf3e64d..87874e31 100644 --- a/lib/extensions/toggle-check.ts +++ b/lib/extensions/toggle-check.ts @@ -8,12 +8,15 @@ import { createServiceClient } from '@/lib/supabase/server' * general extensions that were previously always-on default to * enabled when no toggle row exists. */ -const LEGACY_GENERAL_EXTENSIONS = [ +export const LEGACY_GENERAL_EXTENSIONS = [ 'receipt-ocr', 'ai-categorization', 'ai-chat', + 'push-notifications', 'enable-banking', + 'email', 'arcim-migration', + 'tic', ] export async function isExtensionEnabled(