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:
co-authored by
Claude Fable 5
parent
79013cf092
commit
c6f2bebab9
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user