diff --git a/DECISIONS.md b/DECISIONS.md index d9f145ac..3310dad8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1217,6 +1217,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] WhatsApp instant received-signal = emoji reaction (U+2705) sent from the webhook, not an extra text message: reactions add no chat bubble so the one-combined-ack-per-burst design survives; best-effort and not persisted as an outbound row (cosmetic, like mark-read), gated on the CHAT_ALLOWED_MIME_TYPES allowlist so junk never earns a checkmark. [2026-08-24] MCP lazy authentication (#1814 PR 2) lists the FULL default tool catalog to anonymous clients and only gates tools/call: the agent has to be able to name a protected tool to trigger the 401 challenge that opens the Connect (and signup) prompt; listing only public tools would hide the trigger. Descriptions are public documentation anyway. [2026-08-24] Public (pre-auth) MCP tools are the three documentation tools only (search_tools, list_skills, load_skill); org-number lookup stays behind the challenge for now because the TIC lookup lives in another extension and cross-extension imports are forbidden. +[2026-08-25] SIE IB voucher series (issue #1882): default is 'M' (first candidate of M,O,P,Q,R,S,T,V,W,X,Y,Z not used by the file's #VER), matching the migration-adjustment series and avoiding conventional series letters; user-overridable in the wizard, openingBalanceSeries in APIs, opening_balance_series in MCP. MCP import_opening_balances stays default false (web true) deliberately: agent imports are often continuation imports where an IB entry would duplicate a prior year's UB, and the divergence is now documented in the tool schema. +[2026-08-25] IB accumulation root cause (issue #1882): replace_sie_import deletes only source_type='import' rows and clears the OB pointer, so the IB voucher (source_type='opening_balance') survives every replace cycle and each re-import created another one. Fixed app-side with an orphan-IB guard in executeSIEImport (skip + warning when a posted opening_balance entry already exists in the period) instead of amending the RPC: the RPC change needs a migration + pg tests on a legally sensitive delete path and is left as a follow-up. +[2026-08-25] Orphan-IB guard relinks a single survivor and diffs its amounts (skeptic pass on PR #1896): skipping alone left the period pointer NULL, so reports showed IB 0 (compute_prior_opening_balances excludes entries dated on period_start), year-end's duplicate-IB blocker never armed, and the manual IB flow could double-book; relink is permitted by enforce_opening_balance_immutability while the pointer is NULL, reverseEntry clears it again for the storno-then-reimport path, and a stale orphan whose amounts differ from the file is called out in the warning instead of silently kept. Auto-storno of the stale orphan was deliberately NOT done: reversing posted entries inside an import needs founder sign-off. [2026-08-25] reset_fiscal_year (#1883) catches foreign_key_violation and aborts instead of enumerating every table that references journal_entries: RESTRICT/NO ACTION FKs (assets, accruals, salary runs) fail the whole reset with FISCAL_YEAR_RESET_LINKED_ENTRIES, SET NULL FKs (transactions, invoice payments) unlink as their designers intended. Future FKs stay covered without touching the RPC. [2026-08-25] Fiscal-year reset treats a posted vat_settlement verifikat, SKV declaration lock/submit audit rows and skatteverket-extension submission_* keys as "VAT declared" evidence and refuses: SKV's actual declaration state is not observable from the DB, so every local trace fails closed (same posture as company_migration_reset 20260818224000). [2026-08-25] Proposal prefill/preview (lib/bookkeeping/proposal-lines.ts) mirrors the ENGINE's formulas, not the historical preview: net leg = gross minus single-rounded VAT (independently rounded extractNet/extractVat unbalances 12% grosses at 14 mod 28 ore), plain Math.round ore parity via a local engineRound (roundOre's EPSILON nudge diverges from booked verifikat at exact-half floats like 8.62*0.25), legacy counterparty proposals now render the 2645/2614 fiktiv-moms pair the legacy booking path actually emits (the 2026-07-29 decision claimed preview/engine parity but the preview omitted the pair; once 'Andra rader' made the preview bookable, the omission would book RC expenses without fiktiv moms), sign-mismatched counterparty matches are mirrored like the server, static template accounts are entity-resolved (_ab), the 'none' VAT sentinel is resolved via resolveExplicitVat before line computation, and the settlement swap applies only to a literal 1930 leg (applySettlementAccount parity). Chosen over sourcing the prefill from MappingResult builders directly to keep the client dialog free of server-only inputs; skeptic counterexamples are locked in proposal-lines.test.ts. diff --git a/app/api/import/sie/execute/route.ts b/app/api/import/sie/execute/route.ts index 4d7bc10f..9cf9fe74 100644 --- a/app/api/import/sie/execute/route.ts +++ b/app/api/import/sie/execute/route.ts @@ -109,6 +109,15 @@ export const POST = withRouteContext( importOpeningBalances: options.importOpeningBalances, importTransactions: options.importTransactions, voucherSeries: options.voucherSeries || companyDefaultSeries, + // Series for the Ingående balanser voucher (issue #1882). Optional: + // executeSIEImport falls back to a series the file's vouchers do + // not use, never the hardcoded 'A' that shifted the A numbering. + // Type-checked: this route has no Zod schema on options, and a + // non-string must fall back, not crash mid-import. + openingBalanceSeries: + typeof options.openingBalanceSeries === 'string' + ? options.openingBalanceSeries + : undefined, updateAccountNames: options.updateAccountNames ?? true, markImportedNoDocRequired: options.markImportedNoDocRequired ?? false, }, diff --git a/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts index 89b38874..6c820f44 100644 --- a/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/imports/sie/__tests__/route.test.ts @@ -184,4 +184,17 @@ describe('POST /imports/sie', () => { expect(res.status).toBe(400) expect(executeSIEImportMock).not.toHaveBeenCalled() }) + + it('passes openingBalanceSeries through and leaves it undefined by default (issue #1882)', async () => { + await callRoute({ openingBalanceSeries: 'K' }) + let options = executeSIEImportMock.mock.calls[0][5] as Record + expect(options.openingBalanceSeries).toBe('K') + + executeSIEImportMock.mockClear() + await callRoute() + options = executeSIEImportMock.mock.calls[0][5] as Record + // Undefined lets executeSIEImport pick a series the file's own vouchers + // do not use, instead of a hardcoded default that could collide. + expect(options.openingBalanceSeries).toBeUndefined() + }) }) diff --git a/app/api/v1/companies/[companyId]/imports/sie/route.ts b/app/api/v1/companies/[companyId]/imports/sie/route.ts index a689424a..46b73e02 100644 --- a/app/api/v1/companies/[companyId]/imports/sie/route.ts +++ b/app/api/v1/companies/[companyId]/imports/sie/route.ts @@ -153,6 +153,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( importOpeningBalances: z.boolean().optional().default(true), importTransactions: z.boolean().optional().default(true), voucherSeries: z.string().min(1).max(2).optional().default('A'), + // Series for the Ingående balanser voucher (issue #1882). No default + // here: executeSIEImport picks a series the file's vouchers do not + // use, so the IB entry never shifts the file's own numbering. + openingBalanceSeries: z.string().min(1).max(2).optional(), updateAccountNames: z.boolean().optional().default(true), }) // OWASP V4.5: reject unknown keys so a future schema-extension @@ -294,6 +298,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>( importOpeningBalances: options.importOpeningBalances, importTransactions: options.importTransactions, voucherSeries: options.voucherSeries, + openingBalanceSeries: options.openingBalanceSeries, updateAccountNames: options.updateAccountNames, }, ) diff --git a/components/import/ImportReviewStep.tsx b/components/import/ImportReviewStep.tsx index d23af2b8..0d9d9a43 100644 --- a/components/import/ImportReviewStep.tsx +++ b/components/import/ImportReviewStep.tsx @@ -1,6 +1,7 @@ 'use client' import { useState, useEffect, useRef } from 'react' +import { useTranslations } from 'next-intl' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' @@ -27,7 +28,12 @@ import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import { useCanWrite } from '@/lib/hooks/use-can-write' import { createClient } from '@/lib/supabase/client' import { useCompany } from '@/contexts/CompanyContext' +import { AttnLine } from '@/components/ui/attn-line' import ImportTheater from '@/components/import/ImportTheater' +import { + defaultImportOpeningBalancesOn, + defaultOpeningBalanceSeries, +} from '@/lib/import/opening-balance-defaults' import type { ImportPreview, AccountMapping } from '@/lib/import/types' import type { TheaterModel } from '@/lib/import/theater-model' @@ -50,6 +56,9 @@ export interface ImportExecuteOptions { importTransactions: boolean updateAccountNames: boolean voucherSeries: string + /** Series for the Ingående balanser voucher. Defaults to one the file's + * own vouchers do not use, so their numbering is never shifted (#1882). */ + openingBalanceSeries: string markImportedNoDocRequired: boolean } @@ -63,17 +72,23 @@ export default function ImportReviewStep({ }: ImportReviewStepProps) { const { canWrite } = useCanWrite() const { company } = useCompany() + const t = useTranslations('import') const [options, setOptions] = useState({ createFiscalPeriod: true, importOpeningBalances: true, importTransactions: true, updateAccountNames: true, voucherSeries: 'B', + openingBalanceSeries: defaultOpeningBalanceSeries(preview.voucherSeriesInFile ?? []), markImportedNoDocRequired: false, }) const [defaultSeries, setDefaultSeries] = useState(null) const [existingSeries, setExistingSeries] = useState>(new Set()) const [seriesLoaded, setSeriesLoaded] = useState(false) + // Posted opening-balance vouchers already booked in the file's fiscal year. + // Non-zero means a re-import: the IB toggle then defaults OFF (issue #1882; + // a field report accumulated five IB vouchers from repeated test imports). + const [existingIbCount, setExistingIbCount] = useState(0) const [elapsed, setElapsed] = useState(0) const intervalRef = useRef | null>(null) @@ -84,9 +99,26 @@ export default function ImportReviewStep({ let cancelled = false ;(async () => { + // Smart IB-toggle default (issue #1882): a posted opening-balance + // voucher already booked inside the file's fiscal year means this is + // a re-import, and importing IB again would create a duplicate + // "Ingående balanser" verifikat. + const ibCountQuery = + preview.fiscalYearStart && preview.fiscalYearEnd + ? supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', company.id) + .eq('source_type', 'opening_balance') + .eq('status', 'posted') + .gte('entry_date', preview.fiscalYearStart) + .lte('entry_date', preview.fiscalYearEnd) + : Promise.resolve({ count: 0, error: null }) + const [ { data: settingsData, error: settingsError }, { data: sequencesData, error: sequencesError }, + { count: ibCount, error: ibCountError }, ] = await Promise.all([ supabase .from('company_settings') @@ -97,6 +129,7 @@ export default function ImportReviewStep({ .from('voucher_sequences') .select('voucher_series') .eq('company_id', company.id), + ibCountQuery, ]) if (cancelled) return @@ -107,22 +140,43 @@ export default function ImportReviewStep({ if (sequencesError) { console.error('Failed to load voucher sequences', sequencesError) } + if (ibCountError) { + console.error('Failed to check for existing opening-balance vouchers', ibCountError) + } const companyDefault = settingsData?.default_voucher_series || null const sequences = new Set((sequencesData || []).map((row) => row.voucher_series)) + const existingIb = ibCountError ? 0 : (ibCount ?? 0) setDefaultSeries(companyDefault) setExistingSeries(sequences) + setExistingIbCount(existingIb) const initial = companyDefault || (sequences.has('B') ? 'B' : Array.from(sequences).sort()[0]) || 'A' - setOptions((prev) => ({ ...prev, voucherSeries: initial })) + setOptions((prev) => ({ + ...prev, + voucherSeries: initial, + // Recompute with the effective transaction series excluded: file + // vouchers WITHOUT a series land in that series at import time, so + // the IB default must avoid it too (issue #1882). Safe to overwrite: + // the select is disabled until seriesLoaded, so no user choice can + // be clobbered here. + openingBalanceSeries: defaultOpeningBalanceSeries([ + ...(preview.voucherSeriesInFile ?? []), + initial, + ]), + importOpeningBalances: defaultImportOpeningBalancesOn({ + hasOpeningBalances: preview.openingBalanceTotal > 0, + existingIbEntryCount: existingIb, + }), + })) setSeriesLoaded(true) })() return () => { cancelled = true } - }, [company?.id]) + }, [company?.id, preview.fiscalYearStart, preview.fiscalYearEnd, preview.openingBalanceTotal]) // Block browser close/refresh during import useUnsavedChanges(isLoading) @@ -150,6 +204,13 @@ export default function ImportReviewStep({ setOptions((prev) => ({ ...prev, [key]: value })) } + // Series used by the file's own #VER records, uppercased for comparison. + // Booking the IB voucher in one of these consumes that series' next + // number and shifts the file's numbering by one (issue #1882). + const seriesInFile = new Set( + (preview.voucherSeriesInFile ?? []).map((s) => s.trim().toUpperCase()) + ) + // Calculate what will be imported const mappedCount = mappings.filter((m) => m.targetAccount).length const hasOpeningBalances = preview.openingBalanceTotal > 0 @@ -297,6 +358,9 @@ export default function ImportReviewStep({ ? `Skapar verifikation för IB på ${formatCurrency(preview.openingBalanceTotal)}` : 'Inga ingående balanser i filen'}

+ {existingIbCount > 0 && ( +

{t('ib_exists_hint')}

+ )} + {/* Voucher series for the opening-balance voucher (issue #1882) */} + {options.importOpeningBalances && hasOpeningBalances && ( +
+ + + {seriesInFile.has(options.openingBalanceSeries.toUpperCase()) ? ( + {t('ib_series_collision')} + ) : ( +

{t('ib_series_hint')}

+ )} +
+ )} + {/* Transactions */}
diff --git a/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts b/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts index fe0ee0ec..cd8a0e99 100644 --- a/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts +++ b/extensions/general/mcp-server/__tests__/import-sie-stage.test.ts @@ -234,4 +234,57 @@ describe('gnubok_import_sie: update_account_names staging', () => { expect((staged[0].params as Record).update_account_names).toBe(false) }) + + it('passes opening_balance_series through to the staged params (issue #1882)', async () => { + const { supabase, staged } = buildCapturingSupabase() + + await importSie.execute( + { + file_content: VALID_SIE, + filename: 'bok.se', + mappings: COVER_VALID_SIE, + import_opening_balances: true, + opening_balance_series: 'K', + }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' }, + ) + + expect((staged[0].params as Record).opening_balance_series).toBe('K') + }) + + it('drops a non-string opening_balance_series instead of staging it (hosts do not always enforce inputSchema)', async () => { + const { supabase, staged } = buildCapturingSupabase() + + await importSie.execute( + { + file_content: VALID_SIE, + filename: 'bok.se', + mappings: COVER_VALID_SIE, + opening_balance_series: 123, + }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' }, + ) + + expect((staged[0].params as Record).opening_balance_series).toBeUndefined() + }) + + it('leaves opening_balance_series unset when omitted so the engine picks a non-colliding default', async () => { + const { supabase, staged } = buildCapturingSupabase() + + await importSie.execute( + { file_content: VALID_SIE, filename: 'bok.se', mappings: COVER_VALID_SIE }, + 'company-1', + 'user-1', + supabase as never, + { type: 'api_key' }, + ) + + expect((staged[0].params as Record).opening_balance_series).toBeUndefined() + }) }) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 68bc6462..9b521cfe 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -15862,9 +15862,10 @@ export const tools: McpTool[] = [ items: { type: 'object' }, }, create_fiscal_period: { type: 'boolean' }, - import_opening_balances: { type: 'boolean' }, + import_opening_balances: { type: 'boolean', description: 'Default false (web wizard defaults true)' }, import_transactions: { type: 'boolean' }, voucher_series: { type: 'string', description: 'Override voucher series for imported vouchers' }, + opening_balance_series: { type: 'string', description: 'Series for the IB voucher; default avoids series used by the file' }, update_account_names: { type: 'boolean', description: 'Use #KONTO names from the file for created and existing accounts (default true). Set false to keep BAS default names.' }, }, required: ['file_content', 'filename', 'mappings'], @@ -15944,6 +15945,12 @@ export const tools: McpTool[] = [ import_opening_balances: Boolean(args.import_opening_balances), import_transactions: Boolean(args.import_transactions), voucher_series: args.voucher_series, + // Hosts do not always enforce inputSchema: a non-string must fall + // back to the engine default, not crash at commit time. + opening_balance_series: + typeof args.opening_balance_series === 'string' + ? args.opening_balance_series + : undefined, // Default true: Boolean(undefined) would silently flip it off. update_account_names: args.update_account_names === undefined ? true : Boolean(args.update_account_names), diff --git a/lib/import/__tests__/sie-import-ib-series.test.ts b/lib/import/__tests__/sie-import-ib-series.test.ts new file mode 100644 index 00000000..fce9a1d8 --- /dev/null +++ b/lib/import/__tests__/sie-import-ib-series.test.ts @@ -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) { + const queues = new Map( + Object.entries(tableQueues).map(([k, v]) => [k, [...v]]) + ) + + const makeChain = (result: { data: unknown; error: unknown; count: number | null }): unknown => { + const handler: ProxyHandler = { + 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 { + 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 { + 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) + }) +}) diff --git a/lib/import/opening-balance-defaults.ts b/lib/import/opening-balance-defaults.ts new file mode 100644 index 00000000..dd03ed3b --- /dev/null +++ b/lib/import/opening-balance-defaults.ts @@ -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 { + const used = new Set() + 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 +} diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts index 4c9fbd05..e7d8916f 100644 --- a/lib/import/sie-import.ts +++ b/lib/import/sie-import.ts @@ -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, - roundingAdjustment: number + roundingAdjustment: number, + voucherSeries: string ): Promise { // 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() + 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() + 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( } } } + } } } diff --git a/lib/import/types.ts b/lib/import/types.ts index bfe005e4..97a639d0 100644 --- a/lib/import/types.ts +++ b/lib/import/types.ts @@ -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[] } diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 87b69f5b..1d3750f7 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -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, }) diff --git a/messages/en.json b/messages/en.json index b5921806..9ab3bf2a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7513,6 +7513,11 @@ "tab_import": "Import", "tab_export": "Export", "sandbox_disabled": "Bank connections and migration from other systems require a real account and are disabled in the sandbox. File-based imports (bank files, CSV/Excel and SIE) work as usual.", + "ib_series_label": "Voucher series for opening balances", + "ib_series_hint": "Pick a series the file's own vouchers do not use, otherwise their numbering shifts", + "ib_series_in_file": "used in the file", + "ib_series_collision": "This series is used by the file's own vouchers: the opening-balance voucher takes the series' next number and shifts the file's numbering by one", + "ib_exists_hint": "An opening-balance voucher already exists for this fiscal year, so opening-balance import is off by default", "back_to_choices": "Back to choices", "psd2_title": "Connect bank", "psd2_recommended": "Recommended", diff --git a/messages/sv.json b/messages/sv.json index 18be832e..84fe0ab9 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -7513,6 +7513,11 @@ "tab_import": "Importera", "tab_export": "Exportera", "sandbox_disabled": "Bankkoppling och migrering från andra system kräver ett riktigt konto och är avstängda i sandlådan. Filbaserad import (bankfiler, CSV/Excel och SIE) fungerar som vanligt.", + "ib_series_label": "Verifikationsserie för ingående balanser", + "ib_series_hint": "Välj en serie som inte används av verifikationerna i filen, annars förskjuts deras numrering", + "ib_series_in_file": "används i filen", + "ib_series_collision": "Serien används av verifikationerna i filen: IB-verifikationen tar seriens nästa nummer och filens numrering förskjuts ett steg", + "ib_exists_hint": "En verifikation för ingående balanser finns redan för det här räkenskapsåret, därför är importen av ingående balanser avstängd som standard", "back_to_choices": "Tillbaka till val", "psd2_title": "Koppla bank", "psd2_recommended": "Rekommenderat",