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:
@@ -1217,6 +1217,9 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string, unknown>
|
||||
expect(options.openingBalanceSeries).toBe('K')
|
||||
|
||||
executeSIEImportMock.mockClear()
|
||||
await callRoute()
|
||||
options = executeSIEImportMock.mock.calls[0][5] as Record<string, unknown>
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -234,4 +234,57 @@ describe('gnubok_import_sie: update_account_names staging', () => {
|
||||
|
||||
expect((staged[0].params as Record<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).opening_balance_series).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user