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>
This commit is contained in:
Mattsson
2026-08-07 11:07:14 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent c0a106e591
commit 93f81f03e8
27 changed files with 2400 additions and 29 deletions
+17 -4
View File
@@ -81,6 +81,17 @@ function translateOAuthError(error: string, description: string | null): string
* The override exists so dev environments can route through a single
* registered URI instead of registering every ngrok URL on the OAuth client.
*/
/**
* WINT ships dark until the connection is verified against a live WINT
* account (its API is spec-derived, not sandbox-verified) and the relationship
* question is settled. Flipping WINT_MIGRATION_ENABLED=true is the launch
* switch; the rest of the provider is fully wired.
*/
function enabledProviders(): typeof ARCIM_PROVIDERS {
if (process.env.WINT_MIGRATION_ENABLED === 'true') return ARCIM_PROVIDERS
return ARCIM_PROVIDERS.filter(p => p.id !== 'wint')
}
function resolveArcimCallbackUrl(provider: ArcimProvider | ProviderName): string {
const providerRedirectEnv =
provider === 'visma'
@@ -140,7 +151,7 @@ export const arcimMigrationExtension: Extension = {
method: 'GET',
path: '/providers',
handler: async () => {
return NextResponse.json({ providers: ARCIM_PROVIDERS })
return NextResponse.json({ providers: enabledProviders() })
},
},
@@ -234,7 +245,7 @@ export const arcimMigrationExtension: Extension = {
})
}
const providerInfo = ARCIM_PROVIDERS.find(p => p.id === provider)
const providerInfo = enabledProviders().find(p => p.id === provider)
if (!providerInfo) {
return errorResponseFromCode('PROVIDER_INVALID', moduleLog, {
details: { provider },
@@ -407,8 +418,10 @@ export const arcimMigrationExtension: Extension = {
}
// Briox needs the account ID (the /token clientid param) alongside
// the application token; Bokio/BL need their company GUID.
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox') && !providerCompanyId) {
// the application token; Bokio/BL need their company GUID. WINT
// reuses the field for the login mail (paired with the password in
// apiToken; both are exchanged for tokens and never stored).
if ((provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox' || provider === 'wint') && !providerCompanyId) {
return errorResponseFromCode('PROVIDER_COMPANY_ID_REQUIRED', moduleLog, {
details: { provider },
})
@@ -1,9 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { mockBlGet, mockBokioGetCompany } = vi.hoisted(() => ({
const { mockBlGet, mockBokioGetCompany, mockWintGet, mockLoginWint } = vi.hoisted(() => ({
mockBlGet: vi.fn(),
mockBokioGetCompany: vi.fn(),
mockWintGet: vi.fn(),
mockLoginWint: vi.fn(),
}))
vi.mock('@/lib/supabase/server', () => ({
@@ -44,9 +46,31 @@ vi.mock('@/lib/providers/bokio/client', async (importOriginal) => {
}
})
// WINT: keep the real WintApiError / WintLoginRejectedError classes (the
// instanceof branches in provider-client depend on them), swap the network.
vi.mock('@/lib/providers/wint/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/providers/wint/client')>()
return {
...actual,
WintClient: vi.fn().mockImplementation(function mockClient() {
return { get: mockWintGet }
}),
}
})
vi.mock('@/lib/providers/wint/oauth', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/providers/wint/oauth')>()
return {
...actual,
loginWint: mockLoginWint,
}
})
import { createServiceClient } from '@/lib/supabase/server'
import { BjornLundenApiError } from '@/lib/providers/bjornlunden/client'
import { BokioApiError } from '@/lib/providers/bokio/client'
import { WintApiError } from '@/lib/providers/wint/client'
import { WintLoginRejectedError } from '@/lib/providers/wint/oauth'
import {
submitProviderToken,
ProviderTokenInvalidError,
@@ -293,4 +317,109 @@ describe('submitProviderToken', () => {
'provider_consent_tokens',
])
})
// ── WINT login exchange ───────────────────────────────────────────
//
// WINT has no API keys: the wizard sends mail (as providerCompanyId) +
// password (as apiToken). The password is exchanged for a token pair and
// must never reach the database.
describe('wint', () => {
const wintCompany = {
Id: 4711,
Name: 'Wintbolaget AB',
Org: '556012-5790',
}
const loginOk = () =>
mockLoginWint.mockResolvedValueOnce({
access_token: 'wint-jwt',
refresh_token: 'wint-refresh',
token_type: 'Bearer',
expires_in: 900,
})
it('exchanges the login, labels the consent and stores the token pair with the WINT company id', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: { org_number: '5560125790' } }) // target company
mock.enqueue({ data: null }) // consent label update
mock.enqueue({ data: null }) // token upsert
loginOk()
mockWintGet.mockResolvedValueOnce(wintCompany)
const result = await submitProviderToken('consent-1', 'wint', 'hemligt', 'user@bolag.se', 'company-A')
expect(result).toEqual({ success: true, consentId: 'consent-1' })
expect(mockLoginWint).toHaveBeenCalledWith('user@bolag.se', 'hemligt')
expect(tablesTouched()).toEqual([
'provider_consents',
'companies',
'provider_consents',
'provider_consent_tokens',
])
// The stored row carries the token pair and the WINT company id; the
// login mail and password must never reach the database.
const upsert = mock.findCall('provider_consent_tokens', 'upsert')?.[0] as Record<string, unknown>
expect(upsert).toMatchObject({
provider: 'wint',
access_token: 'wint-jwt',
refresh_token: 'wint-refresh',
provider_company_id: '4711',
})
expect(JSON.stringify(upsert)).not.toContain('user@bolag.se')
expect(JSON.stringify(upsert)).not.toContain('hemligt')
})
it('rejects a login WINT refused (WrongUsernameOrPassword) without writing tokens', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mockLoginWint.mockRejectedValueOnce(new WintLoginRejectedError('WrongUsernameOrPassword'))
const err: unknown = await submitProviderToken(
'consent-1', 'wint', 'fel-lösenord', 'user@bolag.se', 'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect((err as Error).message).toContain('WrongUsernameOrPassword')
expect(tablesTouched()).toEqual(['provider_consents'])
})
it('requires a login e-mail in the companyId field', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
const err: unknown = await submitProviderToken(
'consent-1', 'wint', 'lösenord', undefined, 'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderTokenInvalidError)
expect(mockLoginWint).not.toHaveBeenCalled()
})
it('refuses to store tokens when the WINT company org number differs from the target company', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mock.enqueue({ data: { org_number: '5566778899' } }) // a DIFFERENT target company
loginOk()
mockWintGet.mockResolvedValueOnce(wintCompany)
const err: unknown = await submitProviderToken(
'consent-1', 'wint', 'hemligt', 'user@bolag.se', 'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(ProviderCompanyMismatchError)
expect((err as ProviderCompanyMismatchError).actualOrgNumber).toBe('5560125790')
// No token upsert, no consent label write
expect(tablesTouched()).toEqual(['provider_consents', 'companies'])
})
it('rethrows transient login failures (5xx) as generic errors, not invalid credentials', async () => {
mock.enqueue({ data: [{ id: 'consent-1' }] }) // ownership check
mockLoginWint.mockRejectedValueOnce(new WintApiError('WINT login failed: 503', 503))
const err: unknown = await submitProviderToken(
'consent-1', 'wint', 'hemligt', 'user@bolag.se', 'company-A',
).catch((e: unknown) => e)
expect(err).toBeInstanceOf(WintApiError)
expect(err).not.toBeInstanceOf(ProviderTokenInvalidError)
})
})
})
@@ -73,6 +73,7 @@ describe('providerSupportsSie', () => {
expect(providerSupportsSie('fortnox')).toBe(true)
expect(providerSupportsSie('briox')).toBe(true)
expect(providerSupportsSie('bjornlunden')).toBe(true)
expect(providerSupportsSie('wint')).toBe(true)
expect(providerSupportsSie('visma')).toBe(false)
expect(providerSupportsSie('bokio')).toBe(false)
})
@@ -298,3 +299,168 @@ describe('fetchProviderSieFiles', () => {
})
})
})
describe('fetchProviderSieFiles: wint (SIE rendered from voucher data)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
})
afterEach(() => {
fetchSpy.mockRestore()
})
function wintList(items: unknown[]): Response {
return jsonResponse({ Items: items, Page: 1, NumPerPage: 200, TotalItems: items.length })
}
function routeWint() {
routeFetch(fetchSpy, [
{
match: '/api/Auth',
respond: () =>
jsonResponse({
Id: 123,
Name: 'Bolaget AB',
Org: '556699-0011',
FinancialYears: [
{ Id: 1, Start: `${CY - 1}-01-01T00:00:00`, End: `${CY - 1}-12-31T00:00:00` },
{ Id: 2, Start: `${CY}-01-01T00:00:00`, End: `${CY}-12-31T00:00:00` },
],
}),
},
{
match: '/api/Account',
respond: () =>
wintList([
// Ib anchors to the CURRENT year: 1930 opened this year at 1000
// (built by last year's +1000 movement from 0).
{ Id: 'a1', Name: 'Företagskonto', Number: 1930, SRU: 7281, Ib: 1000 },
{ Id: 'a2', Name: 'Försäljning', Number: 3010, SRU: 7410, Ib: 0 },
]),
},
{
match: `BookingDateStart=${CY - 1}-01-01`,
respond: () =>
wintList([
{
SeriesShortName: 'A',
Number: 1,
BookingDate: `${CY - 1}-05-01T00:00:00`,
Text: 'Försäljning',
Deleted: false,
Transactions: [
{ AccountNumber: 1930, Amount: 1000 },
{ AccountNumber: 3010, Amount: -1000 },
],
},
]),
},
{
match: `BookingDateStart=${CY}-01-01`,
respond: () =>
wintList([
{
SeriesShortName: 'A',
Number: 1,
BookingDate: `${CY}-02-01T00:00:00`,
Text: 'Försäljning i år',
Deleted: false,
Transactions: [
{ AccountNumber: 1930, Amount: 500 },
{ AccountNumber: 3010, Amount: -500 },
],
},
]),
},
])
}
it('renders one parseable SIE file per year with derived opening balances', async () => {
routeWint()
const result = await fetchProviderSieFiles('wint', 'jwt-token', undefined)
expect(result.failedYears).toEqual([])
expect(result.availableYears).toEqual([CY - 1, CY])
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 1, CY])
const prev = result.files[0].rawContent
const curr = result.files[1].rawContent
// Previous year: derived backward from the anchor (1000 - 1000 = 0), and
// the zero IB is still printed because the account has a nonzero UB
expect(prev).toContain('#IB 0 1930 0.00')
expect(prev).toContain('#UB 0 1930 1000.00')
expect(prev).toContain('#VER "A" 1')
// Current year: anchor IB straight from /api/Account
expect(curr).toContain('#IB 0 1930 1000.00')
expect(curr).toContain('#UB 0 1930 1500.00')
expect(curr).toContain('#FNAMN "Bolaget AB"')
expect(curr).toContain('#ORGNR 556699-0011')
})
it('latestOnly renders just the newest year but lists all available years', async () => {
routeWint()
const result = await fetchProviderSieFiles('wint', 'jwt-token', undefined, { latestOnly: true })
expect(result.availableYears).toEqual([CY - 1, CY])
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY])
})
it('fails a year loudly when its voucher fetch errors, and still renders the anchor year', async () => {
routeFetch(fetchSpy, [
{
match: '/api/Auth',
respond: () =>
jsonResponse({
Id: 123,
Name: 'Bolaget AB',
Org: '556699-0011',
FinancialYears: [
{ Id: 1, Start: `${CY - 1}-01-01T00:00:00`, End: `${CY - 1}-12-31T00:00:00` },
{ Id: 2, Start: `${CY}-01-01T00:00:00`, End: `${CY}-12-31T00:00:00` },
],
}),
},
{
match: '/api/Account',
respond: () => wintList([{ Id: 'a1', Name: 'Företagskonto', Number: 1930, SRU: 7281, Ib: 1000 }]),
},
{
// 404 is non-retryable: the older year fails fast
match: `BookingDateStart=${CY - 1}-01-01`,
respond: () => new Response('gone', { status: 404 }),
},
{
match: `BookingDateStart=${CY}-01-01`,
respond: () =>
wintList([
{
SeriesShortName: 'A',
Number: 1,
BookingDate: `${CY}-02-01T00:00:00`,
Text: 'Försäljning i år',
Deleted: false,
Transactions: [
{ AccountNumber: 1930, Amount: 500 },
{ AccountNumber: 3010, Amount: -500 },
],
},
]),
},
])
const result = await fetchProviderSieFiles('wint', 'jwt-token', undefined)
// The anchor year derives from its own Ib and still renders; the failed
// year is REPORTED, never silently dropped (IB/UB continuity would break
// unnoticed otherwise).
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY])
expect(result.files[0].rawContent).toContain('#IB 0 1930 1000.00')
expect(result.failedYears).toEqual([
{ year: CY - 1, error: expect.stringContaining('404') },
])
})
})
@@ -18,6 +18,8 @@ import { BjornLundenClient, BjornLundenApiError } from '@/lib/providers/bjornlun
import { exchangeBrioxCode } from '@/lib/providers/briox/oauth'
import { BrioxApiError } from '@/lib/providers/briox/client'
import { BokioClient, BokioApiError } from '@/lib/providers/bokio/client'
import { WintClient, WintApiError } from '@/lib/providers/wint/client'
import { loginWint, WintLoginRejectedError } from '@/lib/providers/wint/oauth'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import type { ConsentRecord, OtcResponse } from '../types'
@@ -27,6 +29,9 @@ const bjornLundenClient = new BjornLundenClient()
// Singleton (holds the rate limiter): used to verify Bokio company identity
const bokioClient = new BokioClient()
// Singleton (holds the rate limiter): used to verify the WINT login at submit
const wintClient = new WintClient()
/**
* Thrown by submitProviderToken when the provider actively rejects the
* submitted credentials (as opposed to a transient failure). The route maps
@@ -393,6 +398,11 @@ export async function submitProviderToken(
let accessToken = apiToken
let refreshToken: string | null = null
let tokenExpiresAt: string | null = null
// What lands in provider_consent_tokens.provider_company_id. Usually the
// caller-supplied value (BL User-Key, Bokio GUID, Briox account id); WINT
// overrides it below because its caller-supplied value is the login mail,
// which must not be persisted.
let storedProviderCompanyId: string | undefined = providerCompanyId
// BL uses app-level client credentials: get a real token, then prove the
// pasted User-Key actually opens a company before storing anything.
@@ -548,6 +558,68 @@ export async function submitProviderToken(
}
}
// WINT: no API keys exist, so the wizard sends the user's WINT login
// (providerCompanyId = mail, apiToken = password). The pair is exchanged
// HERE, once, for an access/refresh token pair; the password is used for
// this single call and never stored, logged, or echoed. The token is then
// probed against GET /api/Auth to learn WHICH company it opens, mirroring
// the Bokio org-number mismatch guard.
if (provider === 'wint') {
const mail = providerCompanyId?.trim()
if (!mail || !mail.includes('@')) {
throw new ProviderTokenInvalidError('WINT requires the login e-mail address')
}
try {
const tokenResponse = await loginWint(mail, apiToken)
accessToken = tokenResponse.access_token
refreshToken = tokenResponse.refresh_token || null
tokenExpiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
} catch (error) {
// A definitive LoginState (wrong password, locked, BankID-only) is a
// credential verdict. Auth-endpoint 400/401/403 likewise. 429/5xx are
// transient: rethrow as a generic submit failure.
if (error instanceof WintLoginRejectedError) {
throw new ProviderTokenInvalidError(`WINT rejected the login (${error.state})`)
}
if (error instanceof WintApiError && error.statusCode < 500 && error.statusCode !== 429) {
throw new ProviderTokenInvalidError(`WINT rejected the login (HTTP ${error.statusCode})`)
}
throw error
}
const wintCompany = await wintClient.get<Record<string, unknown>>(accessToken, '/api/Auth')
const wintCompanyName = typeof wintCompany['Name'] === 'string' ? (wintCompany['Name'] as string).trim() : ''
const wintOrgNumber = normalizeOrgNumber(wintCompany['Org'] as string | undefined)
const { data: targetCompany } = await supabase
.from('companies')
.select('org_number')
.eq('id', ownerCompanyId)
.maybeSingle()
const targetOrgNumber = normalizeOrgNumber(targetCompany?.org_number)
// Same confident-mismatch-only rule as Bokio: a missing org number on
// either side falls through to labelling, a definite mismatch blocks.
if (wintOrgNumber && targetOrgNumber && wintOrgNumber !== targetOrgNumber) {
throw new ProviderCompanyMismatchError(targetOrgNumber, wintOrgNumber, wintCompanyName || null)
}
// The WINT-internal company id is what later calls may need (CompanyAuth
// company switching); the login mail is deliberately NOT persisted.
storedProviderCompanyId = wintCompany['Id'] != null ? String(wintCompany['Id']) : undefined
if (wintCompanyName || wintOrgNumber) {
await supabase
.from('provider_consents')
.update({
company_name: wintCompanyName || undefined,
org_number: wintOrgNumber || undefined,
})
.eq('id', consentId)
}
}
// Store tokens: consent stays at status 0 until migration/SIE import completes
await supabase
.from('provider_consent_tokens')
@@ -557,7 +629,7 @@ export async function submitProviderToken(
access_token: accessToken,
refresh_token: refreshToken,
token_expires_at: tokenExpiresAt,
provider_company_id: providerCompanyId,
provider_company_id: storedProviderCompanyId,
})
return { success: true, consentId }
@@ -13,6 +13,15 @@
import { FortnoxClient } from '@/lib/providers/fortnox/client'
import { BrioxClient } from '@/lib/providers/briox/client'
import { BjornLundenClient } from '@/lib/providers/bjornlunden/client'
import { WintClient } from '@/lib/providers/wint/client'
import {
buildWintSieFile,
deriveIbByYear,
mapWintAccountForSie,
mapWintVoucherForSie,
type WintSieVoucher,
type WintSieYear,
} from '@/lib/providers/wint/sie-builder'
import type { ProviderName } from '@/lib/providers/types'
import { detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
import { createLogger } from '@/lib/logger'
@@ -54,10 +63,17 @@ export interface ProviderSieFetchResult {
const fortnoxClient = new FortnoxClient()
const brioxClient = new BrioxClient()
const bjornLundenClient = new BjornLundenClient()
const wintClient = new WintClient()
/** True when the provider's API can serve the GL as SIE (no manual upload). */
/**
* True when the provider's API can serve the GL as SIE (no manual upload).
* WINT qualifies even though its v1 API has no SIE endpoint: the ledger is
* fetched voucher-by-voucher and RENDERED as SIE on our side (Tier A; see
* lib/providers/wint/sie-builder.ts). Downstream the file is indistinguishable
* from a provider export and goes through the same parse/validate/import path.
*/
export function providerSupportsSie(provider: ProviderName): boolean {
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden'
return provider === 'fortnox' || provider === 'briox' || provider === 'bjornlunden' || provider === 'wint'
}
interface FiscalYearRef {
@@ -167,6 +183,152 @@ function getSieFetcher(
}
}
if (provider === 'wint') {
// The SIE files are rendered from voucher data, and opening balances for
// years before WINT's current fiscal year are derived by walking the
// voucher deltas backward from the /api/Account Ib anchor. That walk
// needs every year's vouchers at once, so the first fetchSie call builds
// a shared context (this fetcher object lives for exactly one
// fetchProviderSieFiles invocation: the closure is the right lifetime).
interface WintSieContext {
companyName: string
orgNumber?: string
accounts: ReturnType<typeof mapWintAccountForSie>[]
years: (WintSieYear & { id: number })[]
vouchersByYear: Map<number, WintSieVoucher[]>
ibByYear: Map<number, Map<string, number>>
fetchErrors: Map<number, string>
}
let contextPromise: Promise<WintSieContext> | null = null
const loadContext = (accessToken: string): Promise<WintSieContext> => {
contextPromise ??= (async () => {
const company = await wintClient.get<Record<string, unknown>>(accessToken, '/api/Auth')
const rawYears = (company['FinancialYears'] as Record<string, unknown>[] | undefined) ?? []
const allowed = getAllowedFiscalYears()
const allYears = rawYears
.map((fy) => ({
id: Number(fy['Id']),
year: new Date((fy['Start'] as string) ?? '').getFullYear(),
start: ((fy['Start'] as string) ?? '').slice(0, 10),
end: ((fy['End'] as string) ?? '').slice(0, 10),
}))
.sort((a, b) => a.year - b.year)
const years = allYears.filter((fy) => allowed.has(fy.year))
const accountsRaw = await wintClient.getPaginated<Record<string, unknown>>(
accessToken,
'/api/Account',
)
const accounts = accountsRaw.map(mapWintAccountForSie).filter((a) => a.accountNumber)
// The Ib anchor is WINT's ACTIVE fiscal year: selected from the
// UNFILTERED year list, so an active year outside the allowed import
// window can never be silently swapped for the latest allowed year
// (that would attach the anchor balances to the wrong year). When the
// anchor lies outside the window, its vouchers are still fetched below
// so the derivation chain stays complete; only allowed years render.
const today = new Date().toISOString().slice(0, 10)
const anchor =
allYears.find((fy) => fy.start <= today && today <= fy.end) ?? allYears[allYears.length - 1]
const chainYears = new Map(years.map((fy) => [fy.year, fy]))
if (anchor) {
const lo = Math.min(anchor.year, ...years.map((fy) => fy.year))
const hi = Math.max(anchor.year, ...years.map((fy) => fy.year))
for (const fy of allYears) {
if (fy.year >= lo && fy.year <= hi) chainYears.set(fy.year, fy)
}
}
// A single year's fetch failure must not sink the whole migration:
// the year is simply absent from vouchersByYear, deriveIbByYear stops
// at the hole, and the affected years fail loudly in fetchSie while
// the years on the anchor's side of the hole still render.
const vouchersByYear = new Map<number, WintSieVoucher[]>()
const fetchErrors = new Map<number, string>()
for (const fy of [...chainYears.values()].sort((a, b) => a.year - b.year)) {
try {
const raw = await wintClient.getPaginated<Record<string, unknown>>(
accessToken,
`/api/Voucher?BookingDateStart=${fy.start}&BookingDateEnd=${fy.end}&IncludeTransactions=true`,
)
vouchersByYear.set(fy.year, raw.map(mapWintVoucherForSie))
} catch (err) {
const reason = err instanceof Error ? err.message : String(err)
log.warn(`WINT voucher fetch failed for fiscal year ${fy.year}`, { reason })
fetchErrors.set(fy.year, reason)
}
}
const anchorIb = new Map<string, number>()
for (const account of accounts) {
if (account.ib != null && account.ib !== 0) anchorIb.set(account.accountNumber, account.ib)
}
const ibByYear = anchor
? deriveIbByYear(anchor.year, anchorIb, vouchersByYear, years.map((fy) => fy.year))
: new Map<number, Map<string, number>>()
return {
companyName: (company['Name'] as string) ?? 'Okänt företag',
orgNumber: (company['Org'] as string | undefined) || undefined,
accounts,
years,
vouchersByYear,
ibByYear,
fetchErrors,
}
})()
return contextPromise
}
return {
async listYears(accessToken) {
const context = await loadContext(accessToken)
return context.years.map((fy) => ({
id: fy.id,
year: fy.year,
fromDate: fy.start,
toDate: fy.end,
}))
},
async fetchSie(accessToken, fy) {
const context = await loadContext(accessToken)
const yearRef = context.years.find((y) => y.year === fy.year)
const vouchers = context.vouchersByYear.get(fy.year)
const ibByAccount = context.ibByYear.get(fy.year)
if (!yearRef || !vouchers) {
const reason = context.fetchErrors.get(fy.year)
throw new Error(
reason
? `WINT voucher fetch failed for fiscal year ${fy.year}: ${reason}`
: `WINT returned no ledger data for fiscal year ${fy.year}`,
)
}
if (!ibByAccount) {
// A hole in the voucher chain between this year and the Ib anchor
// year: opening balances cannot be established. Failing the year is
// better than importing broken IB/UB continuity.
throw new Error(
`Opening balances for ${fy.year} could not be derived from WINT's ledger data`,
)
}
const previousYear = context.years.find((y) => y.year === fy.year - 1)
return buildWintSieFile({
companyName: context.companyName,
orgNumber: context.orgNumber,
programVersion: '1.0',
generatedDate: new Date().toISOString().slice(0, 10),
year: yearRef,
previousYear,
accounts: context.accounts,
vouchers,
ibByAccount,
})
},
}
}
if (provider === 'bjornlunden') {
// providerCompanyId carries the per-company User-Key header value.
const userKey = providerCompanyId
@@ -14,6 +14,7 @@
"VISMA_REDIRECT_URI",
"BJORN_LUNDEN_CLIENT_ID",
"BJORN_LUNDEN_CLIENT_SECRET",
"WINT_MIGRATION_ENABLED",
"UPSTASH_REDIS_REST_URL",
"UPSTASH_REDIS_REST_TOKEN"
],
+5 -1
View File
@@ -32,7 +32,7 @@ export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
// ── Supported providers ─────────────────────────────────────────────
export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
export type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden' | 'wint'
// `sieViaApi`: the provider serves its general ledger as SIE over the API, so
// the wizard imports bookkeeping automatically: no manual SIE upload needed.
@@ -44,6 +44,10 @@ export const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oaut
{ id: 'bokio', name: 'Bokio', authType: 'token', sieViaApi: false },
{ id: 'bjornlunden', name: 'Björn Lundén', authType: 'token', sieViaApi: true },
{ id: 'briox', name: 'Briox', authType: 'token', sieViaApi: true },
// WINT's "token" is the user's WINT login exchanged once for a JWT pair
// (WINT has no API keys or OAuth). Gated behind WINT_MIGRATION_ENABLED in
// index.ts until verified against a live account.
{ id: 'wint', name: 'WINT', authType: 'token', sieViaApi: true },
]
// ── Migration state ─────────────────────────────────────────────────