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:
co-authored by
Claude Fable 5
parent
c0a106e591
commit
93f81f03e8
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user