fix(import): actionable hint when a bank CSV lands in the opening-balance importer (#953)
* fix(import): hint when a bank statement is uploaded as opening balances Uploading a bank statement CSV to the opening-balance importer produced the generic 'Inga konton med belopp hittades' error with no clue that the file belongs in the bank-transactions importer (#918, users got stuck together with #915). When the opening-balance parse yields zero account rows, the parser now runs the registered bank-file format detectors over the CSV content (the generic CSV fallback never auto-detects, so any match is a real bank format) and reports the matched format name as detected_bank_format on the parse result. The upload step then shows an actionable Swedish error naming the bank plus a button that routes to the bank-transactions importer (/import?mode=bank). Closes #918 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): use the standard bank-import CTA wording (CodeRabbit) 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
b21aa84268
commit
982fe77f72
@@ -814,10 +814,12 @@ const OB_STEP_LABELS: Record<OpeningBalanceStep, string> = {
|
||||
function OpeningBalanceFlow() {
|
||||
const { toast } = useToast()
|
||||
const { dialogProps, confirm } = useDestructiveConfirm()
|
||||
const router = useRouter()
|
||||
|
||||
const [obStep, setObStep] = useState<OpeningBalanceStep>('upload')
|
||||
const [obIsLoading, setObIsLoading] = useState(false)
|
||||
const [obError, setObError] = useState<string | null>(null)
|
||||
const [obBankFormatHint, setObBankFormatHint] = useState<string | null>(null)
|
||||
const [obFile, setObFile] = useState<File | null>(null)
|
||||
const [parseResult, setParseResult] = useState<OpeningBalanceParseResult | null>(null)
|
||||
const [editedRows, setEditedRows] = useState<{
|
||||
@@ -836,6 +838,7 @@ function OpeningBalanceFlow() {
|
||||
|
||||
const handleFileSelect = useCallback(async (file: File) => {
|
||||
setObError(null)
|
||||
setObBankFormatHint(null)
|
||||
setObIsLoading(true)
|
||||
setObFile(file)
|
||||
|
||||
@@ -859,7 +862,13 @@ function OpeningBalanceFlow() {
|
||||
setParseResult(result)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
setObError('Inga konton med belopp hittades i filen. Kontrollera att filen innehåller kontonummer och belopp.')
|
||||
if (result.detected_bank_format) {
|
||||
// The file is a bank statement uploaded to the wrong importer (#918)
|
||||
setObBankFormatHint(result.detected_bank_format)
|
||||
setObError(`Filen ser ut som ett kontoutdrag från ${result.detected_bank_format}, inte ingående balanser. Kontoutdrag importeras under "Banktransaktioner".`)
|
||||
} else {
|
||||
setObError('Inga konton med belopp hittades i filen. Kontrollera att filen innehåller kontonummer och belopp.')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -981,6 +990,7 @@ function OpeningBalanceFlow() {
|
||||
setEditedRows([])
|
||||
setExecuteResult(null)
|
||||
setObError(null)
|
||||
setObBankFormatHint(null)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1016,6 +1026,11 @@ function OpeningBalanceFlow() {
|
||||
onFileSelect={handleFileSelect}
|
||||
isLoading={obIsLoading}
|
||||
error={obError}
|
||||
errorAction={
|
||||
obBankFormatHint
|
||||
? { label: 'Importera banktransaktioner', onClick: () => router.push('/import?mode=bank') }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,12 +10,15 @@ interface OpeningBalanceUploadStepProps {
|
||||
onFileSelect: (file: File) => void
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
/** Optional action rendered under the error text (e.g. route to the bank importer) */
|
||||
errorAction?: { label: string; onClick: () => void }
|
||||
}
|
||||
|
||||
export default function OpeningBalanceUploadStep({
|
||||
onFileSelect,
|
||||
isLoading,
|
||||
error,
|
||||
errorAction,
|
||||
}: OpeningBalanceUploadStepProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
@@ -108,7 +111,14 @@ export default function OpeningBalanceUploadStep({
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3">
|
||||
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{errorAction && (
|
||||
<Button variant="outline" size="sm" onClick={errorAction.onClick}>
|
||||
{errorAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -255,3 +255,68 @@ describe('parseOpeningBalanceFile', () => {
|
||||
expect(result.total_credit).toBe(40000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('bank statement detection (issue #918)', () => {
|
||||
const toBuffer = (csv: string): ArrayBuffer =>
|
||||
new TextEncoder().encode(csv).buffer as ArrayBuffer
|
||||
|
||||
it('flags a Swedbank bank statement CSV that yields no account rows', async () => {
|
||||
const { parseOpeningBalanceFile } = await import('../parser')
|
||||
|
||||
const csv = [
|
||||
'Radnr,Clnr,Kontonr,Produkt,Valuta,Bokfdag,Transdag,Valutadag,Referens,Text,Belopp,Saldo',
|
||||
'1,8385-9,9350000000,Företagskonto,SEK,2026-05-02,2026-05-02,2026-05-02,Hyra maj,Bg-bet. via internet,-12000.00,54321.00',
|
||||
'2,8385-9,9350000000,Företagskonto,SEK,2026-05-03,2026-05-03,2026-05-03,Kundbetalning,Insättning,25000.00,79321.00',
|
||||
].join('\n')
|
||||
|
||||
const result = parseOpeningBalanceFile(toBuffer(csv), 'kontoutdrag.csv')
|
||||
|
||||
expect(result.rows.length).toBe(0)
|
||||
expect(result.detected_bank_format).toBe('Swedbank')
|
||||
})
|
||||
|
||||
it('flags an SEB bank statement CSV that yields no account rows', async () => {
|
||||
const { parseOpeningBalanceFile } = await import('../parser')
|
||||
|
||||
const csv = [
|
||||
'Bokföringsdatum;Valutadatum;Verifikationsnummer;Text;Belopp;Saldo',
|
||||
'2026-05-02;2026-05-02;5501234567;Hyra maj;-12000,00;54321,00',
|
||||
'2026-05-03;2026-05-03;5501234568;Kundbetalning;25000,00;79321,00',
|
||||
].join('\n')
|
||||
|
||||
const result = parseOpeningBalanceFile(toBuffer(csv), 'export.csv')
|
||||
|
||||
expect(result.rows.length).toBe(0)
|
||||
expect(result.detected_bank_format).toBe('SEB')
|
||||
})
|
||||
|
||||
it('does not flag a CSV that yields no rows but matches no bank format', async () => {
|
||||
const { parseOpeningBalanceFile } = await import('../parser')
|
||||
|
||||
const csv = [
|
||||
'Namn,Stad,Antal',
|
||||
'Alfa,Göteborg,3',
|
||||
'Beta,Malmö,7',
|
||||
].join('\n')
|
||||
|
||||
const result = parseOpeningBalanceFile(toBuffer(csv), 'annat.csv')
|
||||
|
||||
expect(result.rows.length).toBe(0)
|
||||
expect(result.detected_bank_format).toBeNull()
|
||||
})
|
||||
|
||||
it('does not flag a valid opening balance file', async () => {
|
||||
const { parseOpeningBalanceFile } = await import('../parser')
|
||||
|
||||
const csv = [
|
||||
'Kontonr,Kontonamn,Debet,Kredit',
|
||||
'1930,Företagskonto,50000,0',
|
||||
'2099,Årets resultat,0,50000',
|
||||
].join('\n')
|
||||
|
||||
const result = parseOpeningBalanceFile(toBuffer(csv), 'ib.csv')
|
||||
|
||||
expect(result.rows.length).toBe(2)
|
||||
expect(result.detected_bank_format).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
import { detectColumns } from './column-detector'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { detectFileFormat } from '../bank-file/parser'
|
||||
import { decodeFileContent } from '../shared/encoding'
|
||||
import { readWorkbookFromBuffer } from '../shared/workbook-reader'
|
||||
import type {
|
||||
DetectedColumns,
|
||||
@@ -31,6 +33,25 @@ export function parseAmount(value: unknown): number {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
/**
|
||||
* When an opening-balance parse yields no rows, check whether the uploaded
|
||||
* file is actually a bank statement (issue #918: users upload bank CSV
|
||||
* exports here and only get a generic "no accounts found" error). Only CSV
|
||||
* files can match: the bank-file detectors operate on decoded text, and the
|
||||
* generic CSV fallback never auto-detects, so any match is a real bank format.
|
||||
*/
|
||||
function detectBankStatementFormat(buffer: ArrayBuffer, filename: string): string | null {
|
||||
const ext = filename.toLowerCase().split('.').pop() ?? ''
|
||||
if (ext !== 'csv') return null
|
||||
try {
|
||||
const content = decodeFileContent(buffer)
|
||||
return detectFileFormat(content, filename)?.name ?? null
|
||||
} catch {
|
||||
// Detection is a best-effort hint: never let it break the parse result
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an opening balance file (Excel or CSV) and return structured rows
|
||||
* with validation and BAS account matching.
|
||||
@@ -87,6 +108,7 @@ export function parseOpeningBalanceFile(
|
||||
total_credit: 0,
|
||||
is_balanced: true,
|
||||
warnings: ['Filen innehåller för få rader.'],
|
||||
detected_bank_format: detectBankStatementFormat(buffer, filename),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,5 +264,7 @@ export function parseOpeningBalanceFile(
|
||||
total_credit: totalCredit,
|
||||
is_balanced: isBalanced,
|
||||
warnings,
|
||||
detected_bank_format:
|
||||
mergedRows.length === 0 ? detectBankStatementFormat(buffer, filename) : null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,12 @@ export interface OpeningBalanceParseResult {
|
||||
total_credit: number
|
||||
is_balanced: boolean
|
||||
warnings: string[]
|
||||
/**
|
||||
* Bank-file format name (e.g. "Swedbank") when the file produced no account
|
||||
* rows but matches a known bank statement format: the user most likely
|
||||
* uploaded a bank statement to the wrong importer. Null otherwise.
|
||||
*/
|
||||
detected_bank_format: string | null
|
||||
}
|
||||
|
||||
/** Input for executing the opening balance import */
|
||||
|
||||
Reference in New Issue
Block a user