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
+1
View File
@@ -815,6 +815,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-06] Sandbox ledger history marked no_doc_required instead of seeding receipt documents: the history represents books kept before the company arrived in Accounted, so its underlag sits in the previous system. Same rationale and same sidecar table the SIE-import opt-in uses. Without it the demo's first screen read "Verifikat utan underlag: 39".
[2026-08-06] Sandbox payroll takes skatteavdrag from FALLBACK_TAX_TABLES_2026 rather than a flat schablon: the draft run ships calculated, so its live "Beräkna om" would have jumped ~4 600 kr away from the sibling booked run, and a wrong skatteavdrag would show unlabelled in the payslip, the 2710 line and the AGI figures.
[2026-08-06] Added guardSandbox to /api/salary/runs/[id]/payslips/send: it was the only send path without one, and seeding a booked salary run put "Skicka lönebesked" one click from an anonymous visitor with live Resend behind it.
[2026-08-06] WINT provider built Tier A only (partner-facing v1 endpoints, SIE rendered by our own sie-builder from /api/Voucher + /api/Account): their native SIE export and IncomingInvoice live in the internal Full spec and are deliberately unused until WINT grants partner access. Ships dark behind WINT_MIGRATION_ENABLED. Auth is credential pass-through (mail+password exchanged once for a JWT pair; password never stored) because WINT has no OAuth or API keys.
[2026-08-06] Login credentials error says "Fel e-postadress eller lösenord", not "Fel lösenord": GoTrue returns one invalid_credentials code for unknown-email and wrong-password alike (anti-enumeration), so a "wrong password" claim would be both unknowable and an account-existence leak. Clarity comes from inline placement + reset link instead.
[2026-08-06] Empty SIE file (0 parsed vouchers AND no raw #VER declaration) finalizes as completed no-op, not failed: Fortnox exports an empty file for a not-yet-booked fiscal year and failing it aborted the whole migration wizard (CashLeads case). The failed-downgrade now fires only when the file contained vouchers that could not be imported; the raw-content #VER cross-check must stay, since a separator/encoding mismatch can swallow every #VER block with only a warning-severity parse issue and would otherwise masquerade as a legitimate empty year. The balance-only continuation-guard scenario rides along as no-op since re-running the same file can never produce a different outcome.
[2026-08-06] Bucket A defaults pass commits the /pending Godkänn pill directly for low/medium risk and keeps the ConfirmationDialog only for high risk: the Granskning row already states source, title, risk and offers Detaljer, so the dialog's second Godkänn restated the row (the audit's expert lens called double-Godkänn the thing professionals do not tolerate). The chat-side "Godkänn alla N" was DEFERRED, not built: ApprovalCard owns its whole state machine internally (commit fetch, account-activation retry, typed high-risk confirm) and a bulk commit from AgentChat would leave committed cards rendering as pending; that is assistant-redesign seam 8.8 (approval batching) and needs the state lifted, not a button.
+1
View File
@@ -2135,6 +2135,7 @@ export default function ImportPage() {
<LogoChip src="/logos/bokio.png" name="Bokio" />
<LogoChip src="/logos/bjornlunden.png" name="Björn Lundén" />
<LogoChip src="/logos/Briox_logo.png" name="Briox" />
<LogoChip src="/logos/wint.svg" name="WINT" />
</>
}
disabled={isSandbox}
@@ -40,18 +40,21 @@ import {
} from 'lucide-react'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden'
type ArcimProvider = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden' | 'wint'
// `sieViaApi`: the provider serves its general ledger as SIE over the API:
// no manual SIE upload needed. Deliberately duplicated from
// extensions/general/arcim-migration/types.ts (core code must not import from
// @/extensions/: CI enforces it). Keep both lists in sync.
// WINT is env-gated server-side (WINT_MIGRATION_ENABLED): the wizard renders
// whatever GET /providers returns, so no client-side gate is needed here.
const ARCIM_PROVIDERS: { id: ArcimProvider; name: string; authType: 'oauth' | 'token'; sieViaApi: boolean }[] = [
{ id: 'fortnox', name: 'Fortnox', authType: 'oauth', sieViaApi: true },
{ id: 'visma', name: 'Visma', authType: 'oauth', sieViaApi: false },
{ 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 },
{ id: 'wint', name: 'WINT', authType: 'token', sieViaApi: true },
]
/**
@@ -253,7 +256,10 @@ interface ConnectionStatus {
}
}
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>([])
// WINT shows as a disabled "Kommer snart" card until the integration is
// verified against a live WINT account. Launch = remove it here AND set
// WINT_MIGRATION_ENABLED=true (the server-side /connect gate).
const COMING_SOON_PROVIDERS = new Set<ArcimProvider>(['wint'])
const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
fortnox: '/logos/fortnox.svg',
@@ -261,6 +267,7 @@ const PROVIDER_LOGOS: Record<ArcimProvider, string> = {
bokio: '/logos/bokio.png',
bjornlunden: '/logos/bjornlunden.png',
briox: '/logos/Briox_logo.png',
wint: '/logos/wint.svg',
}
function ProviderStep({
@@ -291,7 +298,7 @@ function ProviderStep({
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">SIE-import krävs först</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox, Briox och Björn Lundén: där hämtar vi SIE direkt via API:et.
Bokio och Visma hämtar endast kunder, leverantörer och fakturor via API:et. Bokföringsdata (kontoplan, verifikationer och balanser) måste importeras via SIE-fil först. Gäller inte Fortnox, Briox, Björn Lundén och WINT: där hämtar vi bokföringen direkt via API:et.
</p>
<Link
href="/import?mode=sie"
@@ -500,24 +507,34 @@ function ConnectStep({
// BL uses server-side client credentials: only needs company ID, no API key
const isClientCredentials = provider === 'bjornlunden'
// WINT has no API keys: the "token" is the user's WINT login (e-post +
// lösenord), exchanged server-side for ett tokenpar; lösenordet sparas aldrig.
const isWintLogin = provider === 'wint'
const needsApiToken = !isClientCredentials
// Briox: the account ID is the `clientid` half of the token exchange
const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox'
// Briox: the account ID is the `clientid` half of the token exchange;
// WINT reuses the same field for the login e-mail.
const needsCompanyId = provider === 'bokio' || provider === 'bjornlunden' || provider === 'briox' || provider === 'wint'
const companyIdLabel = provider === 'briox'
? 'Konto-ID'
: provider === 'bjornlunden'
? 'Företagsnyckel (User-Key)'
: 'Företags-ID'
: provider === 'wint'
? 'E-postadress'
: 'Företags-ID'
const tokenDescription = isClientCredentials
? `Ange din företagsnyckel (User-Key) från Björn Lundén. ${branding.appName.toLowerCase()} ansluter automatiskt via sin integrationspartner-åtkomst.`
: provider === 'briox'
? `Ange ditt konto-ID och din applikationstoken från Briox för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
: `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
: isWintLogin
? `Logga in med dina WINT-uppgifter för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata. Lösenordet används en gång för att skapa anslutningen och sparas aldrig.`
: provider === 'briox'
? `Ange ditt konto-ID och din applikationstoken från Briox för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
: `Ange din API-nyckel från ${providerName} för att ge ${branding.appName.toLowerCase()} tillgång att läsa din bokföringsdata.`
const tokenHelpText = isClientCredentials
? `Företagsnyckeln (User-Key) är ett GUID som du hittar i Lundify under Integrationer → kugghjulet vid integrationen, eller i aktiveringsmejlet från Björn Lundén.`
: provider === 'bokio'
: isWintLogin
? `Använd samma e-postadress och lösenord som när du loggar in på app.wint.se. Kräver ditt WINT-konto BankID-inloggning kan anslutningen inte skapas ännu: be i så fall WINT om en SIE-fil och importera den manuellt.`
: provider === 'bokio'
? `Du hittar din API-nyckel i ${providerName} under Inställningar \u2192 Integrationer \u2192 API. Ditt företags-ID är det GUID som syns i URL:en när du är inloggad, t.ex. https://app.bokio.se/ditt-företags-id/settings-r/private-integrations.`
: provider === 'briox'
? `Skapa din applikationstoken i Briox under Admin \u2192 Anv\u00e4ndare \u2192 kugghjulet vid din anv\u00e4ndare \u2192 Applikationstoken. Ditt konto-ID \u00e4r det l\u00e5nga numret inom parentes bredvid f\u00f6retagsnamnet under "Ditt konto" i menyn till h\u00f6ger.`
@@ -609,38 +626,50 @@ function ConnectStep({
<p className="text-sm text-muted-foreground">
{tokenHelpText}
</p>
<div className="space-y-3">
{/* WINT is a login form: e-mail reads above password (CSS order;
the button keeps its place). Other token providers keep
token-first order. */}
<div className={cn('space-y-3', isWintLogin && 'flex flex-col gap-3 space-y-0')}>
{needsApiToken && (
<div>
<div className={cn(isWintLogin && 'order-2')}>
<label htmlFor="apiToken" className="text-sm font-medium">
{provider === 'briox' ? 'Applikationstoken' : 'API-nyckel'}
{provider === 'briox' ? 'Applikationstoken' : isWintLogin ? 'Lösenord' : 'API-nyckel'}
</label>
<Input
id="apiToken"
name="apiToken_nocomplete"
type="password"
autoComplete="new-password"
placeholder={provider === 'briox' ? 'Klistra in din applikationstoken' : 'Klistra in din API-nyckel'}
placeholder={
provider === 'briox'
? 'Klistra in din applikationstoken'
: isWintLogin
? 'Ditt lösenord hos WINT'
: 'Klistra in din API-nyckel'
}
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
/>
</div>
)}
{needsCompanyId && (
<div>
<div className={cn(isWintLogin && 'order-1')}>
<label htmlFor="companyId" className="text-sm font-medium">
{companyIdLabel}
</label>
<Input
id="companyId"
name="companyId_nocomplete"
type={isWintLogin ? 'email' : 'text'}
autoComplete="new-password"
placeholder={
isClientCredentials
? 'Företagsnyckel, t.ex. 1f0e2d3c-4b5a-...'
: provider === 'briox'
? 'Det långa numret inom parentes, t.ex. 35649125'
: 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'
: isWintLogin
? 'namn@foretaget.se'
: 'GUID från URL:en, t.ex. 14ccad83-67f6-49bd-...'
}
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
@@ -648,7 +677,7 @@ function ConnectStep({
</div>
)}
<Button
className="min-h-11"
className={cn('min-h-11', isWintLogin && 'order-3')}
onClick={() => onTokenSubmit(apiToken, companyId)}
disabled={!canSubmit}
>
+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 ─────────────────────────────────────────────────
+10
View File
@@ -1099,6 +1099,16 @@ describe('parseSIEFile: tab-separated fields (Bollbok export shape)', () => {
)
})
it('does not warn about a non-BAS kontoplan for `#KPTYP` EUBAS97', () => {
// EUBAS97 is a standard SIE kontoplanstyp (the spec routes every BAS2xxx
// chart through it): flagging it as non-BAS would put a false warning on
// every WINT-rendered and Bollbok file.
const parsed = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
const validation = validateSIEFile(parsed)
expect(parsed.header.kontoPlanType).toBe('EUBAS97')
expect(validation.warnings.filter((w) => w.includes('inte BAS-baserad'))).toEqual([])
})
it('parses tab-separated #KONTO into accounts (2025 shape)', () => {
const result = parseSIEFile(BOLLBOK_TAB_2025_SHAPE)
expect(result.accounts.map((a) => a.number)).toEqual(
+5 -1
View File
@@ -1072,7 +1072,11 @@ export function validateSIEFile(parsed: ParsedSIEFile): ValidationResult {
// Warn if non-BAS kontoplan declared: mapping logic assumes BAS number ranges
if (parsed.header.kontoPlanType) {
const planType = parsed.header.kontoPlanType.toUpperCase()
const isBAS = planType.startsWith('BAS') || planType === 'EUBAS' || planType === 'EU-BAS'
// EUBAS97 is one of the four kontoplanstyp values the SIE 4B spec
// enumerates (BAS95, BAS96, EUBAS97, NE2007), and the spec routes every
// BAS2xxx chart through it. Matched exactly, not by prefix, so this stays
// pinned to the spec's own table.
const isBAS = planType.startsWith('BAS') || planType === 'EUBAS97' || planType === 'EUBAS' || planType === 'EU-BAS'
if (!isBAS) {
warnings.push(
`Kontoplanstyp "${parsed.header.kontoPlanType}" är inte BAS-baserad. Automatisk kontomappning kan bli felaktig: granska alla mappningar manuellt i nästa steg.`
@@ -13,9 +13,14 @@ vi.mock('@/lib/providers/fortnox/oauth', () => ({
refreshFortnoxToken: vi.fn(),
}));
vi.mock('@/lib/providers/wint/oauth', () => ({
refreshWintToken: vi.fn(),
}));
import { createServiceClient } from '@/lib/supabase/server';
import { refreshBrioxToken } from '@/lib/providers/briox/oauth';
import { refreshFortnoxToken } from '@/lib/providers/fortnox/oauth';
import { refreshWintToken } from '@/lib/providers/wint/oauth';
import { resolveConsent } from '../resolve-consent';
import { ProviderCallError } from '../with-provider-call';
@@ -163,4 +168,47 @@ describe('resolveConsent: Briox token refresh concurrency', () => {
expect(err.code).toBe('PROVIDER_AUTH_EXPIRED');
expect(err.provider).toBe('fortnox');
});
it('refreshes an expired WINT consent via refreshWintToken and persists the rotated pair', async () => {
const wintConsent = { id: 'c3', company_id: 'co1', provider: 'wint', status: 1 };
mock.enqueue({ data: [wintConsent] }); // consent lookup
mock.enqueue({ data: [{ ...expiredTokens, provider_company_id: '4711' }] }); // expired token row
mock.enqueue({ data: [{ consent_id: 'c3' }] }); // guarded update matched 1 row
vi.mocked(refreshWintToken).mockResolvedValueOnce({
access_token: 'wint-new-access',
refresh_token: 'wint-new-refresh',
token_type: 'Bearer',
expires_in: 900,
});
const result = await resolveConsent('co1', 'c3');
// WINT refresh takes only the refresh token (the body is the bare string)
expect(refreshWintToken).toHaveBeenCalledWith('old-refresh');
expect(result.accessToken).toBe('wint-new-access');
expect(result.providerCompanyId).toBe('4711');
// The rotated pair went through the guarded update, both tokens included
const updateArgs = mock.findCall('provider_consent_tokens', 'update');
expect(updateArgs?.[0]).toMatchObject({
access_token: 'wint-new-access',
refresh_token: 'wint-new-refresh',
});
});
it('maps a failed WINT refresh to PROVIDER_AUTH_EXPIRED so callers prompt reconnect', async () => {
const wintConsent = { id: 'c3', company_id: 'co1', provider: 'wint', status: 1 };
mock.enqueue({ data: [wintConsent] }); // consent lookup
mock.enqueue({ data: [expiredTokens] }); // expired token row
vi.mocked(refreshWintToken).mockRejectedValueOnce(
new Error('WINT token refresh failed: 401'),
);
const err = await resolveConsent('co1', 'c3').catch((e) => e);
expect(err).toBeInstanceOf(ProviderCallError);
expect(err.code).toBe('PROVIDER_AUTH_EXPIRED');
expect(err.provider).toBe('wint');
});
});
+10 -1
View File
@@ -38,9 +38,18 @@ export function getOAuthConfig(provider: string): OAuthConfig {
redirectUri: '',
};
}
if (provider === 'wint') {
// No app-level credentials: WINT has no OAuth. The user's login is
// exchanged once for a token pair at submit (loginWint). No env vars.
return {
clientId: '',
clientSecret: '',
redirectUri: '',
};
}
throw new Error(`Unknown provider: ${provider}`);
}
export function validateProvider(provider: string): boolean {
return provider === 'fortnox' || provider === 'visma' || provider === 'briox' || provider === 'bokio' || provider === 'bjornlunden';
return provider === 'fortnox' || provider === 'visma' || provider === 'briox' || provider === 'bokio' || provider === 'bjornlunden' || provider === 'wint';
}
+31
View File
@@ -17,6 +17,8 @@ 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)
@@ -25,6 +27,7 @@ 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) ──────────
@@ -110,6 +113,14 @@ export async function fetchCompanyInfoDirect(
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);
@@ -162,6 +173,12 @@ export async function fetchCustomersDirect(
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 [];
}
@@ -212,6 +229,10 @@ export async function fetchSuppliersDirect(
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 [];
}
@@ -260,6 +281,12 @@ export async function fetchSalesInvoicesDirect(
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 [];
}
@@ -310,5 +337,9 @@ export async function fetchSupplierInvoicesDirect(
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 [];
}
+5
View File
@@ -5,6 +5,7 @@ import { refreshFortnoxToken } from './fortnox/oauth';
import { refreshVismaToken } from './visma/oauth';
import { refreshBrioxToken } from './briox/oauth';
import { refreshBjornLundenToken } from './bjornlunden/oauth';
import { refreshWintToken } from './wint/oauth';
import { ProviderCallError, isMissingLicenseError } from './with-provider-call';
import { createLogger } from '@/lib/logger';
@@ -115,6 +116,10 @@ export async function resolveConsent(companyId: string, consentId: string): Prom
// refresh token; no app-level config involved. Both tokens rotate:
// the new refresh_token is persisted below.
refreshed = await refreshBrioxToken(tokens.refresh_token as string, tokens.access_token as string);
} else if (consent.provider === 'wint') {
// WINT rotates the pair on refresh (the response is a full login
// envelope); the guarded update below persists the new refresh_token.
refreshed = await refreshWintToken(tokens.refresh_token as string);
} else {
refreshed = await refreshVismaToken(getOAuthConfig(consent.provider as string), tokens.refresh_token as string);
}
+7 -1
View File
@@ -1,6 +1,6 @@
import type { ResourceType } from './dto';
export type ProviderName = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden';
export type ProviderName = 'fortnox' | 'visma' | 'briox' | 'bokio' | 'bjornlunden' | 'wint';
export interface RateLimitConfig {
maxRequests: number;
@@ -56,3 +56,9 @@ export interface BokioResourceConfig extends ResourceConfig {
export interface BjornLundenResourceConfig extends ResourceConfig {
paginated?: boolean;
}
export interface WintResourceConfig extends ResourceConfig {
paginated?: boolean;
/** Query-param name for incremental fetches (WINT uses `UpdatedAfter` / `LastUpdated`). */
modifiedParam?: string;
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { WintClient, WintApiError } from '../client';
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function listResponse(items: unknown[], page: number, totalItems: number, numPerPage = 200): Response {
return jsonResponse({ Items: items, Page: page, NumPerPage: numPerPage, TotalItems: totalItems });
}
describe('WintClient', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('auth header', () => {
it('sends the JWT as a Bearer token', async () => {
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
const client = new WintClient();
await client.get('jwt-abc', '/api/Auth');
const [, init] = fetchSpy.mock.calls[0];
expect((init as RequestInit).headers).toMatchObject({
Authorization: 'Bearer jwt-abc',
});
});
});
describe('pagination', () => {
it('getPaginated walks pages until TotalItems is reached', async () => {
fetchSpy
.mockResolvedValueOnce(listResponse([{ Id: 1 }, { Id: 2 }], 1, 3, 2))
.mockResolvedValueOnce(listResponse([{ Id: 3 }], 2, 3, 2));
const client = new WintClient();
const items = await client.getPaginated<{ Id: number }>('t', '/api/Customer', { pageSize: 2 });
expect(items.map((i) => i.Id)).toEqual([1, 2, 3]);
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(String(fetchSpy.mock.calls[0][0])).toContain('Page=1');
expect(String(fetchSpy.mock.calls[0][0])).toContain('NumPerPage=2');
expect(String(fetchSpy.mock.calls[1][0])).toContain('Page=2');
});
it('appends pagination params with & when the path already has a query', async () => {
fetchSpy.mockResolvedValueOnce(listResponse([], 1, 0));
const client = new WintClient();
await client.getPage('t', '/api/Voucher?IncludeTransactions=true', { page: 1 });
const url = String(fetchSpy.mock.calls[0][0]);
expect(url).toContain('/api/Voucher?IncludeTransactions=true&Page=1');
});
it('stops after a short page even when TotalItems overcounts', async () => {
fetchSpy.mockResolvedValueOnce(listResponse([{ Id: 1 }], 1, 99, 200));
const client = new WintClient();
const items = await client.getPaginated<{ Id: number }>('t', '/api/Customer');
expect(items).toHaveLength(1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it('throws when the server ignores the Page param (page-echo guard)', async () => {
// Both requests answer Page=1 with a FULL page: without the guard this
// would loop forever appending the same 200 items. Fresh Response per
// call: a Response body can only be consumed once.
fetchSpy.mockImplementation(async () =>
listResponse(Array.from({ length: 200 }, (_, i) => ({ Id: i })), 1, 400, 200),
);
const client = new WintClient();
const err = await client.getPaginated('t', '/api/Transaction').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).message).toContain('did not honor Page=2');
});
});
describe('errors', () => {
it('does NOT retry on 401 and carries the status code', async () => {
fetchSpy.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }));
const client = new WintClient();
const err = await client.get('t', '/api/Auth').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).statusCode).toBe(401);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
});
+161
View File
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import {
mapWintToSalesInvoice,
mapWintToCustomer,
mapWintToAccountingAccount,
mapWintToCompanyInformation,
} from '../mapper';
describe('WINT mappers', () => {
describe('mapWintToSalesInvoice', () => {
const base = {
Id: 42,
SerialNumber: 1007,
Status: 'Unpaid',
PaymentState: 'Unpaid',
CreditStatus: 'Regular',
PostingDate: '2026-03-01T00:00:00',
DueDate: '2026-03-31T00:00:00',
Currency: 'SEK',
TotalAmount: 1250,
TotalTax: 250,
LeftToPay: 1250,
CustomerName: 'Kund AB',
CustomerOrgNo: '556677-8899',
Rows: [
{ Id: 1, Description: 'Konsulttimmar', Quantity: 10, UnitPrice: 100, Vat: 25 },
],
};
it('maps identity, dates and amounts', () => {
const dto = mapWintToSalesInvoice(base);
expect(dto.id).toBe('42');
expect(dto.invoiceNumber).toBe('1007');
expect(dto.issueDate).toBe('2026-03-01');
expect(dto.dueDate).toBe('2026-03-31');
expect(dto.status).toBe('sent');
expect(dto.legalMonetaryTotal.payableAmount.value).toBe(1250);
expect(dto.legalMonetaryTotal.lineExtensionAmount.value).toBe(1000);
expect(dto.taxTotal?.taxAmount.value).toBe(250);
expect(dto.customer.name).toBe('Kund AB');
expect(dto.customer.identifications[0]?.id).toBe('556677-8899');
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(1000);
});
it('derives paid from Status/PaymentState, never from an absent LeftToPay', () => {
expect(mapWintToSalesInvoice({ ...base, Status: 'Paid' }).status).toBe('paid');
expect(mapWintToSalesInvoice({ ...base, PaymentState: 'Paid' }).paymentStatus.paid).toBe(true);
// Absent LeftToPay must read as fully unpaid, not paid
const { LeftToPay: _omitted, ...withoutLeftToPay } = base;
const dto = mapWintToSalesInvoice(withoutLeftToPay);
expect(dto.paymentStatus.paid).toBe(false);
expect(dto.paymentStatus.balance.value).toBe(1250);
});
it('maps overdue and cancelled and credited states', () => {
expect(mapWintToSalesInvoice({ ...base, PaymentState: 'Overdue' }).status).toBe('overdue');
expect(mapWintToSalesInvoice({ ...base, Status: 'Collection' }).status).toBe('overdue');
expect(mapWintToSalesInvoice({ ...base, Status: 'Cancelled' }).status).toBe('cancelled');
expect(mapWintToSalesInvoice({ ...base, CreditStatus: 'Credited' }).status).toBe('credited');
expect(mapWintToSalesInvoice({ ...base, Status: 'NotSent' }).status).toBe('draft');
});
it('forces balance to 0 when paid', () => {
const dto = mapWintToSalesInvoice({ ...base, Status: 'Paid', LeftToPay: 1250 });
expect(dto.paymentStatus.balance.value).toBe(0);
});
it('prefers overridden row description, price and VAT', () => {
const dto = mapWintToSalesInvoice({
...base,
Rows: [{
Id: 1, Description: 'Bas', OverriddenDescription: 'Justerad',
Quantity: 2, UnitPrice: 100, OverriddenUnitPrice: 90,
Vat: 25, OverriddenVat: 12,
}],
});
expect(dto.lines[0]?.description).toBe('Justerad');
expect(dto.lines[0]?.unitPrice?.value).toBe(90);
expect(dto.lines[0]?.lineExtensionAmount.value).toBe(180);
expect(dto.lines[0]?.taxPercent).toBe(12);
});
});
describe('mapWintToCustomer', () => {
it('maps identity, type, addresses and contact', () => {
const dto = mapWintToCustomer({
Id: 7,
Name: 'Kund AB',
OrgNumber: '556677-8899',
Type: 'Company',
VatNumber: 'SE556677889901',
PaymentTerms: 30,
EmailAddress: 'faktura@kund.se',
PhoneNumber: '070-1234567',
BillingAddress: { Street1: 'Storgatan 1', ZipCode: '111 22', City: 'Stockholm', CountryCode: 'SE' },
Inactive: false,
});
expect(dto.id).toBe('7');
expect(dto.type).toBe('company');
expect(dto.active).toBe(true);
expect(dto.vatNumber).toBe('SE556677889901');
expect(dto.defaultPaymentTermsDays).toBe(30);
expect(dto.party.postalAddress?.streetName).toBe('Storgatan 1');
expect(dto.party.contact?.email).toBe('faktura@kund.se');
expect(dto.party.legalEntity?.companyId).toBe('556677-8899');
});
it('maps PrivatePerson and Inactive', () => {
const dto = mapWintToCustomer({ Id: 8, Name: 'Anna', Type: 'PrivatePerson', Inactive: true });
expect(dto.type).toBe('private');
expect(dto.active).toBe(false);
});
});
describe('mapWintToAccountingAccount', () => {
it('stringifies the integer account number and classifies by range', () => {
const dto = mapWintToAccountingAccount({ Id: 'a1', Name: 'Företagskonto', Number: 1930, SRU: 7281, Ib: 50000.505 });
expect(dto.accountNumber).toBe('1930');
expect(typeof dto.accountNumber).toBe('string');
expect(dto.type).toBe('asset');
expect(dto.sruCode).toBe('7281');
expect(dto.balanceBroughtForward).toBe(50000.51);
});
it('classifies revenue, expense, equity and financial-income ranges', () => {
expect(mapWintToAccountingAccount({ Number: 3010, Name: 'Försäljning' }).type).toBe('revenue');
expect(mapWintToAccountingAccount({ Number: 4010, Name: 'Inköp' }).type).toBe('expense');
expect(mapWintToAccountingAccount({ Number: 2440, Name: 'Leverantörsskulder' }).type).toBe('liability');
// 20xx is eget kapital, not a liability
expect(mapWintToAccountingAccount({ Number: 2081, Name: 'Aktiekapital' }).type).toBe('equity');
expect(mapWintToAccountingAccount({ Number: 2099, Name: 'Årets resultat' }).type).toBe('equity');
// 21xx obeskattade reserver stays at liability granularity
expect(mapWintToAccountingAccount({ Number: 2110, Name: 'Periodiseringsfond' }).type).toBe('liability');
// 83xx is financial income, not an expense
expect(mapWintToAccountingAccount({ Number: 8310, Name: 'Ränteintäkter' }).type).toBe('revenue');
expect(mapWintToAccountingAccount({ Number: 8410, Name: 'Räntekostnader' }).type).toBe('expense');
expect(mapWintToAccountingAccount({ Number: 8999, Name: 'Årets resultat' }).type).toBe('expense');
});
});
describe('mapWintToCompanyInformation', () => {
it('maps GET /api/Auth company info', () => {
const dto = mapWintToCompanyInformation({
Id: 123,
Name: 'Bolaget AB',
Org: '556699-0011',
Url: 'https://bolaget.se',
FinancialYears: [{ Id: 1, Start: '2026-01-01T00:00:00', End: '2026-12-31T00:00:00' }],
});
expect(dto.companyName).toBe('Bolaget AB');
expect(dto.organizationNumber).toBe('556699-0011');
expect(dto.fiscalYearStart).toBe('2026-01-01');
expect(dto.baseCurrency).toBe('SEK');
});
});
});
+137
View File
@@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loginWint, refreshWintToken, jwtExpiresInSeconds, WintLoginRejectedError } from '../oauth';
import { WintApiError } from '../client';
function makeJwt(payload: Record<string, unknown>): string {
const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
return `${b64({ alg: 'HS256' })}.${b64(payload)}.signature`;
}
function authResponse(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
State: 'Success',
AuthTokens: { AccessToken: makeJwt({ exp: Math.floor(Date.now() / 1000) + 900 }), RefreshToken: 'refresh-1' },
...overrides,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
describe('WINT auth', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('loginWint', () => {
it('posts Mail/Password to /api/Auth/jwt and returns the token pair', async () => {
fetchSpy.mockResolvedValueOnce(authResponse());
const tokens = await loginWint('user@example.se', 'hemligt');
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain('/api/Auth/jwt');
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
Mail: 'user@example.se',
Password: 'hemligt',
});
expect(tokens.refresh_token).toBe('refresh-1');
expect(tokens.token_type).toBe('Bearer');
expect(tokens.expires_in).toBeGreaterThan(800);
});
it('throws WintLoginRejectedError on a definitive LoginState string', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ State: 'WrongUsernameOrPassword', AuthTokens: null }));
const err = await loginWint('user@example.se', 'fel').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintLoginRejectedError);
expect((err as WintLoginRejectedError).state).toBe('WrongUsernameOrPassword');
});
it('normalizes ordinal LoginState values (7 -> ForceLoginWithBankId)', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ State: 7, AuthTokens: null }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintLoginRejectedError);
expect((err as WintLoginRejectedError).state).toBe('ForceLoginWithBankId');
});
it('carries the HTTP status on auth-endpoint errors', async () => {
fetchSpy.mockResolvedValueOnce(new Response('bad request', { status: 400 }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).statusCode).toBe(400);
});
it('fails cleanly when Success carries no access token', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ AuthTokens: { AccessToken: null, RefreshToken: null } }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).message).toContain('incomplete token pair');
});
it('rejects a Success response missing the refresh token (unrefreshable consent)', async () => {
const jwt = makeJwt({ exp: Math.floor(Date.now() / 1000) + 900 });
fetchSpy.mockResolvedValueOnce(authResponse({ AuthTokens: { AccessToken: jwt, RefreshToken: null } }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintApiError);
expect((err as WintApiError).message).toContain('incomplete token pair');
});
it('rejects an unrecognized LoginState instead of assuming success', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ State: 'SomethingNewFromWint' }));
const err = await loginWint('user@example.se', 'x').catch((e: unknown) => e);
expect(err).toBeInstanceOf(WintLoginRejectedError);
expect((err as WintLoginRejectedError).state).toBe('SomethingNewFromWint');
});
});
describe('refreshWintToken', () => {
it('posts the refresh token as a bare JSON string and returns the rotated pair', async () => {
fetchSpy.mockResolvedValueOnce(authResponse({ AuthTokens: { AccessToken: makeJwt({ exp: Math.floor(Date.now() / 1000) + 600 }), RefreshToken: 'refresh-2' } }));
const tokens = await refreshWintToken('refresh-1');
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toContain('/api/Auth/refresh');
// The swagger types the request body as a plain string.
expect((init as RequestInit).body).toBe('"refresh-1"');
expect(tokens.refresh_token).toBe('refresh-2');
});
});
describe('jwtExpiresInSeconds', () => {
it('reads exp from the JWT payload', () => {
const now = 1_700_000_000_000;
const token = makeJwt({ exp: 1_700_000_000 + 1200 });
expect(jwtExpiresInSeconds(token, now)).toBe(1200);
});
it('falls back to 15 minutes for opaque tokens', () => {
expect(jwtExpiresInSeconds('not-a-jwt')).toBe(900);
});
it('falls back to 15 minutes for an already-expired exp', () => {
const now = 1_700_000_000_000;
const token = makeJwt({ exp: 1_700_000_000 - 60 });
expect(jwtExpiresInSeconds(token, now)).toBe(900);
});
});
});
@@ -0,0 +1,275 @@
import { describe, it, expect } from 'vitest';
import {
buildWintSieFile,
deriveIbByYear,
accountDeltas,
mapWintVoucherForSie,
mapWintAccountForSie,
type WintSieVoucher,
} from '../sie-builder';
import { parseSIEFile, validateSIEFile } from '@/lib/import/sie-parser';
const YEAR_2026 = { year: 2026, start: '2026-01-01', end: '2026-12-31' };
const YEAR_2025 = { year: 2025, start: '2025-01-01', end: '2025-12-31' };
function voucher(overrides: Partial<WintSieVoucher>): WintSieVoucher {
return {
seriesShortName: 'A',
number: 1,
bookingDate: '2026-02-15',
text: 'Testverifikat',
transactions: [],
...overrides,
};
}
const SALES_VOUCHER = voucher({
number: 1,
text: 'Kundfaktura 1007',
transactions: [
{ accountNumber: '1510', amount: 1250, text: 'Fordran' },
{ accountNumber: '2611', amount: -250 },
{ accountNumber: '3010', amount: -1000 },
],
});
const PAYMENT_VOUCHER = voucher({
number: 2,
bookingDate: '2026-03-01',
text: 'Inbetalning "1007"',
transactions: [
{ accountNumber: '1930', amount: 1250 },
{ accountNumber: '1510', amount: -1250 },
],
});
const BASE_OPTIONS = {
companyName: 'Bolaget AB',
orgNumber: '556699-0011',
programVersion: '1.0',
generatedDate: '2026-08-06',
year: YEAR_2026,
previousYear: YEAR_2025,
accounts: [
{ accountNumber: '1510', name: 'Kundfordringar', sruCode: '7251' },
{ accountNumber: '1930', name: 'Företagskonto' },
{ accountNumber: '2611', name: 'Utgående moms 25%' },
{ accountNumber: '3010', name: 'Försäljning' },
],
vouchers: [SALES_VOUCHER, PAYMENT_VOUCHER],
ibByAccount: new Map([['1930', 50000]]),
};
describe('buildWintSieFile', () => {
it('produces a SIE4 file our own parser accepts and validates', () => {
const content = buildWintSieFile(BASE_OPTIONS);
const parsed = parseSIEFile(content);
const validation = validateSIEFile(parsed);
expect(parsed.header.sieType).toBe(4);
expect(parsed.header.companyName).toBe('Bolaget AB');
expect(parsed.header.orgNumber).toBe('556699-0011');
expect(parsed.vouchers).toHaveLength(2);
expect(validation.errors).toEqual([]);
});
it('every rendered verification balances to zero', () => {
const parsed = parseSIEFile(buildWintSieFile(BASE_OPTIONS));
for (const ver of parsed.vouchers) {
const sum = ver.lines.reduce((acc, t) => acc + t.amount, 0);
expect(Math.round(sum * 100) / 100).toBe(0);
}
});
it('renders IB/UB for balance accounts and RES for result accounts', () => {
const content = buildWintSieFile(BASE_OPTIONS);
// 1930: IB 50000 + payment 1250 = UB 51250
expect(content).toContain('#IB 0 1930 50000.00');
expect(content).toContain('#UB 0 1930 51250.00');
// 1510: invoice +1250, payment -1250 -> IB 0/UB 0: omitted entirely
expect(content).not.toContain('#IB 0 1510');
// Result account: RES only, never IB
expect(content).toContain('#RES 0 3010 -1000.00');
expect(content).not.toContain('#IB 0 3010');
// 2611 got no IB but has movement: UB must still appear
expect(content).toContain('#UB 0 2611 -250.00');
});
it('excludes deleted vouchers from the ledger but documents them in a #PROSA record', () => {
const content = buildWintSieFile({
...BASE_OPTIONS,
vouchers: [
SALES_VOUCHER,
voucher({ number: 3, deleted: true, transactions: [{ accountNumber: '1930', amount: 1 }, { accountNumber: '1510', amount: -1 }] }),
voucher({ number: 4, transactions: [] }),
],
});
const parsed = parseSIEFile(content);
expect(parsed.vouchers).toHaveLength(1);
expect(parsed.vouchers[0]?.number).toBe(1);
// BFL 5 kap 6-7 §: the gap in the number series must be accounted for in
// the file, not silently inherited.
expect(content).toContain('#PROSA');
expect(content).toContain('1 verifikat exkluderade (raderade i källsystemet WINT): A-3');
// No #PROSA when nothing was deleted
expect(buildWintSieFile(BASE_OPTIONS)).not.toContain('#PROSA');
});
it('refuses to render a transaction without an account number', () => {
expect(() =>
buildWintSieFile({
...BASE_OPTIONS,
vouchers: [voucher({
number: 9,
transactions: [
{ accountNumber: '', amount: 100 },
{ accountNumber: '1930', amount: -100 },
],
})],
}),
).toThrow(/A-9.*without an account number/);
});
it('refuses to render a voucher without a booking date', () => {
expect(() =>
buildWintSieFile({
...BASE_OPTIONS,
vouchers: [voucher({
number: 9,
bookingDate: '',
transactions: [
{ accountNumber: '3010', amount: -100 },
{ accountNumber: '1930', amount: 100 },
],
})],
}),
).toThrow(/A-9.*no booking date/);
});
it('escapes quotes in voucher texts', () => {
const parsed = parseSIEFile(buildWintSieFile(BASE_OPTIONS));
const payment = parsed.vouchers.find((v) => v.number === 2);
expect(payment?.description).toBe('Inbetalning "1007"');
});
it('emits declared #KONTO for every referenced account and orders vouchers per series', () => {
const content = buildWintSieFile({
...BASE_OPTIONS,
vouchers: [PAYMENT_VOUCHER, SALES_VOUCHER], // deliberately out of order
});
const parsed = parseSIEFile(content);
expect(parsed.accounts.map((a) => a.number)).toEqual(['1510', '1930', '2611', '3010']);
expect(parsed.vouchers.map((v) => v.number)).toEqual([1, 2]);
});
it('maps WINT dimensions to SIE reserved numbers (CostCenter=1, Project=6)', () => {
const content = buildWintSieFile({
...BASE_OPTIONS,
vouchers: [voucher({
number: 1,
transactions: [
{
accountNumber: '3010', amount: -100,
dimensions: [
{ type: 'Project', shortName: 'P01', name: 'Projekt Alpha' },
{ type: 'Tag', shortName: 'ignoreme', name: 'Tag' },
],
},
{ accountNumber: '1510', amount: 100 },
],
})],
});
expect(content).toContain('#DIM 6 "Projekt"');
expect(content).toContain('#OBJEKT 6 "P01" "Projekt Alpha"');
expect(content).toContain('{6 "P01"}');
expect(content).not.toContain('ignoreme');
});
});
describe('deriveIbByYear', () => {
it('walks opening balances backward from the anchor year', () => {
// 2026 IB (anchor): 1930 = 51000. 2025 moved 1930 by +1000 and closed
// result into 2099 (so the 2025 vouchers sum result accounts to zero).
const vouchers2025 = [
voucher({
bookingDate: '2025-06-01',
transactions: [
{ accountNumber: '1930', amount: 1000 },
{ accountNumber: '3010', amount: -1000 },
],
}),
voucher({
number: 2,
bookingDate: '2025-12-31',
text: 'Årets resultat',
transactions: [
{ accountNumber: '8999', amount: 1000 },
{ accountNumber: '2099', amount: -1000 },
],
}),
];
const ibByYear = deriveIbByYear(
2026,
new Map([['1930', 51000], ['2099', -1000]]),
new Map([[2025, vouchers2025]]),
[2025, 2026],
);
const ib2025 = ibByYear.get(2025)!;
expect(ib2025.get('1930')).toBe(50000);
// 2099 was built by 2025's closing: zero at the start of 2025 -> omitted
expect(ib2025.has('2099')).toBe(false);
// Result accounts never get IB
expect(ib2025.has('3010')).toBe(false);
expect(ibByYear.get(2026)!.get('1930')).toBe(51000);
});
it('stops at holes in the voucher chain instead of guessing', () => {
const ibByYear = deriveIbByYear(2026, new Map([['1930', 100]]), new Map(), [2024, 2025, 2026]);
expect(ibByYear.has(2026)).toBe(true);
expect(ibByYear.has(2025)).toBe(false);
expect(ibByYear.has(2024)).toBe(false);
});
});
describe('raw WINT JSON mapping', () => {
it('mapWintVoucherForSie stringifies integer account numbers and strips times', () => {
const mapped = mapWintVoucherForSie({
SeriesShortName: 'A',
Number: 17,
BookingDate: '2026-02-15T00:00:00',
Text: 'Verifikat',
Deleted: false,
Transactions: [
{ AccountNumber: 1930, Amount: 100.005, Text: 'Bank', BookingDate: '2026-02-15T00:00:00', Dimensions: [] },
{ AccountNumber: 3010, Amount: -100.005 },
],
});
expect(mapped.number).toBe(17);
expect(mapped.bookingDate).toBe('2026-02-15');
expect(mapped.transactions[0]?.accountNumber).toBe('1930');
expect(mapped.transactions[0]?.amount).toBe(100.01);
});
it('mapWintAccountForSie carries name, SRU and Ib', () => {
const mapped = mapWintAccountForSie({ Number: 1930, Name: 'Företagskonto', SRU: 7281, Ib: 50000 });
expect(mapped).toEqual({ accountNumber: '1930', name: 'Företagskonto', sruCode: '7281', ib: 50000 });
});
it('accountDeltas nets movements and skips deleted vouchers', () => {
const deltas = accountDeltas([
SALES_VOUCHER,
PAYMENT_VOUCHER,
voucher({ deleted: true, transactions: [{ accountNumber: '1930', amount: 9999 }] }),
]);
expect(deltas.get('1510')).toBe(0);
expect(deltas.get('1930')).toBe(1250);
expect(deltas.get('3010')).toBe(-1000);
});
});
+168
View File
@@ -0,0 +1,168 @@
import { TokenBucketRateLimiter } from '../rate-limiter';
import { withRetry } from '../retry';
import { WINT_BASE_URL, WINT_RATE_LIMIT } from './config';
import { isTimeoutError } from '@/lib/http/fetch-with-timeout';
const FETCH_TIMEOUT_MS = 15_000;
// WINT error bodies can carry customer data, and provider errors get logged
// wholesale by callers (provider-data-fetcher). Keep only a short bounded
// diagnostic on the error object so a full response body never reaches logs.
const MAX_ERROR_BODY_CHARS = 300;
export class WintApiError extends Error {
public readonly body?: string;
constructor(message: string, public readonly statusCode: number, body?: string) {
super(message);
this.name = 'WintApiError';
this.body = body != null ? body.slice(0, MAX_ERROR_BODY_CHARS) : undefined;
}
}
function isRetryableError(error: unknown): boolean {
if (isTimeoutError(error)) return true;
if (error instanceof WintApiError) {
if (error.statusCode === 401 || error.statusCode === 403 || error.statusCode === 404) {
return false;
}
return error.statusCode === 429 || error.statusCode >= 500;
}
return false;
}
/**
* Every WINT list endpoint answers the same envelope. `Page` echoes the page
* that was actually served: the pagination loop keys on it (see getPaginated)
* because we have no documentation guaranteeing the `Page` request param is
* honored, and a provider that silently ignores it would otherwise loop on
* page 1 forever (the exact failure mode Björn Lundén shipped with
* pageRequested/rowsRequested).
*/
export interface WintListResponse<T> {
Items: T[];
Page: number;
NumPerPage: number;
TotalItems: number;
TotalItemsWithOutFilter?: number;
}
export interface WintFinancialYear {
Id: number;
Start: string;
End: string;
}
const DEFAULT_PAGE_SIZE = 200;
export class WintClient {
private readonly rateLimiter: TokenBucketRateLimiter;
private readonly baseUrl: string;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl ?? WINT_BASE_URL;
this.rateLimiter = new TokenBucketRateLimiter(WINT_RATE_LIMIT, 'ratelimit:wint');
}
// Assumption (unverified against a live account, no securityScheme in the
// swagger): the JWT from POST /api/Auth/jwt travels as a standard Bearer
// token. If a live test proves otherwise the change is confined here.
private authHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
}
async get<T>(accessToken: string, path: string): Promise<T> {
return withRetry(
async () => {
await this.rateLimiter.acquire();
const response = await fetch(`${this.baseUrl}${path}`, {
headers: this.authHeaders(accessToken),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new WintApiError(
`WINT API error: ${response.status} ${response.statusText}`,
response.status,
body,
);
}
return response.json() as Promise<T>;
},
{
maxAttempts: 3,
initialDelayMs: 1000,
shouldRetry: isRetryableError,
},
);
}
async getPage<T>(
accessToken: string,
path: string,
options?: { page?: number; pageSize?: number },
): Promise<{ items: T[]; page: number; totalItems: number; pageSize: number }> {
const page = options?.page ?? 1;
const pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE;
const params = new URLSearchParams();
params.set('Page', String(page));
params.set('NumPerPage', String(pageSize));
const separator = path.includes('?') ? '&' : '?';
const response = await this.get<WintListResponse<T>>(
accessToken,
`${path}${separator}${params.toString()}`,
);
return {
items: Array.isArray(response.Items) ? response.Items : [],
page: response.Page ?? page,
totalItems: response.TotalItems ?? 0,
pageSize: response.NumPerPage ?? pageSize,
};
}
async getPaginated<T>(
accessToken: string,
path: string,
options?: { pageSize?: number },
): Promise<T[]> {
const allItems: T[] = [];
let page = 1;
for (;;) {
const result = await this.getPage<T>(accessToken, path, {
page,
pageSize: options?.pageSize,
});
// Page-echo guard: a server that ignores the Page param serves page 1
// for every request; without this check the loop appends the same items
// until TotalItems is (never) reached.
if (result.page !== page) {
throw new WintApiError(
`WINT pagination did not honor Page=${page} (served ${result.page}) for ${path}`,
502,
);
}
allItems.push(...result.items);
const done =
result.items.length === 0 ||
allItems.length >= result.totalItems ||
result.items.length < result.pageSize;
if (done) break;
page++;
}
return allItems;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { ResourceType } from '../dto';
import type { RateLimitConfig, WintResourceConfig } from '../types';
import {
mapWintToSalesInvoice,
mapWintToCustomer,
mapWintToAccountingAccount,
mapWintToCompanyInformation,
} from './mapper';
// WINT has no published developer docs; every endpoint below comes from the
// OpenAPI specs their API host serves itself (https://api.wint.se/index.html,
// spec /swagger/v1/swagger.json, fetched 2026-08-06). We deliberately stay on
// the partner-facing "v1" surface: the SIE export and IncomingInvoice
// endpoints exist only in their Full/Internal specs and are NOT used here
// (Tier A: the general ledger is reconstructed from /api/Voucher + /api/Account
// and rendered as SIE on our side; see sie-builder.ts).
export const WINT_BASE_URL = 'https://api.wint.se';
// Undocumented; start conservative until WINT confirms a real budget.
export const WINT_RATE_LIMIT: RateLimitConfig = { maxRequests: 3, windowMs: 1000 };
export const WINT_RESOURCE_CONFIGS: Partial<Record<ResourceType, WintResourceConfig>> = {
[ResourceType.SalesInvoices]: {
listEndpoint: '/api/Invoice',
detailEndpoint: '/api/Invoice/{id}',
idField: 'Id',
mapper: mapWintToSalesInvoice,
paginated: true,
modifiedParam: 'LastUpdated',
},
[ResourceType.Customers]: {
listEndpoint: '/api/Customer',
detailEndpoint: '/api/Customer/{id}',
idField: 'Id',
mapper: mapWintToCustomer,
paginated: true,
modifiedParam: 'UpdatedAfter',
},
[ResourceType.AccountingAccounts]: {
listEndpoint: '/api/Account',
detailEndpoint: '/api/Account',
idField: 'Id',
mapper: mapWintToAccountingAccount,
paginated: true,
},
[ResourceType.CompanyInformation]: {
// GET /api/Auth describes the company the current token is scoped to
// (Id, Name, Org, NoVat, FinancialYears, ...): WINT has no separate
// company-information endpoint on the v1 surface.
listEndpoint: '/api/Auth',
detailEndpoint: '/api/Auth',
idField: 'Id',
mapper: mapWintToCompanyInformation,
singleton: true,
},
};
+246
View File
@@ -0,0 +1,246 @@
import type {
SalesInvoiceDto, SalesInvoiceLineDto, InvoiceStatusCode,
LegalMonetaryTotalDto, PaymentStatusDto,
CustomerDto,
AccountingAccountDto, AccountType,
CompanyInformationDto,
AmountType, PartyDto, PostalAddress,
} from '../dto';
// Field names follow WINT's v1 swagger exactly (PascalCase). Amounts arrive as
// JSON numbers; account numbers arrive as INTEGERS and must leave every mapper
// as strings (they are identifiers, never quantities).
function amount(value: number | undefined | null, currency: string = 'SEK'): AmountType {
return { value: round2(value ?? 0), currencyCode: currency };
}
function round2(value: number): number {
return Math.round(value * 100) / 100;
}
function num(value: unknown): number | undefined {
if (value == null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
/** WINT date-times are ISO with a time part; our DTOs carry date-only strings. */
function dateOnly(value: unknown): string | undefined {
if (typeof value !== 'string' || value === '') return undefined;
return value.slice(0, 10);
}
function mapAddress(raw: Record<string, unknown> | undefined | null): PostalAddress | undefined {
if (!raw) return undefined;
return {
streetName: raw['Street1'] as string | undefined,
additionalStreetName: raw['Street2'] as string | undefined,
cityName: raw['City'] as string | undefined,
postalZone: raw['ZipCode'] as string | undefined,
countryCode: raw['CountryCode'] as string | undefined,
};
}
function buildParty(
name: string,
orgNumber?: string,
opts?: { address?: Record<string, unknown> | null; email?: string; phone?: string },
): PartyDto {
return {
name,
identifications: orgNumber ? [{ id: orgNumber, schemeId: 'SE:ORGNR' }] : [],
postalAddress: mapAddress(opts?.address),
legalEntity: orgNumber ? {
registrationName: name,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
} : undefined,
contact: (opts?.email || opts?.phone) ? {
email: opts?.email,
telephone: opts?.phone,
} : undefined,
};
}
/**
* WINT invoice Status enum: NotSent | Unpaid | OverdueReminderSent |
* OverdueReminderNotSent | Paid | Cancelled | Collection |
* ReminderFeeNotFullyPaid | Expires. PaymentState refines it: Unpaid | Paid |
* OverdueSoon | Overdue | PartiallyPaid | PartiallyPaidOverdue.
*/
function deriveWintInvoiceStatus(raw: Record<string, unknown>): InvoiceStatusCode {
const status = raw['Status'] as string | undefined;
const creditStatus = raw['CreditStatus'] as string | undefined;
if (status === 'Cancelled') return 'cancelled';
if (creditStatus === 'Credited') return 'credited';
if (isWintInvoicePaid(raw)) return 'paid';
if (status === 'NotSent') return 'draft';
const paymentState = raw['PaymentState'] as string | undefined;
if (
paymentState === 'Overdue' ||
paymentState === 'PartiallyPaidOverdue' ||
status === 'OverdueReminderSent' ||
status === 'OverdueReminderNotSent' ||
status === 'Collection'
) {
return 'overdue';
}
return 'sent';
}
/**
* Paid means WINT says so, from either enum. LeftToPay corroborates but a
* missing LeftToPay never reads as paid (mirrors the Fortnox/Briox absent-
* balance hardening).
*/
function isWintInvoicePaid(raw: Record<string, unknown>): boolean {
if (raw['Status'] === 'Paid' || raw['PaymentState'] === 'Paid') return true;
const total = num(raw['TotalAmount']);
const leftToPay = num(raw['LeftToPay']);
return total != null && total > 0 && leftToPay != null && leftToPay <= 0;
}
export function mapWintToSalesInvoice(raw: Record<string, unknown>): SalesInvoiceDto {
const currency = (raw['Currency'] as string) || 'SEK';
const total = num(raw['TotalAmount']) ?? 0;
const totalTax = num(raw['TotalTax']);
const paid = isWintInvoicePaid(raw);
const balance = paid ? 0 : (num(raw['LeftToPay']) ?? total);
const rows = (raw['Rows'] as Record<string, unknown>[] | undefined) ?? [];
const lines: SalesInvoiceLineDto[] = rows.map((row, idx) => {
const quantity = num(row['Quantity']);
const unitPrice = num(row['OverriddenUnitPrice']) ?? num(row['UnitPrice']);
const lineTotal = quantity != null && unitPrice != null ? round2(quantity * unitPrice) : 0;
const unit = row['Unit'] as Record<string, unknown> | undefined;
return {
id: String(row['Id'] ?? idx + 1),
description: (row['OverriddenDescription'] ?? row['Description']) as string | undefined,
quantity,
unitCode: unit?.['Text'] as string | undefined,
unitPrice: unitPrice != null ? amount(unitPrice, currency) : undefined,
lineExtensionAmount: amount(lineTotal, currency),
taxPercent: num(row['OverriddenVat']) ?? num(row['Vat']),
itemName: row['Description'] as string | undefined,
articleNumber: row['ArticleId'] != null ? String(row['ArticleId']) : undefined,
};
});
const legalMonetaryTotal: LegalMonetaryTotalDto = {
lineExtensionAmount: amount(totalTax != null ? round2(total - totalTax) : total, currency),
taxInclusiveAmount: amount(total, currency),
payableAmount: amount(total, currency),
};
const paymentStatus: PaymentStatusDto = {
paid,
balance: amount(balance, currency),
lastPaymentDate: dateOnly(raw['PaymentDate']),
};
return {
id: String(raw['Id'] ?? ''),
invoiceNumber: String(raw['SerialNumber'] ?? raw['Id'] ?? ''),
// WINT's list item has no separate invoice-date field: PostingDate is the
// date the invoice was posted/issued.
issueDate: dateOnly(raw['PostingDate']) ?? '',
dueDate: dateOnly(raw['DueDate']),
deliveryDate: dateOnly(raw['DeliveryDate']),
currencyCode: currency,
status: deriveWintInvoiceStatus(raw),
supplier: buildParty(''),
customer: buildParty(
(raw['CustomerName'] ?? '') as string,
(raw['CustomerOrgNo'] as string | undefined) || undefined,
),
lines,
taxTotal: totalTax != null ? { taxAmount: amount(totalTax, currency) } : undefined,
legalMonetaryTotal,
paymentStatus,
paymentTerms: raw['PaymentTerms'] != null ? `${raw['PaymentTerms']} dagar` : undefined,
note: raw['Notes'] as string | undefined,
buyerReference: raw['CustomerReference'] as string | undefined,
updatedAt: raw['LastUpdated'] as string | undefined,
_raw: raw,
};
}
export function mapWintToCustomer(raw: Record<string, unknown>): CustomerDto {
const name = (raw['Name'] as string) ?? '';
const orgNumber = (raw['OrgNumber'] as string | undefined) || undefined;
return {
id: String(raw['Id'] ?? ''),
// WINT has no separate customer number on the v1 surface: the Id is the
// stable identifier their own invoices reference.
customerNumber: String(raw['Id'] ?? ''),
type: raw['Type'] === 'PrivatePerson' ? 'private' : 'company',
party: buildParty(name, orgNumber, {
address: raw['BillingAddress'] as Record<string, unknown> | null,
email: (raw['EmailAddress'] as string | undefined) || undefined,
phone: (raw['PhoneNumber'] as string | undefined) || undefined,
}),
deliveryAddresses: raw['DeliveryAddress']
? [mapAddress(raw['DeliveryAddress'] as Record<string, unknown>)!]
: undefined,
active: raw['Inactive'] !== true,
vatNumber: (raw['VatNumber'] as string | undefined) || undefined,
defaultPaymentTermsDays: num(raw['PaymentTerms']),
updatedAt: raw['LastUpdated'] as string | undefined,
_raw: raw,
};
}
export function mapWintToAccountingAccount(raw: Record<string, unknown>): AccountingAccountDto {
// WINT serves Number as an INTEGER: stringify immediately, arithmetic on
// account numbers is always a bug. The classification below may only look
// at the numeric value, never store it.
const numberValue = num(raw['Number']);
let type: AccountType | undefined;
if (numberValue != null) {
if (numberValue >= 1000 && numberValue < 2000) type = 'asset';
// 20xx is eget kapital, not a liability; 21xx+ (obeskattade reserver,
// avsättningar, skulder) stays 'liability' at this metadata granularity.
else if (numberValue >= 2000 && numberValue < 2100) type = 'equity';
else if (numberValue >= 2100 && numberValue < 3000) type = 'liability';
else if (numberValue >= 3000 && numberValue < 4000) type = 'revenue';
// 83xx is financial income (ränteintäkter m.m.), not an expense.
else if (numberValue >= 8300 && numberValue < 8400) type = 'revenue';
else if (numberValue >= 4000 && numberValue < 9000) type = 'expense';
}
return {
accountNumber: raw['Number'] != null ? String(raw['Number']) : '',
name: (raw['Name'] as string) ?? '',
type,
active: true,
balanceBroughtForward: raw['Ib'] != null ? round2(Number(raw['Ib'])) : undefined,
sruCode: raw['SRU'] != null ? String(raw['SRU']) : undefined,
_raw: raw,
};
}
export function mapWintToCompanyInformation(raw: Record<string, unknown>): CompanyInformationDto {
const companyName = (raw['Name'] as string) ?? '';
const orgNumber = (raw['Org'] as string | undefined) || undefined;
const financialYears = (raw['FinancialYears'] as Record<string, unknown>[] | undefined) ?? [];
const firstYear = financialYears[0];
return {
companyName,
organizationNumber: orgNumber,
legalEntity: {
registrationName: companyName,
companyId: orgNumber,
companyIdSchemeId: 'SE:ORGNR',
},
contact: raw['Url'] ? { website: raw['Url'] as string } : undefined,
fiscalYearStart: firstYear ? dateOnly(firstYear['Start']) : undefined,
baseCurrency: 'SEK',
_raw: raw,
};
}
+153
View File
@@ -0,0 +1,153 @@
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');
}
+376
View File
@@ -0,0 +1,376 @@
/**
* Render a SIE 4E file from WINT voucher + account data.
*
* WINT's partner-facing v1 API has no SIE export (that endpoint lives in
* their internal Full spec, which we deliberately do not call: Tier A). The
* v1 surface does expose the complete ledger (/api/Voucher with transactions,
* /api/Account with names/SRU/IB), which is everything a SIE 4E file carries,
* so we render the file ourselves and feed it to the provider-agnostic SIE
* import pipeline (parse -> validate -> map accounts -> import).
*
* Format authority: SIE 4B spec (record-types reference in the
* swedish-sie-import-export skill). Invariants honored here:
* - every #VER's #TRANS amounts sum to 0.00 (source is double-entry; we
* never adjust amounts, only round to 2 decimals)
* - debit positive / credit negative (WINT transaction amounts are already
* signed this way: a voucher's Transactions sum to zero)
* - #IB only for balance-sheet accounts (1xxx-2xxx); #RES for result accounts
* - vouchers ascending by number within each series
*/
import { isBalanceSheetAccount } from '@/lib/import/sie-parser';
export interface WintSieTransaction {
/** Integer in WINT's JSON; stringified at the mapping boundary. */
accountNumber: string;
accountName?: string;
amount: number;
text?: string;
bookingDate?: string;
dimensions?: { type: string; shortName?: string; name?: string; id?: string }[];
}
export interface WintSieVoucher {
seriesShortName: string;
number: number;
bookingDate: string;
text?: string;
deleted?: boolean;
transactions: WintSieTransaction[];
}
export interface WintSieAccount {
accountNumber: string;
name: string;
sruCode?: string;
/** Opening balance for WINT's CURRENT financial year (see deriveIbByYear). */
ib?: number;
}
export interface WintSieYear {
/** Calendar year the fiscal year starts in (SIE #RAR is date-ranged; this keys maps). */
year: number;
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
}
export interface BuildWintSieOptions {
companyName: string;
orgNumber?: string;
programVersion: string;
/** Generation date, YYYY-MM-DD (passed in: keeps the builder pure/testable). */
generatedDate: string;
year: WintSieYear;
previousYear?: WintSieYear;
accounts: WintSieAccount[];
/** Vouchers belonging to `year` only. Deleted vouchers are skipped. */
vouchers: WintSieVoucher[];
/** Opening balances for `year`, per account number (balance accounts only). */
ibByAccount: Map<string, number>;
}
const round2 = (value: number): number => Math.round(value * 100) / 100;
/** Raw /api/Voucher item (PascalCase, IncludeTransactions=true) -> builder shape. */
export function mapWintVoucherForSie(raw: Record<string, unknown>): WintSieVoucher {
const transactions = ((raw['Transactions'] as Record<string, unknown>[] | undefined) ?? []).map(
(t): WintSieTransaction => ({
// Integer in WINT's JSON: stringified here, never used numerically again.
accountNumber: t['AccountNumber'] != null ? String(t['AccountNumber']) : '',
accountName: t['AccountName'] as string | undefined,
amount: round2(Number(t['Amount'] ?? 0)),
text: (t['Text'] as string | undefined) || undefined,
bookingDate: (t['BookingDate'] as string | undefined)?.slice(0, 10),
dimensions: ((t['Dimensions'] as Record<string, unknown>[] | undefined) ?? []).map((d) => ({
type: (d['Type'] as string) ?? '',
shortName: d['ShortName'] as string | undefined,
name: d['Name'] as string | undefined,
id: d['Id'] != null ? String(d['Id']) : undefined,
})),
}),
);
return {
seriesShortName: (raw['SeriesShortName'] as string) || 'A',
number: Number(raw['Number'] ?? 0),
bookingDate: ((raw['BookingDate'] as string) ?? '').slice(0, 10),
text: (raw['Text'] as string | undefined) || undefined,
deleted: raw['Deleted'] === true,
transactions,
};
}
/** Raw /api/Account item -> builder shape (Ib anchors to WINT's current FY). */
export function mapWintAccountForSie(raw: Record<string, unknown>): WintSieAccount {
return {
accountNumber: raw['Number'] != null ? String(raw['Number']) : '',
name: (raw['Name'] as string) ?? '',
sruCode: raw['SRU'] != null && raw['SRU'] !== 0 ? String(raw['SRU']) : undefined,
ib: raw['Ib'] != null ? round2(Number(raw['Ib'])) : undefined,
};
}
function fmtAmount(value: number): string {
return round2(value).toFixed(2);
}
function fmtDate(isoDate: string): string {
return isoDate.slice(0, 10).replaceAll('-', '');
}
function quote(text: string): string {
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]+/g, ' ')}"`;
}
/**
* WINT dimension types -> SIE reserved dimension numbers. Only the reserved
* numbers with an exact semantic match are emitted; everything else is
* dropped rather than guessed into the wrong dimension.
*/
const SIE_DIMENSIONS: Record<string, number> = {
CostCenter: 1,
Project: 6,
Employee: 7,
Customer: 8,
Supplier: 9,
};
const SIE_DIMENSION_NAMES: Record<number, string> = {
1: 'Kostnadsställe',
6: 'Projekt',
7: 'Anställd',
8: 'Kund',
9: 'Leverantör',
};
interface DimensionRef {
dimNo: number;
objectNo: string;
objectName: string;
}
function sieDimensionRefs(t: WintSieTransaction): DimensionRef[] {
const refs: DimensionRef[] = [];
for (const d of t.dimensions ?? []) {
const dimNo = SIE_DIMENSIONS[d.type];
const objectNo = (d.shortName || d.id || '').trim();
if (!dimNo || !objectNo) continue;
refs.push({ dimNo, objectNo, objectName: d.name || objectNo });
}
return refs;
}
/**
* Net movement per account over a set of vouchers (deleted ones excluded).
* Used both for #RES/#UB rendering and for walking IB between years.
*/
export function accountDeltas(vouchers: WintSieVoucher[]): Map<string, number> {
const deltas = new Map<string, number>();
for (const voucher of vouchers) {
if (voucher.deleted) continue;
for (const t of voucher.transactions) {
deltas.set(t.accountNumber, round2((deltas.get(t.accountNumber) ?? 0) + t.amount));
}
}
return deltas;
}
/**
* WINT's /api/Account Ib anchors to the company's CURRENT financial year.
* Earlier imported years derive their opening balances by walking backward:
* IB(y) = IB(y+1) - delta(y) for balance-sheet accounts. The walk is
* deterministic because the voucher lists include the year-end closing
* vouchers WINT posted (which zero the result accounts into equity), exactly
* as they would appear in a native SIE export.
*
* `vouchersByYear` must cover every year between the earliest requested year
* and the anchor year, else the chain has a hole and the missing years are
* simply not returned (callers surface those years as failed rather than
* importing a ledger with broken IB/UB continuity).
*/
export function deriveIbByYear(
anchorYear: number,
anchorIb: Map<string, number>,
vouchersByYear: Map<number, WintSieVoucher[]>,
wantedYears: number[],
): Map<number, Map<string, number>> {
const result = new Map<number, Map<string, number>>();
result.set(anchorYear, anchorIb);
const earliest = Math.min(...wantedYears, anchorYear);
let current = anchorIb;
for (let y = anchorYear - 1; y >= earliest; y--) {
const vouchers = vouchersByYear.get(y);
if (!vouchers) break; // hole in the chain: stop deriving
const deltas = accountDeltas(vouchers);
const ib = new Map<string, number>();
const accounts = new Set([...current.keys(), ...deltas.keys()]);
for (const account of accounts) {
if (!isBalanceSheetAccount(account)) continue;
const value = round2((current.get(account) ?? 0) - (deltas.get(account) ?? 0));
if (value !== 0) ib.set(account, value);
}
result.set(y, ib);
current = ib;
}
// Forward walk covers wanted years after the anchor (unusual, but a company
// whose current WINT year is not the latest imported year must still get
// correct opening balances): IB(y+1) = IB(y) + delta(y).
current = anchorIb;
const latest = Math.max(...wantedYears, anchorYear);
for (let y = anchorYear + 1; y <= latest; y++) {
const prevVouchers = vouchersByYear.get(y - 1);
if (!prevVouchers) break;
const deltas = accountDeltas(prevVouchers);
const ib = new Map<string, number>();
const accounts = new Set([...current.keys(), ...deltas.keys()]);
for (const account of accounts) {
if (!isBalanceSheetAccount(account)) continue;
const value = round2((current.get(account) ?? 0) + (deltas.get(account) ?? 0));
if (value !== 0) ib.set(account, value);
}
result.set(y, ib);
current = ib;
}
return result;
}
export function buildWintSieFile(options: BuildWintSieOptions): string {
const {
companyName, orgNumber, programVersion, generatedDate,
year, previousYear, accounts, vouchers, ibByAccount,
} = options;
const active = vouchers.filter((v) => !v.deleted && v.transactions.length > 0);
const deleted = vouchers.filter((v) => v.deleted);
const deltas = accountDeltas(active);
// Structurally invalid source data must fail the year loudly, never render:
// a #TRANS without an account or a #VER without a date shifts the positional
// fields and the parser reads the NEXT token as account/date, silently
// corrupting the voucher.
for (const voucher of active) {
const label = `${voucher.seriesShortName || '?'}-${voucher.number}`;
if (!voucher.bookingDate) {
throw new Error(`WINT voucher ${label} has no booking date; refusing to render SIE`);
}
for (const t of voucher.transactions) {
if (!t.accountNumber) {
throw new Error(`WINT voucher ${label} has a transaction without an account number; refusing to render SIE`);
}
}
}
// Every account referenced anywhere must be declared with #KONTO.
const accountNames = new Map<string, WintSieAccount>();
for (const account of accounts) accountNames.set(account.accountNumber, account);
const referenced = new Set<string>([...deltas.keys(), ...ibByAccount.keys()]);
for (const account of accounts) referenced.add(account.accountNumber);
const lines: string[] = [];
lines.push('#FLAGGA 0');
lines.push(`#PROGRAM ${quote('Accounted')} ${quote(programVersion)}`);
// Declared PC8 with a UTF-8 body, the same de-facto convention Fortnox and
// Bokio ship: this file goes straight into our own parser, whose encoding
// detection ignores the header, and never leaves the import pipeline as
// bytes. Revisit if the raw file is ever offered for download.
lines.push('#FORMAT PC8');
lines.push(`#GEN ${fmtDate(generatedDate)}`);
lines.push('#SIETYP 4');
if (orgNumber) lines.push(`#ORGNR ${orgNumber}`);
lines.push(`#FNAMN ${quote(companyName)}`);
lines.push(`#RAR 0 ${fmtDate(year.start)} ${fmtDate(year.end)}`);
if (previousYear) {
lines.push(`#RAR -1 ${fmtDate(previousYear.start)} ${fmtDate(previousYear.end)}`);
}
lines.push('#KPTYP EUBAS97');
if (deleted.length > 0) {
// BFL 5 kap 6-7 §: the voucher number series must be accounted for. WINT
// flags these vouchers Deleted and excludes them from its own ledger, so
// they cannot be rendered as transactions, but the resulting gaps in the
// series must be documented in the file rather than silently inherited.
const numbers = deleted
.map((v) => `${v.seriesShortName || 'A'}-${v.number}`)
.slice(0, 50)
.join(', ');
lines.push(`#PROSA ${quote(
`${deleted.length} verifikat exkluderade (raderade i källsystemet WINT): ${numbers}${deleted.length > 50 ? ', ...' : ''}`,
)}`);
}
// Chart of accounts
const sortedAccounts = [...referenced].sort((a, b) => a.localeCompare(b, 'sv'));
for (const accountNumber of sortedAccounts) {
const meta = accountNames.get(accountNumber);
lines.push(`#KONTO ${accountNumber} ${quote(meta?.name || `Konto ${accountNumber}`)}`);
if (meta?.sruCode) lines.push(`#SRU ${accountNumber} ${meta.sruCode}`);
}
// Dimensions actually used
const usedDimensions = new Map<number, Map<string, string>>();
for (const voucher of active) {
for (const t of voucher.transactions) {
for (const ref of sieDimensionRefs(t)) {
if (!usedDimensions.has(ref.dimNo)) usedDimensions.set(ref.dimNo, new Map());
usedDimensions.get(ref.dimNo)!.set(ref.objectNo, ref.objectName);
}
}
}
for (const [dimNo, objects] of [...usedDimensions.entries()].sort((a, b) => a[0] - b[0])) {
lines.push(`#DIM ${dimNo} ${quote(SIE_DIMENSION_NAMES[dimNo] ?? `Dimension ${dimNo}`)}`);
for (const [objectNo, objectName] of [...objects.entries()].sort((a, b) => a[0].localeCompare(b[0], 'sv'))) {
lines.push(`#OBJEKT ${dimNo} ${quote(objectNo)} ${quote(objectName)}`);
}
}
// Balances: #IB/#UB for balance-sheet accounts, #RES for result accounts.
for (const accountNumber of sortedAccounts) {
if (!isBalanceSheetAccount(accountNumber)) continue;
const ib = round2(ibByAccount.get(accountNumber) ?? 0);
const ub = round2(ib + (deltas.get(accountNumber) ?? 0));
if (ib === 0 && ub === 0) continue;
lines.push(`#IB 0 ${accountNumber} ${fmtAmount(ib)}`);
lines.push(`#UB 0 ${accountNumber} ${fmtAmount(ub)}`);
}
for (const accountNumber of sortedAccounts) {
if (isBalanceSheetAccount(accountNumber)) continue;
const res = round2(deltas.get(accountNumber) ?? 0);
if (res === 0) continue;
lines.push(`#RES 0 ${accountNumber} ${fmtAmount(res)}`);
}
// Vouchers, ascending per series
const sorted = [...active].sort((a, b) =>
a.seriesShortName === b.seriesShortName
? a.number - b.number
: a.seriesShortName.localeCompare(b.seriesShortName, 'sv'),
);
for (const voucher of sorted) {
const series = voucher.seriesShortName || 'A';
const header = `#VER ${quote(series)} ${voucher.number} ${fmtDate(voucher.bookingDate)}`
+ (voucher.text ? ` ${quote(voucher.text)}` : '');
lines.push(header);
lines.push('{');
for (const t of voucher.transactions) {
const refs = sieDimensionRefs(t);
const objectList = refs.length > 0
? `{${refs.map((r) => `${r.dimNo} ${quote(r.objectNo)}`).join(' ')}}`
: '{}';
let line = ` #TRANS ${t.accountNumber} ${objectList} ${fmtAmount(t.amount)}`;
const transDate = t.bookingDate ? fmtDate(t.bookingDate) : undefined;
if (transDate && transDate !== fmtDate(voucher.bookingDate)) {
line += ` ${transDate}`;
if (t.text) line += ` ${quote(t.text)}`;
} else if (t.text) {
// transtext is positional after transdate: emit the date when a text follows
line += ` ${fmtDate(voucher.bookingDate)} ${quote(t.text)}`;
}
lines.push(line);
}
lines.push('}');
}
return lines.join('\r\n') + '\r\n';
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 40" role="img" aria-label="WINT">
<!-- Placeholder wordmark: swap for WINT's official logo asset before launch -->
<text x="60" y="27" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="20" font-weight="700" letter-spacing="3" fill="#1a1a2e">WINT</text>
</svg>

After

Width:  |  Height:  |  Size: 346 B