* 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>
154 lines
5.3 KiB
TypeScript
154 lines
5.3 KiB
TypeScript
import { WINT_BASE_URL } from './config';
|
|
import { WintApiError } from './client';
|
|
import type { TokenResponse } from '../types';
|
|
import {
|
|
fetchWithTimeout,
|
|
OAUTH_TIMEOUT_MS,
|
|
} from '@/lib/http/fetch-with-timeout';
|
|
|
|
// WINT has no OAuth and no API keys (per their own swagger): authentication is
|
|
// the user's WINT login exchanged ONCE, server-side, for an
|
|
// AccessToken/RefreshToken pair. The password is used in loginWint and nowhere
|
|
// else: it must never be persisted or logged. Only the token pair is stored.
|
|
|
|
/** Mirror of WINT's LoginState enum (POST /api/Auth/jwt response). */
|
|
export type WintLoginState =
|
|
| 'Success'
|
|
| 'WrongUsernameOrPassword'
|
|
| 'AccountLocked'
|
|
| 'NoCompanies'
|
|
| 'Timeout'
|
|
| 'UnknownBankIdError'
|
|
| 'IncorrectPersonalNumber'
|
|
| 'ForceLoginWithBankId';
|
|
|
|
export class WintLoginRejectedError extends Error {
|
|
constructor(public readonly state: WintLoginState | string) {
|
|
super(`WINT rejected the login: ${state}`);
|
|
this.name = 'WintLoginRejectedError';
|
|
}
|
|
}
|
|
|
|
interface WintAuthResponse {
|
|
State?: WintLoginState | number | string;
|
|
AuthTokens?: {
|
|
AccessToken?: string | null;
|
|
RefreshToken?: string | null;
|
|
} | null;
|
|
CompanyNames?: unknown[];
|
|
}
|
|
|
|
// The response serializes State as a string in the swagger examples but the
|
|
// enum doc also lists ordinals ("0 - Success, 1 - WrongUsernameOrPassword,
|
|
// ..."): accept both shapes.
|
|
const LOGIN_STATES: WintLoginState[] = [
|
|
'Success',
|
|
'WrongUsernameOrPassword',
|
|
'AccountLocked',
|
|
'NoCompanies',
|
|
'Timeout',
|
|
'UnknownBankIdError',
|
|
'IncorrectPersonalNumber',
|
|
'ForceLoginWithBankId',
|
|
];
|
|
|
|
function normalizeLoginState(state: WintAuthResponse['State']): WintLoginState | string {
|
|
if (typeof state === 'number') return LOGIN_STATES[state] ?? `Unknown(${state})`;
|
|
if (typeof state === 'string' && state !== '') {
|
|
const asIndex = Number(state);
|
|
if (Number.isInteger(asIndex) && LOGIN_STATES[asIndex]) return LOGIN_STATES[asIndex];
|
|
return state;
|
|
}
|
|
return 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* WINT does not document token lifetimes. The access token is a JWT, so read
|
|
* `exp` straight from its payload; fall back to 15 minutes when the token is
|
|
* opaque or unparsable so the refresh path engages early rather than never.
|
|
*/
|
|
export function jwtExpiresInSeconds(token: string, nowMs: number = Date.now()): number {
|
|
const FALLBACK_SECONDS = 15 * 60;
|
|
const parts = token.split('.');
|
|
if (parts.length !== 3) return FALLBACK_SECONDS;
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(parts[1]!, 'base64url').toString('utf8')) as {
|
|
exp?: unknown;
|
|
};
|
|
const exp = Number(payload.exp);
|
|
if (!Number.isFinite(exp)) return FALLBACK_SECONDS;
|
|
const seconds = Math.floor(exp - nowMs / 1000);
|
|
return seconds > 0 ? seconds : FALLBACK_SECONDS;
|
|
} catch {
|
|
return FALLBACK_SECONDS;
|
|
}
|
|
}
|
|
|
|
function toTokenResponse(auth: WintAuthResponse, context: string): TokenResponse {
|
|
// Strict on purpose: anything other than an explicit Success is rejected.
|
|
// Accepting an ambiguous response here would mint a consent that LOOKS
|
|
// connected but cannot refresh, which surfaces days later as a broken
|
|
// migration instead of failing loudly at connect time.
|
|
const state = normalizeLoginState(auth.State);
|
|
if (state !== 'Success') {
|
|
throw new WintLoginRejectedError(state);
|
|
}
|
|
|
|
const accessToken = auth.AuthTokens?.AccessToken;
|
|
const refreshToken = auth.AuthTokens?.RefreshToken;
|
|
if (!accessToken || !refreshToken) {
|
|
// Refresh is WINT's only token-revival path (no stored password, no API
|
|
// keys): a pair without a refresh token is as unusable as no pair.
|
|
throw new WintApiError(`${context}: response carried an incomplete token pair`, 502);
|
|
}
|
|
|
|
return {
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken,
|
|
token_type: 'Bearer',
|
|
expires_in: jwtExpiresInSeconds(accessToken),
|
|
};
|
|
}
|
|
|
|
async function postAuth(path: string, body: unknown, context: string): Promise<TokenResponse> {
|
|
const response = await fetchWithTimeout(
|
|
`${WINT_BASE_URL}${path}`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
},
|
|
{ timeoutMs: OAUTH_TIMEOUT_MS, description: context },
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const responseBody = await response.text().catch(() => '');
|
|
// 400/401 from the auth endpoints is a credential verdict; carry the
|
|
// status so submitProviderToken can tell it apart from an outage.
|
|
throw new WintApiError(`${context} failed: ${response.status}`, response.status, responseBody);
|
|
}
|
|
|
|
const result = (await response.json()) as WintAuthResponse;
|
|
return toTokenResponse(result, context);
|
|
}
|
|
|
|
/**
|
|
* Exchange the user's WINT login for a token pair. The mail/password pair is
|
|
* intentionally NOT retained in any form after this call resolves.
|
|
*/
|
|
export async function loginWint(mail: string, password: string): Promise<TokenResponse> {
|
|
return postAuth('/api/Auth/jwt', { Mail: mail, Password: password }, 'WINT login');
|
|
}
|
|
|
|
/**
|
|
* Refresh via POST /api/Auth/refresh. The swagger types the request body as a
|
|
* bare JSON string (the refresh token). The response is the same auth envelope
|
|
* as login; treat both tokens as rotated and persist the returned pair.
|
|
*/
|
|
export async function refreshWintToken(refreshToken: string): Promise<TokenResponse> {
|
|
return postAuth('/api/Auth/refresh', refreshToken, 'WINT token refresh');
|
|
}
|