db8983ba9e
* feat(arcim-migration): Briox provider with SIE-over-API import - Briox auth via account ID + application token (no app-level credentials); both tokens rotate on refresh and are persisted - New sie-fetcher pulls the general ledger as SIE through the provider API for Fortnox, Briox and Bjorn Lunden - Wizard stops on a failed SIE import and surfaces the real errors instead of proceeding to the misleading migrate-guard message - PROVIDER_SIE_ONLY_FORTNOX renamed to PROVIDER_SIE_NOT_SUPPORTED; new PROVIDER_TOKEN_INVALID for rejected provider credentials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): per-line accruals (periodisering) on invoices and supplier invoices Defer revenue/costs per invoice line to 29xx/17xx interim accounts with automatic monthly dissolution (nightly cron + catch-up at registration), schedule cancellation on credit, year-end auto-detect exclusion for already-scheduled invoices, invoice-inbox service-period extraction for prefill, and an MCP tool to list schedules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bokslut): iXBRL arsredovisning generation and Bolagsverket digital filing Generate the annual report as iXBRL from a generated taxonomy registry (K2 element lists, taxonomy:generate/check scripts + CI guard), expose it via the fiscal-period API, and add the bolagsverket extension for digital submission to eget utrymme with webhook-driven status tracking (submissions table + pg tests, lifecycle events, year-end wizard UI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(mcp): raise origin-guard test timeout to 20s The dynamic import pulls in the full server module; the parse alone flirts with the 5s default under full-suite parallel load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add new scripts and documentation for K2 AB taxonomy generation and validation - Introduced `generate-taxonomy-registry.ts` to automate the generation of the iXBRL taxonomy concept registry from official element lists and tuple models. - Added `validate-ixbrl.mjs` for validating generated iXBRL reports against the official taxonomy package using Arelle. - Included new documentation files: - `k2-ab-arsredovisning-elementlista-2024-09-12_rev20250312_sv.xlsx` - `tuple-innehallsmodell-arsredovisning-k2-2024-09-12.xlsx` - `taxonomi-paket-2024-09-12_rev20250312.zip` * Add tests for bookkeeping accruals dissolution and supplier invoices - Implement tests for the POST /api/bookkeeping/accruals/[id]/dissolve route, covering success and error scenarios. - Add tests for the DELETE /api/supplier-invoices/[id] route, including authentication checks and validation of invoice deletion conditions. - Introduce tests for the Arcim migration provider client, ensuring token handling and error classification. - Create tests for the Bolagsverket extension, validating submission role enforcement and environment settings. - Add Zod schemas for Bolagsverket response payloads to ensure proper validation. - Implement tests for MCP server's list accrual schedules, confirming registration and scope mapping. - Add consistency tests for IXBRL document generation, ensuring duplicate facts and XML escaping are handled correctly. - Introduce typed domain errors for accrual schedules to improve error handling in the service. - Add tests for resolving consent with Briox token refresh concurrency, ensuring proper token management and error handling. * fix(tests): update payload size guard comments to reflect recent changes in tool descriptions and ceiling adjustments * fix(gitattributes): mark generated JSON files in bokslut taxonomy as linguist-generated * feat(migrations): add backfill for invoices.journal_entry_id and fallback for next_voucher_number user_id * feat(bokslut): enhance compliance and financial processing features with new submission details and security measures --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
235 lines
8.5 KiB
TypeScript
235 lines
8.5 KiB
TypeScript
/**
|
|
* Per-provider SIE-over-API fetcher.
|
|
*
|
|
* Providers that expose their general ledger as a SIE export over the API get
|
|
* the "Fortnox-grade" migration experience: the wizard pulls the GL itself
|
|
* instead of requiring a manual SIE upload. Everything downstream (parsing,
|
|
* validation, account mapping, replace-mode import) is provider-agnostic —
|
|
* this module's only job is to produce raw SIE file contents per fiscal year.
|
|
*
|
|
* Shared by /preview (latest year, for stats) and /sie-data (all years).
|
|
*/
|
|
|
|
import { FortnoxClient } from '@/lib/providers/fortnox/client'
|
|
import { BrioxClient } from '@/lib/providers/briox/client'
|
|
import { BjornLundenClient } from '@/lib/providers/bjornlunden/client'
|
|
import type { ProviderName } from '@/lib/providers/types'
|
|
import { detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
|
import { createLogger } from '@/lib/logger'
|
|
|
|
const log = createLogger('extensions/arcim-migration/sie-fetcher')
|
|
|
|
/**
|
|
* Fiscal years we support importing — the current year and the two before it.
|
|
* Derived at call time (not a module constant) so the window rolls forward
|
|
* automatically at new year without a code change.
|
|
*/
|
|
export function getAllowedFiscalYears(now: Date = new Date()): Set<number> {
|
|
const currentYear = now.getFullYear()
|
|
return new Set([currentYear - 2, currentYear - 1, currentYear])
|
|
}
|
|
|
|
export interface ProviderSieFile {
|
|
fiscalYear: number
|
|
rawContent: string
|
|
}
|
|
|
|
export interface ProviderSieFetchResult {
|
|
files: ProviderSieFile[]
|
|
/**
|
|
* Every fiscal year available at the provider within the allowed window —
|
|
* also populated when latestOnly fetched just one file, so /preview can show
|
|
* the full year list without a second round-trip.
|
|
*/
|
|
availableYears: number[]
|
|
/**
|
|
* Allowed years whose export failed (or came back empty). Callers MUST
|
|
* surface these to the user: silently importing e.g. 2024+2026 without 2025
|
|
* breaks IB/UB continuity between the years without anyone noticing.
|
|
*/
|
|
failedYears: { year: number; error: string }[]
|
|
}
|
|
|
|
// Singleton clients (they hold rate limiters)
|
|
const fortnoxClient = new FortnoxClient()
|
|
const brioxClient = new BrioxClient()
|
|
const bjornLundenClient = new BjornLundenClient()
|
|
|
|
/** True when the provider's API can serve the GL as SIE (no manual upload). */
|
|
export function providerSupportsSie(provider: ProviderName): boolean {
|
|
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden'
|
|
}
|
|
|
|
interface FiscalYearRef {
|
|
id: string | number
|
|
year: number
|
|
/** Period bounds — required by BL, whose export URL is date-ranged. */
|
|
fromDate?: string
|
|
toDate?: string
|
|
}
|
|
|
|
/**
|
|
* Fetch SIE type-4 exports from the provider, one file per allowed fiscal
|
|
* year (oldest first). Years whose export fails do not block the rest of the
|
|
* migration, but they are reported in `failedYears` so the caller can warn
|
|
* the user before importing a gap (IB/UB continuity).
|
|
*/
|
|
export async function fetchProviderSieFiles(
|
|
provider: ProviderName,
|
|
accessToken: string,
|
|
providerCompanyId: string | undefined,
|
|
opts?: { latestOnly?: boolean },
|
|
): Promise<ProviderSieFetchResult> {
|
|
const fetcher = getSieFetcher(provider, providerCompanyId)
|
|
if (!fetcher) {
|
|
throw new Error(`Provider ${provider} does not support SIE over API`)
|
|
}
|
|
|
|
const allowedFiscalYears = getAllowedFiscalYears()
|
|
const allYears = await fetcher.listYears(accessToken)
|
|
const allowedYears = allYears
|
|
.filter((fy) => allowedFiscalYears.has(fy.year))
|
|
.sort((a, b) => a.year - b.year)
|
|
|
|
const availableYears = allowedYears.map((fy) => fy.year)
|
|
const toFetch = opts?.latestOnly ? allowedYears.slice(-1) : allowedYears
|
|
|
|
const files: ProviderSieFile[] = []
|
|
const failedYears: { year: number; error: string }[] = []
|
|
for (const fy of toFetch) {
|
|
try {
|
|
const rawContent = await fetcher.fetchSie(accessToken, fy)
|
|
if (rawContent) {
|
|
files.push({ fiscalYear: fy.year, rawContent })
|
|
} else {
|
|
failedYears.push({ year: fy.year, error: 'Provider returned an empty SIE export' })
|
|
}
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err)
|
|
log.warn(`Failed to fetch SIE for ${provider} fiscal year ${fy.year} (id ${fy.id})`, {
|
|
reason,
|
|
})
|
|
failedYears.push({ year: fy.year, error: reason })
|
|
}
|
|
}
|
|
|
|
return { files, availableYears, failedYears }
|
|
}
|
|
|
|
interface SieFetcher {
|
|
listYears(accessToken: string): Promise<FiscalYearRef[]>
|
|
fetchSie(accessToken: string, fy: FiscalYearRef): Promise<string>
|
|
}
|
|
|
|
function getSieFetcher(
|
|
provider: ProviderName,
|
|
providerCompanyId: string | undefined,
|
|
): SieFetcher | null {
|
|
if (provider === 'fortnox') {
|
|
return {
|
|
async listYears(accessToken) {
|
|
const fyResponse = await fortnoxClient.get<Record<string, unknown>>(
|
|
accessToken,
|
|
'/financialyears',
|
|
)
|
|
const years = (fyResponse['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
|
|
return years.map((fy) => ({
|
|
id: fy['Id'] as number,
|
|
year: new Date(fy['FromDate'] as string).getFullYear(),
|
|
}))
|
|
},
|
|
async fetchSie(accessToken, fy) {
|
|
// Fortnox normally serves the SIE body as UTF-8, but endpoint variants
|
|
// have been seen answering CP437 (the SIE spec encoding) — a blind
|
|
// response.text() would turn å/ä/ö into U+FFFD irrecoverably. Fetch
|
|
// raw bytes and detect-decode like the Briox/BL paths.
|
|
const buffer = await fortnoxClient.getBytes(accessToken, `/sie/4?financialyear=${fy.id}`)
|
|
return decodeBuffer(buffer, detectEncoding(buffer))
|
|
},
|
|
}
|
|
}
|
|
|
|
if (provider === 'briox') {
|
|
return {
|
|
async listYears(accessToken) {
|
|
const years = await brioxClient.listFinancialYears(accessToken)
|
|
return years.map((fy) => ({
|
|
id: fy.id,
|
|
year: new Date(fy.fromdate).getFullYear(),
|
|
}))
|
|
},
|
|
async fetchSie(accessToken, fy) {
|
|
// Briox serves SIE as an octet-stream whose encoding varies
|
|
// (CP437/Windows-1252/UTF-8) — fetch bytes and detect-decode.
|
|
const buffer = await brioxClient.getBytes(accessToken, `/sie/${fy.id}/4`)
|
|
return decodeBuffer(buffer, detectEncoding(buffer))
|
|
},
|
|
}
|
|
}
|
|
|
|
if (provider === 'bjornlunden') {
|
|
// providerCompanyId carries the per-company User-Key header value.
|
|
const userKey = providerCompanyId
|
|
if (!userKey) {
|
|
throw new Error('Björn Lundén requires a company User-Key — reconnect the provider')
|
|
}
|
|
return {
|
|
async listYears(accessToken) {
|
|
const years = await bjornLundenClient.listFinancialYears(accessToken, userKey)
|
|
return years.map((fy) => ({
|
|
id: fy.id ?? fy.entityId,
|
|
year: new Date(fy.fromDate).getFullYear(),
|
|
fromDate: fy.fromDate,
|
|
toDate: fy.toDate,
|
|
}))
|
|
},
|
|
async fetchSie(accessToken, fy) {
|
|
// BL's export is date-ranged rather than year-id based. Sandbox-
|
|
// verified: the body is RAW SIE bytes (CP437, Content-Type
|
|
// text/vnd.sie-gruppen.si) even though the swagger declares a base64
|
|
// string — decodeSieBytes handles both shapes.
|
|
const buffer = await bjornLundenClient.getBytes(
|
|
accessToken,
|
|
userKey,
|
|
`/sie/export/${fy.fromDate}/${fy.toDate}`,
|
|
)
|
|
return decodeSieBytes(buffer)
|
|
},
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Decode a SIE payload that may arrive either as raw SIE bytes or as a
|
|
* base64 string (optionally JSON-quoted). BL's swagger declares base64 but
|
|
* the live API sends raw CP437 — handle both so a future API change doesn't
|
|
* silently break the import.
|
|
*/
|
|
function decodeSieBytes(buffer: ArrayBuffer): string {
|
|
const direct = decodeBuffer(buffer, detectEncoding(buffer))
|
|
if (looksLikeSie(direct)) return direct
|
|
|
|
const candidate = direct.trim().replace(/^"|"$/g, '')
|
|
if (/^[A-Za-z0-9+/=\s]+$/.test(candidate)) {
|
|
try {
|
|
const bytes = Buffer.from(candidate, 'base64')
|
|
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
|
const decoded = decodeBuffer(ab, detectEncoding(ab))
|
|
if (looksLikeSie(decoded)) return decoded
|
|
} catch {
|
|
// fall through to returning the direct decode
|
|
}
|
|
}
|
|
|
|
// Neither shape matched — return the direct decode and let the SIE parser
|
|
// produce its own diagnostics instead of failing silently here.
|
|
return direct
|
|
}
|
|
|
|
/** SIE files start with a #-record (#FLAGGA per spec; be lenient about order). */
|
|
function looksLikeSie(text: string): boolean {
|
|
return text.trimStart().startsWith('#')
|
|
}
|