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

247 lines
9.5 KiB
TypeScript

import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
CustomerDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
AmountType, PartyDto, PostalAddress,
} from '../dto';
// Field names follow WINT's v1 swagger exactly (PascalCase). Amounts arrive as
// JSON numbers; account numbers arrive as INTEGERS and must leave every mapper
// as strings (they are identifiers, never quantities).
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: round2(value ?? 0), currencyCode: currency };
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function num(value: unknown): number | undefined {
if (value == null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
/** WINT date-times are ISO with a time part; our DTOs carry date-only strings. */
function dateOnly(value: unknown): string | undefined {
if (typeof value !== 'string' || value === '') return undefined;
return value.slice(0, 10);
}
function mapAddress(raw: Record<string, unknown> | undefined | null): PostalAddress | undefined {
if (!raw) return undefined;
return {
streetName: raw['Street1'] as string | undefined,
additionalStreetName: raw['Street2'] as string | undefined,
cityName: raw['City'] as string | undefined,
postalZone: raw['ZipCode'] as string | undefined,
countryCode: raw['CountryCode'] as string | undefined,
};
}
function buildParty(
name: string,
orgNumber?: string,
opts?: { address?: Record<string, unknown> | null; email?: string; phone?: string },
): PartyDto {
return {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: mapAddress(opts?.address),
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: (opts?.email || opts?.phone) ? {
email: opts?.email,
telephone: opts?.phone,
} : undefined,
};
}
/**
* WINT invoice Status enum: NotSent | Unpaid | OverdueReminderSent |
* OverdueReminderNotSent | Paid | Cancelled | Collection |
* ReminderFeeNotFullyPaid | Expires. PaymentState refines it: Unpaid | Paid |
* OverdueSoon | Overdue | PartiallyPaid | PartiallyPaidOverdue.
*/
function deriveWintInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const status = raw['Status'] as string | undefined;
const creditStatus = raw['CreditStatus'] as string | undefined;
if (status === 'Cancelled') return 'cancelled';
if (creditStatus === 'Credited') return 'credited';
if (isWintInvoicePaid(raw)) return 'paid';
if (status === 'NotSent') return 'draft';
const paymentState = raw['PaymentState'] as string | undefined;
if (
paymentState === 'Overdue' ||
paymentState === 'PartiallyPaidOverdue' ||
status === 'OverdueReminderSent' ||
status === 'OverdueReminderNotSent' ||
status === 'Collection'
) {
return 'overdue';
}
return 'sent';
}
/**
* Paid means WINT says so, from either enum. LeftToPay corroborates but a
* missing LeftToPay never reads as paid (mirrors the Fortnox/Briox absent-
* balance hardening).
*/
function isWintInvoicePaid(raw: Record<string, unknown>): boolean {
if (raw['Status'] === 'Paid' || raw['PaymentState'] === 'Paid') return true;
const total = num(raw['TotalAmount']);
const leftToPay = num(raw['LeftToPay']);
return total != null && total > 0 && leftToPay != null && leftToPay <= 0;
}
export function mapWintToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['Currency'] as string) || 'SEK';
const total = num(raw['TotalAmount']) ?? 0;
const totalTax = num(raw['TotalTax']);
const paid = isWintInvoicePaid(raw);
const balance = paid ? 0 : (num(raw['LeftToPay']) ?? total);
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
const quantity = num(row['Quantity']);
const unitPrice = num(row['OverriddenUnitPrice']) ?? num(row['UnitPrice']);
const lineTotal = quantity != null && unitPrice != null ? round2(quantity * unitPrice) : 0;
const unit = row['Unit'] as Record<string, unknown> | undefined;
return {
id: String(row['Id'] ?? idx + 1),
description: (row['OverriddenDescription'] ?? row['Description']) as string | undefined,
quantity,
unitCode: unit?.['Text'] as string | undefined,
unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineTotal, currency),
taxPercent: num(row['OverriddenVat']) ?? num(row['Vat']),
itemName: row['Description'] as string | undefined,
articleNumber: row['ArticleId'] != null ? String(row['ArticleId']) : undefined,
};
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalTax != null ? round2(total - totalTax) : total, currency),
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid,
balance: amount(balance, currency),
lastPaymentDate: dateOnly(raw['PaymentDate']),
};
return {
id: String(raw['Id'] ?? ''),
invoiceNumber: String(raw['SerialNumber'] ?? raw['Id'] ?? ''),
// WINT's list item has no separate invoice-date field: PostingDate is the
// date the invoice was posted/issued.
issueDate: dateOnly(raw['PostingDate']) ?? '',
dueDate: dateOnly(raw['DueDate']),
deliveryDate: dateOnly(raw['DeliveryDate']),
currencyCode: currency,
status: deriveWintInvoiceStatus(raw),
supplier: buildParty(''),
customer: buildParty(
(raw['CustomerName'] ?? '') as string,
(raw['CustomerOrgNo'] as string | undefined) || undefined,
),
lines,
taxTotal: totalTax != null ? { taxAmount: amount(totalTax, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
paymentTerms: raw['PaymentTerms'] != null ? `${raw['PaymentTerms']} dagar` : undefined,
note: raw['Notes'] as string | undefined,
buyerReference: raw['CustomerReference'] as string | undefined,
updatedAt: raw['LastUpdated'] as string | undefined,
_raw: raw,
};
}
export function mapWintToCustomer(raw: Record<string, unknown>): CustomerDto {
const name = (raw['Name'] as string) ?? '';
const orgNumber = (raw['OrgNumber'] as string | undefined) || undefined;
return {
id: String(raw['Id'] ?? ''),
// WINT has no separate customer number on the v1 surface: the Id is the
// stable identifier their own invoices reference.
customerNumber: String(raw['Id'] ?? ''),
type: raw['Type'] === 'PrivatePerson' ? 'private' : 'company',
party: buildParty(name, orgNumber, {
address: raw['BillingAddress'] as Record<string, unknown> | null,
email: (raw['EmailAddress'] as string | undefined) || undefined,
phone: (raw['PhoneNumber'] as string | undefined) || undefined,
}),
deliveryAddresses: raw['DeliveryAddress']
? [mapAddress(raw['DeliveryAddress'] as Record<string, unknown>)!]
: undefined,
active: raw['Inactive'] !== true,
vatNumber: (raw['VatNumber'] as string | undefined) || undefined,
defaultPaymentTermsDays: num(raw['PaymentTerms']),
updatedAt: raw['LastUpdated'] as string | undefined,
_raw: raw,
};
}
export function mapWintToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
// WINT serves Number as an INTEGER: stringify immediately, arithmetic on
// account numbers is always a bug. The classification below may only look
// at the numeric value, never store it.
const numberValue = num(raw['Number']);
let type: AccountType | undefined;
if (numberValue != null) {
if (numberValue >= 1000 && numberValue < 2000) type = 'asset';
// 20xx is eget kapital, not a liability; 21xx+ (obeskattade reserver,
// avsättningar, skulder) stays 'liability' at this metadata granularity.
else if (numberValue >= 2000 && numberValue < 2100) type = 'equity';
else if (numberValue >= 2100 && numberValue < 3000) type = 'liability';
else if (numberValue >= 3000 && numberValue < 4000) type = 'revenue';
// 83xx is financial income (ränteintäkter m.m.), not an expense.
else if (numberValue >= 8300 && numberValue < 8400) type = 'revenue';
else if (numberValue >= 4000 && numberValue < 9000) type = 'expense';
}
return {
accountNumber: raw['Number'] != null ? String(raw['Number']) : '',
name: (raw['Name'] as string) ?? '',
type,
active: true,
balanceBroughtForward: raw['Ib'] != null ? round2(Number(raw['Ib'])) : undefined,
sruCode: raw['SRU'] != null ? String(raw['SRU']) : undefined,
_raw: raw,
};
}
export function mapWintToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
const companyName = (raw['Name'] as string) ?? '';
const orgNumber = (raw['Org'] as string | undefined) || undefined;
const financialYears = (raw['FinancialYears'] as Record<string, unknown>[] | undefined) ?? [];
const firstYear = financialYears[0];
return {
companyName,
organizationNumber: orgNumber,
legalEntity: {
registrationName: companyName,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
},
contact: raw['Url'] ? { website: raw['Url'] as string } : undefined,
fiscalYearStart: firstYear ? dateOnly(firstYear['Start']) : undefined,
baseCurrency: 'SEK',
_raw: raw,
};
}