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:
Jakob Wennberg
2026-07-09 21:10:09 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b21aa84268
commit 982fe77f72
5 changed files with 122 additions and 2 deletions
+16 -1
View File
@@ -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
}
/>
)}
+11 -1
View File
@@ -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()
})
})
+24
View File
@@ -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,
}
}
+6
View File
@@ -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 */