fix(arcim-migration): require a completed SIE import for Fortnox too (#2000) (#2006)

* fix(arcim-migration): require a completed SIE import for Fortnox too (#2000)

POST /migrate refused entity import without a completed SIE import for
every provider except Fortnox, on the assumption that the wizard always
runs SIE-over-API first. The wizard lets the user uncheck
"Bokföringsdata (SIE)" while keeping customers, suppliers and invoices
checked, so a Fortnox company could end up with subledgers but no chart
of accounts and no opening balances: the incomplete bokföring the guard
exists to prevent.

The guard now applies to all providers as "a completed SIE import must
exist for the company", not "must be part of this run", so an
entities-only re-run after an earlier full migration still works. For
providers that serve SIE over the API (Fortnox, Briox, Björn Lundén,
WINT) the 409 message points at the wizard checkbox instead of "ladda
upp en SIE-fil". The options step shows one attn line and disables
"Starta migrering" when SIE is unchecked, never imported, and any API
import is selected, so the user sees the rule before the run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(arcim-migration): address review findings (#2000)

- Findings 1, 3, 4 (stale hasSieData on retry): the options step now
  derives "SIE already imported" from the preview OR this session's
  successful /import-sie results, so an entities-only retry after a
  phase-2 failure is no longer blocked with a false "not imported yet"
  hint and a forced replace-mode ledger re-import.
- Finding 2 (company-info-only run gated): importCompanyInfo is dropped
  from the wizard gate and POST /migrate skips the SIE guard when every
  entity flag is false. Company info writes no accounts, balances or
  subledger rows, so the BFL rationale does not apply. Tests pin both
  the company-info-only pass and the block when any entity flag is set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-28 16:52:12 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 7993700589
commit b4b11c33ca
6 changed files with 174 additions and 33 deletions
@@ -10,8 +10,11 @@ import type { ExtensionContext } from '@/lib/extensions/types'
* invoices): it never posts to the general ledger. The GL (kontoplan, ingående
* balanser, verifikationer) arrives via SIE. Importing entities without the
* SIE-derived ledger leaves an incomplete bokföring under BFL, so the route MUST
* refuse to run for non-Fortnox providers until a completed SIE import exists.
* Fortnox is exempt because it pulls SIE itself via API.
* refuse to run for EVERY provider until a completed SIE import exists for the
* company. Fortnox used to be exempt (it pulls SIE itself via API), but the
* wizard lets the user uncheck "Bokföringsdata (SIE)" while keeping entities
* checked (#2000), so the exemption is gone. The rule is "must exist", not "must
* be part of this run": an entities-only re-run after a full migration passes.
*
* Previously this was only an advisory banner + step-gating in the React wizard,
* which a direct API call or a stale client could bypass. This test locks the
@@ -63,26 +66,51 @@ function buildCtx(count: number | null): ExtensionContext {
return { supabase, companyId: 'company-1' } as unknown as ExtensionContext
}
function migrateRequest() {
function migrateRequest(body: Record<string, unknown> = { consentId: 'consent-1' }) {
return createMockRequest('http://localhost/api/extensions/ext/arcim-migration/migrate', {
method: 'POST',
body: { consentId: 'consent-1' },
body,
})
}
type GuardErrorBody = { error: { code: string; message: string; message_en?: string } }
describe('POST /migrate: SIE-import-required guard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns 401 when there is no authenticated user', async () => {
const ctx = buildCtx(0)
;(ctx.supabase as unknown as { auth: { getUser: Mock } }).auth.getUser
.mockResolvedValue({ data: { user: null } })
const res = await handler(migrateRequest(), ctx)
expect(res.status).toBe(401)
expect(getConsent).not.toHaveBeenCalled()
expect(executeMigration).not.toHaveBeenCalled()
})
it('returns 400 when consentId is missing', async () => {
const res = await handler(migrateRequest({}), buildCtx(1))
expect(res.status).toBe(400)
expect(getConsent).not.toHaveBeenCalled()
expect(executeMigration).not.toHaveBeenCalled()
})
it('blocks a non-Fortnox provider when no completed SIE import exists', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'visma' })
const res = await handler(migrateRequest(), buildCtx(0))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(res)
const { status, body } = await parseJsonResponse<GuardErrorBody>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('PROVIDER_SIE_IMPORT_REQUIRED')
// Visma has no SIE-over-API: the static registry text ("ladda upp en
// SIE-fil") is the right instruction and must stay.
expect(body.error.message).toMatch(/ladda upp en SIE-fil/i)
expect(executeMigration).not.toHaveBeenCalled()
})
@@ -95,10 +123,71 @@ describe('POST /migrate: SIE-import-required guard', () => {
expect(executeMigration).toHaveBeenCalledTimes(1)
})
it('exempts Fortnox: entity import runs even with no SIE import (SIE comes via API)', async () => {
it('blocks Fortnox when no completed SIE import exists (SIE step unchecked in the wizard)', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(migrateRequest(), buildCtx(0))
const { status, body } = await parseJsonResponse<GuardErrorBody>(res)
expect(status).toBe(409)
expect(body.error.code).toBe('PROVIDER_SIE_IMPORT_REQUIRED')
expect(executeMigration).not.toHaveBeenCalled()
})
it('tells SIE-over-API providers to tick the wizard checkbox, not to upload a file', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(migrateRequest(), buildCtx(0))
const { body } = await parseJsonResponse<GuardErrorBody>(res)
expect(body.error.message).toContain('Bokföringsdata (SIE)')
expect(body.error.message).not.toMatch(/ladda upp/i)
expect(body.error.message_en).toContain('Bokföringsdata (SIE)')
expect(body.error.message_en).not.toMatch(/upload/i)
})
it('allows a company-info-only run with no SIE import (writes no ledger data)', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(
migrateRequest({
consentId: 'consent-1',
importCompanyInfo: true,
importCustomers: false,
importSuppliers: false,
importSalesInvoices: false,
importSupplierInvoices: false,
}),
buildCtx(0),
)
expect(res.status).toBe(200)
expect(executeMigration).toHaveBeenCalledTimes(1)
})
it('still blocks when company info is combined with any entity flag', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(
migrateRequest({
consentId: 'consent-1',
importCompanyInfo: true,
importCustomers: false,
importSuppliers: true,
importSalesInvoices: false,
importSupplierInvoices: false,
}),
buildCtx(0),
)
expect(res.status).toBe(409)
expect(executeMigration).not.toHaveBeenCalled()
})
it('allows Fortnox once a completed SIE import exists (entities-only re-run)', async () => {
;(getConsent as Mock).mockResolvedValue({ id: 'consent-1', status: 1, provider: 'fortnox' })
const res = await handler(migrateRequest(), buildCtx(1))
expect(res.status).toBe(200)
expect(executeMigration).toHaveBeenCalledTimes(1)
+50 -24
View File
@@ -1174,31 +1174,57 @@ export const arcimMigrationExtension: Extension = {
}
// ── Guard: a completed SIE import is required before entity import ──
// Most providers expose ONLY entity data (customers, suppliers,
// invoices) via API: never the general ledger. Fortnox pulls the GL
// itself via SIE-over-API and is exempt. Briox and Björn Lundén also
// serve SIE over the API, but the wizard runs /import-sie before
// /migrate, so this guard stays satisfied, and keeps protecting
// against a skipped SIE step. Importing entities without the
// SIE-derived ledger (kontoplan,
// ingående balanser, verifikationer) would leave an incomplete
// bokföring under BFL: a subledger with no chart of accounts and no
// opening balances, so every subsequent posting and balance is wrong.
// The wizard surfaces this as an advisory banner, but it must be
// enforced here so the rule cannot be bypassed by a direct API call,
// a skipped wizard step, or a stale client.
if (consent.provider !== 'fortnox') {
const { count: completedSieImports } = await supabase
.from('sie_imports')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'completed')
// Provider APIs expose ONLY entity data (customers, suppliers,
// invoices): never the general ledger. The GL (kontoplan, ingående
// balanser, verifikationer) arrives via SIE, either uploaded by the
// user or, for Fortnox, Briox, Björn Lundén and WINT, pulled over the
// API by the wizard's /import-sie phase. Importing entities without
// that ledger would leave an incomplete bokföring under BFL: a
// subledger with no chart of accounts and no opening balances, so
// every subsequent posting and balance is wrong.
//
// The rule is "a completed SIE import must EXIST for the company",
// not "must be part of this run": an entities-only re-run after an
// earlier full migration passes. No provider is exempt. Fortnox used
// to be, on the assumption that the wizard always runs SIE-over-API
// first, but the wizard lets the user uncheck "Bokföringsdata (SIE)"
// while keeping entities checked (#2000), and a direct API call or a
// stale client can skip it regardless.
//
// Company info (name, org number, VAT number) writes no accounts,
// balances or subledger rows, so a run that imports only that is
// not gated.
const importsEntities = importCustomers ||
importSuppliers ||
importSalesInvoices ||
importSupplierInvoices
const { count: completedSieImports } = importsEntities
? await supabase
.from('sie_imports')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'completed')
: { count: null }
if (!completedSieImports || completedSieImports < 1) {
return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, {
details: { provider: consent.provider },
})
}
if (importsEntities && (!completedSieImports || completedSieImports < 1)) {
// Providers that serve SIE over the API have no file to upload:
// point the user at the wizard checkbox instead of the static
// "ladda upp en SIE-fil" text. Same code and status either way so
// the wizard's error path renders it unchanged.
const sieViaApi = ARCIM_PROVIDERS.some(
(p) => p.id === consent.provider && p.sieViaApi,
)
return errorResponseFromCode('PROVIDER_SIE_IMPORT_REQUIRED', moduleLog, {
details: { provider: consent.provider },
...(sieViaApi
? {
messageSv:
'Bokföringsdata (SIE) måste importeras först. Kryssa i "Bokföringsdata (SIE)" i guiden så att kontoplan, ingående balanser och verifikationer hämtas innan kunder, leverantörer och fakturor importeras.',
messageEn:
'A completed SIE import is required first. Tick "Bokföringsdata (SIE)" in the wizard so the chart of accounts, opening balances and verifications are fetched before customers, suppliers and invoices are imported.',
}
: {}),
})
}
log.info(`Starting migration for user ${user.id} from ${consent.provider}`)