Files
accounted/lib/providers/wint/__tests__/mapper.test.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

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');
});
});
});