* 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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
7993700589
commit
b4b11c33ca
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user