diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx
index 63713fb2..0f2b964b 100644
--- a/components/extensions/general/ArcimMigrationWorkspace.tsx
+++ b/components/extensions/general/ArcimMigrationWorkspace.tsx
@@ -9,6 +9,7 @@ import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
+import { Checkbox } from '@/components/ui/checkbox'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
@@ -304,6 +305,13 @@ interface PreviewData {
transactionCount: number
fiscalYears: number[]
} | null
+ // Every fiscal year the source has, oldest first, with the default
+ // selection marked: rendered as the year picker so the user chooses
+ // before the import runs and no year is left out silently (#2211, #2238).
+ sourceYears?: SourceFiscalYear[]
+ // The most years one run may select: /sie-data refuses more. Read from
+ // the server so the picker never drifts from the route.
+ maxSelectedYears?: number
assetStats: {
total: number
importable: number
@@ -338,9 +346,33 @@ interface SIEData {
// Fiscal years whose provider export failed. Importing the remaining years
// anyway leaves an IB/UB gap: the options step warns before proceeding.
failedYears?: { year: number; error: string }[]
+ // Source fiscal years outside the selection: not fetched, named in the
+ // result so nobody believes the books are complete (#2211).
+ omittedYears?: SourceFiscalYear[]
basAccounts: BASAccount[]
}
+/**
+ * A fiscal year as the source reports it. Mirrors SourceFiscalYear in
+ * extensions/general/arcim-migration/lib/sie-fetcher.ts (deliberate
+ * duplication: core must not import from @/extensions/).
+ */
+interface SourceFiscalYear {
+ year: number
+ fromDate: string | null
+ toDate: string | null
+ inDefaultSelection: boolean
+}
+
+/** "2022-09-01 till 2023-12-31" when the provider gave bounds, else the start year. */
+function useFiscalYearSpanLabel(): (fy: SourceFiscalYear) => string {
+ const t = useTranslations('extensions')
+ return (fy) =>
+ fy.fromDate && fy.toDate
+ ? t('ext_arcim_fiscal_year_span', { from: fy.fromDate, to: fy.toDate })
+ : String(fy.year)
+}
+
// ── Shared step chrome ───────────────────────────────────────────
// Living Paper: step content sits directly on the page. The serif headline
// is the step's one display element; sections are kickers over hairline
@@ -875,6 +907,8 @@ function PreviewStep({
error,
authExpired,
licenseMissing,
+ selectedYears,
+ onSelectedYearsChange,
onReconnect,
onContinue,
onBack,
@@ -884,13 +918,30 @@ function PreviewStep({
error: string | null
authExpired: boolean
licenseMissing: boolean
+ /** Fiscal years (start years) ticked in the picker; default = the three latest. */
+ selectedYears: number[]
+ onSelectedYearsChange: (years: number[]) => void
onReconnect: () => void
onContinue: () => void
onBack: () => void
}) {
+ const t = useTranslations('extensions')
+ const fiscalYearSpanLabel = useFiscalYearSpanLabel()
const providerName = preview
? ARCIM_PROVIDERS.find(p => p.id === preview.consent.provider)?.name ?? preview.consent.provider
: ''
+ const sourceYears = preview?.sieAvailable ? preview.sourceYears ?? [] : []
+ const showYearPicker = sourceYears.length > 0 && !isLoading
+ const noYearSelected = showYearPicker && selectedYears.length === 0
+ const maxSelectable = preview?.maxSelectedYears ?? null
+ const tooManySelected = showYearPicker && maxSelectable != null && selectedYears.length > maxSelectable
+ const toggleYear = (year: number) => {
+ onSelectedYearsChange(
+ selectedYears.includes(year)
+ ? selectedYears.filter((y) => y !== year)
+ : [...selectedYears, year].sort((a, b) => a - b),
+ )
+ }
return (
@@ -941,6 +992,45 @@ function PreviewStep({
)}
+ {/* ── The year picker (issues #2211, #2238) ──
+ Every fiscal year the source has, as hairline rows with a checkbox.
+ The three latest are ticked by default (the limit that used to be a
+ silent cap); older years are the user's own choice and their own
+ wait: each one is another SIE export fetched in the next step. */}
+ {showYearPicker && (
+
+ {t('ext_arcim_year_select_kicker')}
+ {t('ext_arcim_year_select_lede')}
+
+ {sourceYears.map((fy) => {
+ const id = `arcim-year-${fy.year}-${fy.fromDate ?? ''}`
+ return (
+
+ toggleYear(fy.year)}
+ aria-label={fiscalYearSpanLabel(fy)}
+ />
+ {fiscalYearSpanLabel(fy)}
+ {!fy.inDefaultSelection && (
+ {t('ext_arcim_year_select_older')}
+ )}
+
+ )
+ })}
+
+ {noYearSelected && {t('ext_arcim_year_select_none')} }
+ {tooManySelected && maxSelectable != null && (
+ {t('ext_arcim_year_select_too_many', { max: maxSelectable })}
+ )}
+
+ )}
+
{error && (
@@ -979,7 +1069,11 @@ function PreviewStep({
Tillbaka
-
+
Fortsätt
@@ -1706,6 +1800,7 @@ const NEXT_STEPS: { title: string; sub: string }[] = [
function ResultStep({
results,
sieResults,
+ omittedYears,
error,
documentImportState,
theaterModel,
@@ -1718,6 +1813,8 @@ function ResultStep({
}: {
results: MigrationResults | null
sieResults: ImportResult[]
+ /** Source fiscal years outside the selection: not fetched in this run. */
+ omittedYears: SourceFiscalYear[]
error: string | null
documentImportState: ArcimDocumentImportState
theaterModel: TheaterModel | null
@@ -1729,6 +1826,7 @@ function ResultStep({
onReconnectDocuments: () => void
}) {
const t = useTranslations('extensions')
+ const fiscalYearSpanLabel = useFiscalYearSpanLabel()
if (error) {
return (
@@ -1954,6 +2052,27 @@ function ResultStep({
)}
+ {/* ── Source fiscal years outside the selection (#2211) ──
+ Named here so nobody believes the books are complete: a new run
+ with those years ticked fetches them (documents come along), or
+ the SIE path does. */}
+ {sieResults.length > 0 && omittedYears.length > 0 && (
+
+ {t('ext_arcim_omitted_years_kicker')}
+
+ {omittedYears.map((fy) => (
+
+ {fiscalYearSpanLabel(fy)}
+
+ ))}
+
+
+
+ )}
+
{/* ── API import results: quiet two-column line list ── */}
{entityLines.length > 0 && (
@@ -2122,6 +2241,9 @@ export default function ArcimMigrationWorkspace({
// Preview state
const [preview, setPreview] = useState(null)
+ // Fiscal years (start years) ticked in the preview step's picker. Set from
+ // the preview's default selection on load; sent to /sie-data as `years`.
+ const [selectedYears, setSelectedYears] = useState([])
// Set when a preview/sync fails because the provider connection expired
// (dead refresh token → PROVIDER_AUTH_EXPIRED). Drives the "Återanslut"
// affordance so the user can re-authorize in place instead of disconnecting.
@@ -2223,8 +2345,11 @@ export default function ArcimMigrationWorkspace({
throw new Error(apiErrorMessage(data, `HTTP ${res.status}`))
}
- const data = await res.json()
+ const data = await res.json() as PreviewData
setPreview(data)
+ setSelectedYears(
+ (data.sourceYears ?? []).filter((fy) => fy.inDefaultSelection).map((fy) => fy.year),
+ )
const previewProvider = data?.consent?.provider
if (ARCIM_PROVIDERS.some((provider) => provider.id === previewProvider)) {
setSelectedProvider(previewProvider as ArcimProvider)
@@ -2701,7 +2826,10 @@ export default function ArcimMigrationWorkspace({
setErrorDetails(null)
try {
- const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}`)
+ // The picker's selection travels as `years`; without a picker (no
+ // source years known) the route falls back to its default selection.
+ const yearsQuery = selectedYears.length > 0 ? `&years=${selectedYears.join(',')}` : ''
+ const res = await fetch(`/api/extensions/ext/arcim-migration/sie-data?consentId=${consentId}${yearsQuery}`)
if (!res.ok) {
const data = await res.json().catch(() => ({})) as {
error?: unknown
@@ -2741,7 +2869,7 @@ export default function ArcimMigrationWorkspace({
} finally {
setIsLoading(false)
}
- }, [consentId, refreshCompanyAccounts])
+ }, [consentId, refreshCompanyAccounts, selectedYears])
const handlePreviewContinue = useCallback(() => {
if (preview?.sieAvailable) {
@@ -3050,6 +3178,8 @@ export default function ArcimMigrationWorkspace({
error={error}
authExpired={authExpired}
licenseMissing={licenseMissing}
+ selectedYears={selectedYears}
+ onSelectedYearsChange={setSelectedYears}
onReconnect={() => {
if (selectedProvider && consentId) handleReconnect(selectedProvider, consentId)
}}
@@ -3101,6 +3231,7 @@ export default function ArcimMigrationWorkspace({
({
+ executeMigration: vi.fn(),
+}))
+
+vi.mock('../lib/provider-client', () => ({
+ createConsent: vi.fn(),
+ getConsent: vi.fn(),
+ listConsents: vi.fn(),
+ generateOtc: vi.fn(),
+ consumeOAuthState: vi.fn(),
+ getAuthUrl: vi.fn(),
+ exchangeAuthToken: vi.fn(),
+ submitProviderToken: vi.fn(),
+ acceptConsent: vi.fn(),
+ deleteConsent: vi.fn(),
+ resolveConsent: vi.fn(),
+ fetchCompanyInfoDirect: vi.fn(),
+ ProviderTokenInvalidError: class ProviderTokenInvalidError extends Error {},
+ ProviderCompanyMismatchError: class ProviderCompanyMismatchError extends Error {},
+ ConsentNotFoundError: class ConsentNotFoundError extends Error {},
+}))
+
+vi.mock('../lib/sie-fetcher', () => ({
+ providerSupportsSie: vi.fn().mockReturnValue(true),
+ fetchProviderSieFiles: vi.fn(),
+ getAllowedFiscalYears: vi.fn(),
+ FiscalYearSelectionError: class FiscalYearSelectionError extends Error {
+ constructor(public readonly unknownYears: number[]) {
+ super(`unknown ${unknownYears.join(', ')}`)
+ }
+ },
+ MAX_SELECTED_FISCAL_YEARS: 6,
+}))
+
+// The Fortnox asset preview would otherwise go to the network.
+vi.mock('../lib/import-assets', () => ({
+ fetchFortnoxAssetPreview: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('../lib/mapping-targets', () => ({
+ buildMappingTargets: vi.fn().mockResolvedValue([]),
+}))
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+ createServiceClient: vi.fn(),
+}))
+
+import { arcimMigrationExtension } from '../index'
+import { getConsent, resolveConsent, fetchCompanyInfoDirect } from '../lib/provider-client'
+import {
+ fetchProviderSieFiles,
+ getAllowedFiscalYears,
+ FiscalYearSelectionError,
+ MAX_SELECTED_FISCAL_YEARS,
+} from '../lib/sie-fetcher'
+
+type RouteHandler = (request: Request, ctx?: ExtensionContext) => Promise
+
+function handler(path: string): RouteHandler {
+ return (arcimMigrationExtension.apiRoutes ?? []).find((r) => r.method === 'GET' && r.path === path)!
+ .handler as RouteHandler
+}
+
+const CY = new Date().getFullYear()
+
+// One fiscal year inside the window, enough SIE for the parser and the
+// validator (#SIETYP + #RAR) so /sie-data reaches its response.
+const SIE_IN_WINDOW = [
+ '#FLAGGA 0',
+ '#SIETYP 4',
+ '#FNAMN "Bolaget AB"',
+ `#RAR 0 ${CY}0101 ${CY}1231`,
+ '#KONTO 1930 "Företagskonto"',
+ '',
+].join('\n')
+
+const BROKEN_FIRST_YEAR = {
+ year: CY - 4,
+ fromDate: `${CY - 4}-09-01`,
+ toDate: `${CY - 3}-12-31`,
+ inDefaultSelection: false,
+}
+const CURRENT_YEAR = { year: CY, fromDate: `${CY}-01-01`, toDate: `${CY}-12-31`, inDefaultSelection: true }
+const SOURCE_YEARS = [BROKEN_FIRST_YEAR, CURRENT_YEAR]
+const OMITTED = [BROKEN_FIRST_YEAR]
+
+function buildCtx(): ExtensionContext {
+ const { supabase, mockResult } = createMockSupabase()
+ // Every query in these routes reads lists or counts: none, everywhere.
+ mockResult({ data: [], count: 0 })
+ ;(supabase as unknown as { auth: unknown }).auth = {
+ getUser: vi.fn().mockResolvedValue({ data: { user: { id: 'user-1' } } }),
+ }
+ return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
+}
+
+function request(path: string, consentId = 'consent-1') {
+ return createMockRequest(`http://localhost/api/extensions/ext/arcim-migration${path}`, {
+ searchParams: { consentId },
+ })
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ ;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
+ ;(resolveConsent as Mock).mockResolvedValue({
+ consent: { provider: 'fortnox' },
+ accessToken: 'tok',
+ providerCompanyId: undefined,
+ })
+ ;(fetchCompanyInfoDirect as Mock).mockResolvedValue(null)
+ ;(getAllowedFiscalYears as Mock).mockReturnValue(new Set([CY - 2, CY - 1, CY]))
+ ;(fetchProviderSieFiles as Mock).mockResolvedValue({
+ files: [{ fiscalYear: CY, rawContent: SIE_IN_WINDOW }],
+ availableYears: [CY],
+ sourceYears: SOURCE_YEARS,
+ failedYears: [],
+ omittedYears: OMITTED,
+ })
+})
+
+describe('GET /preview: every source year, with the default selection marked (#2211)', () => {
+ it('answers 401 without a user', async () => {
+ const ctx = buildCtx()
+ ;(ctx.supabase as unknown as { auth: { getUser: Mock } }).auth.getUser.mockResolvedValue({
+ data: { user: null },
+ })
+
+ const res = await handler('/preview')(request('/preview'), ctx)
+
+ expect(res.status).toBe(401)
+ })
+
+ it('carries every source year with its bounds and default flag, on the default selection', async () => {
+ const res = await handler('/preview')(request('/preview'), buildCtx())
+ const { status, body } = await parseJsonResponse<{
+ sieAvailable: boolean
+ sieStats: { fiscalYears: number[] }
+ sourceYears: typeof SOURCE_YEARS
+ }>(res)
+
+ expect(status).toBe(200)
+ expect(body.sieAvailable).toBe(true)
+ expect(body.sieStats.fiscalYears).toEqual([CY])
+ // The bounds as the provider reports them: a broken year is named as
+ // "2022-09-01 till 2023-12-31", not as a wrong calendar year, and it is
+ // unticked by default rather than absent.
+ expect(body.sourceYears).toEqual(SOURCE_YEARS)
+ // The preview never sends a selection: its stats are the default's.
+ expect((fetchProviderSieFiles as Mock).mock.calls[0][3]).toBeUndefined()
+ })
+
+ it('hands the picker the same cap /sie-data enforces', async () => {
+ const res = await handler('/preview')(request('/preview'), buildCtx())
+ const { body } = await parseJsonResponse<{ maxSelectedYears: number }>(res)
+
+ expect(body.maxSelectedYears).toBe(MAX_SELECTED_FISCAL_YEARS)
+ })
+
+ it('still lists the source years when nothing inside the default selection came back', async () => {
+ ;(fetchProviderSieFiles as Mock).mockResolvedValue({
+ files: [],
+ availableYears: [],
+ sourceYears: [BROKEN_FIRST_YEAR],
+ failedYears: [],
+ omittedYears: [BROKEN_FIRST_YEAR],
+ })
+
+ const res = await handler('/preview')(request('/preview'), buildCtx())
+ const { status, body } = await parseJsonResponse<{ sieAvailable: boolean; sourceYears: unknown[] }>(res)
+
+ expect(status).toBe(200)
+ expect(body.sieAvailable).toBe(false)
+ expect(body.sourceYears).toEqual([BROKEN_FIRST_YEAR])
+ })
+})
+
+describe('GET /sie-data: the ticked years are fetched, the rest are named (#2211, #2238)', () => {
+ it('answers 400 without a consentId', async () => {
+ const res = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data'),
+ buildCtx(),
+ )
+
+ expect(res.status).toBe(400)
+ })
+
+ it('answers 400 VALIDATION_ERROR on a malformed years selection, before touching the provider', async () => {
+ const res = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data', {
+ searchParams: { consentId: 'consent-1', years: `${CY},abc` },
+ }),
+ buildCtx(),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('VALIDATION_ERROR')
+ expect(fetchProviderSieFiles).not.toHaveBeenCalled()
+ })
+
+ it('falls back to the default selection without a years param and returns omittedYears', async () => {
+ const res = await handler('/sie-data')(request('/sie-data'), buildCtx())
+ const { status, body } = await parseJsonResponse<{
+ fileStatuses: { fiscalYear: number }[]
+ failedYears: unknown[]
+ omittedYears: typeof OMITTED
+ }>(res)
+
+ expect(status).toBe(200)
+ expect((fetchProviderSieFiles as Mock).mock.calls[0][3]).toBeUndefined()
+ expect(body.fileStatuses.map((f) => f.fiscalYear)).toEqual([CY])
+ expect(body.failedYears).toEqual([])
+ expect(body.omittedYears).toEqual(OMITTED)
+ })
+
+ it('passes the ticked years to the fetcher, deduplicated and oldest first', async () => {
+ ;(fetchProviderSieFiles as Mock).mockResolvedValue({
+ files: [
+ { fiscalYear: CY - 4, rawContent: SIE_IN_WINDOW.replace(`#RAR 0 ${CY}0101 ${CY}1231`, `#RAR 0 ${CY - 4}0901 ${CY - 3}1231`) },
+ { fiscalYear: CY, rawContent: SIE_IN_WINDOW },
+ ],
+ availableYears: [CY - 4, CY],
+ sourceYears: SOURCE_YEARS,
+ failedYears: [],
+ omittedYears: [],
+ })
+
+ const res = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data', {
+ searchParams: { consentId: 'consent-1', years: `${CY},${CY - 4},${CY}` },
+ }),
+ buildCtx(),
+ )
+ const { status, body } = await parseJsonResponse<{
+ fileStatuses: { fiscalYear: number }[]
+ omittedYears: unknown[]
+ }>(res)
+
+ expect(status).toBe(200)
+ expect((fetchProviderSieFiles as Mock).mock.calls[0][3]).toEqual({ years: [CY - 4, CY] })
+ expect(body.fileStatuses.map((f) => f.fiscalYear)).toEqual([CY - 4, CY])
+ expect(body.omittedYears).toEqual([])
+ })
+
+ it('accepts a selection at the cap and rejects one over it before any provider call', async () => {
+ const atCap = Array.from({ length: MAX_SELECTED_FISCAL_YEARS }, (_, i) => CY - i)
+ const overCap = [...atCap, CY - MAX_SELECTED_FISCAL_YEARS]
+
+ const ok = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data', {
+ searchParams: { consentId: 'consent-1', years: atCap.join(',') },
+ }),
+ buildCtx(),
+ )
+ expect(ok.status).toBe(200)
+ expect((fetchProviderSieFiles as Mock).mock.calls[0][3]).toEqual({
+ years: [...atCap].sort((a, b) => a - b),
+ })
+
+ vi.clearAllMocks()
+ const rejected = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data', {
+ searchParams: { consentId: 'consent-1', years: overCap.join(',') },
+ }),
+ buildCtx(),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(rejected)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('VALIDATION_ERROR')
+ expect(body.error.message).toContain(String(MAX_SELECTED_FISCAL_YEARS))
+ // Refused before the consent is even resolved: no provider work at all.
+ expect(fetchProviderSieFiles).not.toHaveBeenCalled()
+ expect(resolveConsent).not.toHaveBeenCalled()
+ })
+
+ it('rejects a ticked year the source does not have, as the fetcher reports it before any export', async () => {
+ ;(fetchProviderSieFiles as Mock).mockRejectedValue(new FiscalYearSelectionError([CY - 7]))
+
+ const res = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data', {
+ searchParams: { consentId: 'consent-1', years: `${CY},${CY - 7}` },
+ }),
+ buildCtx(),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
+
+ expect(status).toBe(400)
+ expect(body.error.code).toBe('VALIDATION_ERROR')
+ expect(body.error.message).toContain(String(CY - 7))
+ })
+
+ it('names the selection in PROVIDER_SIE_NO_YEARS when none of the ticked years exist at the source', async () => {
+ ;(fetchProviderSieFiles as Mock).mockResolvedValue({
+ files: [],
+ availableYears: [],
+ sourceYears: SOURCE_YEARS,
+ failedYears: [],
+ omittedYears: SOURCE_YEARS,
+ })
+
+ const res = await handler('/sie-data')(
+ createMockRequest('http://localhost/api/extensions/ext/arcim-migration/sie-data', {
+ searchParams: { consentId: 'consent-1', years: `${CY - 7}` },
+ }),
+ buildCtx(),
+ )
+ const { status, body } = await parseJsonResponse<{ error: { code: string; message: string } }>(res)
+
+ expect(status).toBe(404)
+ expect(body.error.code).toBe('PROVIDER_SIE_NO_YEARS')
+ expect(body.error.message).toContain(String(CY - 7))
+ })
+})
diff --git a/extensions/general/arcim-migration/__tests__/preview-provider-errors.test.ts b/extensions/general/arcim-migration/__tests__/preview-provider-errors.test.ts
index 1182604c..4cd73704 100644
--- a/extensions/general/arcim-migration/__tests__/preview-provider-errors.test.ts
+++ b/extensions/general/arcim-migration/__tests__/preview-provider-errors.test.ts
@@ -45,6 +45,8 @@ vi.mock('../lib/sie-fetcher', () => ({
providerSupportsSie: vi.fn().mockReturnValue(false),
fetchProviderSieFiles: vi.fn(),
getAllowedFiscalYears: vi.fn().mockReturnValue([]),
+ FiscalYearSelectionError: class FiscalYearSelectionError extends Error {},
+ MAX_SELECTED_FISCAL_YEARS: 6,
}))
vi.mock('@/lib/supabase/server', () => ({
diff --git a/extensions/general/arcim-migration/index.ts b/extensions/general/arcim-migration/index.ts
index b76d0236..1eab0b1d 100644
--- a/extensions/general/arcim-migration/index.ts
+++ b/extensions/general/arcim-migration/index.ts
@@ -17,7 +17,14 @@ import {
ProviderCompanyMismatchError,
ConsentNotFoundError,
} from './lib/provider-client'
-import { providerSupportsSie, fetchProviderSieFiles, getAllowedFiscalYears } from './lib/sie-fetcher'
+import {
+ providerSupportsSie,
+ fetchProviderSieFiles,
+ getAllowedFiscalYears,
+ FiscalYearSelectionError,
+ MAX_SELECTED_FISCAL_YEARS,
+ type SourceFiscalYear,
+} from './lib/sie-fetcher'
import { mapCompanyInfo } from './lib/entity-mapper'
import { executeMigration } from './lib/migration-orchestrator'
import {
@@ -837,6 +844,12 @@ export const arcimMigrationExtension: Extension = {
// Try to fetch SIE data (Fortnox and Briox serve SIE over the API)
let sieAvailable = false
let sieStats: { accountCount: number; transactionCount: number; fiscalYears: number[] } | null = null
+ // Every fiscal year the source has, with the default selection
+ // marked: the preview step renders them as the year picker, so the
+ // user chooses BEFORE the import runs and no year is left out
+ // silently (issues #2211, #2238). The stats below stay on the
+ // default selection: that is what the preview fetched.
+ let sourceYears: SourceFiscalYear[] = []
if (providerSupportsSie(provider)) {
try {
@@ -848,11 +861,12 @@ export const arcimMigrationExtension: Extension = {
// "0 verifikationer" and then imported 4153). Costs one SIE
// export per extra year, the same work /sie-data repeats right
// after: honest numbers are worth it.
- const { files, availableYears } = await fetchProviderSieFiles(
+ const { files, availableYears, sourceYears: source } = await fetchProviderSieFiles(
provider,
resolved.accessToken,
resolved.providerCompanyId,
)
+ sourceYears = source
if (files.length > 0) {
const merged = mergeParsedSIEFiles(files.map((f) => parseSIEFile(f.rawContent)))
sieAvailable = true
@@ -896,6 +910,10 @@ export const arcimMigrationExtension: Extension = {
companyInfo: mapped,
sieAvailable,
sieStats,
+ sourceYears,
+ // The picker enforces the same cap as /sie-data, read from here
+ // rather than duplicated in the client.
+ maxSelectedYears: MAX_SELECTED_FISCAL_YEARS,
assetStats,
hasSieData: (sieImportCount ?? 0) > 0,
})
@@ -936,6 +954,34 @@ export const arcimMigrationExtension: Extension = {
return NextResponse.json({ error: 'consentId is required' }, { status: 400 })
}
+ // `years`: the fiscal years the user ticked in the preview step
+ // (start years, comma-separated). Absent = the default selection.
+ // Every selected year is one SIE export fetched and parsed in THIS
+ // invocation, so the selection is the user's own wait; it fails
+ // loudly here, before any ledger write, if it is too much.
+ const yearsParam = url.searchParams.get('years')
+ let requestedYears: number[] | undefined
+ if (yearsParam !== null) {
+ const parsed = yearsParam.split(',').filter(Boolean).map(Number)
+ const valid = parsed.length > 0 && parsed.every((y) => Number.isInteger(y) && y >= 1900 && y <= 2200)
+ if (!valid) {
+ return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
+ details: { field: 'years', reason: 'comma-separated fiscal year start years' },
+ })
+ }
+ requestedYears = [...new Set(parsed)].sort((a, b) => a - b)
+ // The resource bound: each selected year is one provider export
+ // fetched and parsed in this invocation (derivation on the
+ // constant). Rejected before any provider call.
+ if (requestedYears.length > MAX_SELECTED_FISCAL_YEARS) {
+ return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
+ messageSv: `Högst ${MAX_SELECTED_FISCAL_YEARS} räkenskapsår kan hämtas per körning. Välj färre år; äldre år kan hämtas i en ny körning.`,
+ messageEn: `At most ${MAX_SELECTED_FISCAL_YEARS} fiscal years can be fetched per run. Select fewer years; older years can be fetched in a second run.`,
+ details: { field: 'years', reason: 'too_many', max: MAX_SELECTED_FISCAL_YEARS, requested: requestedYears.length },
+ })
+ }
+ }
+
try {
// Resolve consent
const resolved = await resolveConsent(companyId, consentId)
@@ -947,19 +993,38 @@ export const arcimMigrationExtension: Extension = {
})
}
- // Fetch SIE type 4 for each allowed fiscal year
- const { files: sieFiles, failedYears } = await fetchProviderSieFiles(
- provider,
- resolved.accessToken,
- resolved.providerCompanyId,
- )
+ // Fetch SIE type 4 for each selected fiscal year. A selected year
+ // the source does not have is refused by the fetcher right after
+ // the year listing, before any export is fetched.
+ let fetched: Awaited>
+ try {
+ fetched = await fetchProviderSieFiles(
+ provider,
+ resolved.accessToken,
+ resolved.providerCompanyId,
+ requestedYears ? { years: requestedYears } : undefined,
+ )
+ } catch (err) {
+ if (err instanceof FiscalYearSelectionError) {
+ const years = err.unknownYears.join(', ')
+ return errorResponseFromCode('VALIDATION_ERROR', moduleLog, {
+ messageSv: `Räkenskapsår ${years} finns inte hos leverantören.`,
+ messageEn: `Fiscal years ${years} do not exist at the provider.`,
+ details: { field: 'years', reason: 'unknown_year', unknownYears: err.unknownYears },
+ })
+ }
+ throw err
+ }
+ const { files: sieFiles, failedYears, omittedYears } = fetched
if (sieFiles.length === 0) {
- // The allowed window is rolling (current year and the two before
- // it): interpolate the actual range instead of the static
- // registry message so the text never goes stale.
- const allowedYears = [...getAllowedFiscalYears()].sort((a, b) => a - b)
- const range = `${allowedYears[0]}-${allowedYears[allowedYears.length - 1]}`
+ // Name the selection: the explicit years when the picker sent
+ // them, else the rolling default window (interpolated so the
+ // text never goes stale).
+ const selection = requestedYears ?? [...getAllowedFiscalYears()].sort((a, b) => a - b)
+ const range = requestedYears
+ ? requestedYears.join(', ')
+ : `${selection[0]}-${selection[selection.length - 1]}`
return errorResponseFromCode('PROVIDER_SIE_NO_YEARS', moduleLog, {
messageSv: `Inga räkenskapsår ${range} hittades hos leverantören.`,
messageEn: `No fiscal years available for ${range}.`,
@@ -1112,6 +1177,9 @@ export const arcimMigrationExtension: Extension = {
// Allowed years whose provider export failed: the wizard warns
// the user before proceeding so an IB/UB gap cannot slip through.
failedYears,
+ // Source years outside the selection: the result step names them
+ // so nobody believes the books are complete (#2211).
+ omittedYears,
basAccounts: mappingTargets,
})
} catch (error) {
diff --git a/extensions/general/arcim-migration/lib/__tests__/sie-fetcher.test.ts b/extensions/general/arcim-migration/lib/__tests__/sie-fetcher.test.ts
index eb269107..1150c16b 100644
--- a/extensions/general/arcim-migration/lib/__tests__/sie-fetcher.test.ts
+++ b/extensions/general/arcim-migration/lib/__tests__/sie-fetcher.test.ts
@@ -3,6 +3,8 @@ import {
providerSupportsSie,
fetchProviderSieFiles,
getAllowedFiscalYears,
+ FiscalYearSelectionError,
+ MAX_SELECTED_FISCAL_YEARS,
} from '../sie-fetcher'
// The allowed window is rolling (current year and the two before it): derive
@@ -54,7 +56,7 @@ function octetResponse(bytes: Uint8Array): Response {
})
}
-describe('getAllowedFiscalYears', () => {
+describe('getAllowedFiscalYears (the default selection)', () => {
it('is a rolling three-year window ending at the current year', () => {
const years = getAllowedFiscalYears(new Date('2031-06-15'))
expect([...years].sort((a, b) => a - b)).toEqual([2029, 2030, 2031])
@@ -68,6 +70,14 @@ describe('getAllowedFiscalYears', () => {
})
})
+describe('MAX_SELECTED_FISCAL_YEARS', () => {
+ it('is six: 6 years x 48 s worst case (3 x 15 s timeout + 1 s + 2 s backoff) = 288 s inside the 300 s function', () => {
+ expect(MAX_SELECTED_FISCAL_YEARS).toBe(6)
+ expect(MAX_SELECTED_FISCAL_YEARS * (3 * 15 + 1 + 2)).toBeLessThan(300)
+ expect((MAX_SELECTED_FISCAL_YEARS + 1) * (3 * 15 + 1 + 2)).toBeGreaterThan(300)
+ })
+})
+
describe('providerSupportsSie', () => {
it('is true for providers with SIE-over-API, false otherwise', () => {
expect(providerSupportsSie('fortnox')).toBe(true)
@@ -125,6 +135,17 @@ describe('fetchProviderSieFiles', () => {
expect(result.availableYears).toEqual([CY - 2, CY - 1])
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 2, CY - 1])
expect(result.failedYears).toEqual([])
+ // Every source year is NAMED with the provider's own bounds and its
+ // default-selection flag (the picker), and the one outside the
+ // selection is reported as omitted (#2211): nothing is left out silently.
+ expect(result.sourceYears).toEqual([
+ { year: CY - 3, fromDate: `${CY - 3}-01-01`, toDate: `${CY - 3}-12-31`, inDefaultSelection: false },
+ { year: CY - 2, fromDate: `${CY - 2}-01-01`, toDate: `${CY - 2}-12-31`, inDefaultSelection: true },
+ { year: CY - 1, fromDate: `${CY - 1}-01-01`, toDate: `${CY - 1}-12-31`, inDefaultSelection: true },
+ ])
+ expect(result.omittedYears).toEqual([
+ { year: CY - 3, fromDate: `${CY - 3}-01-01`, toDate: `${CY - 3}-12-31`, inDefaultSelection: false },
+ ])
// CP437 bytes decoded into proper Swedish characters
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 2}`)
expect(result.files[1].rawContent).toContain(`Företagskonto ${CY - 1}`)
@@ -174,6 +195,9 @@ describe('fetchProviderSieFiles', () => {
respond: () =>
jsonResponse({
FinancialYears: [
+ // A broken first year (issue #2211): starts before the window,
+ // so it is left out. Listed first as Fortnox does.
+ { Id: 4, FromDate: `${CY - 4}-09-01`, ToDate: `${CY - 3}-12-31` },
{ Id: 5, FromDate: `${CY - 2}-01-01`, ToDate: `${CY - 2}-12-31` },
{ Id: 6, FromDate: `${CY - 1}-01-01`, ToDate: `${CY - 1}-12-31` },
],
@@ -198,10 +222,84 @@ describe('fetchProviderSieFiles', () => {
const sieUrls = fetchSpy.mock.calls
.map((c: unknown[]) => String(c[0]))
.filter((u: string) => u.includes('/sie/4'))
+ expect(sieUrls).toHaveLength(2)
expect(sieUrls[0]).toContain('financialyear=5')
expect(sieUrls[1]).toContain('financialyear=6')
})
+ it('names the broken first year outside the default selection, with the bounds Fortnox reports (#2211)', async () => {
+ routeFetch(fetchSpy, [
+ yearRoutes,
+ {
+ match: '/sie/4?financialyear=',
+ respond: () => new Response('#FLAGGA 0\n#KONTO 1930 "Företagskonto"\n', { status: 200 }),
+ },
+ ])
+
+ const result = await fetchProviderSieFiles('fortnox', 'token', undefined)
+
+ // Derived from the /financialyears list already fetched: no extra call,
+ // and no SIE export for the year outside the selection.
+ const broken = { year: CY - 4, fromDate: `${CY - 4}-09-01`, toDate: `${CY - 3}-12-31`, inDefaultSelection: false }
+ expect(result.sourceYears[0]).toEqual(broken)
+ expect(result.sourceYears.map((fy) => fy.inDefaultSelection)).toEqual([false, true, true])
+ expect(result.omittedYears).toEqual([broken])
+ const fyListCalls = fetchSpy.mock.calls.filter((c: unknown[]) => String(c[0]).includes('/financialyears'))
+ expect(fyListCalls).toHaveLength(1)
+ expect(fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))).not.toContainEqual(
+ expect.stringContaining('financialyear=4'),
+ )
+ })
+
+ it('refuses a selected year the source does not have, before any SIE export', async () => {
+ routeFetch(fetchSpy, [
+ yearRoutes,
+ {
+ match: '/sie/4?financialyear=',
+ respond: () => new Response('#FLAGGA 0\n#KONTO 1930 "Företagskonto"\n', { status: 200 }),
+ },
+ ])
+
+ const attempt = fetchProviderSieFiles('fortnox', 'token', undefined, { years: [CY - 1, CY - 9] })
+
+ await expect(attempt).rejects.toBeInstanceOf(FiscalYearSelectionError)
+ await expect(attempt).rejects.toMatchObject({ unknownYears: [CY - 9] })
+ // Only the year listing was called: the unknown year cost no export,
+ // and neither did the known one.
+ const urls = fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))
+ expect(urls.filter((u: string) => u.includes('/financialyears'))).toHaveLength(1)
+ expect(urls.filter((u: string) => u.includes('/sie/4'))).toHaveLength(0)
+ })
+
+ it('fetches exactly the years the picker selected, oldest first (#2238)', async () => {
+ routeFetch(fetchSpy, [
+ yearRoutes,
+ {
+ match: '/sie/4?financialyear=',
+ respond: () => new Response('#FLAGGA 0\n#KONTO 1930 "Företagskonto"\n', { status: 200 }),
+ },
+ ])
+
+ // The broken first year ticked, the middle default year unticked.
+ const result = await fetchProviderSieFiles('fortnox', 'token', undefined, {
+ years: [CY - 1, CY - 4],
+ })
+
+ expect(result.availableYears).toEqual([CY - 4, CY - 1])
+ expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 4, CY - 1])
+ const sieUrls = fetchSpy.mock.calls
+ .map((c: unknown[]) => String(c[0]))
+ .filter((u: string) => u.includes('/sie/4'))
+ expect(sieUrls).toHaveLength(2)
+ expect(sieUrls[0]).toContain('financialyear=4')
+ expect(sieUrls[1]).toContain('financialyear=6')
+ // The unticked default year is the omitted one now; the flag still
+ // says what the default would have been.
+ expect(result.omittedYears).toEqual([
+ { year: CY - 2, fromDate: `${CY - 2}-01-01`, toDate: `${CY - 2}-12-31`, inDefaultSelection: true },
+ ])
+ })
+
it('decodes CP437 bytes from the Fortnox SIE endpoint (no blind UTF-8 text())', async () => {
// Some Fortnox endpoint variants serve the SIE body in CP437 (the SIE
// spec encoding). A blind response.text() would turn å/ä/ö into U+FFFD
@@ -237,6 +335,13 @@ describe('fetchProviderSieFiles', () => {
match: '/financialyear',
respond: () =>
jsonResponse([
+ {
+ entityId: 0,
+ id: `${CY - 5}01`,
+ fromDate: `${CY - 5}-01-01`,
+ toDate: `${CY - 5}-12-31`,
+ open: false,
+ },
{
entityId: 1,
id: `${CY - 1}01`,
@@ -253,6 +358,12 @@ describe('fetchProviderSieFiles', () => {
expect(result.files).toHaveLength(1)
expect(result.files[0].fiscalYear).toBe(CY - 1)
expect(result.files[0].rawContent).toContain(`Företagskonto ${CY - 1}`)
+ expect(result.omittedYears).toEqual([
+ { year: CY - 5, fromDate: `${CY - 5}-01-01`, toDate: `${CY - 5}-12-31`, inDefaultSelection: false },
+ ])
+ expect(fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))).not.toContainEqual(
+ expect.stringContaining(`/sie/export/${CY - 5}`),
+ )
const exportCall = fetchSpy.mock.calls.find((c: unknown[]) => String(c[0]).includes('/sie/export/'))
expect(exportCall).toBeDefined()
@@ -325,6 +436,8 @@ describe('fetchProviderSieFiles: wint (SIE rendered from voucher data)', () => {
Name: 'Bolaget AB',
Org: '556699-0011',
FinancialYears: [
+ // Before the window: named in omittedYears, never fetched.
+ { Id: 0, Start: `${CY - 3}-01-01T00:00:00`, End: `${CY - 3}-12-31T00:00:00` },
{ 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` },
],
@@ -385,6 +498,12 @@ describe('fetchProviderSieFiles: wint (SIE rendered from voucher data)', () => {
expect(result.failedYears).toEqual([])
expect(result.availableYears).toEqual([CY - 1, CY])
expect(result.files.map((f) => f.fiscalYear)).toEqual([CY - 1, CY])
+ expect(result.omittedYears).toEqual([
+ { year: CY - 3, fromDate: `${CY - 3}-01-01`, toDate: `${CY - 3}-12-31`, inDefaultSelection: false },
+ ])
+ expect(fetchSpy.mock.calls.map((c: unknown[]) => String(c[0]))).not.toContainEqual(
+ expect.stringContaining(`BookingDateStart=${CY - 3}`),
+ )
const prev = result.files[0].rawContent
const curr = result.files[1].rawContent
diff --git a/extensions/general/arcim-migration/lib/sie-fetcher.ts b/extensions/general/arcim-migration/lib/sie-fetcher.ts
index 044de258..2cc76e1d 100644
--- a/extensions/general/arcim-migration/lib/sie-fetcher.ts
+++ b/extensions/general/arcim-migration/lib/sie-fetcher.ts
@@ -29,7 +29,13 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('extensions/arcim-migration/sie-fetcher')
/**
- * Fiscal years we support importing: the current year and the two before it.
+ * The DEFAULT selection of fiscal years: the current year and the two before
+ * it, keyed on each fiscal year's start year. A default, not a cap (issue
+ * #2211 / #2238): the wizard lets the user add older years, and
+ * fetchProviderSieFiles takes an explicit `years` selection. The default is
+ * the cost bound: every selected year is one SIE export fetched and parsed
+ * inside the single /sie-data invocation (hosted function limit 300 s), so
+ * an older history is the user's own wait, chosen in the preview step.
* Derived at call time (not a module constant) so the window rolls forward
* automatically at new year without a code change.
*/
@@ -38,6 +44,32 @@ export function getAllowedFiscalYears(now: Date = new Date()): Set {
return new Set([currentYear - 2, currentYear - 1, currentYear])
}
+/**
+ * The most fiscal years one import run may select. The bound is the single
+ * /sie-data invocation that fetches and parses one SIE export per selected
+ * year: hosted function limit 300 s; one export call is 15 s per attempt
+ * (FETCH_TIMEOUT_MS in lib/providers/fortnox/client.ts), 3 attempts with
+ * 1 s and 2 s backoff between them (lib/providers/retry.ts defaults, capped
+ * at 30 s), so a year that times out on every attempt costs 15 + 1 + 15 +
+ * 2 + 15 = 48 s. Six such years are 288 s, which leaves the remaining 12 s
+ * for the /financialyears listing, parsing and the response. Older years
+ * beyond the cap go in a second run. Enforced server-side in /sie-data and
+ * mirrored by the preview step's picker (which reads it from /preview).
+ */
+export const MAX_SELECTED_FISCAL_YEARS = 6
+
+/**
+ * Thrown by fetchProviderSieFiles when an explicit selection names a year
+ * the source does not have: raised right after the year listing, before any
+ * SIE export is fetched, so an unknown year never costs a provider export.
+ */
+export class FiscalYearSelectionError extends Error {
+ constructor(public readonly unknownYears: number[]) {
+ super(`Fiscal years not found at the provider: ${unknownYears.join(', ')}`)
+ this.name = 'FiscalYearSelectionError'
+ }
+}
+
export interface ProviderSieFile {
fiscalYear: number
rawContent: string
@@ -46,17 +78,45 @@ export interface ProviderSieFile {
export interface ProviderSieFetchResult {
files: ProviderSieFile[]
/**
- * Every fiscal year available at the provider within the allowed window:
- * also populated when latestOnly fetched just one file, so /preview can show
- * the full year list without a second round-trip.
+ * Every fiscal year available at the provider within the selection (the
+ * default window, or the explicit `years`): also populated when latestOnly
+ * fetched just one file, so /preview can show the full year list without a
+ * second round-trip.
*/
availableYears: number[]
+ /**
+ * Every fiscal year the source has, oldest first, with the provider's own
+ * bounds and whether it is in the default selection. The preview step
+ * renders these as the year picker, so no year can be left out silently.
+ */
+ sourceYears: SourceFiscalYear[]
/**
* Allowed years whose export failed (or came back empty). Callers MUST
* surface these to the user: silently importing e.g. 2024+2026 without 2025
* breaks IB/UB continuity between the years without anyone noticing.
*/
failedYears: { year: number; error: string }[]
+ /**
+ * Source fiscal years that were NOT part of this fetch (outside the
+ * selection), oldest first. Until issue #2211 these were never mentioned:
+ * the result step names them so nobody believes the books are complete.
+ */
+ omittedYears: SourceFiscalYear[]
+}
+
+/**
+ * A fiscal year as the source reports it. Bounds are the provider's own
+ * (ISO yyyy-mm-dd) so a broken year can be named as "2022-09-01 till
+ * 2023-12-31" rather than as a calendar year that is wrong for it; null when
+ * the provider reported none. `year` (the start year) is the key the whole
+ * import uses for a fiscal year, and what `fetchProviderSieFiles` selects on.
+ */
+export interface SourceFiscalYear {
+ year: number
+ fromDate: string | null
+ toDate: string | null
+ /** True when the year falls in the default selection (getAllowedFiscalYears). */
+ inDefaultSelection: boolean
}
// Singleton clients (they hold rate limiters)
@@ -85,31 +145,53 @@ interface FiscalYearRef {
}
/**
- * Fetch SIE type-4 exports from the provider, one file per allowed fiscal
- * year (oldest first). Years whose export fails do not block the rest of the
- * migration, but they are reported in `failedYears` so the caller can warn
- * the user before importing a gap (IB/UB continuity).
+ * Fetch SIE type-4 exports from the provider, one file per selected fiscal
+ * year (oldest first). The selection is `opts.years` (start years, as the
+ * preview step's picker sends them) or, when absent, the default window.
+ * Years whose export fails do not block the rest of the migration, but they
+ * are reported in `failedYears` so the caller can warn the user before
+ * importing a gap (IB/UB continuity). Years at the source outside the
+ * selection are reported in `omittedYears`; the full list is `sourceYears`.
*/
export async function fetchProviderSieFiles(
provider: ProviderName,
accessToken: string,
providerCompanyId: string | undefined,
- opts?: { latestOnly?: boolean },
+ opts?: { latestOnly?: boolean; years?: number[] },
): Promise {
- const fetcher = getSieFetcher(provider, providerCompanyId)
+ const fetcher = getSieFetcher(provider, providerCompanyId, opts?.years)
if (!fetcher) {
throw new Error(`Provider ${provider} does not support SIE over API`)
}
- const allowedFiscalYears = getAllowedFiscalYears()
- const allYears = await fetcher.listYears(accessToken)
- const allowedYears = allYears
- .filter((fy) => allowedFiscalYears.has(fy.year))
- .sort((a, b) => a.year - b.year)
+ const defaultFiscalYears = getAllowedFiscalYears()
+ const selectedFiscalYears = opts?.years ? new Set(opts.years) : defaultFiscalYears
+ const byYear = (a: FiscalYearRef, b: FiscalYearRef) =>
+ a.year - b.year || (a.fromDate ?? '').localeCompare(b.fromDate ?? '')
+ const allYears = (await fetcher.listYears(accessToken)).sort(byYear)
+ if (opts?.years) {
+ // Reject an unknown year here, before the first export: the listing is
+ // the only provider call made so far.
+ const known = new Set(allYears.map((fy) => fy.year))
+ const unknownYears = opts.years.filter((y) => !known.has(y))
+ if (unknownYears.length > 0) throw new FiscalYearSelectionError(unknownYears)
+ }
+ const allowedYears = allYears.filter((fy) => selectedFiscalYears.has(fy.year))
const availableYears = allowedYears.map((fy) => fy.year)
const toFetch = opts?.latestOnly ? allowedYears.slice(-1) : allowedYears
+ // Both derived from the year list already fetched: naming every source
+ // year, and the ones left out, costs no extra provider call.
+ const describe = (fy: FiscalYearRef): SourceFiscalYear => ({
+ year: fy.year,
+ fromDate: fy.fromDate ?? null,
+ toDate: fy.toDate ?? null,
+ inDefaultSelection: defaultFiscalYears.has(fy.year),
+ })
+ const sourceYears = allYears.map(describe)
+ const omittedYears = allYears.filter((fy) => !selectedFiscalYears.has(fy.year)).map(describe)
+
const files: ProviderSieFile[] = []
const failedYears: { year: number; error: string }[] = []
for (const fy of toFetch) {
@@ -129,7 +211,7 @@ export async function fetchProviderSieFiles(
}
}
- return { files, availableYears, failedYears }
+ return { files, availableYears, sourceYears, failedYears, omittedYears }
}
interface SieFetcher {
@@ -140,6 +222,7 @@ interface SieFetcher {
function getSieFetcher(
provider: ProviderName,
providerCompanyId: string | undefined,
+ selectedYears?: number[],
): SieFetcher | null {
if (provider === 'fortnox') {
return {
@@ -152,6 +235,8 @@ function getSieFetcher(
return years.map((fy) => ({
id: fy['Id'] as number,
year: new Date(fy['FromDate'] as string).getFullYear(),
+ fromDate: typeof fy['FromDate'] === 'string' ? fy['FromDate'] : undefined,
+ toDate: typeof fy['ToDate'] === 'string' ? fy['ToDate'] : undefined,
}))
},
async fetchSie(accessToken, fy) {
@@ -172,6 +257,8 @@ function getSieFetcher(
return years.map((fy) => ({
id: fy.id,
year: new Date(fy.fromdate).getFullYear(),
+ fromDate: fy.fromdate,
+ toDate: fy.todate,
}))
},
async fetchSie(accessToken, fy) {
@@ -194,6 +281,9 @@ function getSieFetcher(
companyName: string
orgNumber?: string
accounts: ReturnType[]
+ /** Every year the source has, unfiltered: what listYears reports. */
+ allYears: (WintSieYear & { id: number })[]
+ /** The allowed subset: the years that render as SIE. */
years: (WintSieYear & { id: number })[]
vouchersByYear: Map
ibByYear: Map>
@@ -205,7 +295,9 @@ function getSieFetcher(
contextPromise ??= (async () => {
const company = await wintClient.get>(accessToken, '/api/Auth')
const rawYears = (company['FinancialYears'] as Record[] | undefined) ?? []
- const allowed = getAllowedFiscalYears()
+ // The same selection fetchProviderSieFiles applies: the explicit
+ // years when given, else the default window.
+ const allowed = selectedYears ? new Set(selectedYears) : getAllowedFiscalYears()
const allYears = rawYears
.map((fy) => ({
id: Number(fy['Id']),
@@ -273,6 +365,7 @@ function getSieFetcher(
companyName: (company['Name'] as string) ?? 'Okänt företag',
orgNumber: (company['Org'] as string | undefined) || undefined,
accounts,
+ allYears,
years,
vouchersByYear,
ibByYear,
@@ -284,8 +377,10 @@ function getSieFetcher(
return {
async listYears(accessToken) {
+ // The unfiltered list: fetchProviderSieFiles applies the window
+ // itself and needs the years outside it to name what is left out.
const context = await loadContext(accessToken)
- return context.years.map((fy) => ({
+ return context.allYears.map((fy) => ({
id: fy.id,
year: fy.year,
fromDate: fy.start,
diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts
index d904b103..55311d31 100644
--- a/extensions/general/mcp-server/server.ts
+++ b/extensions/general/mcp-server/server.ts
@@ -4114,7 +4114,7 @@ export const tools: McpTool[] = [
keywords: ['migrering', 'byta system', 'flytta bokföring'],
title: 'Connect Previous System',
description:
- 'Connect card into the migration wizard for a NAMED previous system. API systems (fortnox/bjornlunden/briox/wint) fetch all fiscal years plus invoices, customers and documents; visma/bokio complement AFTER a SIE import. Same one-click feel as the bank/Skatteverket cards.',
+ 'Connect card into the migration wizard for a NAMED previous system. API systems (fortnox/bjornlunden/briox/wint) fetch the three latest fiscal years by default (older years selectable) plus invoices, customers and documents; visma/bokio complement AFTER a SIE import.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -4165,7 +4165,7 @@ export const tools: McpTool[] = [
api_connected: info.api,
connect_url: connectUrl,
instructions: info.api
- ? `On claude.ai/Claude Desktop a connect card with an open-in-browser button renders with this result; elsewhere give the user the connect_url. The wizard connects to ${info.name} (login there), fetches every fiscal year and imports bookkeeping PLUS invoices, customers, suppliers and documents. The user comes back here when the wizard reports done.`
+ ? `On claude.ai/Claude Desktop a connect card with an open-in-browser button renders with this result; elsewhere give the user the connect_url. The wizard connects to ${info.name} (login there), fetches the three latest fiscal years by default (older years can be ticked in the wizard; they take longer) and imports bookkeeping PLUS invoices, customers, suppliers and documents. The user comes back here when the wizard reports done.`
: `${info.name} has no API export: run the SIE-file import FIRST (gnubok_create_sie_upload drop card). This wizard link then complements with invoices and customers. On claude.ai/Desktop a connect card renders; elsewhere give the user the connect_url.`,
}
},
diff --git a/extensions/general/mcp-server/skills/onboarding.ts b/extensions/general/mcp-server/skills/onboarding.ts
index 1c6edee7..d9e99943 100644
--- a/extensions/general/mcp-server/skills/onboarding.ts
+++ b/extensions/general/mcp-server/skills/onboarding.ts
@@ -119,9 +119,10 @@ reaches far enough back anyway.
offer TWO paths and recommend by need. The FULL migration: call
\`gnubok_connect_migration\` with the provider; it renders a connect
card (same feel as bank/Skatteverket) whose button opens the wizard
- that logs into the old system and fetches every fiscal year PLUS
- invoices, customers, suppliers and documents: recommend it when they
- have open fakturor or want underlag along. The QUICK path is a SIE
+ that logs into the old system and fetches the three latest fiscal
+ years by default (older years can be ticked in the wizard; they take
+ longer) PLUS invoices, customers, suppliers and documents: recommend
+ it when they have open fakturor or want underlag along. The QUICK path is a SIE
export dropped here (Fortnox: Register → Exportera → SIE 4): ledger
only, fastest. Either way the result lands in the same books.
- **Visma eEkonomi / Bokio**: no API export exists; ask for the SIE
diff --git a/messages/en.json b/messages/en.json
index f34d7051..58c1c064 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5627,6 +5627,15 @@
"ext_arcim_documents_provider_message": "Source system response: {message}",
"ext_arcim_option_series_help": "Vouchers keep their series from the source system (A, D, E ...). The series here is only used for vouchers without one.",
"ext_arcim_option_sie_required_hint": "The ledger (chart of accounts, opening balances and verifications) has not been imported yet. Tick Bokföringsdata (SIE) to fetch it in the same run: customers, suppliers and invoices cannot be imported without it.",
+ "ext_arcim_year_select_kicker": "Fiscal years to fetch",
+ "ext_arcim_year_select_lede": "The three most recent fiscal years are selected by default. Tick older years to fetch them too: it takes longer, and attached documents come along.",
+ "ext_arcim_year_select_older": "takes longer",
+ "ext_arcim_year_select_none": "Select at least one fiscal year.",
+ "ext_arcim_year_select_too_many": "At most {max} fiscal years can be fetched at once. Older years can be fetched in a second run.",
+ "ext_arcim_omitted_years_kicker": "Fiscal years not fetched",
+ "ext_arcim_omitted_years_result": "{count, plural, one {Not selected in this run. Run a new migration with it ticked to fetch that year too (attached documents come along), or upload it as a SIE file under Import.} other {Not selected in this run. Run a new migration with them ticked to fetch those years too (attached documents come along), or upload them as one SIE file per year under Import, oldest first.}}",
+ "ext_arcim_omitted_years_sie_link": "Go to the SIE import",
+ "ext_arcim_fiscal_year_span": "{from} to {to}",
"ext_arcim_documents_result_description": "The document import is complete.",
"ext_arcim_documents_imported": "Imported",
"ext_arcim_documents_skipped": "Already present",
diff --git a/messages/sv.json b/messages/sv.json
index e592b5b3..4490ed63 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -5627,6 +5627,15 @@
"ext_arcim_documents_provider_message": "Svar från källsystemet: {message}",
"ext_arcim_option_series_help": "Verifikat behåller sin serie från källsystemet (A, D, E ...). Serien här används bara för verifikat som saknar serie.",
"ext_arcim_option_sie_required_hint": "Bokföringen (kontoplan, ingående balanser och verifikationer) har inte importerats än. Kryssa i Bokföringsdata (SIE) för att hämta den i samma körning: kunder, leverantörer och fakturor kan inte importeras utan den.",
+ "ext_arcim_year_select_kicker": "Räkenskapsår att hämta",
+ "ext_arcim_year_select_lede": "De tre senaste räkenskapsåren är förvalda. Bocka i äldre år för att hämta dem också: det tar längre tid, och kopplade underlag följer med.",
+ "ext_arcim_year_select_older": "tar längre tid",
+ "ext_arcim_year_select_none": "Välj minst ett räkenskapsår.",
+ "ext_arcim_year_select_too_many": "Högst {max} räkenskapsår kan hämtas åt gången. Äldre år kan hämtas i en ny körning.",
+ "ext_arcim_omitted_years_kicker": "Räkenskapsår som inte hämtades",
+ "ext_arcim_omitted_years_result": "{count, plural, one {Inte valt i den här körningen. Kör en ny migrering och bocka i det för att hämta även det året (kopplade underlag följer med), eller ladda upp det som en SIE-fil under Import.} other {Inte valda i den här körningen. Kör en ny migrering och bocka i dem för att hämta även de åren (kopplade underlag följer med), eller ladda upp dem som en SIE-fil per år under Import, äldsta året först.}}",
+ "ext_arcim_omitted_years_sie_link": "Gå till SIE-importen",
+ "ext_arcim_fiscal_year_span": "{from} till {to}",
"ext_arcim_documents_result_description": "Underlagsimporten är klar.",
"ext_arcim_documents_imported": "Importerade",
"ext_arcim_documents_skipped": "Fanns redan",