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 && (
+