Files
accounted/extensions/general/arcim-migration/lib/sie-fetcher.ts
T
MattssonandClaude Fable 5 93f81f03e8 feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED (#1446)
* feat(providers): WINT migration provider behind WINT_MIGRATION_ENABLED

Adds WINT (wint.se) as a sixth migration provider, built against the
OpenAPI specs WINT's own API host serves publicly. Tier A scope: only the
partner-facing v1 endpoints are used; the general ledger is fetched as
vouchers/accounts and rendered as SIE 4E by our own sie-builder, with
opening balances for earlier years derived backward from the current-year
Ib anchor. Auth is the user's WINT login exchanged once for a JWT pair;
the password is never stored.

Ships dark: the wizard shows a disabled "Kommer snart" card, and the
server-side /connect gate rejects WINT until WINT_MIGRATION_ENABLED=true.
Live verification against a real WINT account is still outstanding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(providers): harden WINT provider per PR #1446 review findings

Addresses CodeRabbit and Swedish accounting review feedback in one pass:

- Ib anchor selection now uses WINT's unfiltered fiscal-year list, so an
  active year outside the allowed import window can never silently anchor
  the wrong year; the voucher chain is extended through the anchor and a
  per-year fetch failure fails that year loudly instead of sinking the
  whole migration.
- Auth token exchange is strict: only LoginState Success with a complete
  access+refresh pair mints a consent (a pair without a refresh token is
  unrefreshable and would break days later).
- WintApiError no longer retains full response bodies (bounded 300-char
  diagnostic; bodies can carry customer data and errors get logged).
- sie-builder refuses to render structurally invalid vouchers (missing
  account number or booking date) and documents deleted-voucher gaps in a
  #PROSA record per BFL 5 kap 6-7 §.
- Account classification: 20xx is equity, 83xx is financial income.
- SIE validator accepts EUBAS97 as BAS-based (standard kontoplanstyp; it
  previously produced a false non-BAS warning on every WINT/Bollbok file).
- New tests: resolveConsent WINT refresh flow, credential upsert payload
  (no mail/password persisted), WINT fetch failure path, EUBAS97 warning
  regression, builder invalid-data rejection, vi.clearAllMocks hygiene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(import): pin EUBAS97 acceptance to the exact SIE spec value

Review follow-up on PR #1446: match EUBAS97 exactly instead of any
EUBAS* prefix, so the non-BAS kontoplan warning stays pinned to the four
kontoplanstyp values the SIE 4B spec enumerates (BAS95, BAS96, EUBAS97,
NE2007) rather than silently accepting unknown future variants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 11:07:14 +02:00

397 lines
15 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 { WintClient } from '@/lib/providers/wint/client'
import {
buildWintSieFile,
deriveIbByYear,
mapWintAccountForSie,
mapWintVoucherForSie,
type WintSieVoucher,
type WintSieYear,
} from '@/lib/providers/wint/sie-builder'
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()
const wintClient = new WintClient()
/**
* True when the provider's API can serve the GL as SIE (no manual upload).
* WINT qualifies even though its v1 API has no SIE endpoint: the ledger is
* fetched voucher-by-voucher and RENDERED as SIE on our side (Tier A; see
* lib/providers/wint/sie-builder.ts). Downstream the file is indistinguishable
* from a provider export and goes through the same parse/validate/import path.
*/
export function providerSupportsSie(provider: ProviderName): boolean {
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden' || provider === 'wint'
}
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 === 'wint') {
// The SIE files are rendered from voucher data, and opening balances for
// years before WINT's current fiscal year are derived by walking the
// voucher deltas backward from the /api/Account Ib anchor. That walk
// needs every year's vouchers at once, so the first fetchSie call builds
// a shared context (this fetcher object lives for exactly one
// fetchProviderSieFiles invocation: the closure is the right lifetime).
interface WintSieContext {
companyName: string
orgNumber?: string
accounts: ReturnType<typeof mapWintAccountForSie>[]
years: (WintSieYear & { id: number })[]
vouchersByYear: Map<number, WintSieVoucher[]>
ibByYear: Map<number, Map<string, number>>
fetchErrors: Map<number, string>
}
let contextPromise: Promise<WintSieContext> | null = null
const loadContext = (accessToken: string): Promise<WintSieContext> => {
contextPromise ??= (async () => {
const company = await wintClient.get<Record<string, unknown>>(accessToken, '/api/Auth')
const rawYears = (company['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
const allowed = getAllowedFiscalYears()
const allYears = rawYears
.map((fy) => ({
id: Number(fy['Id']),
year: new Date((fy['Start'] as string) ?? '').getFullYear(),
start: ((fy['Start'] as string) ?? '').slice(0, 10),
end: ((fy['End'] as string) ?? '').slice(0, 10),
}))
.sort((a, b) => a.year - b.year)
const years = allYears.filter((fy) => allowed.has(fy.year))
const accountsRaw = await wintClient.getPaginated<Record<string, unknown>>(
accessToken,
'/api/Account',
)
const accounts = accountsRaw.map(mapWintAccountForSie).filter((a) => a.accountNumber)
// The Ib anchor is WINT's ACTIVE fiscal year: selected from the
// UNFILTERED year list, so an active year outside the allowed import
// window can never be silently swapped for the latest allowed year
// (that would attach the anchor balances to the wrong year). When the
// anchor lies outside the window, its vouchers are still fetched below
// so the derivation chain stays complete; only allowed years render.
const today = new Date().toISOString().slice(0, 10)
const anchor =
allYears.find((fy) => fy.start <= today && today <= fy.end) ?? allYears[allYears.length - 1]
const chainYears = new Map(years.map((fy) => [fy.year, fy]))
if (anchor) {
const lo = Math.min(anchor.year, ...years.map((fy) => fy.year))
const hi = Math.max(anchor.year, ...years.map((fy) => fy.year))
for (const fy of allYears) {
if (fy.year >= lo && fy.year <= hi) chainYears.set(fy.year, fy)
}
}
// A single year's fetch failure must not sink the whole migration:
// the year is simply absent from vouchersByYear, deriveIbByYear stops
// at the hole, and the affected years fail loudly in fetchSie while
// the years on the anchor's side of the hole still render.
const vouchersByYear = new Map<number, WintSieVoucher[]>()
const fetchErrors = new Map<number, string>()
for (const fy of [...chainYears.values()].sort((a, b) => a.year - b.year)) {
try {
const raw = await wintClient.getPaginated<Record<string, unknown>>(
accessToken,
`/api/Voucher?BookingDateStart=${fy.start}&BookingDateEnd=${fy.end}&IncludeTransactions=true`,
)
vouchersByYear.set(fy.year, raw.map(mapWintVoucherForSie))
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
log.warn(`WINT voucher fetch failed for fiscal year ${fy.year}`, { reason })
fetchErrors.set(fy.year, reason)
}
}
const anchorIb = new Map<string, number>()
for (const account of accounts) {
if (account.ib != null && account.ib !== 0) anchorIb.set(account.accountNumber, account.ib)
}
const ibByYear = anchor
? deriveIbByYear(anchor.year, anchorIb, vouchersByYear, years.map((fy) => fy.year))
: new Map<number, Map<string, number>>()
return {
companyName: (company['Name'] as string) ?? 'Okänt företag',
orgNumber: (company['Org'] as string | undefined) || undefined,
accounts,
years,
vouchersByYear,
ibByYear,
fetchErrors,
}
})()
return contextPromise
}
return {
async listYears(accessToken) {
const context = await loadContext(accessToken)
return context.years.map((fy) => ({
id: fy.id,
year: fy.year,
fromDate: fy.start,
toDate: fy.end,
}))
},
async fetchSie(accessToken, fy) {
const context = await loadContext(accessToken)
const yearRef = context.years.find((y) => y.year === fy.year)
const vouchers = context.vouchersByYear.get(fy.year)
const ibByAccount = context.ibByYear.get(fy.year)
if (!yearRef || !vouchers) {
const reason = context.fetchErrors.get(fy.year)
throw new Error(
reason
? `WINT voucher fetch failed for fiscal year ${fy.year}: ${reason}`
: `WINT returned no ledger data for fiscal year ${fy.year}`,
)
}
if (!ibByAccount) {
// A hole in the voucher chain between this year and the Ib anchor
// year: opening balances cannot be established. Failing the year is
// better than importing broken IB/UB continuity.
throw new Error(
`Opening balances for ${fy.year} could not be derived from WINT's ledger data`,
)
}
const previousYear = context.years.find((y) => y.year === fy.year - 1)
return buildWintSieFile({
companyName: context.companyName,
orgNumber: context.orgNumber,
programVersion: '1.0',
generatedDate: new Date().toISOString().slice(0, 10),
year: yearRef,
previousYear,
accounts: context.accounts,
vouchers,
ibByAccount,
})
},
}
}
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('#')
}