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:
co-authored by
Claude Opus 4.6
parent
86348c80c7
commit
e18b8dc789
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user