)
}
diff --git a/components/settings/sections/BookkeepingSettingsContent.tsx b/components/settings/sections/BookkeepingSettingsContent.tsx
index d565a502..9fbeaa59 100644
--- a/components/settings/sections/BookkeepingSettingsContent.tsx
+++ b/components/settings/sections/BookkeepingSettingsContent.tsx
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
+import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
@@ -20,7 +21,7 @@ const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export function BookkeepingSettingsContent() {
const t = useTranslations('settings_bookkeeping')
- const { settings, isLoading, updateSettings } = useSettings()
+ const { settings, isLoading, updateSettings, refetch } = useSettings()
const { company } = useCompany()
// Local mirror of the company-level accounting_framework so the K2/K3
// selector can reflect its own saves without waiting for the layout to
@@ -30,7 +31,8 @@ export function BookkeepingSettingsContent() {
company?.accounting_framework ?? 'k2',
)
- if (isLoading || !settings) return
function handleSave(formData: FormData) {
const autoLockValue = formData.get('auto_lock_period_days') as string
diff --git a/components/settings/sections/CompanySettingsContent.tsx b/components/settings/sections/CompanySettingsContent.tsx
index 99694a22..6a8cb087 100644
--- a/components/settings/sections/CompanySettingsContent.tsx
+++ b/components/settings/sections/CompanySettingsContent.tsx
@@ -8,15 +8,17 @@ import { CompanyProfileSection } from '@/components/settings/CompanyProfileSecti
import { FiscalPeriodEditor } from '@/components/settings/FiscalPeriodEditor'
import { LogoUpload } from '@/components/settings/LogoUpload'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
+import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import type { CompanySettings } from '@/types'
export function CompanySettingsContent() {
const router = useRouter()
- const { settings, isLoading, updateSettings } = useSettings()
+ const { settings, isLoading, updateSettings, refetch } = useSettings()
- if (isLoading || !settings) return
= {
diff --git a/components/settings/sections/InvoicingSettingsContent.tsx b/components/settings/sections/InvoicingSettingsContent.tsx
index 826a1370..e4638ae2 100644
--- a/components/settings/sections/InvoicingSettingsContent.tsx
+++ b/components/settings/sections/InvoicingSettingsContent.tsx
@@ -6,6 +6,7 @@ import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
import { InvoicePreviewCard } from '@/components/settings/InvoicePreviewCard'
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
+import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
@@ -14,10 +15,11 @@ import type { CompanySettings } from '@/types'
export function InvoicingSettingsContent() {
const t = useTranslations('settings_invoicing')
- const { settings, isLoading, updateSettings } = useSettings()
+ const { settings, isLoading, updateSettings, refetch } = useSettings()
const { toast } = useToast()
- if (isLoading || !settings) return
+ if (isLoading) return
+ if (!settings) return
function handleSave(formData: FormData) {
const bankErrors = validateBankFields(formData)
diff --git a/components/settings/sections/TaxSettingsContent.tsx b/components/settings/sections/TaxSettingsContent.tsx
index dad0b855..f1cd4af6 100644
--- a/components/settings/sections/TaxSettingsContent.tsx
+++ b/components/settings/sections/TaxSettingsContent.tsx
@@ -1,46 +1,27 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect } from 'react'
import { useTranslations } from 'next-intl'
import { useSearchParams, useRouter } from 'next/navigation'
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
+import { SettingsLoadError } from '@/components/settings/SettingsLoadError'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { SkatteverketConnectPanel } from '@/components/settings/SkatteverketConnectPanel'
import { useSettings } from '@/components/settings/useSettings'
import { useToast } from '@/components/ui/use-toast'
-import { useCompany } from '@/contexts/CompanyContext'
-import { createClient } from '@/lib/supabase/client'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import type { CompanySettings } from '@/types'
export function TaxSettingsContent() {
- const { settings, isLoading, updateSettings } = useSettings()
- const { company } = useCompany()
+ const { settings, isLoading, updateSettings, refetch } = useSettings()
const t = useTranslations('settings_skatteverket')
const searchParams = useSearchParams()
const router = useRouter()
const { toast } = useToast()
- const [isSandbox, setIsSandbox] = useState(false)
-
const hasSkatteverketExtension = ENABLED_EXTENSION_IDS.has('skatteverket')
- // Sandbox companies don't connect to the real Skatteverket — hide the panel,
- // matching the old Skatteverket tab's visibility gate.
- useEffect(() => {
- if (!company?.id) return
- const supabase = createClient()
- supabase
- .from('company_settings')
- .select('is_sandbox')
- .eq('company_id', company.id)
- .single()
- .then(({ data }) => {
- if (data?.is_sandbox) setIsSandbox(true)
- })
- }, [company?.id])
-
// Skatteverket OAuth callback — the connect flow returns to /settings/tax with
// a status query param (returnTo set in SkatteverketConnectPanel).
useEffect(() => {
@@ -61,7 +42,8 @@ export function TaxSettingsContent() {
}
}, [searchParams, router, toast, t])
- if (isLoading || !settings) return
+ if (isLoading) return
+ if (!settings) return
function handleSave(formData: FormData) {
const vatRegistered = formData.get('vat_registered') === 'true'
@@ -88,7 +70,10 @@ export function TaxSettingsContent() {
}
}
- const showSkatteverket = hasSkatteverketExtension && !isSandbox
+ // Sandbox companies don't connect to the real Skatteverket — hide the panel,
+ // matching the old Skatteverket tab's visibility gate. Read straight off the
+ // already-loaded settings row (no separate query needed).
+ const showSkatteverket = hasSkatteverketExtension && !settings.is_sandbox
return (
diff --git a/components/settings/useSettings.ts b/components/settings/useSettings.ts
index cd95e81c..b85285b5 100644
--- a/components/settings/useSettings.ts
+++ b/components/settings/useSettings.ts
@@ -1,41 +1,107 @@
'use client'
-import { useState, useEffect, useCallback } from 'react'
-import { useRouter } from 'next/navigation'
+import {
+ createContext,
+ createElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useState,
+ type ReactNode,
+} from 'react'
import { createClient } from '@/lib/supabase/client'
import { useCompany } from '@/contexts/CompanyContext'
import type { CompanySettings } from '@/types'
-export function useSettings() {
- const router = useRouter()
+export interface SettingsState {
+ settings: CompanySettings | null
+ /** True while the fetch for the active company is in flight. */
+ isLoading: boolean
+ /** True once a fetch finished without a row (or errored) — distinct from loading. */
+ error: boolean
+ updateSettings: (updates: Partial) => void
+ refetch: () => Promise
+}
+
+/**
+ * Standalone settings fetcher: loads `company_settings` for the active company
+ * (resolved from CompanyContext). Use this OUTSIDE the settings surface (e.g. the
+ * reports VAT view). Inside the settings surface, read the shared instance with
+ * `useSettings()` instead — `SettingsProvider` mounts exactly one of these so
+ * switching sections reuses the loaded data rather than refetching.
+ *
+ * Auth is already enforced by middleware before any authenticated page renders,
+ * so this no longer round-trips `auth.getUser()` — it gates purely on the
+ * resolved company id, removing a request from the path the skeleton waits on.
+ */
+export function useCompanySettings(): SettingsState {
const { company } = useCompany()
const [settings, setSettings] = useState(null)
const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(false)
const fetchSettings = useCallback(async () => {
- const supabase = createClient()
- const { data: { user } } = await supabase.auth.getUser()
- if (!user) { router.push('/login'); return }
-
- if (company?.id) {
- const { data } = await supabase
- .from('company_settings')
- .select('*')
- .eq('company_id', company.id)
- .single()
- setSettings(data)
+ if (!company?.id) {
+ // No active company (the no-company escape hatch). Nothing to load; surface
+ // a settled empty state rather than a perpetual spinner.
+ setSettings(null)
+ setError(false)
+ setIsLoading(false)
+ return
}
+ setIsLoading(true)
+ setError(false)
+
+ const supabase = createClient()
+ // maybeSingle() so a missing row resolves to { data: null } instead of
+ // throwing PGRST116 — a company created outside the onboarding flow may have
+ // no company_settings row yet, and that must not be treated as a hard error
+ // mid-query (it's surfaced as `error` below once the fetch settles).
+ const { data, error: queryError } = await supabase
+ .from('company_settings')
+ .select('*')
+ .eq('company_id', company.id)
+ .maybeSingle()
+
+ setSettings(data)
+ setError(Boolean(queryError) || !data)
setIsLoading(false)
- }, [company?.id, router])
+ }, [company?.id])
useEffect(() => {
fetchSettings()
}, [fetchSettings])
const updateSettings = useCallback((updates: Partial) => {
- setSettings(prev => prev ? { ...prev, ...updates } as CompanySettings : null)
+ setSettings((prev) => (prev ? ({ ...prev, ...updates } as CompanySettings) : prev))
}, [])
- return { settings, isLoading, updateSettings, refetch: fetchSettings }
+ return { settings, isLoading, error, updateSettings, refetch: fetchSettings }
+}
+
+const SettingsContext = createContext(null)
+
+/**
+ * Hosts one shared settings fetch for the whole settings surface. Mounted once by
+ * `SettingsShell`, it survives section swaps (the shell re-renders rather than
+ * remounting when the active section changes), so moving between settings tabs
+ * reuses the loaded data instead of refetching and re-flashing the skeleton.
+ */
+export function SettingsProvider({ children }: { children: ReactNode }) {
+ const value = useCompanySettings()
+ return createElement(SettingsContext.Provider, { value }, children)
+}
+
+/**
+ * Read the shared settings instance. Must be rendered within a `SettingsProvider`
+ * (every settings section is, via `SettingsShell`). Outside the settings surface,
+ * use `useCompanySettings()` instead.
+ */
+export function useSettings(): SettingsState {
+ const ctx = useContext(SettingsContext)
+ if (!ctx) {
+ throw new Error('useSettings must be used within a SettingsProvider')
+ }
+ return ctx
}
diff --git a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts
index 99ca854d..05358316 100644
--- a/extensions/general/enable-banking/lib/__tests__/api-client.test.ts
+++ b/extensions/general/enable-banking/lib/__tests__/api-client.test.ts
@@ -220,6 +220,185 @@ describe('api-client', () => {
warnSpy.mockRestore()
})
+
+ // Danske Bank rejects a history window beyond its ~90-day PSD2 limit with a
+ // blanket ASPSP_ERROR rather than clamping. The window must be narrowed.
+ const ASPSP_ERROR_BODY =
+ '{"code":400,"message":"Error interacting with ASPSP","detail":"Unknown error","error":"ASPSP_ERROR"}'
+
+ it('narrows date_from when the ASPSP rejects the history window', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ fetchSpy
+ // strategy=longest, full 120-day window → ASPSP_ERROR
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 }))
+ // strategy dropped, still full window → ASPSP_ERROR (window is the problem)
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 }))
+ // narrowed to 90 days before date_to → success
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ transactions: [{ transaction_amount: { amount: '42', currency: 'SEK' } }] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ )
+
+ const result = await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07', 'longest')
+
+ expect(result.transactions).toHaveLength(1)
+ expect(fetchSpy).toHaveBeenCalledTimes(3)
+
+ const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
+ expect(urls[0]).toContain('date_from=2026-02-07')
+ expect(urls[0]).toContain('strategy=longest')
+ expect(urls[1]).toContain('date_from=2026-02-07')
+ expect(urls[1]).not.toContain('strategy=')
+ // 90 days before 2026-06-07
+ expect(urls[2]).toContain('date_from=2026-03-09')
+ expect(urls[2]).toContain('date_to=2026-06-07')
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ '[enable-banking] ASPSP rejected history window, retrying with narrower date_from',
+ expect.objectContaining({ previousDateFrom: '2026-02-07', nextDateFrom: '2026-03-09' })
+ )
+
+ warnSpy.mockRestore()
+ })
+
+ it('steps through successive narrower windows until one succeeds', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ fetchSpy
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // 90 days
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // 60 days
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ transactions: [] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ ) // 30 days → success
+
+ await getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
+
+ expect(fetchSpy).toHaveBeenCalledTimes(4)
+ const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
+ expect(urls[0]).toContain('date_from=2026-02-07')
+ expect(urls[1]).toContain('date_from=2026-03-09') // 90 days before date_to
+ expect(urls[2]).toContain('date_from=2026-04-08') // 60 days
+ expect(urls[3]).toContain('date_from=2026-05-08') // 30 days
+
+ warnSpy.mockRestore()
+ })
+
+ it('does not narrow the window on a non-ASPSP 400', async () => {
+ fetchSpy.mockResolvedValueOnce(new Response('{"error":"INVALID_REQUEST"}', { status: 400 }))
+
+ await expect(
+ getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
+ ).rejects.toThrow('Failed to get transactions (400)')
+
+ // No strategy to drop + not an ASPSP error → fail fast, no retries.
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('throws once every narrower window is exhausted', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ // Fresh Response per call — a body can only be read once.
+ fetchSpy.mockImplementation(() => Promise.resolve(new Response(ASPSP_ERROR_BODY, { status: 400 })))
+
+ await expect(
+ getAllTransactionsWithRaw('acc-1', '2026-02-07', '2026-06-07')
+ ).rejects.toThrow('Failed to get transactions (400)')
+
+ // full window + 90 + 60 + 30 = 4 attempts, then give up
+ expect(fetchSpy).toHaveBeenCalledTimes(4)
+
+ warnSpy.mockRestore()
+ errorSpy.mockRestore()
+ })
+ })
+
+ // -------------------------------------------------------------------------
+ // getAllTransactions — same first-page fallbacks via the paginated path
+ // -------------------------------------------------------------------------
+ describe('getAllTransactions fallbacks', () => {
+ const ASPSP_ERROR_BODY =
+ '{"code":400,"message":"Error interacting with ASPSP","detail":"Unknown error","error":"ASPSP_ERROR"}'
+
+ it('narrows the window when the ASPSP rejects the history range', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ fetchSpy
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // full window
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ transactions: [{ transaction_amount: { amount: '10', currency: 'SEK' } }] }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ ) // narrowed to 90 days → success
+
+ const result = await getAllTransactions('acc-1', '2026-02-07', '2026-06-07')
+
+ expect(result).toHaveLength(1)
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
+ const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
+ expect(urls[0]).toContain('date_from=2026-02-07')
+ expect(urls[1]).toContain('date_from=2026-03-09') // 90 days before date_to
+
+ warnSpy.mockRestore()
+ })
+
+ it('drops the strategy then narrows the window (Danske flow)', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+
+ fetchSpy
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // strategy=longest
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // no strategy, full window
+ .mockResolvedValueOnce(
+ new Response(JSON.stringify({ transactions: [] }), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ ) // narrowed to 90 days → success
+
+ await getAllTransactions('acc-1', '2026-02-07', '2026-06-07', 'longest')
+
+ expect(fetchSpy).toHaveBeenCalledTimes(3)
+ const urls = fetchSpy.mock.calls.map((c: unknown[]) => c[0] as string)
+ expect(urls[0]).toContain('strategy=longest')
+ expect(urls[1]).not.toContain('strategy=')
+ expect(urls[1]).toContain('date_from=2026-02-07')
+ expect(urls[2]).toContain('date_from=2026-03-09')
+
+ warnSpy.mockRestore()
+ })
+
+ it('does not rewrite the query mid-pagination', async () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ fetchSpy
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ transactions: [{ transaction_amount: { amount: '5', currency: 'SEK' } }],
+ continuation_key: 'page2',
+ }),
+ { status: 200, headers: { 'Content-Type': 'application/json' } }
+ )
+ ) // page 1 ok, hands back a continuation_key
+ .mockResolvedValueOnce(new Response(ASPSP_ERROR_BODY, { status: 400 })) // page 2 fails
+
+ // A continuation_key is scoped to its window, so page 2 must not narrow —
+ // it fails fast instead.
+ await expect(
+ getAllTransactions('acc-1', '2026-02-07', '2026-06-07')
+ ).rejects.toThrow('Failed to get transactions (400)')
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
+
+ errorSpy.mockRestore()
+ })
})
})
diff --git a/extensions/general/enable-banking/lib/api-client.ts b/extensions/general/enable-banking/lib/api-client.ts
index 963d4c1e..51da34d4 100644
--- a/extensions/general/enable-banking/lib/api-client.ts
+++ b/extensions/general/enable-banking/lib/api-client.ts
@@ -165,6 +165,22 @@ const RETRY_DELAY_MS = 1000
const MAX_PAGINATION_PAGES = 100
const DEFAULT_PAGE_SIZE = 500
+/**
+ * Thrown by getAccountTransactions on a non-OK response. Carries the HTTP
+ * status and raw body so the pagination caller (getAllTransactions) can run the
+ * same first-page strategy/window fallbacks as getAllTransactionsWithRaw. The
+ * message is identical to the previous plain Error for back-compat.
+ */
+class TransactionsFetchError extends Error {
+ constructor(
+ readonly status: number,
+ readonly body: string
+ ) {
+ super(`Failed to get transactions (${status}): ${body}`)
+ this.name = 'TransactionsFetchError'
+ }
+}
+
// API Helper
async function authenticatedFetch(
@@ -505,7 +521,7 @@ export async function getAccountTransactions(
strategy,
hasContinuationKey: !!continuationKey,
})
- throw new Error(`Failed to get transactions (${response.status}): ${body}`)
+ throw new TransactionsFetchError(response.status, body)
}
return response.json()
@@ -523,15 +539,57 @@ export async function getAllTransactions(
const allTransactions: Transaction[] = []
let continuationKey: string | undefined
let page = 0
+ let activeStrategy = strategy
+ // date_from is narrowed in place when the ASPSP rejects the window (below).
+ let activeDateFrom = dateFrom
- do {
- const response = await getAccountTransactions(
- accountUid,
- dateFrom,
- dateTo,
- continuationKey,
- strategy
- )
+ while (true) {
+ let response: TransactionsResponse
+ try {
+ response = await getAccountTransactions(
+ accountUid,
+ activeDateFrom,
+ dateTo,
+ continuationKey,
+ activeStrategy
+ )
+ } catch (err) {
+ // Apply the same first-page recovery as getAllTransactionsWithRaw. Only
+ // TransactionsFetchError carries the status/body needed to decide;
+ // network errors and the like propagate untouched.
+ if (err instanceof TransactionsFetchError) {
+ const recovery = planFirstPageRecovery({
+ status: err.status,
+ body: err.body,
+ page,
+ hasContinuationKey: !!continuationKey,
+ activeStrategy,
+ activeDateFrom,
+ dateTo,
+ })
+ if (recovery.type === 'drop-strategy') {
+ console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
+ accountUid,
+ strategy: activeStrategy,
+ body: err.body,
+ })
+ activeStrategy = undefined
+ continue
+ }
+ if (recovery.type === 'narrow') {
+ console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', {
+ accountUid,
+ previousDateFrom: activeDateFrom,
+ nextDateFrom: recovery.dateFrom,
+ dateTo,
+ body: err.body,
+ })
+ activeDateFrom = recovery.dateFrom
+ continue
+ }
+ }
+ throw err
+ }
allTransactions.push(...response.transactions)
continuationKey = response.continuation_key
@@ -541,11 +599,95 @@ export async function getAllTransactions(
console.warn(`[enable-banking] Pagination cap reached (${MAX_PAGINATION_PAGES} pages) for account ${accountUid}`)
break
}
- } while (continuationKey)
+ if (!continuationKey) break
+ }
return allTransactions
}
+/**
+ * Lookback windows (days before date_to) we step through when an ASPSP rejects
+ * the requested transaction history. Descending so each fallback yields a
+ * strictly narrower window. PSD2 obliges banks to ~90 days without fresh SCA;
+ * the smaller rungs cover banks that cap below that.
+ */
+const ASPSP_HISTORY_FALLBACK_DAYS = [90, 60, 30] as const
+
+/**
+ * Enable Banking wraps upstream-bank failures in a generic envelope, e.g.
+ * {"code":400,"message":"Error interacting with ASPSP","error":"ASPSP_ERROR"}.
+ * A too-wide history window is the most common trigger — see the date-narrowing
+ * fallback in getAllTransactionsWithRaw.
+ */
+function isAspspError(body: string): boolean {
+ return body.includes('ASPSP_ERROR') || body.includes('interacting with ASPSP')
+}
+
+/**
+ * Given the date_from we just tried, return the next strictly-narrower
+ * date_from from ASPSP_HISTORY_FALLBACK_DAYS, anchored to date_to. Returns
+ * undefined when no narrower window remains (or date_to is missing), which
+ * ends the retry loop. The "strictly narrower" guard keeps the loop monotonic
+ * and terminating even if the original window was already short.
+ */
+function nextNarrowerDateFrom(
+ currentDateFrom: string | undefined,
+ dateTo: string | undefined
+): string | undefined {
+ if (!dateTo) return undefined
+ const anchor = new Date(`${dateTo}T00:00:00Z`)
+ if (!Number.isFinite(anchor.getTime())) return undefined
+
+ for (const days of ASPSP_HISTORY_FALLBACK_DAYS) {
+ const candidate = new Date(anchor.getTime() - days * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .split('T')[0]
+ if (!currentDateFrom || candidate > currentDateFrom) {
+ return candidate
+ }
+ }
+ return undefined
+}
+
+/**
+ * First-page recovery policy shared by getAllTransactions and
+ * getAllTransactionsWithRaw, so the two pagination loops can't drift. Fallbacks
+ * apply only to the very first request (page 0, no continuation_key) — a
+ * continuation_key is scoped to the window/strategy that produced it, so the
+ * query is never rewritten mid-pagination.
+ *
+ * - 'drop-strategy' — an unsupported strategy enum: retry the same window.
+ * - 'narrow' — the ASPSP rejected the history window: retry with a
+ * narrower date_from (the bank caps history below the ask).
+ * - 'give-up' — nothing left to try; the caller should rethrow.
+ */
+type FirstPageRecovery =
+ | { type: 'drop-strategy' }
+ | { type: 'narrow'; dateFrom: string }
+ | { type: 'give-up' }
+
+function planFirstPageRecovery(args: {
+ status: number
+ body: string
+ page: number
+ hasContinuationKey: boolean
+ activeStrategy: TransactionsFetchStrategy | undefined
+ activeDateFrom: string | undefined
+ dateTo: string | undefined
+}): FirstPageRecovery {
+ const { status, body, page, hasContinuationKey, activeStrategy, activeDateFrom, dateTo } = args
+ if (status !== 400 || page !== 0 || hasContinuationKey) return { type: 'give-up' }
+ // Drop an unsupported strategy first — preserves the full requested window.
+ if (activeStrategy) return { type: 'drop-strategy' }
+ // Then handle the ASPSP rejecting the window itself (e.g. Danske past ~90
+ // days): step date_from toward date_to so a partial sync survives.
+ if (isAspspError(body)) {
+ const dateFrom = nextNarrowerDateFrom(activeDateFrom, dateTo)
+ if (dateFrom) return { type: 'narrow', dateFrom }
+ }
+ return { type: 'give-up' }
+}
+
/**
* Get all transactions with raw JSON responses for archival.
* Returns both parsed transactions and the raw response strings.
@@ -553,6 +695,12 @@ export async function getAllTransactions(
* If `strategy` is provided and the API rejects it with a 400 on the first
* request, retry once without `strategy` so unknown enum values can't break
* the sync. Logs a warning when the fallback fires.
+ *
+ * If the ASPSP then still rejects the first page with an ASPSP_ERROR (typically
+ * a history window beyond the bank's PSD2 limit, e.g. Danske past ~90 days),
+ * progressively narrow date_from toward date_to (90→60→30 days) so a partial
+ * sync of the recent window survives instead of failing outright. Logs a
+ * warning on each narrowing.
*/
export async function getAllTransactionsWithRaw(
accountUid: string,
@@ -565,10 +713,12 @@ export async function getAllTransactionsWithRaw(
let continuationKey: string | undefined
let page = 0
let activeStrategy = strategy
+ // date_from is narrowed in place when the ASPSP rejects the window (below).
+ let activeDateFrom = dateFrom
while (true) {
const params = new URLSearchParams()
- if (dateFrom) params.set('date_from', dateFrom)
+ if (activeDateFrom) params.set('date_from', activeDateFrom)
if (dateTo) params.set('date_to', dateTo)
if (continuationKey) params.set('continuation_key', continuationKey)
if (activeStrategy) params.set('strategy', activeStrategy)
@@ -581,9 +731,16 @@ export async function getAllTransactionsWithRaw(
if (!response.ok) {
const body = await response.text()
- // If the API rejects an unknown strategy on the very first request,
- // fall back to the implicit default and retry the same page.
- if (response.status === 400 && activeStrategy && page === 0 && !continuationKey) {
+ const recovery = planFirstPageRecovery({
+ status: response.status,
+ body,
+ page,
+ hasContinuationKey: !!continuationKey,
+ activeStrategy,
+ activeDateFrom,
+ dateTo,
+ })
+ if (recovery.type === 'drop-strategy') {
console.warn('[enable-banking] strategy rejected by API, retrying without strategy', {
accountUid,
strategy: activeStrategy,
@@ -592,12 +749,23 @@ export async function getAllTransactionsWithRaw(
activeStrategy = undefined
continue
}
+ if (recovery.type === 'narrow') {
+ console.warn('[enable-banking] ASPSP rejected history window, retrying with narrower date_from', {
+ accountUid,
+ previousDateFrom: activeDateFrom,
+ nextDateFrom: recovery.dateFrom,
+ dateTo,
+ body,
+ })
+ activeDateFrom = recovery.dateFrom
+ continue
+ }
console.error('[enable-banking] getAllTransactionsWithRaw failed', {
status: response.status,
statusText: response.statusText,
body,
accountUid,
- dateFrom,
+ dateFrom: activeDateFrom,
dateTo,
strategy: activeStrategy,
page,
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 512bd362..68b72965 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -1738,6 +1738,11 @@ const SALARY_OVERRIDE_MAX = 10_000_000
export const SalaryEmployeeOverrideSchema = z
.object({
+ // Per-run monthly salary for this employee, editable while the run is a
+ // draft. 0 is allowed (an intentional nollkörning). This is NOT a review
+ // override — it sets the base the engine uses for this month only and does
+ // not require a reason. The route gates this field to `draft` status.
+ monthly_salary: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).optional(),
tax_withheld_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_amount_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
avgifter_basis_override: z.number().nonnegative().max(SALARY_OVERRIDE_MAX).nullable().optional(),
diff --git a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts
index 5d321ea8..ad4b445f 100644
--- a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts
+++ b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts
@@ -277,6 +277,59 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
+ it('persists document_id on the supplier_invoices row (so it can carry to the payment verifikat under cash method)', async () => {
+ let capturedInsert: Record | null = null
+ const { supabase, enqueue } = createQueuedMockSupabase()
+ const originalFrom = supabase.from
+ ;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
+ if (table === 'supplier_invoices') {
+ return {
+ insert: (row: Record) => {
+ capturedInsert = row
+ return {
+ select: () => ({
+ single: () =>
+ Promise.resolve({
+ data: makeSupplierInvoice({ id: 'inv-cash', supplier_invoice_number: 'INV-100' }),
+ error: null,
+ }),
+ }),
+ }
+ },
+ }
+ }
+ return (originalFrom as (t: string) => unknown)(table)
+ })
+
+ enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
+ enqueue({
+ data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
+ error: null,
+ }) // inbox fetch
+ enqueue({
+ data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
+ error: null,
+ }) // supplier fetch
+ enqueue({ data: 42, error: null }) // arrival number
+ // supplier_invoices insert handled by the override above
+ enqueue({ data: null, error: null }) // items insert
+ enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings → cash
+ enqueue({ data: null, error: null }) // invoice_inbox_items update
+ enqueue({ data: null, error: null }) // dispatcher's commit update
+
+ const result = await commitPendingOperation(
+ supabase as never,
+ 'user-1',
+ 'company-1',
+ makePendingOp(),
+ )
+
+ expect(result.status).toBe('committed')
+ // The inbox document id from the staged params must land on the row so
+ // mark-paid can attach it to the kontantmetoden cash verifikat.
+ expect(capturedInsert).toMatchObject({ document_id: 'doc-1' })
+ })
+
it('rolls back the parent invoice when item insert fails (no orphan supplier_invoices row)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index 2b2076c0..28475814 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -1685,6 +1685,7 @@ async function commitCreateSupplierInvoiceFromInbox(
total_sek: totalSek,
paid_amount: 0,
remaining_amount: totalRounded,
+ document_id: documentId,
notes,
})
.select()
diff --git a/lib/salary/__tests__/agi-xml.test.ts b/lib/salary/__tests__/agi-xml.test.ts
index 049c085d..865fc625 100644
--- a/lib/salary/__tests__/agi-xml.test.ts
+++ b/lib/salary/__tests__/agi-xml.test.ts
@@ -484,3 +484,38 @@ describe('generateAGIXml — Frånvarouppgift', () => {
expect(xml).not.toContain('Franvarouppgift')
})
})
+
+describe('generateAGIXml — nolldeklaration (HU-only, no IU)', () => {
+ const zeroTotals: AGITotals = {
+ totalTax: 0,
+ totalAvgifterBasis: 0,
+ totalAvgifterAmount: 0,
+ totalSjuklonekostnad: 0,
+ avgifterByCategory: {},
+ }
+
+ it('produces a valid declaration with an HU but no individuppgifter when the roster is empty', () => {
+ const xml = generateAGIXml(company, [], zeroTotals)
+ // Root + HU present
+ expect(xml).toContain('')
+ expect(xml).toContain('202604')
+ expect(xml).toContain('165561234567')
+ // No individuppgifter and no absence section
+ expect(xml).not.toContain('')
+ expect(xml).not.toContain('Franvarouppgift')
+ })
+
+ it('omits every zero HU total field (FK497/FK487/FK499)', () => {
+ const xml = generateAGIXml(company, [], zeroTotals)
+ expect(xml).not.toContain('faltkod="497"') // SummaSkatteavdr
+ expect(xml).not.toContain('faltkod="487"') // SummaArbAvgSlf
+ expect(xml).not.toContain('faltkod="499"') // TotalSjuklonekostnad
+ })
+
+ it('still validates required company data for a nolldeklaration', () => {
+ expect(() =>
+ generateAGIXml({ ...company, orgNumber: '' }, [], zeroTotals),
+ ).toThrow(AGIIncompleteDataError)
+ })
+})
diff --git a/lib/salary/agi/generate-declaration.ts b/lib/salary/agi/generate-declaration.ts
index 2fc20551..5f407797 100644
--- a/lib/salary/agi/generate-declaration.ts
+++ b/lib/salary/agi/generate-declaration.ts
@@ -62,6 +62,10 @@ const LineItemSchema = z
const SalaryRunEmployeeRowSchema = z
.object({
employee_id: z.string().uuid(),
+ // Per-run snapshot of the monthly salary (authoritative for this run; the
+ // engine reads it, not the employee master). Used for the FK499
+ // sjuklönekostnad daily-rate below.
+ monthly_salary: z.number().nullable().optional(),
gross_salary: z.number(),
tax_withheld: z.number(),
tax_withheld_override: z.number().nullable().optional(),
@@ -180,7 +184,10 @@ export async function generateAgiDeclaration(
)
.eq('salary_run_id', salaryRunId)
- if (!runEmployees || runEmployees.length === 0) {
+ // An empty roster is valid — a registered employer must file a
+ // nolldeklaration (HU-only, no individuppgifter) for months without payroll.
+ // Only a genuine query failure (null) is treated as an error here.
+ if (!runEmployees) {
return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
}
@@ -351,6 +358,27 @@ export async function generateAgiDeclaration(
}
},
)
+ // Drop individuppgifter with nothing to report. An employee who took 0 kr
+ // and had no benefits, tax or absence this month is simply omitted (you
+ // only file an IU for a person who received something). This yields a clean
+ // HU-only nolldeklaration for a full nollkörning, and omits zero-paid
+ // employees in a mixed run. Borttag (removed) tombstones are always kept.
+ .filter(
+ (e) =>
+ e.removed === true ||
+ (e.grossSalary ?? 0) > 0 ||
+ (e.taxWithheld ?? 0) > 0 ||
+ (e.fSkattPayment ?? 0) > 0 ||
+ (e.benefitCar ?? 0) > 0 ||
+ (e.benefitFuel ?? 0) > 0 ||
+ (e.benefitMeals ?? 0) > 0 ||
+ (e.benefitOther ?? 0) > 0 ||
+ e.housingBenefit !== undefined ||
+ (e.sickDays ?? 0) > 0 ||
+ (e.vabDays ?? 0) > 0 ||
+ (e.parentalDays ?? 0) > 0 ||
+ (e.absenceEvents?.length ?? 0) > 0,
+ )
// 5. Build totals: avgifter by category (with rate-heuristic fallback for legacy runs).
// Removed-from-AGI rows (FK205 borttag) are tombstones — they must not
@@ -392,7 +420,7 @@ export async function generateAgiDeclaration(
const sjuklonRate = calcParams.sjuklonRate ?? calcParams.sjuklon_rate ?? 0.8
let totalSjuklonekostnad = 0
for (const sre of activeEmployees) {
- const monthly = sre.employee?.monthly_salary ?? 0
+ const monthly = sre.monthly_salary ?? 0
if (!monthly) continue
const dailyRate = monthly / 21
const lineItems = (sre.line_items ?? []) as Array<{ item_type: string; amount?: number | null; quantity?: number | null }>
diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts
index 8cdfc220..47f2a018 100644
--- a/lib/salary/run-calculation.ts
+++ b/lib/salary/run-calculation.ts
@@ -148,15 +148,20 @@ export async function runSalaryCalculation(
// foreign key. RLS already constrains the table per-company, but per
// CLAUDE.md every query carries the company_id filter explicitly so a
// future RLS lapse can't surface cross-tenant rows.
- const { data: runEmployees, error: empError } = await supabase
+ const { data: runEmployeesData, error: empError } = await supabase
.from('salary_run_employees')
.select('*, employee:employees(*), line_items:salary_line_items(*)')
.eq('salary_run_id', id)
.eq('company_id', companyId)
- if (empError || !runEmployees || runEmployees.length === 0) {
- return { ok: false, code: 'SALARY_RUN_NO_EMPLOYEES' }
+ if (empError) {
+ return { ok: false, code: 'DATABASE_ERROR', details: empError }
}
+ // An empty roster is valid — a registered employer must still file a
+ // nolldeklaration (HU-only AGI) for months without payroll. Calculation
+ // then yields all-zero totals plus a frozen calculation_params snapshot,
+ // and every downstream loop simply iterates zero times.
+ const runEmployees = runEmployeesData ?? []
// 4. Pre-calculation validation — ensure every employee has the data the
// engine needs. We accumulate ALL errors so the caller sees a complete
@@ -167,8 +172,12 @@ export async function runSalaryCalculation(
if (!emp) continue
const name = `${emp.first_name} ${emp.last_name}`
- if (emp.salary_type === 'monthly' && (!emp.monthly_salary || emp.monthly_salary <= 0)) {
- validationErrors.push(`${name}: Månadslön saknas eller är 0`)
+ // A per-run monthly salary of 0 is allowed: it represents an intentional
+ // nollkörning (the user edited this month's salary down to 0). Only a
+ // negative value is rejected. New employees still require monthly_salary > 0
+ // at creation (CreateEmployeeSchema), so a stray 0 cannot arise by accident.
+ if (emp.salary_type === 'monthly' && sre.monthly_salary < 0) {
+ validationErrors.push(`${name}: Månadslön kan inte vara negativ`)
}
if (emp.salary_type === 'hourly' && (!emp.hourly_rate || emp.hourly_rate <= 0)) {
validationErrors.push(`${name}: Timlön saknas eller är 0`)
@@ -295,7 +304,7 @@ export async function runSalaryCalculation(
supabase,
companyId,
employeeId: emp.id,
- monthlySalary: emp.monthly_salary || 0,
+ monthlySalary: sre.monthly_salary || 0,
payrollConfig: config,
periodStart,
periodEnd,
@@ -360,6 +369,22 @@ export async function runSalaryCalculation(
}
}
+ // Refresh the monthly 'Grundlön' line so the displayed Lönerader table
+ // matches the per-run monthly salary the engine actually uses. The engine
+ // recomputes baseSalary from sre.monthly_salary (not from this line item),
+ // so this update is display-only — it keeps the row consistent after the
+ // user edits this month's salary on the draft.
+ if (emp.salary_type === 'monthly') {
+ const baseAmount =
+ Math.round((sre.monthly_salary || 0) * (emp.employment_degree / 100) * 100) / 100
+ await supabase
+ .from('salary_line_items')
+ .update({ amount: baseAmount })
+ .eq('salary_run_employee_id', sre.id)
+ .eq('company_id', companyId)
+ .eq('item_type', 'monthly_salary')
+ }
+
const employeeName = `${emp.first_name} ${emp.last_name}`
if (absenceResult.flagLakarintyg) lakarintygEmployees.push(employeeName)
if (absenceResult.flagFkReporting) fkReportingEmployees.push(employeeName)
@@ -485,7 +510,7 @@ export async function runSalaryCalculation(
const baseHourlyRate = effectiveHourlyRate({
salary_type: emp.salary_type,
hourly_rate: emp.hourly_rate,
- monthly_salary: emp.monthly_salary,
+ monthly_salary: sre.monthly_salary,
})
const shifts: WorkedDayShift[] = workedDayRows.map((row) => ({
work_date: row.work_date,
@@ -576,7 +601,7 @@ export async function runSalaryCalculation(
{
employmentType: emp.employment_type,
salaryType: emp.salary_type,
- monthlySalary: emp.monthly_salary || 0,
+ monthlySalary: sre.monthly_salary || 0,
hourlyRate: emp.hourly_rate || undefined,
hoursWorked:
derivedHoursWorked !== null && derivedHoursWorked > 0