* 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>
346 lines
14 KiB
TypeScript
346 lines
14 KiB
TypeScript
import type {
|
|
CompanyInformationDto,
|
|
CustomerDto,
|
|
SupplierDto,
|
|
SalesInvoiceDto,
|
|
SupplierInvoiceDto,
|
|
} from './dto';
|
|
import type { ProviderName } from './types';
|
|
|
|
import { FortnoxClient } from './fortnox/client';
|
|
import { FORTNOX_RESOURCE_CONFIGS } from './fortnox/config';
|
|
import { VismaClient } from './visma/client';
|
|
import { VISMA_RESOURCE_CONFIGS } from './visma/config';
|
|
import { BrioxClient } from './briox/client';
|
|
import { BRIOX_RESOURCE_CONFIGS } from './briox/config';
|
|
import { BokioClient, BokioApiError } from './bokio/client';
|
|
import { BOKIO_RESOURCE_CONFIGS } from './bokio/config';
|
|
import { BjornLundenClient } from './bjornlunden/client';
|
|
import { BL_RESOURCE_CONFIGS } from './bjornlunden/config';
|
|
import { WintClient } from './wint/client';
|
|
import { WINT_RESOURCE_CONFIGS } from './wint/config';
|
|
import { ResourceType } from './dto';
|
|
|
|
// Singleton clients (they hold rate limiters)
|
|
const fortnoxClient = new FortnoxClient();
|
|
const vismaClient = new VismaClient();
|
|
const brioxClient = new BrioxClient();
|
|
const bokioClient = new BokioClient();
|
|
const bjornLundenClient = new BjornLundenClient();
|
|
const wintClient = new WintClient();
|
|
|
|
// ── Helper to paginate Bokio (uses getPage with companyId) ──────────
|
|
|
|
async function bokioPaginate<T>(
|
|
accessToken: string,
|
|
companyId: string,
|
|
path: string,
|
|
): Promise<T[]> {
|
|
const allItems: T[] = [];
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const result = await bokioClient.getPage<T>(accessToken, companyId, path, { page });
|
|
allItems.push(...result.items);
|
|
totalPages = result.totalPages;
|
|
page++;
|
|
} while (page <= totalPages);
|
|
|
|
console.log(`[bokio-paginate] ${path}: fetched ${allItems.length} total items across ${totalPages} page(s)`);
|
|
return allItems;
|
|
}
|
|
|
|
// ── Helper to paginate BjornLunden (uses getPage with userKey) ──────
|
|
|
|
async function blPaginate<T>(
|
|
accessToken: string,
|
|
userKey: string,
|
|
path: string,
|
|
): Promise<T[]> {
|
|
const allItems: T[] = [];
|
|
let page = 1;
|
|
let totalPages = 1;
|
|
|
|
do {
|
|
const result = await bjornLundenClient.getPage<T>(accessToken, userKey, path, { page });
|
|
allItems.push(...result.items);
|
|
totalPages = result.totalPages;
|
|
page++;
|
|
} while (page <= totalPages);
|
|
|
|
return allItems;
|
|
}
|
|
|
|
// ── Public fetch functions ──────────────────────────────────────────
|
|
|
|
export async function fetchCompanyInfoDirect(
|
|
provider: ProviderName,
|
|
accessToken: string,
|
|
providerCompanyId?: string,
|
|
): Promise<CompanyInformationDto | null> {
|
|
try {
|
|
if (provider === 'fortnox') {
|
|
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
|
const response = await fortnoxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
const data = response[config.detailKey];
|
|
return data ? config.mapper(data as Record<string, unknown>) as CompanyInformationDto : null;
|
|
}
|
|
|
|
if (provider === 'visma') {
|
|
const config = VISMA_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
|
const response = await vismaClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return config.mapper(response) as CompanyInformationDto;
|
|
}
|
|
|
|
if (provider === 'briox') {
|
|
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
|
const response = await brioxClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return config.mapper(response) as CompanyInformationDto;
|
|
}
|
|
|
|
if (provider === 'bokio') {
|
|
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.CompanyInformation];
|
|
if (!config || !providerCompanyId) return null;
|
|
const response = await bokioClient.getCompany<Record<string, unknown>>(accessToken, providerCompanyId);
|
|
return response ? config.mapper(response) as CompanyInformationDto : null;
|
|
}
|
|
|
|
if (provider === 'bjornlunden') {
|
|
const config = BL_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
|
if (!providerCompanyId) return null;
|
|
const response = await bjornLundenClient.get<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return config.mapper(response) as CompanyInformationDto;
|
|
}
|
|
|
|
if (provider === 'wint') {
|
|
// The WINT token is company-scoped: GET /api/Auth describes the company
|
|
// the token opens, no providerCompanyId needed on the request.
|
|
const config = WINT_RESOURCE_CONFIGS[ResourceType.CompanyInformation]!;
|
|
const response = await wintClient.get<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return config.mapper(response) as CompanyInformationDto;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error(`[provider-data-fetcher] Failed to fetch company info from ${provider}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function fetchCustomersDirect(
|
|
provider: ProviderName,
|
|
accessToken: string,
|
|
providerCompanyId?: string,
|
|
): Promise<CustomerDto[]> {
|
|
if (provider === 'fortnox') {
|
|
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
|
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
|
accessToken, config.listEndpoint, config.listKey,
|
|
);
|
|
return items.map((item) => config.mapper(item) as CustomerDto);
|
|
}
|
|
|
|
if (provider === 'visma') {
|
|
const config = VISMA_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
|
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as CustomerDto);
|
|
}
|
|
|
|
if (provider === 'briox') {
|
|
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
|
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
|
return items.map((item) => config.mapper(item) as CustomerDto);
|
|
}
|
|
|
|
if (provider === 'bokio') {
|
|
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Customers];
|
|
if (!config || !providerCompanyId) {
|
|
console.warn(`[provider-data-fetcher] Bokio customers: skipped, config=${!!config}, providerCompanyId=${providerCompanyId ?? 'undefined'}`);
|
|
return [];
|
|
}
|
|
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
if (items.length > 0) {
|
|
console.log(`[provider-data-fetcher] Bokio customers: first item keys: ${Object.keys(items[0]).join(', ')}`);
|
|
}
|
|
return items.map((item) => config.mapper(item) as CustomerDto);
|
|
}
|
|
|
|
if (provider === 'bjornlunden') {
|
|
const config = BL_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
|
if (!providerCompanyId) return [];
|
|
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as CustomerDto);
|
|
}
|
|
|
|
if (provider === 'wint') {
|
|
const config = WINT_RESOURCE_CONFIGS[ResourceType.Customers]!;
|
|
const items = await wintClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as CustomerDto);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function fetchSuppliersDirect(
|
|
provider: ProviderName,
|
|
accessToken: string,
|
|
providerCompanyId?: string,
|
|
): Promise<SupplierDto[]> {
|
|
if (provider === 'fortnox') {
|
|
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
|
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
|
accessToken, config.listEndpoint, config.listKey,
|
|
);
|
|
return items.map((item) => config.mapper(item) as SupplierDto);
|
|
}
|
|
|
|
if (provider === 'visma') {
|
|
const config = VISMA_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
|
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SupplierDto);
|
|
}
|
|
|
|
if (provider === 'briox') {
|
|
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
|
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
|
return items.map((item) => config.mapper(item) as SupplierDto);
|
|
}
|
|
|
|
if (provider === 'bokio') {
|
|
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.Suppliers];
|
|
if (!config || !providerCompanyId) return [];
|
|
try {
|
|
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SupplierDto);
|
|
} catch (err) {
|
|
if (err instanceof BokioApiError && err.statusCode === 404) {
|
|
console.log('[provider-data-fetcher] Bokio suppliers endpoint not available (404), skipping');
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (provider === 'bjornlunden') {
|
|
const config = BL_RESOURCE_CONFIGS[ResourceType.Suppliers]!;
|
|
if (!providerCompanyId) return [];
|
|
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SupplierDto);
|
|
}
|
|
|
|
// WINT (Tier A): the supplier register lives on the IncomingInvoice surface,
|
|
// which exists only in WINT's internal Full spec. Deliberately not fetched:
|
|
// see lib/providers/wint/config.ts.
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function fetchSalesInvoicesDirect(
|
|
provider: ProviderName,
|
|
accessToken: string,
|
|
providerCompanyId?: string,
|
|
): Promise<SalesInvoiceDto[]> {
|
|
if (provider === 'fortnox') {
|
|
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
|
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
|
accessToken, config.listEndpoint, config.listKey,
|
|
);
|
|
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'visma') {
|
|
const config = VISMA_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
|
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'briox') {
|
|
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
|
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
|
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'bokio') {
|
|
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SalesInvoices];
|
|
if (!config || !providerCompanyId) {
|
|
console.warn(`[provider-data-fetcher] Bokio invoices: skipped, config=${!!config}, providerCompanyId=${providerCompanyId ?? 'undefined'}`);
|
|
return [];
|
|
}
|
|
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
if (items.length > 0) {
|
|
console.log(`[provider-data-fetcher] Bokio invoices: first item keys: ${Object.keys(items[0]).join(', ')}`);
|
|
}
|
|
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'bjornlunden') {
|
|
const config = BL_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
|
if (!providerCompanyId) return [];
|
|
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'wint') {
|
|
const config = WINT_RESOURCE_CONFIGS[ResourceType.SalesInvoices]!;
|
|
const items = await wintClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SalesInvoiceDto);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
export async function fetchSupplierInvoicesDirect(
|
|
provider: ProviderName,
|
|
accessToken: string,
|
|
providerCompanyId?: string,
|
|
): Promise<SupplierInvoiceDto[]> {
|
|
if (provider === 'fortnox') {
|
|
const config = FORTNOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
|
const items = await fortnoxClient.getPaginated<Record<string, unknown>>(
|
|
accessToken, config.listEndpoint, config.listKey,
|
|
);
|
|
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'visma') {
|
|
const config = VISMA_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
|
const items = await vismaClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'briox') {
|
|
const config = BRIOX_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
|
const items = await brioxClient.getPaginated<Record<string, unknown>>(accessToken, config.listEndpoint, config.listKey);
|
|
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
|
}
|
|
|
|
if (provider === 'bokio') {
|
|
const config = BOKIO_RESOURCE_CONFIGS[ResourceType.SupplierInvoices];
|
|
if (!config || !providerCompanyId) return [];
|
|
try {
|
|
const items = await bokioPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
|
} catch (err) {
|
|
if (err instanceof BokioApiError && err.statusCode === 404) {
|
|
console.log('[provider-data-fetcher] Bokio supplier-invoices endpoint not available (404), skipping');
|
|
return [];
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (provider === 'bjornlunden') {
|
|
const config = BL_RESOURCE_CONFIGS[ResourceType.SupplierInvoices]!;
|
|
if (!providerCompanyId) return [];
|
|
const items = await blPaginate<Record<string, unknown>>(accessToken, providerCompanyId, config.listEndpoint);
|
|
return items.map((item) => config.mapper(item) as SupplierInvoiceDto);
|
|
}
|
|
|
|
// WINT (Tier A): supplier invoices (/api/IncomingInvoice) are Full-spec
|
|
// only; not fetched. The GL vouchers they produced still arrive via the
|
|
// SIE path, so the ledger stays complete: only the AP register is skipped.
|
|
|
|
return [];
|
|
}
|