feat: BFL-compliant descriptions, cancelled status, and TIC company lookup (#57)

* fix: include reversed entries in all reports (general ledger, trial balance, VAT, SIE, NE, INK2)

Reversed entries (storno) must appear alongside their original posted entries
in reports for a complete audit trail. Previously, filtering by status='posted'
excluded them, causing discrepancies when corrections had been made.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: semi-manual invoice payment booking with editable journal lines

When marking an invoice as paid, users now see a dialog where they can:
- Choose which bank/cash account the payment goes to (1910, 1920, 1930, etc.)
- Review and edit the proposed journal entry lines before committing
- The happy path remains fast — lines are pre-filled correctly

Implementation:
- Pure proposePaymentLines() function for line computation (accrual + cash)
- PaymentBookingDialog with AccountCombobox, balance validation, date picker
- API accepts optional custom lines, falls back to auto-generation without them
- 18 tests (8 unit + 10 API) all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — validation fallback, balance check, error handling

- P1: Return 400 on invalid body instead of silently falling back to
  auto-generated lines (split JSON parse from schema validation)
- P1: Add server-side balance check for custom lines before committing
  (debit must equal credit, totalDebit > 0)
- P2: Wrap PaymentBookingDialog init() in try/catch with toast on
  failure and auto-close instead of silent empty state
- Add 2 new tests: unbalanced lines → 400, invalid schema → 400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: OAuth callback redirect for local dev and timeout resilience

- Pass redirectUri dynamically from NEXT_PUBLIC_APP_URL so OAuth
  callbacks work on localhost (not just production)
- Encode consentId/provider in OAuth state (base64url JSON) so the
  callback doesn't depend on session storage
- Add skipAuth flag to extension API routes for OAuth callbacks
  (external provider redirects have no user session cookie)
- Wrap AbortError in descriptive timeout messages in arcim-client
- Make preview endpoint resilient to partial failures (company info
  and SIE fetch are individually non-blocking)
- Simplify login page (remove unused magic link auth mode)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: create journal entry before marking invoice as paid

Move journal entry creation before the invoice status update so that
if accounting fails, the invoice is not permanently marked paid without
a corresponding entry. Previously the error was silently swallowed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update mark-paid tests for journal-first ordering

Reorder mock queue to match new flow (settings before update), update
failure test to expect 500 instead of silent success, add try-catch
with proper error response in route handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add reverse charge VAT (ruta 20-32) and improve mobile UX across dashboard

Add full reverse charge (omvänd skattskyldighet) support to the VAT declaration:
- Map accounts 2614/2624/2634 to ruta 30/31/32 for self-assessed output VAT
- Calculate purchase bases (ruta 20-24) from supplier invoices by supplier type
- Include ruta 30-32 in ruta 49 formula and totalOutputVat summary
- Display reverse charge section in reports UI and composition chart
- Add comprehensive test coverage for all reverse charge scenarios

Improve mobile UX across the app:
- Convert nav drawer to bottom sheet with drag handle and safe area padding
- Add mobile card layout for PaymentBookingDialog journal lines
- Replace settings tab pills with dropdown selector on mobile
- Make wizard step indicators responsive (collapsed on mobile)
- Ensure all dialog footers stack buttons full-width on mobile
- Add 44px minimum touch targets throughout
- Make onboarding buttons full-width on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — indentation, query efficiency, tab dedup

- Fix misleading try-block indentation in mark-paid route
- Filter reversed entries at DB level (.eq('status', 'posted')) instead
  of fetching then discarding in memory
- Extract shared settingsTabs array so mobile Select and desktop
  TabsList stay in sync automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add resilience fallbacks, Arcim retry logic, and client tests

Add FallbackPrompt component and integrate it across banking and migration
error states so users always have a manual import escape hatch. Add retry
with exponential backoff to Arcim API client for transient failures (429,
502, 503, 504) and timeouts. Expand import page deep-linking with ?mode=
parameter. Add persistent error banner on settings page for bank connection
failures. Include 18 new tests for the Arcim client covering retry, backoff,
pagination, timeout, env validation, and singleton resource unwrapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — setActiveTab, test cleanup, redundant clearTimeout

- Add missing setActiveTab('banking') when handling bank_error query
  param so the error banner is actually visible (P1)
- Guard env-var cleanup with try/finally in arcim-client tests to
  prevent state leakage on assertion failure (P2)
- Only mock retry-range setTimeout delays in backoff test, letting
  AbortController timers pass through real setTimeout (P2)
- Remove redundant clearTimeout in catch block — finally handles it (P2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add BFL-compliant counterparty names to journal descriptions and cancelled entry status

Journal descriptions now include customer/supplier names for traceability
(e.g. "Kundfaktura 1001, Foretag AB"). Failed draft entries are marked as
'cancelled' instead of deleted, respecting immutability constraints.
Includes DB migration for the new journal_entries status value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — Swedish typos, missing source type, trigger and reversal cleanup

- Fix Swedish spelling: leverantor → leverantör in all supplier description prefixes
- Add supplier_credit_note to supplierSourceTypes in VAT declaration so credit
  notes correctly reduce reverse-charge bases (ruta 20–24)
- Mark orphaned concurrent reversals as cancelled instead of attempting deletion
  that the immutability trigger blocks
- Allow posted → cancelled transition in trigger for orphaned reversal cleanup
- Restrict cancelled entry line trigger to DELETE-only (block INSERT/UPDATE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use main's Step3TaxRegistration (onboarding restructured in PR #54)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: retrigger Greptile review

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add TIC company lookup extension, extension nav items, and legacy toggle fallback

Introduces the TIC (Bolagsuppgifter) extension for automatic company data lookup
via org number during onboarding. Adds dynamic extension nav items in the sidebar,
legacy general extension fallback for toggle checks, and company lookup type
definitions in core.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — restore push-notifications, filter nav by toggles, fix timeout error name

- Restore push-notifications to LEGACY_GENERAL_EXTENSIONS (was silently
  dropped when extracting the shared constant)
- Remove tic and arcim-migration from legacy defaults (new extensions
  should not default to enabled for all users)
- Filter getExtensionNavItems() against user's enabled extensions so
  disabled extensions don't appear in the sidebar
- Fix AbortSignal.timeout() error name check — Node.js throws
  TimeoutError, not AbortError

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add all bundled extensions to legacy defaults (email, arcim-migration, tic)

Bundled extensions configured in extensions.config.json should default
to enabled. Adds email, arcim-migration, and tic alongside the
existing legacy defaults so they are accessible without explicit
toggle rows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-19 15:31:56 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 86348c80c7
commit e18b8dc789
28 changed files with 1899 additions and 75 deletions
+7 -2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { getExtensionDefinition } from '@/lib/extensions/sectors'
import { LEGACY_GENERAL_EXTENSIONS } from '@/lib/extensions/toggle-check'
import ExtensionWorkspaceLoader from '@/components/extensions/ExtensionWorkspaceLoader'
export default async function ExtensionWorkspacePage({
@@ -17,7 +18,7 @@ export default async function ExtensionWorkspacePage({
const definition = getExtensionDefinition(sector, slug)
if (!definition) notFound()
// Check toggle
// Check toggle — fall back to legacy list when no toggle row exists
const { data: toggle } = await supabase
.from('extension_toggles')
.select('enabled')
@@ -26,7 +27,11 @@ export default async function ExtensionWorkspacePage({
.eq('extension_slug', slug)
.single()
if (!toggle?.enabled) redirect('/extensions')
const isEnabled = toggle
? toggle.enabled
: sector === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(slug)
if (!isEnabled) redirect('/extensions')
return (
<ExtensionWorkspaceLoader
+14 -1
View File
@@ -4,6 +4,8 @@ import DashboardNav from '@/components/dashboard/DashboardNav'
import { RecaptIdentify } from '@/components/RecaptIdentify'
import { SentryIdentify } from '@/components/SentryIdentify'
import { SandboxBanner } from '@/components/dashboard/SandboxBanner'
import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { LEGACY_GENERAL_EXTENSIONS } from '@/lib/extensions/toggle-check'
import type { EntityType } from '@/types'
export default async function DashboardLayout({
@@ -19,7 +21,7 @@ export default async function DashboardLayout({
redirect('/login')
}
const [{ data: settings }, { count: uncategorizedCount }] = await Promise.all([
const [{ data: settings }, { count: uncategorizedCount }, { data: enabledToggles }] = await Promise.all([
supabase
.from('company_settings')
.select('company_name, onboarding_complete, entity_type, is_sandbox')
@@ -30,6 +32,11 @@ export default async function DashboardLayout({
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.is('is_business', null),
supabase
.from('extension_toggles')
.select('sector_slug, extension_slug')
.eq('user_id', user.id)
.eq('enabled', true),
])
if (!settings?.onboarding_complete) {
@@ -55,6 +62,12 @@ export default async function DashboardLayout({
entityType={entityType}
uncategorizedTransactionCount={uncategorizedCount ?? 0}
isSandbox={isSandbox}
extensionNavItems={getExtensionNavItems([
...(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 })),
])}
/>
<main id="main-content" className="safe-area-main-padding md:!pb-0 md:pl-[232px]" role="main">
<div className="max-w-5xl mx-auto px-5 py-8 md:px-8 md:py-10">
+7 -1
View File
@@ -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 })),
]}
/>
)
}
+11 -4
View File
@@ -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<Partial<CompanySettings>>({})
const { enabled: ticEnabled } = useExtensionToggle('general', 'tic')
const [ticLookup, setTicLookup] = useState<CompanyLookupResult | null>(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 && (
<Step3TaxRegistration
initialData={{
f_skatt: settings.f_skatt ?? undefined,
f_skatt: settings.f_skatt ?? (ticLookup ? ticLookup.registration.fTax : undefined),
fiscal_year_start_month: settings.fiscal_year_start_month ?? undefined,
}}
entityType={settings.entity_type as EntityType}
@@ -482,7 +489,7 @@ function OnboardingPageContent() {
{currentStep === 4 && (
<Step4VatAccounting
initialData={{
vat_registered: settings.vat_registered ?? undefined,
vat_registered: settings.vat_registered ?? (ticLookup ? ticLookup.registration.vat : undefined),
vat_number: settings.vat_number ?? undefined,
moms_period: (settings.moms_period as MomsPeriod | null) ?? undefined,
accounting_method: (settings.accounting_method as 'accrual' | 'cash') ?? undefined,
@@ -501,8 +508,8 @@ function OnboardingPageContent() {
bank_name: settings.bank_name ?? undefined,
clearing_number: settings.clearing_number ?? undefined,
account_number: settings.account_number ?? undefined,
iban: settings.iban ?? undefined,
bic: settings.bic ?? undefined,
iban: settings.iban ?? (ticLookup?.bankAccounts.find((b) => b.type === 'iban')?.accountNumber) ?? undefined,
bic: settings.bic ?? (ticLookup?.bankAccounts.find((b) => b.type === 'iban')?.bic) ?? undefined,
}}
onComplete={async (data) => {
if (data) {
@@ -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,
+52 -2
View File
@@ -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<string, string> = {
ö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<ReturnType<typeof setTimeout> | 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
</button>
{isOvrigtExpanded && (
<div className="space-y-px animate-fade-in">
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'group flex items-center px-3 py-[7px] text-[13px] transition-colors duration-150 rounded-lg',
active
? 'bg-primary/12 text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/40'
)}
>
<Icon className={cn(
"mr-2.5 h-[15px] w-[15px] flex-shrink-0",
active ? "text-primary" : "text-muted-foreground group-hover:text-foreground"
)} />
{item.label}
</Link>
)
})}
{ö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 */}
<div className="space-y-0.5">
{extensionNavItems.map((item) => {
const Icon = resolveIcon(item.icon)
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
onClick={closeMobileMenu}
className={cn(
'flex items-center gap-3 px-3 min-h-[44px] rounded-lg transition-colors',
active
? 'bg-primary/10 text-primary font-medium'
: 'text-foreground active:bg-muted/60'
)}
>
<Icon className={cn("h-[18px] w-[18px] flex-shrink-0", active ? "text-primary" : "text-muted-foreground")} />
<span className="text-sm">{item.label}</span>
</Link>
)
})}
{övrigtItems.map((item) => {
const Icon = item.icon
const active = isActive(item.href)
@@ -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 (
<div>
{/* Breadcrumb */}
<nav className="flex items-center gap-1.5 text-sm text-muted-foreground mb-6">
<Link href="/extensions" className="hover:text-foreground transition-colors">
Tillägg
</Link>
<span>/</span>
<Link
href={`/extensions/${definition.sector}`}
className="hover:text-foreground transition-colors"
>
{sector?.name ?? definition.sector}
</Link>
<span>/</span>
<span className="text-foreground">{definition.name}</span>
</nav>
{/* Header */}
<div className="flex items-start gap-4 mb-8">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10 flex-shrink-0">
@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
<Skeleton className="h-6 w-16" />
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
</div>
)
}
export default function TicWorkspace({ userId }: WorkspaceComponentProps) {
const { getByKey, save, isLoading: isDataLoading } = useExtensionData('general', 'tic')
const { toast } = useToast()
const [profile, setProfile] = useState<TICCompanyProfile | null>(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 <ProfileSkeleton />
}
if (noOrgNumber) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Settings className="h-12 w-12 text-muted-foreground/40 mb-4" />
<h3 className="text-lg font-medium text-foreground">
Inget organisationsnummer
</h3>
<p className="text-sm text-muted-foreground mt-1 max-w-md">
Ange organisationsnummer under Inställningar för att visa företagsprofilen.
</p>
<Button variant="outline" className="mt-4" asChild>
<Link href="/settings">Gå till Inställningar</Link>
</Button>
</div>
)
}
if (isFetching && !profile) {
return <ProfileSkeleton />
}
if (!profile) return null
const isActive = profile.activityStatus !== 'ceased'
return (
<div className="space-y-6">
{/* Status bar */}
<div className="flex flex-wrap items-center gap-2">
<Badge variant={isActive ? 'success' : 'destructive'}>
{isActive ? 'Aktiv' : 'Avregistrerat'}
</Badge>
{profile.registration.fTax && <Badge variant="outline">F-skatt</Badge>}
{profile.registration.vat && <Badge variant="outline">Moms</Badge>}
{profile.registration.payroll && <Badge variant="outline">Arbetsgivare</Badge>}
<span className="ml-auto text-xs text-muted-foreground">
Uppdaterad {timeAgo(profile.fetchedAt)}
</span>
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Company info card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building2 className="h-4 w-4" />
{profile.companyName}
</CardTitle>
<CardDescription>
{profile.orgNumber} &middot; {profile.legalEntityType}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{profile.address && (
<div className="flex items-start gap-2 text-muted-foreground">
<MapPin className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>
{[profile.address.street, `${profile.address.postalCode} ${profile.address.city}`]
.filter(Boolean)
.join(', ')}
</span>
</div>
)}
{profile.email && (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-3.5 w-3.5 shrink-0" />
<span>{profile.email}</span>
</div>
)}
{profile.phone && (
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-3.5 w-3.5 shrink-0" />
<span>{profile.phone}</span>
</div>
)}
{profile.sniCodes.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">SNI-koder</p>
<div className="space-y-0.5">
{profile.sniCodes
.filter((sni, i, arr) => arr.findIndex(s => s.code === sni.code) === i)
.map((sni) => (
<p key={sni.code} className="text-xs text-muted-foreground">
<span className="font-mono tabular-nums">{sni.code}</span>{' '}
{sni.name}
</p>
))}
</div>
</div>
)}
{profile.bankAccounts.length > 0 && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Bankuppgifter</p>
<div className="space-y-0.5">
{profile.bankAccounts.map((ba, i) => (
<p key={i} className="text-xs text-muted-foreground">
<span className="capitalize">{ba.type}</span>:{' '}
<span className="font-mono tabular-nums">{ba.accountNumber}</span>
</p>
))}
</div>
</div>
)}
{profile.purpose && (
<div className="pt-2 border-t">
<p className="text-xs font-medium text-muted-foreground mb-1">Verksamhet</p>
<p className="text-xs text-muted-foreground">{profile.purpose}</p>
</div>
)}
{(profile.employeeRange || profile.turnoverRange) && (
<div className="pt-2 border-t">
{profile.employeeRange && (
<p className="text-xs text-muted-foreground">
Anställda: {profile.employeeRange}
</p>
)}
{profile.turnoverRange && (
<p className="text-xs text-muted-foreground">
Omsättning: {profile.turnoverRange}
</p>
)}
</div>
)}
</CardContent>
</Card>
{/* Financials card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Senaste bokslut</CardTitle>
{profile.financials && (
<CardDescription>
{formatPeriod(profile.financials.periodStart, profile.financials.periodEnd)}
</CardDescription>
)}
</CardHeader>
<CardContent>
{profile.financials ? (
<div className="grid grid-cols-2 gap-4">
<FinancialCell label="Omsättning" value={formatKSEK(profile.financials.netSalesK)} />
<FinancialCell
label="Rörelseresultat"
value={formatKSEK(profile.financials.operatingProfitK)}
negative={(profile.financials.operatingProfitK ?? 0) < 0}
/>
<FinancialCell label="Totala tillgångar" value={formatKSEK(profile.financials.totalAssetsK)} />
<FinancialCell
label="Anställda"
value={profile.financials.numberOfEmployees !== null
? String(profile.financials.numberOfEmployees)
: '—'}
/>
<FinancialCell
label="Rörelsemarginal"
value={formatPercent(profile.financials.operatingMargin)}
negative={(profile.financials.operatingMargin ?? 0) < 0}
/>
<FinancialCell
label="Soliditet"
value={formatPercent(profile.financials.equityAssetsRatio)}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">Inga finansiella uppgifter tillgängliga.</p>
)}
</CardContent>
</Card>
</div>
{/* Financial reports table */}
{profile.financialReports.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Årsredovisningar</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Period</TableHead>
<TableHead>Titel</TableHead>
<TableHead>Inlämnad</TableHead>
<TableHead>Reviderad</TableHead>
<TableHead>Revisionsutlåtande</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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) => (
<TableRow key={report.financialReportSummaryId ?? i}>
<TableCell className="font-mono tabular-nums text-xs">
{report.periodStart && report.periodEnd
? `${report.periodStart.slice(0, 10)} – ${report.periodEnd.slice(0, 10)}`
: '—'}
</TableCell>
<TableCell className="text-xs">{report.title ?? '—'}</TableCell>
<TableCell className="text-xs">
{report.arrivalDate
? new Date(report.arrivalDate).toLocaleDateString('sv-SE')
: '—'}
</TableCell>
<TableCell>
{report.isAudited === true ? (
<CheckCircle className="h-3.5 w-3.5 text-success" />
) : report.isAudited === false ? (
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="text-xs">{report.auditOpinion ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)
}
function FinancialCell({
label,
value,
negative = false,
}: {
label: string
value: string
negative?: boolean
}) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p
className={`text-sm font-medium tabular-nums ${
negative ? 'text-destructive' : 'text-foreground'
}`}
>
{value}
</p>
</div>
)
}
+126 -18
View File
@@ -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<typeof schema>
const ORG_NUMBER_REGEX = /^\d{6,8}[-\s]?\d{4}$/
interface Step2Props {
initialData: Partial<FormData>
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<FormData>({
resolver: zodResolver(schema),
@@ -53,6 +63,81 @@ export default function Step2CompanyDetails({
},
})
const [isLooking, setIsLooking] = useState(false)
const [lookupError, setLookupError] = useState<string | null>(null)
const [lookupDone, setLookupDone] = useState<CompanyLookupResult | null>(null)
const abortRef = useRef<AbortController | null>(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({
<CardHeader>
<CardTitle>Grunduppgifter</CardTitle>
<CardDescription>
{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.'}
</CardDescription>
</CardHeader>
<CardContent>
@@ -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">
<div className="space-y-2">
<Label htmlFor="company_name">
{isAB ? 'Företagsnamn' : 'Verksamhetsnamn (Eller ditt namn vid EF)'} *
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
{...register('company_name')}
/>
{errors.company_name && (
<p className="text-sm text-destructive">{errors.company_name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="org_number">
Organisationsnummer *
@@ -103,6 +176,41 @@ export default function Step2CompanyDetails({
? 'Obligatoriskt för aktiebolag'
: 'Vid enskild firma är orgnummer samma som ditt personnummer'}
</p>
{ticEnabled && isLooking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Hämtar företagsuppgifter...
</div>
)}
{ticEnabled && lookupDone && !lookupDone.isCeased && (
<div className="flex items-center gap-2 text-sm text-primary">
<CheckCircle2 className="h-3.5 w-3.5" />
{lookupDone.companyName}
</div>
)}
{ticEnabled && lookupDone?.isCeased && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
{lookupDone.companyName} — företaget är avregistrerat
</div>
)}
{ticEnabled && lookupError && (
<p className="text-xs text-muted-foreground">{lookupError}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="company_name">
{isAB ? 'Företagsnamn' : 'Verksamhetsnamn (Eller ditt namn vid EF)'} *
</Label>
<Input
id="company_name"
placeholder={isAB ? 'AB Företaget' : 'Alices Konsultverksamhet'}
{...register('company_name')}
/>
{errors.company_name && (
<p className="text-sm text-destructive">{errors.company_name.message}</p>
)}
</div>
<div className="pt-4 border-t">
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic"]}
+2 -1
View File
@@ -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."
+27 -13
View File
@@ -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<string, { number: string; name: string }>()
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,
@@ -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)
})
})
@@ -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([])
})
})
@@ -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)
})
})
})
+346
View File
@@ -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: [],
}
+102
View File
@@ -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<T>(endpoint: string): Promise<T | null> {
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<TICCompanyDocument | null> {
const cleaned = orgNumber.replace(/[\s-]/g, '')
const data = await ticApiFetch<TICCompanyResponse>(
`/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<TICBankAccount[] | null> {
return ticApiFetch<TICBankAccount[]>(`/datasets/companies/${companyId}/bank-accounts`)
}
/** Get SNI codes for a company. */
export async function getSNICodes(companyId: number): Promise<TICSNICode[] | null> {
return ticApiFetch<TICSNICode[]>(`/datasets/companies/${companyId}/se/sni`)
}
/** Get email addresses for a company. */
export async function getEmails(companyId: number): Promise<TICEmail[] | null> {
return ticApiFetch<TICEmail[]>(`/datasets/companies/${companyId}/email-addresses`)
}
/** Get phone numbers for a company. */
export async function getPhones(companyId: number): Promise<TICPhone[] | null> {
return ticApiFetch<TICPhone[]>(`/datasets/companies/${companyId}/phone-numbers`)
}
/** Get company purpose / verksamhetsbeskrivning. */
export async function getCompanyPurpose(companyId: number): Promise<TICCompanyPurpose[] | null> {
return ticApiFetch<TICCompanyPurpose[]>(`/datasets/companies/${companyId}/purpose`)
}
/** Get financial report summaries for a company. */
export async function getFinancialReportSummaries(companyId: number): Promise<TICFinancialReportSummary[] | null> {
return ticApiFetch<TICFinancialReportSummary[]>(`/datasets/companies/${companyId}/financial-report-summaries`)
}
+159
View File
@@ -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'
}
}
+26
View File
@@ -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
}
}
}
+15
View File
@@ -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 }[]
}
+2 -2
View File
@@ -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', () => {
@@ -4,4 +4,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'enable-banking',
'email',
'arcim-migration',
'tic',
])
@@ -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,
]
@@ -40,5 +40,23 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"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
}
},
],
}
@@ -6,4 +6,5 @@ import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
'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')),
}
+6
View File
@@ -28,6 +28,9 @@ import {
Ship,
FileText,
Shield,
Building2,
ArrowRightLeft,
Mail,
type LucideIcon,
} from 'lucide-react'
@@ -61,6 +64,9 @@ const ICON_MAP: Record<string, LucideIcon> = {
Ship,
FileText,
Shield,
Building2,
ArrowRightLeft,
Mail,
}
export function resolveIcon(name: string): LucideIcon {
+25
View File
@@ -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,
}))
}
+4 -1
View File
@@ -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(