* 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>
276 lines
9.1 KiB
TypeScript
276 lines
9.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
buildWintSieFile,
|
|
deriveIbByYear,
|
|
accountDeltas,
|
|
mapWintVoucherForSie,
|
|
mapWintAccountForSie,
|
|
type WintSieVoucher,
|
|
} from '../sie-builder';
|
|
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser';
|
|
|
|
const YEAR_2026 = { year: 2026, start: '2026-01-01', end: '2026-12-31' };
|
|
const YEAR_2025 = { year: 2025, start: '2025-01-01', end: '2025-12-31' };
|
|
|
|
function voucher(overrides: Partial<WintSieVoucher>): WintSieVoucher {
|
|
return {
|
|
seriesShortName: 'A',
|
|
number: 1,
|
|
bookingDate: '2026-02-15',
|
|
text: 'Testverifikat',
|
|
transactions: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
const SALES_VOUCHER = voucher({
|
|
number: 1,
|
|
text: 'Kundfaktura 1007',
|
|
transactions: [
|
|
{ accountNumber: '1510', amount: 1250, text: 'Fordran' },
|
|
{ accountNumber: '2611', amount: -250 },
|
|
{ accountNumber: '3010', amount: -1000 },
|
|
],
|
|
});
|
|
|
|
const PAYMENT_VOUCHER = voucher({
|
|
number: 2,
|
|
bookingDate: '2026-03-01',
|
|
text: 'Inbetalning "1007"',
|
|
transactions: [
|
|
{ accountNumber: '1930', amount: 1250 },
|
|
{ accountNumber: '1510', amount: -1250 },
|
|
],
|
|
});
|
|
|
|
const BASE_OPTIONS = {
|
|
companyName: 'Bolaget AB',
|
|
orgNumber: '556699-0011',
|
|
programVersion: '1.0',
|
|
generatedDate: '2026-08-06',
|
|
year: YEAR_2026,
|
|
previousYear: YEAR_2025,
|
|
accounts: [
|
|
{ accountNumber: '1510', name: 'Kundfordringar', sruCode: '7251' },
|
|
{ accountNumber: '1930', name: 'Företagskonto' },
|
|
{ accountNumber: '2611', name: 'Utgående moms 25%' },
|
|
{ accountNumber: '3010', name: 'Försäljning' },
|
|
],
|
|
vouchers: [SALES_VOUCHER, PAYMENT_VOUCHER],
|
|
ibByAccount: new Map([['1930', 50000]]),
|
|
};
|
|
|
|
describe('buildWintSieFile', () => {
|
|
it('produces a SIE4 file our own parser accepts and validates', () => {
|
|
const content = buildWintSieFile(BASE_OPTIONS);
|
|
const parsed = parseSIEFile(content);
|
|
const validation = validateSIEFile(parsed);
|
|
|
|
expect(parsed.header.sieType).toBe(4);
|
|
expect(parsed.header.companyName).toBe('Bolaget AB');
|
|
expect(parsed.header.orgNumber).toBe('556699-0011');
|
|
expect(parsed.vouchers).toHaveLength(2);
|
|
expect(validation.errors).toEqual([]);
|
|
});
|
|
|
|
it('every rendered verification balances to zero', () => {
|
|
const parsed = parseSIEFile(buildWintSieFile(BASE_OPTIONS));
|
|
for (const ver of parsed.vouchers) {
|
|
const sum = ver.lines.reduce((acc, t) => acc + t.amount, 0);
|
|
expect(Math.round(sum * 100) / 100).toBe(0);
|
|
}
|
|
});
|
|
|
|
it('renders IB/UB for balance accounts and RES for result accounts', () => {
|
|
const content = buildWintSieFile(BASE_OPTIONS);
|
|
|
|
// 1930: IB 50000 + payment 1250 = UB 51250
|
|
expect(content).toContain('#IB 0 1930 50000.00');
|
|
expect(content).toContain('#UB 0 1930 51250.00');
|
|
// 1510: invoice +1250, payment -1250 -> IB 0/UB 0: omitted entirely
|
|
expect(content).not.toContain('#IB 0 1510');
|
|
// Result account: RES only, never IB
|
|
expect(content).toContain('#RES 0 3010 -1000.00');
|
|
expect(content).not.toContain('#IB 0 3010');
|
|
// 2611 got no IB but has movement: UB must still appear
|
|
expect(content).toContain('#UB 0 2611 -250.00');
|
|
});
|
|
|
|
it('excludes deleted vouchers from the ledger but documents them in a #PROSA record', () => {
|
|
const content = buildWintSieFile({
|
|
...BASE_OPTIONS,
|
|
vouchers: [
|
|
SALES_VOUCHER,
|
|
voucher({ number: 3, deleted: true, transactions: [{ accountNumber: '1930', amount: 1 }, { accountNumber: '1510', amount: -1 }] }),
|
|
voucher({ number: 4, transactions: [] }),
|
|
],
|
|
});
|
|
const parsed = parseSIEFile(content);
|
|
|
|
expect(parsed.vouchers).toHaveLength(1);
|
|
expect(parsed.vouchers[0]?.number).toBe(1);
|
|
// BFL 5 kap 6-7 §: the gap in the number series must be accounted for in
|
|
// the file, not silently inherited.
|
|
expect(content).toContain('#PROSA');
|
|
expect(content).toContain('1 verifikat exkluderade (raderade i källsystemet WINT): A-3');
|
|
// No #PROSA when nothing was deleted
|
|
expect(buildWintSieFile(BASE_OPTIONS)).not.toContain('#PROSA');
|
|
});
|
|
|
|
it('refuses to render a transaction without an account number', () => {
|
|
expect(() =>
|
|
buildWintSieFile({
|
|
...BASE_OPTIONS,
|
|
vouchers: [voucher({
|
|
number: 9,
|
|
transactions: [
|
|
{ accountNumber: '', amount: 100 },
|
|
{ accountNumber: '1930', amount: -100 },
|
|
],
|
|
})],
|
|
}),
|
|
).toThrow(/A-9.*without an account number/);
|
|
});
|
|
|
|
it('refuses to render a voucher without a booking date', () => {
|
|
expect(() =>
|
|
buildWintSieFile({
|
|
...BASE_OPTIONS,
|
|
vouchers: [voucher({
|
|
number: 9,
|
|
bookingDate: '',
|
|
transactions: [
|
|
{ accountNumber: '3010', amount: -100 },
|
|
{ accountNumber: '1930', amount: 100 },
|
|
],
|
|
})],
|
|
}),
|
|
).toThrow(/A-9.*no booking date/);
|
|
});
|
|
|
|
it('escapes quotes in voucher texts', () => {
|
|
const parsed = parseSIEFile(buildWintSieFile(BASE_OPTIONS));
|
|
const payment = parsed.vouchers.find((v) => v.number === 2);
|
|
expect(payment?.description).toBe('Inbetalning "1007"');
|
|
});
|
|
|
|
it('emits declared #KONTO for every referenced account and orders vouchers per series', () => {
|
|
const content = buildWintSieFile({
|
|
...BASE_OPTIONS,
|
|
vouchers: [PAYMENT_VOUCHER, SALES_VOUCHER], // deliberately out of order
|
|
});
|
|
const parsed = parseSIEFile(content);
|
|
|
|
expect(parsed.accounts.map((a) => a.number)).toEqual(['1510', '1930', '2611', '3010']);
|
|
expect(parsed.vouchers.map((v) => v.number)).toEqual([1, 2]);
|
|
});
|
|
|
|
it('maps WINT dimensions to SIE reserved numbers (CostCenter=1, Project=6)', () => {
|
|
const content = buildWintSieFile({
|
|
...BASE_OPTIONS,
|
|
vouchers: [voucher({
|
|
number: 1,
|
|
transactions: [
|
|
{
|
|
accountNumber: '3010', amount: -100,
|
|
dimensions: [
|
|
{ type: 'Project', shortName: 'P01', name: 'Projekt Alpha' },
|
|
{ type: 'Tag', shortName: 'ignoreme', name: 'Tag' },
|
|
],
|
|
},
|
|
{ accountNumber: '1510', amount: 100 },
|
|
],
|
|
})],
|
|
});
|
|
|
|
expect(content).toContain('#DIM 6 "Projekt"');
|
|
expect(content).toContain('#OBJEKT 6 "P01" "Projekt Alpha"');
|
|
expect(content).toContain('{6 "P01"}');
|
|
expect(content).not.toContain('ignoreme');
|
|
});
|
|
});
|
|
|
|
describe('deriveIbByYear', () => {
|
|
it('walks opening balances backward from the anchor year', () => {
|
|
// 2026 IB (anchor): 1930 = 51000. 2025 moved 1930 by +1000 and closed
|
|
// result into 2099 (so the 2025 vouchers sum result accounts to zero).
|
|
const vouchers2025 = [
|
|
voucher({
|
|
bookingDate: '2025-06-01',
|
|
transactions: [
|
|
{ accountNumber: '1930', amount: 1000 },
|
|
{ accountNumber: '3010', amount: -1000 },
|
|
],
|
|
}),
|
|
voucher({
|
|
number: 2,
|
|
bookingDate: '2025-12-31',
|
|
text: 'Årets resultat',
|
|
transactions: [
|
|
{ accountNumber: '8999', amount: 1000 },
|
|
{ accountNumber: '2099', amount: -1000 },
|
|
],
|
|
}),
|
|
];
|
|
|
|
const ibByYear = deriveIbByYear(
|
|
2026,
|
|
new Map([['1930', 51000], ['2099', -1000]]),
|
|
new Map([[2025, vouchers2025]]),
|
|
[2025, 2026],
|
|
);
|
|
|
|
const ib2025 = ibByYear.get(2025)!;
|
|
expect(ib2025.get('1930')).toBe(50000);
|
|
// 2099 was built by 2025's closing: zero at the start of 2025 -> omitted
|
|
expect(ib2025.has('2099')).toBe(false);
|
|
// Result accounts never get IB
|
|
expect(ib2025.has('3010')).toBe(false);
|
|
expect(ibByYear.get(2026)!.get('1930')).toBe(51000);
|
|
});
|
|
|
|
it('stops at holes in the voucher chain instead of guessing', () => {
|
|
const ibByYear = deriveIbByYear(2026, new Map([['1930', 100]]), new Map(), [2024, 2025, 2026]);
|
|
expect(ibByYear.has(2026)).toBe(true);
|
|
expect(ibByYear.has(2025)).toBe(false);
|
|
expect(ibByYear.has(2024)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('raw WINT JSON mapping', () => {
|
|
it('mapWintVoucherForSie stringifies integer account numbers and strips times', () => {
|
|
const mapped = mapWintVoucherForSie({
|
|
SeriesShortName: 'A',
|
|
Number: 17,
|
|
BookingDate: '2026-02-15T00:00:00',
|
|
Text: 'Verifikat',
|
|
Deleted: false,
|
|
Transactions: [
|
|
{ AccountNumber: 1930, Amount: 100.005, Text: 'Bank', BookingDate: '2026-02-15T00:00:00', Dimensions: [] },
|
|
{ AccountNumber: 3010, Amount: -100.005 },
|
|
],
|
|
});
|
|
|
|
expect(mapped.number).toBe(17);
|
|
expect(mapped.bookingDate).toBe('2026-02-15');
|
|
expect(mapped.transactions[0]?.accountNumber).toBe('1930');
|
|
expect(mapped.transactions[0]?.amount).toBe(100.01);
|
|
});
|
|
|
|
it('mapWintAccountForSie carries name, SRU and Ib', () => {
|
|
const mapped = mapWintAccountForSie({ Number: 1930, Name: 'Företagskonto', SRU: 7281, Ib: 50000 });
|
|
expect(mapped).toEqual({ accountNumber: '1930', name: 'Företagskonto', sruCode: '7281', ib: 50000 });
|
|
});
|
|
|
|
it('accountDeltas nets movements and skips deleted vouchers', () => {
|
|
const deltas = accountDeltas([
|
|
SALES_VOUCHER,
|
|
PAYMENT_VOUCHER,
|
|
voucher({ deleted: true, transactions: [{ accountNumber: '1930', amount: 9999 }] }),
|
|
]);
|
|
expect(deltas.get('1510')).toBe(0);
|
|
expect(deltas.get('1930')).toBe(1250);
|
|
expect(deltas.get('3010')).toBe(-1000);
|
|
});
|
|
});
|