93f81f03e8
* 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>
162 lines
6.7 KiB
TypeScript
162 lines
6.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
mapWintToSalesInvoice,
|
|
mapWintToCustomer,
|
|
mapWintToAccountingAccount,
|
|
mapWintToCompanyInformation,
|
|
} from '../mapper';
|
|
|
|
describe('WINT mappers', () => {
|
|
describe('mapWintToSalesInvoice', () => {
|
|
const base = {
|
|
Id: 42,
|
|
SerialNumber: 1007,
|
|
Status: 'Unpaid',
|
|
PaymentState: 'Unpaid',
|
|
CreditStatus: 'Regular',
|
|
PostingDate: '2026-03-01T00:00:00',
|
|
DueDate: '2026-03-31T00:00:00',
|
|
Currency: 'SEK',
|
|
TotalAmount: 1250,
|
|
TotalTax: 250,
|
|
LeftToPay: 1250,
|
|
CustomerName: 'Kund AB',
|
|
CustomerOrgNo: '556677-8899',
|
|
Rows: [
|
|
{ Id: 1, Description: 'Konsulttimmar', Quantity: 10, UnitPrice: 100, Vat: 25 },
|
|
],
|
|
};
|
|
|
|
it('maps identity, dates and amounts', () => {
|
|
const dto = mapWintToSalesInvoice(base);
|
|
|
|
expect(dto.id).toBe('42');
|
|
expect(dto.invoiceNumber).toBe('1007');
|
|
expect(dto.issueDate).toBe('2026-03-01');
|
|
expect(dto.dueDate).toBe('2026-03-31');
|
|
expect(dto.status).toBe('sent');
|
|
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(1250);
|
|
expect(dto.legalMonetaryTotal.lineExtensionAmount.value).toBe(1000);
|
|
expect(dto.taxTotal?.taxAmount.value).toBe(250);
|
|
expect(dto.customer.name).toBe('Kund AB');
|
|
expect(dto.customer.identifications[0]?.id).toBe('556677-8899');
|
|
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(1000);
|
|
});
|
|
|
|
it('derives paid from Status/PaymentState, never from an absent LeftToPay', () => {
|
|
expect(mapWintToSalesInvoice({ ...base, Status: 'Paid' }).status).toBe('paid');
|
|
expect(mapWintToSalesInvoice({ ...base, PaymentState: 'Paid' }).paymentStatus.paid).toBe(true);
|
|
|
|
// Absent LeftToPay must read as fully unpaid, not paid
|
|
const { LeftToPay: _omitted, ...withoutLeftToPay } = base;
|
|
const dto = mapWintToSalesInvoice(withoutLeftToPay);
|
|
expect(dto.paymentStatus.paid).toBe(false);
|
|
expect(dto.paymentStatus.balance.value).toBe(1250);
|
|
});
|
|
|
|
it('maps overdue and cancelled and credited states', () => {
|
|
expect(mapWintToSalesInvoice({ ...base, PaymentState: 'Overdue' }).status).toBe('overdue');
|
|
expect(mapWintToSalesInvoice({ ...base, Status: 'Collection' }).status).toBe('overdue');
|
|
expect(mapWintToSalesInvoice({ ...base, Status: 'Cancelled' }).status).toBe('cancelled');
|
|
expect(mapWintToSalesInvoice({ ...base, CreditStatus: 'Credited' }).status).toBe('credited');
|
|
expect(mapWintToSalesInvoice({ ...base, Status: 'NotSent' }).status).toBe('draft');
|
|
});
|
|
|
|
it('forces balance to 0 when paid', () => {
|
|
const dto = mapWintToSalesInvoice({ ...base, Status: 'Paid', LeftToPay: 1250 });
|
|
expect(dto.paymentStatus.balance.value).toBe(0);
|
|
});
|
|
|
|
it('prefers overridden row description, price and VAT', () => {
|
|
const dto = mapWintToSalesInvoice({
|
|
...base,
|
|
Rows: [{
|
|
Id: 1, Description: 'Bas', OverriddenDescription: 'Justerad',
|
|
Quantity: 2, UnitPrice: 100, OverriddenUnitPrice: 90,
|
|
Vat: 25, OverriddenVat: 12,
|
|
}],
|
|
});
|
|
expect(dto.lines[0]?.description).toBe('Justerad');
|
|
expect(dto.lines[0]?.unitPrice?.value).toBe(90);
|
|
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(180);
|
|
expect(dto.lines[0]?.taxPercent).toBe(12);
|
|
});
|
|
});
|
|
|
|
describe('mapWintToCustomer', () => {
|
|
it('maps identity, type, addresses and contact', () => {
|
|
const dto = mapWintToCustomer({
|
|
Id: 7,
|
|
Name: 'Kund AB',
|
|
OrgNumber: '556677-8899',
|
|
Type: 'Company',
|
|
VatNumber: 'SE556677889901',
|
|
PaymentTerms: 30,
|
|
EmailAddress: 'faktura@kund.se',
|
|
PhoneNumber: '070-1234567',
|
|
BillingAddress: { Street1: 'Storgatan 1', ZipCode: '111 22', City: 'Stockholm', CountryCode: 'SE' },
|
|
Inactive: false,
|
|
});
|
|
|
|
expect(dto.id).toBe('7');
|
|
expect(dto.type).toBe('company');
|
|
expect(dto.active).toBe(true);
|
|
expect(dto.vatNumber).toBe('SE556677889901');
|
|
expect(dto.defaultPaymentTermsDays).toBe(30);
|
|
expect(dto.party.postalAddress?.streetName).toBe('Storgatan 1');
|
|
expect(dto.party.contact?.email).toBe('faktura@kund.se');
|
|
expect(dto.party.legalEntity?.companyId).toBe('556677-8899');
|
|
});
|
|
|
|
it('maps PrivatePerson and Inactive', () => {
|
|
const dto = mapWintToCustomer({ Id: 8, Name: 'Anna', Type: 'PrivatePerson', Inactive: true });
|
|
expect(dto.type).toBe('private');
|
|
expect(dto.active).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('mapWintToAccountingAccount', () => {
|
|
it('stringifies the integer account number and classifies by range', () => {
|
|
const dto = mapWintToAccountingAccount({ Id: 'a1', Name: 'Företagskonto', Number: 1930, SRU: 7281, Ib: 50000.505 });
|
|
|
|
expect(dto.accountNumber).toBe('1930');
|
|
expect(typeof dto.accountNumber).toBe('string');
|
|
expect(dto.type).toBe('asset');
|
|
expect(dto.sruCode).toBe('7281');
|
|
expect(dto.balanceBroughtForward).toBe(50000.51);
|
|
});
|
|
|
|
it('classifies revenue, expense, equity and financial-income ranges', () => {
|
|
expect(mapWintToAccountingAccount({ Number: 3010, Name: 'Försäljning' }).type).toBe('revenue');
|
|
expect(mapWintToAccountingAccount({ Number: 4010, Name: 'Inköp' }).type).toBe('expense');
|
|
expect(mapWintToAccountingAccount({ Number: 2440, Name: 'Leverantörsskulder' }).type).toBe('liability');
|
|
// 20xx is eget kapital, not a liability
|
|
expect(mapWintToAccountingAccount({ Number: 2081, Name: 'Aktiekapital' }).type).toBe('equity');
|
|
expect(mapWintToAccountingAccount({ Number: 2099, Name: 'Årets resultat' }).type).toBe('equity');
|
|
// 21xx obeskattade reserver stays at liability granularity
|
|
expect(mapWintToAccountingAccount({ Number: 2110, Name: 'Periodiseringsfond' }).type).toBe('liability');
|
|
// 83xx is financial income, not an expense
|
|
expect(mapWintToAccountingAccount({ Number: 8310, Name: 'Ränteintäkter' }).type).toBe('revenue');
|
|
expect(mapWintToAccountingAccount({ Number: 8410, Name: 'Räntekostnader' }).type).toBe('expense');
|
|
expect(mapWintToAccountingAccount({ Number: 8999, Name: 'Årets resultat' }).type).toBe('expense');
|
|
});
|
|
});
|
|
|
|
describe('mapWintToCompanyInformation', () => {
|
|
it('maps GET /api/Auth company info', () => {
|
|
const dto = mapWintToCompanyInformation({
|
|
Id: 123,
|
|
Name: 'Bolaget AB',
|
|
Org: '556699-0011',
|
|
Url: 'https://bolaget.se',
|
|
FinancialYears: [{ Id: 1, Start: '2026-01-01T00:00:00', End: '2026-12-31T00:00:00' }],
|
|
});
|
|
|
|
expect(dto.companyName).toBe('Bolaget AB');
|
|
expect(dto.organizationNumber).toBe('556699-0011');
|
|
expect(dto.fiscalYearStart).toBe('2026-01-01');
|
|
expect(dto.baseCurrency).toBe('SEK');
|
|
});
|
|
});
|
|
});
|