Files
accounted/lib/providers/wint/oauth.ts
T
MattssonandClaude Fable 5 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

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