c6f2bebab9
* 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>
144 lines
5.3 KiB
TypeScript
144 lines
5.3 KiB
TypeScript
import { NextResponse } from 'next/server'
|
|
import { parseSIEFile, detectEncoding, decodeBuffer } from '@/lib/import/sie-parser'
|
|
import { suggestMappings } from '@/lib/import/account-mapper'
|
|
import { executeSIEImport, checkDuplicateImport } from '@/lib/import/sie-import'
|
|
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
|
import { withRouteContext } from '@/lib/api/with-route-context'
|
|
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
|
import type { AccountMapping, SIEAccountMappingRecord } from '@/lib/import/types'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
// SIE imports with many vouchers need extended execution time
|
|
export const maxDuration = 300
|
|
|
|
/** POST /api/import/sie/execute: execute the SIE import. */
|
|
export const POST = withRouteContext(
|
|
'sie_import.execute',
|
|
async (request, ctx) => {
|
|
const { user, supabase, companyId, log, requestId } = ctx
|
|
|
|
const formData = await request.formData()
|
|
const file = formData.get('file') as File | null
|
|
const mappingsJson = formData.get('mappings') as string | null
|
|
const optionsJson = formData.get('options') as string | null
|
|
|
|
if (!file) {
|
|
return errorResponseFromCode('SIE_PARSE_NO_FILE', log, { requestId })
|
|
}
|
|
|
|
const opLog = log.child({ filename: file.name, sizeBytes: file.size })
|
|
|
|
try {
|
|
// The voucherSeries option is a fallback for vouchers that arrive without
|
|
// a series (SIE4I subsystem files); the import engine preserves each
|
|
// #VER's source series per voucher.
|
|
const parsedOptions = optionsJson ? JSON.parse(optionsJson) : null
|
|
const { data: companySettings } = await supabase
|
|
.from('company_settings')
|
|
.select('default_voucher_series')
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
const companyDefaultSeries = companySettings?.default_voucher_series || 'B'
|
|
|
|
const options = parsedOptions ?? {
|
|
createFiscalPeriod: true,
|
|
importOpeningBalances: true,
|
|
importTransactions: true,
|
|
voucherSeries: companyDefaultSeries,
|
|
updateAccountNames: true,
|
|
}
|
|
|
|
const arrayBuffer = await file.arrayBuffer()
|
|
const encoding = detectEncoding(arrayBuffer)
|
|
const content = decodeBuffer(arrayBuffer, encoding)
|
|
|
|
const parsed = parseSIEFile(content)
|
|
|
|
const duplicate = await checkDuplicateImport(supabase, companyId!, content)
|
|
if (duplicate) {
|
|
return errorResponseFromCode('SIE_DUPLICATE_FILE', opLog, {
|
|
requestId,
|
|
details: { importId: duplicate.id, importedAt: duplicate.imported_at },
|
|
})
|
|
}
|
|
|
|
let mappings: AccountMapping[]
|
|
|
|
if (mappingsJson) {
|
|
mappings = JSON.parse(mappingsJson)
|
|
} else {
|
|
const { data: storedMappings } = await supabase
|
|
.from('sie_account_mappings')
|
|
.select('*')
|
|
.eq('company_id', companyId)
|
|
|
|
mappings = suggestMappings(
|
|
parsed.accounts,
|
|
BAS_REFERENCE,
|
|
(storedMappings as SIEAccountMappingRecord[]) || undefined,
|
|
)
|
|
}
|
|
|
|
const unmapped = mappings.filter((m) => !m.targetAccount)
|
|
if (unmapped.length > 0) {
|
|
return errorResponseFromCode('SIE_IMPORT_UNMAPPED_ACCOUNTS', opLog, {
|
|
requestId,
|
|
details: {
|
|
unmappedCount: unmapped.length,
|
|
unmappedAccounts: unmapped.slice(0, 5).map((m) => ({
|
|
account: m.sourceAccount,
|
|
name: m.sourceName,
|
|
})),
|
|
},
|
|
})
|
|
}
|
|
|
|
// Account creation (and #KONTO renames) happen inside executeSIEImport
|
|
// via syncMappedAccounts: the pre-create block that used to live here
|
|
// was a duplicate of that logic.
|
|
const result = await executeSIEImport(
|
|
supabase,
|
|
companyId!,
|
|
user.id,
|
|
parsed,
|
|
mappings,
|
|
{
|
|
filename: file.name,
|
|
fileContent: content,
|
|
createFiscalPeriod: options.createFiscalPeriod,
|
|
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,
|
|
},
|
|
)
|
|
|
|
if (!result.success) {
|
|
return errorResponseFromCode('SIE_IMPORT_FAILED', opLog, {
|
|
requestId,
|
|
details: { result },
|
|
})
|
|
}
|
|
|
|
return NextResponse.json({ success: true, result })
|
|
} catch (err) {
|
|
opLog.error('sie execute unexpected error', err as Error)
|
|
return errorResponseFromCode('SIE_IMPORT_UNEXPECTED', opLog, {
|
|
requestId,
|
|
details: { reason: err instanceof Error ? getUserErrorMessage(err) : 'unknown' },
|
|
})
|
|
}
|
|
},
|
|
{ requireWrite: true },
|
|
)
|