Files
accounted/lib/providers/wint/sie-builder.ts
T
Mattsson 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

377 lines
14 KiB
TypeScript

/**
* Render a SIE 4E file from WINT voucher + account data.
*
* WINT's partner-facing v1 API has no SIE export (that endpoint lives in
* their internal Full spec, which we deliberately do not call: Tier A). The
* v1 surface does expose the complete ledger (/api/Voucher with transactions,
* /api/Account with names/SRU/IB), which is everything a SIE 4E file carries,
* so we render the file ourselves and feed it to the provider-agnostic SIE
* import pipeline (parse -> validate -> map accounts -> import).
*
* Format authority: SIE 4B spec (record-types reference in the
* swedish-sie-import-export skill). Invariants honored here:
* - every #VER's #TRANS amounts sum to 0.00 (source is double-entry; we
* never adjust amounts, only round to 2 decimals)
* - debit positive / credit negative (WINT transaction amounts are already
* signed this way: a voucher's Transactions sum to zero)
* - #IB only for balance-sheet accounts (1xxx-2xxx); #RES for result accounts
* - vouchers ascending by number within each series
*/
import { isBalanceSheetAccount } from '@/lib/import/sie-parser';
export interface WintSieTransaction {
/** Integer in WINT's JSON; stringified at the mapping boundary. */
accountNumber: string;
accountName?: string;
amount: number;
text?: string;
bookingDate?: string;
dimensions?: { type: string; shortName?: string; name?: string; id?: string }[];
}
export interface WintSieVoucher {
seriesShortName: string;
number: number;
bookingDate: string;
text?: string;
deleted?: boolean;
transactions: WintSieTransaction[];
}
export interface WintSieAccount {
accountNumber: string;
name: string;
sruCode?: string;
/** Opening balance for WINT's CURRENT financial year (see deriveIbByYear). */
ib?: number;
}
export interface WintSieYear {
/** Calendar year the fiscal year starts in (SIE #RAR is date-ranged; this keys maps). */
year: number;
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
}
export interface BuildWintSieOptions {
companyName: string;
orgNumber?: string;
programVersion: string;
/** Generation date, YYYY-MM-DD (passed in: keeps the builder pure/testable). */
generatedDate: string;
year: WintSieYear;
previousYear?: WintSieYear;
accounts: WintSieAccount[];
/** Vouchers belonging to `year` only. Deleted vouchers are skipped. */
vouchers: WintSieVoucher[];
/** Opening balances for `year`, per account number (balance accounts only). */
ibByAccount: Map<string, number>;
}
const round2 = (value: number): number => Math.round(value * 100) / 100;
/** Raw /api/Voucher item (PascalCase, IncludeTransactions=true) -> builder shape. */
export function mapWintVoucherForSie(raw: Record<string, unknown>): WintSieVoucher {
const transactions = ((raw['Transactions'] as Record<string, unknown>[] | undefined) ?? []).map(
(t): WintSieTransaction => ({
// Integer in WINT's JSON: stringified here, never used numerically again.
accountNumber: t['AccountNumber'] != null ? String(t['AccountNumber']) : '',
accountName: t['AccountName'] as string | undefined,
amount: round2(Number(t['Amount'] ?? 0)),
text: (t['Text'] as string | undefined) || undefined,
bookingDate: (t['BookingDate'] as string | undefined)?.slice(0, 10),
dimensions: ((t['Dimensions'] as Record<string, unknown>[] | undefined) ?? []).map((d) => ({
type: (d['Type'] as string) ?? '',
shortName: d['ShortName'] as string | undefined,
name: d['Name'] as string | undefined,
id: d['Id'] != null ? String(d['Id']) : undefined,
})),
}),
);
return {
seriesShortName: (raw['SeriesShortName'] as string) || 'A',
number: Number(raw['Number'] ?? 0),
bookingDate: ((raw['BookingDate'] as string) ?? '').slice(0, 10),
text: (raw['Text'] as string | undefined) || undefined,
deleted: raw['Deleted'] === true,
transactions,
};
}
/** Raw /api/Account item -> builder shape (Ib anchors to WINT's current FY). */
export function mapWintAccountForSie(raw: Record<string, unknown>): WintSieAccount {
return {
accountNumber: raw['Number'] != null ? String(raw['Number']) : '',
name: (raw['Name'] as string) ?? '',
sruCode: raw['SRU'] != null && raw['SRU'] !== 0 ? String(raw['SRU']) : undefined,
ib: raw['Ib'] != null ? round2(Number(raw['Ib'])) : undefined,
};
}
function fmtAmount(value: number): string {
return round2(value).toFixed(2);
}
function fmtDate(isoDate: string): string {
return isoDate.slice(0, 10).replaceAll('-', '');
}
function quote(text: string): string {
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ')}"`;
}
/**
* WINT dimension types -> SIE reserved dimension numbers. Only the reserved
* numbers with an exact semantic match are emitted; everything else is
* dropped rather than guessed into the wrong dimension.
*/
const SIE_DIMENSIONS: Record<string, number> = {
CostCenter: 1,
Project: 6,
Employee: 7,
Customer: 8,
Supplier: 9,
};
const SIE_DIMENSION_NAMES: Record<number, string> = {
1: 'Kostnadsställe',
6: 'Projekt',
7: 'Anställd',
8: 'Kund',
9: 'Leverantör',
};
interface DimensionRef {
dimNo: number;
objectNo: string;
objectName: string;
}
function sieDimensionRefs(t: WintSieTransaction): DimensionRef[] {
const refs: DimensionRef[] = [];
for (const d of t.dimensions ?? []) {
const dimNo = SIE_DIMENSIONS[d.type];
const objectNo = (d.shortName || d.id || '').trim();
if (!dimNo || !objectNo) continue;
refs.push({ dimNo, objectNo, objectName: d.name || objectNo });
}
return refs;
}
/**
* Net movement per account over a set of vouchers (deleted ones excluded).
* Used both for #RES/#UB rendering and for walking IB between years.
*/
export function accountDeltas(vouchers: WintSieVoucher[]): Map<string, number> {
const deltas = new Map<string, number>();
for (const voucher of vouchers) {
if (voucher.deleted) continue;
for (const t of voucher.transactions) {
deltas.set(t.accountNumber, round2((deltas.get(t.accountNumber) ?? 0) + t.amount));
}
}
return deltas;
}
/**
* WINT's /api/Account Ib anchors to the company's CURRENT financial year.
* Earlier imported years derive their opening balances by walking backward:
* IB(y) = IB(y+1) - delta(y) for balance-sheet accounts. The walk is
* deterministic because the voucher lists include the year-end closing
* vouchers WINT posted (which zero the result accounts into equity), exactly
* as they would appear in a native SIE export.
*
* `vouchersByYear` must cover every year between the earliest requested year
* and the anchor year, else the chain has a hole and the missing years are
* simply not returned (callers surface those years as failed rather than
* importing a ledger with broken IB/UB continuity).
*/
export function deriveIbByYear(
anchorYear: number,
anchorIb: Map<string, number>,
vouchersByYear: Map<number, WintSieVoucher[]>,
wantedYears: number[],
): Map<number, Map<string, number>> {
const result = new Map<number, Map<string, number>>();
result.set(anchorYear, anchorIb);
const earliest = Math.min(...wantedYears, anchorYear);
let current = anchorIb;
for (let y = anchorYear - 1; y >= earliest; y--) {
const vouchers = vouchersByYear.get(y);
if (!vouchers) break; // hole in the chain: stop deriving
const deltas = accountDeltas(vouchers);
const ib = new Map<string, number>();
const accounts = new Set([...current.keys(), ...deltas.keys()]);
for (const account of accounts) {
if (!isBalanceSheetAccount(account)) continue;
const value = round2((current.get(account) ?? 0) - (deltas.get(account) ?? 0));
if (value !== 0) ib.set(account, value);
}
result.set(y, ib);
current = ib;
}
// Forward walk covers wanted years after the anchor (unusual, but a company
// whose current WINT year is not the latest imported year must still get
// correct opening balances): IB(y+1) = IB(y) + delta(y).
current = anchorIb;
const latest = Math.max(...wantedYears, anchorYear);
for (let y = anchorYear + 1; y <= latest; y++) {
const prevVouchers = vouchersByYear.get(y - 1);
if (!prevVouchers) break;
const deltas = accountDeltas(prevVouchers);
const ib = new Map<string, number>();
const accounts = new Set([...current.keys(), ...deltas.keys()]);
for (const account of accounts) {
if (!isBalanceSheetAccount(account)) continue;
const value = round2((current.get(account) ?? 0) + (deltas.get(account) ?? 0));
if (value !== 0) ib.set(account, value);
}
result.set(y, ib);
current = ib;
}
return result;
}
export function buildWintSieFile(options: BuildWintSieOptions): string {
const {
companyName, orgNumber, programVersion, generatedDate,
year, previousYear, accounts, vouchers, ibByAccount,
} = options;
const active = vouchers.filter((v) => !v.deleted && v.transactions.length > 0);
const deleted = vouchers.filter((v) => v.deleted);
const deltas = accountDeltas(active);
// Structurally invalid source data must fail the year loudly, never render:
// a #TRANS without an account or a #VER without a date shifts the positional
// fields and the parser reads the NEXT token as account/date, silently
// corrupting the voucher.
for (const voucher of active) {
const label = `${voucher.seriesShortName || '?'}-${voucher.number}`;
if (!voucher.bookingDate) {
throw new Error(`WINT voucher ${label} has no booking date; refusing to render SIE`);
}
for (const t of voucher.transactions) {
if (!t.accountNumber) {
throw new Error(`WINT voucher ${label} has a transaction without an account number; refusing to render SIE`);
}
}
}
// Every account referenced anywhere must be declared with #KONTO.
const accountNames = new Map<string, WintSieAccount>();
for (const account of accounts) accountNames.set(account.accountNumber, account);
const referenced = new Set<string>([...deltas.keys(), ...ibByAccount.keys()]);
for (const account of accounts) referenced.add(account.accountNumber);
const lines: string[] = [];
lines.push('#FLAGGA 0');
lines.push(`#PROGRAM ${quote('Accounted')} ${quote(programVersion)}`);
// Declared PC8 with a UTF-8 body, the same de-facto convention Fortnox and
// Bokio ship: this file goes straight into our own parser, whose encoding
// detection ignores the header, and never leaves the import pipeline as
// bytes. Revisit if the raw file is ever offered for download.
lines.push('#FORMAT PC8');
lines.push(`#GEN ${fmtDate(generatedDate)}`);
lines.push('#SIETYP 4');
if (orgNumber) lines.push(`#ORGNR ${orgNumber}`);
lines.push(`#FNAMN ${quote(companyName)}`);
lines.push(`#RAR 0 ${fmtDate(year.start)} ${fmtDate(year.end)}`);
if (previousYear) {
lines.push(`#RAR -1 ${fmtDate(previousYear.start)} ${fmtDate(previousYear.end)}`);
}
lines.push('#KPTYP EUBAS97');
if (deleted.length > 0) {
// BFL 5 kap 6-7 §: the voucher number series must be accounted for. WINT
// flags these vouchers Deleted and excludes them from its own ledger, so
// they cannot be rendered as transactions, but the resulting gaps in the
// series must be documented in the file rather than silently inherited.
const numbers = deleted
.map((v) => `${v.seriesShortName || 'A'}-${v.number}`)
.slice(0, 50)
.join(', ');
lines.push(`#PROSA ${quote(
`${deleted.length} verifikat exkluderade (raderade i källsystemet WINT): ${numbers}${deleted.length > 50 ? ', ...' : ''}`,
)}`);
}
// Chart of accounts
const sortedAccounts = [...referenced].sort((a, b) => a.localeCompare(b, 'sv'));
for (const accountNumber of sortedAccounts) {
const meta = accountNames.get(accountNumber);
lines.push(`#KONTO ${accountNumber} ${quote(meta?.name || `Konto ${accountNumber}`)}`);
if (meta?.sruCode) lines.push(`#SRU ${accountNumber} ${meta.sruCode}`);
}
// Dimensions actually used
const usedDimensions = new Map<number, Map<string, string>>();
for (const voucher of active) {
for (const t of voucher.transactions) {
for (const ref of sieDimensionRefs(t)) {
if (!usedDimensions.has(ref.dimNo)) usedDimensions.set(ref.dimNo, new Map());
usedDimensions.get(ref.dimNo)!.set(ref.objectNo, ref.objectName);
}
}
}
for (const [dimNo, objects] of [...usedDimensions.entries()].sort((a, b) => a[0] - b[0])) {
lines.push(`#DIM ${dimNo} ${quote(SIE_DIMENSION_NAMES[dimNo] ?? `Dimension ${dimNo}`)}`);
for (const [objectNo, objectName] of [...objects.entries()].sort((a, b) => a[0].localeCompare(b[0], 'sv'))) {
lines.push(`#OBJEKT ${dimNo} ${quote(objectNo)} ${quote(objectName)}`);
}
}
// Balances: #IB/#UB for balance-sheet accounts, #RES for result accounts.
for (const accountNumber of sortedAccounts) {
if (!isBalanceSheetAccount(accountNumber)) continue;
const ib = round2(ibByAccount.get(accountNumber) ?? 0);
const ub = round2(ib + (deltas.get(accountNumber) ?? 0));
if (ib === 0 && ub === 0) continue;
lines.push(`#IB 0 ${accountNumber} ${fmtAmount(ib)}`);
lines.push(`#UB 0 ${accountNumber} ${fmtAmount(ub)}`);
}
for (const accountNumber of sortedAccounts) {
if (isBalanceSheetAccount(accountNumber)) continue;
const res = round2(deltas.get(accountNumber) ?? 0);
if (res === 0) continue;
lines.push(`#RES 0 ${accountNumber} ${fmtAmount(res)}`);
}
// Vouchers, ascending per series
const sorted = [...active].sort((a, b) =>
a.seriesShortName === b.seriesShortName
? a.number - b.number
: a.seriesShortName.localeCompare(b.seriesShortName, 'sv'),
);
for (const voucher of sorted) {
const series = voucher.seriesShortName || 'A';
const header = `#VER ${quote(series)} ${voucher.number} ${fmtDate(voucher.bookingDate)}`
+ (voucher.text ? ` ${quote(voucher.text)}` : '');
lines.push(header);
lines.push('{');
for (const t of voucher.transactions) {
const refs = sieDimensionRefs(t);
const objectList = refs.length > 0
? `{${refs.map((r) => `${r.dimNo} ${quote(r.objectNo)}`).join(' ')}}`
: '{}';
let line = ` #TRANS ${t.accountNumber} ${objectList} ${fmtAmount(t.amount)}`;
const transDate = t.bookingDate ? fmtDate(t.bookingDate) : undefined;
if (transDate && transDate !== fmtDate(voucher.bookingDate)) {
line += ` ${transDate}`;
if (t.text) line += ` ${quote(t.text)}`;
} else if (t.text) {
// transtext is positional after transdate: emit the date when a text follows
line += ` ${fmtDate(voucher.bookingDate)} ${quote(t.text)}`;
}
lines.push(line);
}
lines.push('}');
}
return lines.join('\r\n') + '\r\n';
}