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