Files
accounted/extensions/general/arcim-migration/lib/sie-fetcher.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

235 lines
8.4 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('#')
}