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

169 lines
5.0 KiB
TypeScript

import { TokenBucketRateLimiter } from '../rate-limiter';
import { withRetry } from '../retry';
import { WINT_BASE_URL, WINT_RATE_LIMIT } from './config';
import { isTimeoutError } from '@/lib/http/fetch-with-timeout';
const FETCH_TIMEOUT_MS = 15_000;
// WINT error bodies can carry customer data, and provider errors get logged
// wholesale by callers (provider-data-fetcher). Keep only a short bounded
// diagnostic on the error object so a full response body never reaches logs.
const MAX_ERROR_BODY_CHARS = 300;
export class WintApiError extends Error {
public readonly body?: string;
constructor(message: string, public readonly statusCode: number, body?: string) {
super(message);
this.name = 'WintApiError';
this.body = body != null ? body.slice(0, MAX_ERROR_BODY_CHARS) : undefined;
}
}
function isRetryableError(error: unknown): boolean {
if (isTimeoutError(error)) return true;
if (error instanceof WintApiError) {
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
return false;
}
return error.statusCode === 429 || error.statusCode >= 500;
}
return false;
}
/**
* Every WINT list endpoint answers the same envelope. `Page` echoes the page
* that was actually served: the pagination loop keys on it (see getPaginated)
* because we have no documentation guaranteeing the `Page` request param is
* honored, and a provider that silently ignores it would otherwise loop on
* page 1 forever (the exact failure mode Björn Lundén shipped with
* pageRequested/rowsRequested).
*/
export interface WintListResponse<T> {
Items: T[];
Page: number;
NumPerPage: number;
TotalItems: number;
TotalItemsWithOutFilter?: number;
}
export interface WintFinancialYear {
Id: number;
Start: string;
End: string;
}
const DEFAULT_PAGE_SIZE = 200;
export class WintClient {
private readonly rateLimiter: TokenBucketRateLimiter;
private readonly baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl ?? WINT_BASE_URL;
this.rateLimiter = new TokenBucketRateLimiter(WINT_RATE_LIMIT, 'ratelimit:wint');
}
// Assumption (unverified against a live account, no securityScheme in the
// swagger): the JWT from POST /api/Auth/jwt travels as a standard Bearer
// token. If a live test proves otherwise the change is confined here.
private authHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
}
async get<T>(accessToken: string, path: string): Promise<T> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.authHeaders(accessToken),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new WintApiError(
`WINT API error: ${response.status} ${response.statusText}`,
response.status,
body,
);
}
return response.json() as Promise<T>;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
async getPage<T>(
accessToken: string,
path: string,
options?: { page?: number; pageSize?: number },
): Promise<{ items: T[]; page: number; totalItems: number; pageSize: number }> {
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE;
const params = new URLSearchParams();
params.set('Page', String(page));
params.set('NumPerPage', String(pageSize));
const separator = path.includes('?') ? '&' : '?';
const response = await this.get<WintListResponse<T>>(
accessToken,
`${path}${separator}${params.toString()}`,
);
return {
items: Array.isArray(response.Items) ? response.Items : [],
page: response.Page ?? page,
totalItems: response.TotalItems ?? 0,
pageSize: response.NumPerPage ?? pageSize,
};
}
async getPaginated<T>(
accessToken: string,
path: string,
options?: { pageSize?: number },
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
for (;;) {
const result = await this.getPage<T>(accessToken, path, {
page,
pageSize: options?.pageSize,
});
// Page-echo guard: a server that ignores the Page param serves page 1
// for every request; without this check the loop appends the same items
// until TotalItems is (never) reached.
if (result.page !== page) {
throw new WintApiError(
`WINT pagination did not honor Page=${page} (served ${result.page}) for ${path}`,
502,
);
}
allItems.push(...result.items);
const done =
result.items.length === 0 ||
allItems.length >= result.totalItems ||
result.items.length < result.pageSize;
if (done) break;
page++;
}
return allItems;
}
}