fix(sie): selectable IB voucher series, smarter IB toggle on re-import, orphan-IB guard (#1896)

* fix(sie): selectable IB voucher series that never collides with the file's numbering

The Ingående balanser voucher was hardcoded to series A and created before
the file's vouchers, so it consumed the A series' next number and shifted
every imported A voucher one number higher than in the source system
(issue #1882).

- IB voucher series is now selectable in the import wizard; the default is
  the first of M,O,P,Q,R,S,T,V,W,X,Y,Z not used by the file's #VER records
  (M matches the existing migration-adjustment series).
- Plumbed end to end: wizard -> /api/import/sie/execute -> executeSIEImport,
  v1 REST options.openingBalanceSeries, MCP gnubok_import_sie
  opening_balance_series -> commitImportSie.
- The wizard's 'Importera ingående balanser' toggle now defaults OFF when a
  posted IB voucher already exists inside the file's fiscal year, with a
  hint saying why.
- Orphan-IB guard in executeSIEImport: replace_sie_import deletes only
  source_type='import' entries and clears the period's OB pointer, so a
  prior import's IB voucher survived every replace cycle and each re-import
  created another one (field report: five accumulated). The import now
  skips IB creation with a warning when a posted opening_balance entry
  already exists in the period.
- MCP import_opening_balances default (false) vs web (true) documented as
  deliberate in the tool schema and DECISIONS.md.

Fixes #1882

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sie): harden IB series fix after skeptic review (relink orphan, exclude fallback series, type-check option)

Skeptic findings on PR #1896, all four blocking items:

- Orphan-IB guard now relinks a single surviving opening-balance voucher
  as the period's OB entry (permitted by the immutability trigger while
  the pointer is NULL): without it, reports showed IB 0, year-end's
  duplicate-IB blocker never armed, and the manual IB flow could
  double-book. It also diffs the survivor's lines against the file's IB
  and calls out stale amounts in the warning instead of keeping them
  silently; reverseEntry clears the pointer again for the
  storno-then-reimport path.
- Series-less #VER records resolve to the transaction fallback series at
  import time, so the IB default picker now treats that series as used by
  the file (the same #1882 shift pattern through the fallback). The
  wizard recomputes its IB default with the effective transaction series
  once loaded.
- openingBalanceSeries is type-checked on the web execute route, the MCP
  stage, and the staged-operation commit: a non-string falls back to the
  default instead of crashing mid-import after side effects.
- The wizard's IB series select flags series used by the file and shows
  an attention line when the chosen series collides; the engine warns
  when an explicitly chosen series collides with the file's series (the
  choice is honored).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sie): uppercase caller-chosen IB series before persisting

