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
+111 -2
View File
@@ -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<ImportExecuteOptions>({
createFiscalPeriod: true,
importOpeningBalances: true,
importTransactions: true,
updateAccountNames: true,
voucherSeries: 'B',
openingBalanceSeries: defaultOpeningBalanceSeries(preview.voucherSeriesInFile ?? []),
markImportedNoDocRequired: false,
})
const [defaultSeries, setDefaultSeries] = useState<string | null>(null)
const [existingSeries, setExistingSeries] = useState<Set<string>>(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<ReturnType<typeof setInterval> | 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<string>((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'}
</p>
{existingIbCount > 0 && (
<p className="text-sm text-muted-foreground">{t('ib_exists_hint')}</p>
)}
</div>
<Switch
id="import-opening-balances"
@@ -306,6 +370,51 @@ export default function ImportReviewStep({
/>
</div>
{/* Voucher series for the opening-balance voucher (issue #1882) */}
{options.importOpeningBalances && hasOpeningBalances && (
<div className="space-y-2">
<Label htmlFor="opening-balance-series" className="font-medium">
{t('ib_series_label')}
</Label>
<Select
value={options.openingBalanceSeries}
onValueChange={(value) => updateOption('openingBalanceSeries', value)}
disabled={!seriesLoaded}
>
<SelectTrigger id="opening-balance-series" className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SERIES_LETTERS.map((letter) => {
// The collision that matters for the IB voucher is with
// the FILE's own series (issue #1882): flag those first,
// ahead of the company-sequence hints.
const isInFile = seriesInFile.has(letter)
const isDefault = defaultSeries === letter
const isExisting = existingSeries.has(letter)
const suffix = isInFile
? `, ${t('ib_series_in_file')}`
: isDefault
? ', standard'
: isExisting
? ', används redan'
: ''
return (
<SelectItem key={letter} value={letter}>
{`Serie ${letter}${suffix}`}
</SelectItem>
)
})}
</SelectContent>
</Select>
{seriesInFile.has(options.openingBalanceSeries.toUpperCase()) ? (
<AttnLine>{t('ib_series_collision')}</AttnLine>
) : (
<p className="text-sm text-muted-foreground">{t('ib_series_hint')}</p>
)}
</div>
)}
{/* Transactions */}
<div className="flex items-start justify-between">
<div className="space-y-0.5">