Swedish accounting review on PR #1896: a lowercase series from v1 or
MCP was persisted as-is, booking a case-distinct parallel series next
to its uppercase sibling (BFL 5 kap requires one systematic series)
and slipping past the file-collision warning. Normalize centrally in
executeSIEImport, the single funnel for web, v1, and MCP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-25 15:14:59 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 79013cf092
commit c6f2bebab9
14 changed files with 1020 additions and 6 deletions
@@ -0,0 +1,544 @@
/**
* Issue #1882: the Ingående balanser voucher was hardcoded to series A and
* created BEFORE the file's vouchers, so it consumed the A series' next
* number and shifted every A voucher one number higher than in the source
* system. It must book in a caller-chosen series, defaulting to one the
* file's own vouchers do not use.
*
* Also covers the orphan-IB guard: replace_sie_import deletes only
* source_type='import' entries and clears the period's OB pointer, so a
* prior import's IB voucher survives every replace cycle: without the
* guard each re-import created another IB verifikat (a field report
* accumulated five).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { executeSIEImport } from '../sie-import'
import {
DEFAULT_OPENING_BALANCE_SERIES,
defaultOpeningBalanceSeries,
defaultImportOpeningBalancesOn,
} from '../opening-balance-defaults'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type { ParsedSIEFile, AccountMapping } from '../types'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(async () => ({ id: 'ob-entry-1' })),
replaceOpeningBalanceEntry: vi.fn(),
}))
vi.mock('@/lib/reports/imbalance-diagnosis', () => ({
findUntransferredResults: vi.fn(async () => []),
}))
// --- Helpers (same routing-mock pattern as sie-import-derived-ib.test.ts) ---
type QueuedResult = { data?: unknown; error?: unknown; count?: number | null }
function buildRoutingSupabase(tableQueues: Record<string, QueuedResult[]>) {
const queues = new Map<string, QueuedResult[]>(
Object.entries(tableQueues).map(([k, v]) => [k, [...v]])
)
const makeChain = (result: { data: unknown; error: unknown; count: number | null }): unknown => {
const handler: ProxyHandler<object> = {
get(_target, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return (..._args: unknown[]) => makeChain(result)
},
}
return new Proxy({}, handler)
}
const supabase = {
from: (table: string) => {
const next = queues.get(table)?.shift() ?? {}
return makeChain({
data: next.data ?? null,
error: next.error ?? null,
count: next.count ?? null,
})
},
rpc: async () => ({ data: null, error: null }),
storage: {
from: () => ({ upload: async () => ({ error: null }) }),
},
}
return supabase as unknown as SupabaseClient
}
function makeParsedFile(overrides?: Partial<ParsedSIEFile>): ParsedSIEFile {
return {
header: {
sieType: 4,
flagga: 0,
program: 'TestProg',
programVersion: '1.0',
generatedDate: '2024-01-01',
format: 'PC8',
companyName: 'Serie AB',
orgNumber: '5566778899',
address: null,
fiscalYears: [{ yearIndex: 0, start: '2024-01-01', end: '2024-12-31' }],
currency: 'SEK',
kontoPlanType: null,
},
accounts: [
{ number: '1930', name: 'Företagskonto' },
{ number: '2010', name: 'Eget kapital' },
],
openingBalances: [
{ yearIndex: 0, account: '1930', amount: 5000 },
{ yearIndex: 0, account: '2010', amount: -5000 },
],
closingBalances: [],
resultBalances: [],
dimensions: [],
dimensionValues: [],
vouchers: [],
issues: [],
stats: {
totalAccounts: 2,
totalVouchers: 0,
totalTransactionLines: 0,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
...overrides,
}
}
function makeVoucher(series: string, number: number) {
return {
series,
number,
date: new Date(2024, 5, 1),
description: `Voucher ${series}${number}`,
lines: [
{ account: '1930', amount: 10 },
{ account: '2010', amount: -10 },
],
}
}
function makeMapping(source: string, target: string): AccountMapping {
return {
sourceAccount: source,
sourceName: `Account ${source}`,
targetAccount: target,
targetName: `Target ${target}`,
confidence: 1,
matchType: 'exact',
isOverride: false,
}
}
function standardQueues(): Record<string, QueuedResult[]> {
return {
sie_imports: [
{ data: null }, // checkDuplicateImport: no duplicate
{}, // cleanupStaleImportRecords delete
{ data: { id: 'imp-1' } }, // createPendingImportRecord insert
{ data: null }, // checkDuplicatePeriodImport: no duplicate
],
chart_of_accounts: [
{
data: [
{ account_number: '1930', account_name: 'Företagskonto' },
{ account_number: '2010', account_name: 'Eget kapital' },
],
},
],
fiscal_periods: [
{ data: { id: 'fp-1' } }, // find existing fiscal period
{ data: { opening_balances_set: false, opening_balance_entry_id: null } }, // IB-block check
],
journal_entries: [
{ count: 0 }, // companyHasPriorActivity: first-ever import
{ data: [] }, // orphan-IB guard: no surviving IB voucher
],
}
}
/** Lines matching makeParsedFile()'s IB: 1930 D 5000 / 2010 K 5000. */
function matchingOrphanLines(): QueuedResult {
return {
data: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
{ account_number: '2010', debit_amount: 0, credit_amount: 5000 },
],
}
}
const standardOptions = {
filename: 'serie.se',
fileContent: '#dummy',
createFiscalPeriod: false,
importOpeningBalances: true,
// False so voucher batch insertion is skipped: the vouchers in these
// fixtures exist only to exercise the IB default-series computation.
importTransactions: false,
updateAccountNames: false,
}
const standardMappings = [makeMapping('1930', '1930'), makeMapping('2010', '2010')]
// --- Tests ---
describe('opening-balance defaults helpers', () => {
it('defaults to M and never A', () => {
expect(DEFAULT_OPENING_BALANCE_SERIES).toBe('M')
expect(defaultOpeningBalanceSeries([])).toBe('M')
expect(defaultOpeningBalanceSeries(['A', 'B', 'F'])).toBe('M')
})
it('avoids series the file already uses', () => {
expect(defaultOpeningBalanceSeries(['A', 'M'])).toBe('O')
expect(defaultOpeningBalanceSeries(['m', ' o '])).toBe('P')
})
it('falls back to M when every candidate is taken', () => {
const all = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
expect(defaultOpeningBalanceSeries(all)).toBe('M')
})
it('IB toggle defaults on only for a first import with IB in the file', () => {
expect(
defaultImportOpeningBalancesOn({ hasOpeningBalances: true, existingIbEntryCount: 0 })
).toBe(true)
expect(
defaultImportOpeningBalancesOn({ hasOpeningBalances: true, existingIbEntryCount: 1 })
).toBe(false)
expect(
defaultImportOpeningBalancesOn({ hasOpeningBalances: false, existingIbEntryCount: 0 })
).toBe(false)
})
})
describe('executeSIEImport: IB voucher series (issue #1882)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('books the IB voucher in the caller-chosen series', async () => {
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
{ ...standardOptions, openingBalanceSeries: 'K' },
)
expect(result.errors).toEqual([])
expect(result.success).toBe(true)
expect(createJournalEntry).toHaveBeenCalledTimes(1)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.source_type).toBe('opening_balance')
expect(input.voucher_series).toBe('K')
})
it('defaults to M, not A, for a file whose vouchers use series A', async () => {
const parsed = makeParsedFile({
vouchers: [makeVoucher('A', 1), makeVoucher('A', 2)],
stats: {
totalAccounts: 2,
totalVouchers: 2,
totalTransactionLines: 4,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
})
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
parsed,
standardMappings,
standardOptions,
)
expect(result.success).toBe(true)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('M')
})
it('moves off M when the file itself uses an M series', async () => {
const parsed = makeParsedFile({
vouchers: [makeVoucher('A', 1), makeVoucher('M', 1)],
stats: {
totalAccounts: 2,
totalVouchers: 2,
totalTransactionLines: 4,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
})
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
parsed,
standardMappings,
standardOptions,
)
expect(result.success).toBe(true)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('O')
})
it('ignores a blank openingBalanceSeries option and falls back to the default', async () => {
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
{ ...standardOptions, openingBalanceSeries: ' ' },
)
expect(result.success).toBe(true)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('M')
})
it('uppercases a caller-chosen lowercase series so it cannot book a case-distinct parallel series', async () => {
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
{ ...standardOptions, openingBalanceSeries: 'k' },
)
expect(result.success).toBe(true)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('K')
})
it('falls back to the default when openingBalanceSeries is not a string', async () => {
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
// Web execute and MCP accept untyped JSON: a non-string must not
// crash mid-import (after the fiscal period is already created).
{ ...standardOptions, openingBalanceSeries: 123 as unknown as string },
)
expect(result.errors).toEqual([])
expect(result.success).toBe(true)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('M')
})
it('avoids the effective default series when the file has series-less vouchers', async () => {
// Series-less #VER records resolve to options.voucherSeries at import
// time, so an IB voucher in that series would shift their numbering:
// the same #1882 pattern through the fallback.
const parsed = makeParsedFile({
vouchers: [makeVoucher('', 1), makeVoucher('', 2)],
stats: {
totalAccounts: 2,
totalVouchers: 2,
totalTransactionLines: 4,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
})
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
parsed,
standardMappings,
{ ...standardOptions, voucherSeries: 'M' },
)
expect(result.success).toBe(true)
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('O')
})
it('warns when an explicitly chosen IB series collides with the file', async () => {
const parsed = makeParsedFile({
vouchers: [makeVoucher('A', 1)],
stats: {
totalAccounts: 2,
totalVouchers: 1,
totalTransactionLines: 2,
fiscalYearStart: '2024-01-01',
fiscalYearEnd: '2024-12-31',
},
})
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
parsed,
standardMappings,
// importTransactions must be on for the shift to be real: the
// warning is gated on it.
{ ...standardOptions, importTransactions: true, openingBalanceSeries: 'A' },
)
expect(result.warnings.join(' ')).toMatch(
/Vald verifikationsserie för ingående balanser \(A\) används även av filens verifikationer/
)
// The choice is honored: the caller may know better.
const input = vi.mocked(createJournalEntry).mock.calls[0][3]
expect(input.voucher_series).toBe('A')
})
})
describe('executeSIEImport: orphan-IB guard (issue #1882)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('skips IB creation and relinks the surviving voucher when one posted IB already exists', async () => {
const queues = standardQueues()
queues.journal_entries = [
{ count: 0 }, // companyHasPriorActivity: replace deleted all import entries
{ data: [{ id: 'orphan-ib-1' }] }, // orphan-IB guard: one survivor
]
queues.journal_entry_lines = [matchingOrphanLines()]
queues.fiscal_periods = [
{ data: { id: 'fp-1' } },
{ data: { opening_balances_set: false, opening_balance_entry_id: null } },
{}, // linkOpeningBalanceEntryToPeriod update: ok
]
const result = await executeSIEImport(
buildRoutingSupabase(queues),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(createJournalEntry).not.toHaveBeenCalled()
expect(result.openingBalanceEntryId).toBeNull()
const warnings = result.warnings.join(' ')
expect(warnings).toMatch(/verifikation för ingående balanser finns redan i räkenskapsåret/)
// Relinked: the survivor becomes the period's OB entry again, so
// reports, year-end's duplicate-IB blocker, and the manual IB gate
// all see it.
expect(warnings).toMatch(/har kopplats som räkenskapsårets ingående balans/)
// Amounts match the file: no stale-IB callout.
expect(warnings).not.toMatch(/skiljer sig/)
expect(result.success).toBe(true)
expect(result.errors).toEqual([])
})
it('calls out a surviving IB whose amounts differ from the file', async () => {
const queues = standardQueues()
queues.journal_entries = [
{ count: 0 },
{ data: [{ id: 'orphan-ib-1' }] },
]
// Stale orphan: 1930 D 4000 (file says 5000).
queues.journal_entry_lines = [
{
data: [
{ account_number: '1930', debit_amount: 4000, credit_amount: 0 },
{ account_number: '2010', debit_amount: 0, credit_amount: 4000 },
],
},
]
queues.fiscal_periods = [
{ data: { id: 'fp-1' } },
{ data: { opening_balances_set: false, opening_balance_entry_id: null } },
{}, // relink update: ok
]
const result = await executeSIEImport(
buildRoutingSupabase(queues),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(createJournalEntry).not.toHaveBeenCalled()
const warnings = result.warnings.join(' ')
expect(warnings).toMatch(/skiljer sig från filens ingående balanser/)
expect(warnings).toMatch(/Ångra \(storno\)/)
expect(result.success).toBe(true)
})
it('skips without relinking when several posted IB vouchers exist', async () => {
const queues = standardQueues()
queues.journal_entries = [
{ count: 0 },
{ data: [{ id: 'orphan-ib-1' }, { id: 'orphan-ib-2' }] },
]
const result = await executeSIEImport(
buildRoutingSupabase(queues),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(createJournalEntry).not.toHaveBeenCalled()
const warnings = result.warnings.join(' ')
expect(warnings).toMatch(/2 verifikationer för ingående balanser finns redan/)
expect(warnings).not.toMatch(/har kopplats/)
expect(result.success).toBe(true)
})
it('fails closed against duplication when the orphan check errors', async () => {
const queues = standardQueues()
queues.journal_entries = [
{ count: 0 },
{ error: { message: 'boom' } }, // orphan-IB guard query fails
]
const result = await executeSIEImport(
buildRoutingSupabase(queues),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(createJournalEntry).not.toHaveBeenCalled()
expect(result.openingBalanceEntryId).toBeNull()
expect(result.warnings.join(' ')).toMatch(
/det gick inte att kontrollera om en IB-verifikation redan finns/
)
expect(result.success).toBe(true)
})
it('still creates the IB voucher when no prior IB exists', async () => {
const result = await executeSIEImport(
buildRoutingSupabase(standardQueues()),
'company-1',
'user-1',
makeParsedFile(),
standardMappings,
standardOptions,
)
expect(createJournalEntry).toHaveBeenCalledTimes(1)
expect(result.openingBalanceEntryId).toBe('ob-entry-1')
expect(result.success).toBe(true)
})
})
+63
View File
@@ -0,0 +1,63 @@
/**
* Defaults for the SIE-import opening-balance (Ingående balanser) voucher.
*
* Pure module: imported by both the import engine (server) and the import
* wizard (client), so it must stay free of Supabase/engine dependencies.
*
* Issue #1882: the IB voucher used to be hardcoded to series A and created
* BEFORE the file's vouchers, so it consumed the A series' next number and
* shifted every A voucher one number higher than in the source system. The
* default series must therefore never collide with the series the file's
* own vouchers use.
*/
/**
* Default series for the IB voucher. 'M' matches the series the import
* engine already uses for its other system voucher (the migration
* adjustment / omföringsverifikation in sie-import.ts) and is not part of
* the common Swedish source-system conventions (A huvudserie, B automat,
* F kundfakturor, I inbetalningar, J bokslut, L leverantörsfakturor,
* N löner, U utbetalningar).
*/
export const DEFAULT_OPENING_BALANCE_SERIES = 'M'
/**
* Candidate series tried in order when the file's own vouchers already use
* the preferred default. Letters with a conventional meaning in Swedish
* bookkeeping (A, B, F, I, J, L, N, U) are deliberately excluded so the IB
* voucher never lands in a series a migrated company recognizes as
* something else.
*/
const SERIES_CANDIDATES = ['M', 'O', 'P', 'Q', 'R', 'S', 'T', 'V', 'W', 'X', 'Y', 'Z'] as const
/**
* Pick the default IB-voucher series: the first candidate not used by the
* file's own vouchers. Falls back to 'M' in the (practically impossible)
* case where a file uses every candidate; the user can still override in
* the wizard.
*/
export function defaultOpeningBalanceSeries(seriesInFile: Iterable<string>): string {
const used = new Set<string>()
for (const s of seriesInFile) {
const normalized = typeof s === 'string' ? s.trim().toUpperCase() : ''
if (normalized) used.add(normalized)
}
for (const candidate of SERIES_CANDIDATES) {
if (!used.has(candidate)) return candidate
}
return DEFAULT_OPENING_BALANCE_SERIES
}
/**
* Smart default for the wizard's "Importera ingående balanser" toggle.
* OFF when the file carries no IB, and OFF on re-import when the fiscal
* year already has a posted opening-balance voucher: importing again would
* create a duplicate "Ingående balanser" verifikat (the field report behind
* issue #1882 had five accumulated ones).
*/
export function defaultImportOpeningBalancesOn(args: {
hasOpeningBalances: boolean
existingIbEntryCount: number
}): boolean {
return args.hasOpeningBalances && args.existingIbEntryCount === 0
}
+182 -3
View File
@@ -19,8 +19,10 @@ import type {
MigrationDocumentation,
} from './types'
import type { CreateJournalEntryLineInput } from '@/types'
import { roundOre } from '@/lib/money'
import { mappingsToMap, getMappingStats } from './account-mapper'
import { syncMappedAccounts } from './account-sync'
import { defaultOpeningBalanceSeries } from './opening-balance-defaults'
import {
calculateFileHash,
getEffectiveOpeningBalances,
@@ -99,6 +101,13 @@ export function generateImportPreview(
lowConfidence: mappingStats.lowConfidence,
},
excludedSystemAccounts: [],
voucherSeriesInFile: [
...new Set(
parsed.vouchers
.map((v) => (v.series ?? '').trim())
.filter((s) => s.length > 0)
),
].sort(),
issues: derivedFromPriorYearUB
? [
...parsed.issues,
@@ -763,7 +772,8 @@ async function createOpeningBalanceEntry(
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>,
roundingAdjustment: number
roundingAdjustment: number,
voucherSeries: string
): Promise<string | null> {
// Effective set: explicit #IB 0, or IB derived from #UB -1 (issue #675).
const { balances: currentYearBalances, derivedFromPriorYearUB } =
@@ -831,7 +841,11 @@ async function createOpeningBalanceEntry(
? 'Ingående balanser från SIE-import (härledda från föregående års utgående balans)'
: 'Ingående balanser från SIE-import',
source_type: 'opening_balance',
voucher_series: 'A',
// Never hardcoded to 'A': the IB entry is created BEFORE the file's
// vouchers, so booking it in a series the file uses would consume that
// series' next number and shift every imported voucher one number
// higher than in the source system (issue #1882).
voucher_series: voucherSeries,
lines,
})
@@ -2025,6 +2039,9 @@ export async function executeSIEImport(
importOpeningBalances: boolean
importTransactions: boolean
voucherSeries?: string
// Series for the opening-balance voucher. Defaults to a series the
// file's own vouchers do not use (issue #1882): never 'A'.
openingBalanceSeries?: string
onExistingPeriod?: 'block' | 'replace'
updateAccountNames?: boolean
// Opt-in: mark every imported (source_type='import') verifikat as "Inget
@@ -2353,6 +2370,52 @@ export async function executeSIEImport(
// Source series from #VER are preserved per-voucher by importVouchers.
const defaultSeries = options.voucherSeries || 'B'
// Series for the IB voucher: caller's choice, else the first candidate
// the file's own vouchers do not use. The IB entry is created before
// the file's vouchers, so a colliding series would consume its next
// number and shift the whole series by one (issue #1882).
//
// Type-checked, not just trimmed: the web execute route and MCP accept
// untyped JSON, and a non-string here must fall back to the default,
// not crash mid-import after the fiscal period was already created.
// Uppercased before persisting: a lowercase 'a' would otherwise book a
// case-distinct parallel series next to 'A', fragmenting what BFL
// 5 kap requires to be one systematic series, and would slip past the
// collision warning below.
const requestedOpeningBalanceSeries =
typeof options.openingBalanceSeries === 'string'
? options.openingBalanceSeries.trim().toUpperCase()
: ''
const seriesUsedByFile = new Set(
parsed.vouchers
.map((v) => (v.series ?? '').trim().toUpperCase())
.filter((s) => s.length > 0)
)
// Series-less #VER records (SIE4I subsystem files) resolve to
// defaultSeries at import time, so the default picker must treat that
// series as used by the file too: otherwise the IB voucher can land in
// it and shift those vouchers' numbering, the same #1882 pattern.
if (parsed.vouchers.some((v) => !(v.series ?? '').trim())) {
seriesUsedByFile.add(defaultSeries.trim().toUpperCase())
}
const openingBalanceSeries =
requestedOpeningBalanceSeries || defaultOpeningBalanceSeries(seriesUsedByFile)
// An explicitly chosen IB series that the file's vouchers also use
// reintroduces the numbering shift this option exists to prevent.
// Honor the choice (the caller may know better) but say what it does.
if (
requestedOpeningBalanceSeries &&
options.importOpeningBalances &&
options.importTransactions &&
seriesUsedByFile.has(requestedOpeningBalanceSeries)
) {
result.warnings.push(
`Vald verifikationsserie för ingående balanser (${requestedOpeningBalanceSeries}) används även av filens verifikationer: ` +
'IB-verifikationen tar seriens nästa nummer, så filens verifikationer i den serien kan förskjutas ett nummer jämfört med källsystemet.'
)
}
// Validate and import opening balances.
//
// IB imbalance is NORMAL in Swedish SIE files for two common reasons:
@@ -2401,6 +2464,120 @@ export async function executeSIEImport(
'Stäm av mot SIE-filens #IB om du är osäker.'
)
} else {
// Orphan-IB guard (issue #1882): the period pointer above is not
// proof that no IB voucher exists. replace_sie_import deletes only
// source_type='import' entries and CLEARS the period's OB pointer,
// so a prior import's IB voucher (source_type='opening_balance')
// survives every replace cycle with no pointer left behind: each
// re-import then created another "Ingående balanser" verifikat
// (field report: five accumulated). Look the survivors up directly
// and skip when any exist. On a failed check, skip too (fail
// closed against duplication) and say why.
const { data: existingIbEntries, error: existingIbError } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', result.fiscalPeriodId)
.eq('source_type', 'opening_balance')
.eq('status', 'posted')
if (existingIbError) {
result.warnings.push(
`Ingående balanser hoppades över: det gick inte att kontrollera om en IB-verifikation redan finns (${existingIbError.message}). ` +
'Importera om filen med enbart ingående balanser, eller skapa IB manuellt, om ingen IB-verifikation finns.'
)
} else if ((existingIbEntries?.length ?? 0) > 0) {
// Skip the duplicate, but leave a consistent state behind. With
// the pointer NULL, getOpeningBalances falls back to
// compute_prior_opening_balances, which excludes an IB entry
// dated ON period_start (reports then show IB = 0), the manual
// IB flow (gated on opening_balances_set) can double-book, and
// year-end's duplicate-IB blocker never arms. So when the
// survivor is unambiguous (exactly one), relink it as the
// period's OB entry (permitted by
// enforce_opening_balance_immutability while the pointer is
// NULL) and diff its lines against the file's IB so a stale
// orphan is called out instead of silently kept. Both steps are
// best effort: the skip alone already stops the duplication, and
// reverseEntry clears the pointer again if the user stornos the
// relinked voucher to re-import corrected balances.
const orphans = existingIbEntries ?? []
let relinked = false
let amountsDiffer = false
if (orphans.length === 1) {
try {
const expected = validateIBBalance(parsed, accountMap)
const expectedNet = new Map<string, number>()
for (const line of expected.lines) {
const prev = expectedNet.get(line.account_number) ?? 0
expectedNet.set(
line.account_number,
roundOre(prev + line.debit_amount - line.credit_amount)
)
}
if (Math.abs(expected.roundingAdjustment) > 0.01) {
// createOpeningBalanceEntry books the adjustment on 2099
// with the opposite sign of the mapped diff.
const prev = expectedNet.get('2099') ?? 0
expectedNet.set('2099', roundOre(prev - expected.roundingAdjustment))
}
const { data: orphanLines, error: orphanLinesError } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount')
.eq('journal_entry_id', orphans[0].id)
if (orphanLinesError) {
throw new Error(orphanLinesError.message)
}
const orphanNet = new Map<string, number>()
for (const line of orphanLines ?? []) {
const prev = orphanNet.get(line.account_number) ?? 0
orphanNet.set(
line.account_number,
roundOre(prev + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0))
)
}
for (const account of new Set([...expectedNet.keys(), ...orphanNet.keys()])) {
const diff = (expectedNet.get(account) ?? 0) - (orphanNet.get(account) ?? 0)
if (Math.abs(diff) > 0.01) {
amountsDiffer = true
break
}
}
await linkOpeningBalanceEntryToPeriod(
supabase,
companyId,
result.fiscalPeriodId,
orphans[0].id
)
relinked = true
} catch (relinkError) {
// Best effort only: a failed comparison or relink keeps the
// pre-guard state, and the warning below still says what to
// do about the surviving IB voucher.
console.error('[sie-import] orphan-IB relink skipped (non-fatal):', relinkError)
}
}
let ibSkipWarning =
orphans.length === 1
? 'En verifikation för ingående balanser finns redan i räkenskapsåret: hoppar över IB-import för att inte skapa en dubblett.'
: `${orphans.length} verifikationer för ingående balanser finns redan i räkenskapsåret: hoppar över IB-import för att inte skapa ännu en dubblett.`
if (relinked) {
ibSkipWarning +=
' Den befintliga IB-verifikationen har kopplats som räkenskapsårets ingående balans.'
}
if (amountsDiffer) {
ibSkipWarning +=
' OBS: den befintliga IB-verifikationens belopp skiljer sig från filens ingående balanser. ' +
'Ångra (storno) den gamla IB-verifikationen och importera om filen om filens belopp är de rätta.'
} else {
ibSkipWarning +=
' Ångra eller ta bort den gamla IB-verifikationen först om du vill importera om ingående balanser.'
}
result.warnings.push(ibSkipWarning)
} else {
const ibValidation = validateIBBalance(parsed, accountMap)
if (ibValidation.lines.length > 0) {
@@ -2448,7 +2625,8 @@ export async function executeSIEImport(
result.fiscalPeriodId,
parsed,
accountMap,
ibRoundingAdjustment
ibRoundingAdjustment,
openingBalanceSeries
)
if (result.openingBalanceEntryId) {
@@ -2464,6 +2642,7 @@ export async function executeSIEImport(
}
}
}
}
}
}
+12
View File
@@ -265,6 +265,12 @@ export interface ImportOptions {
// Voucher series to use for imported entries
voucherSeries?: string
// Voucher series for the opening-balance (Ingående balanser) entry.
// Defaults to a series the file's own vouchers do not use (see
// lib/import/opening-balance-defaults.ts) so the IB voucher never shifts
// the numbering of the file's series (issue #1882).
openingBalanceSeries?: string
// Opt-in: mark imported verifikat as "Inget underlag krävs" so a migration
// doesn't flood "Att hantera: saknade underlag". OFF by default.
markImportedNoDocRequired?: boolean
@@ -413,6 +419,12 @@ export interface ImportPreview {
// Source-system accounts excluded from import (e.g. Fortnox 0099)
excludedSystemAccounts: { number: string; name: string }[]
// Distinct voucher series used by the file's #VER records. Lets the
// wizard default the IB-voucher series to one that does not collide
// with the file's own numbering (issue #1882). Optional: previews built
// before this field existed lack it; consumers must treat absence as [].
voucherSeriesInFile?: string[]
// Issues to review
issues: ParseIssue[]
}
+7
View File
@@ -4721,6 +4721,12 @@ async function commitImportSie(
const importOpeningBalances = Boolean(params.import_opening_balances)
const importTransactions = Boolean(params.import_transactions)
const voucherSeries = params.voucher_series as string | undefined
// Optional IB-voucher series (issue #1882). Absent on operations staged
// before the param existed: executeSIEImport then defaults to a series
// the file's own vouchers do not use. Type-checked, not cast: staged
// params are caller-supplied JSON.
const openingBalanceSeries =
typeof params.opening_balance_series === 'string' ? params.opening_balance_series : undefined
// Default true (not Boolean(...): operations staged before this param
// existed must keep the file's account names, matching the UI default).
const updateAccountNames =
@@ -4745,6 +4751,7 @@ async function commitImportSie(
importOpeningBalances,
importTransactions,
voucherSeries,
openingBalanceSeries,
updateAccountNames,
})