fix: address user feedback — RC preview, bank sync lookback, CSV import robustness (#233)
Three confirmed issues from user feedback: 1. Reverse charge preview now uses per-item VAT rates and correct accounts (2645/2647, 2614/2624/2634) instead of hardcoded 25%/2614 2. Bank sync uses 90-day lookback on first sync (when last_synced_at is null) instead of hardcoded 7 days for all syncs 3. Bank file import improvements: - Shared date normalizer supporting DD.MM.YYYY, DD/MM/YYYY, YYYYMMDD - Silent row skips now reported with reason in issues[] - Decimal separator mismatch detection in generic CSV - Swedish error message with format diagnostics on detection failure - Date format selector in column mapping UI Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
2fcebffb34
commit
9753f18533
@@ -132,7 +132,13 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
|
||||
// First sync: 90-day lookback (PSD2 max). Subsequent: 7-day window.
|
||||
const isFirstSync = !connection.last_synced_at
|
||||
const lookbackDays = isFirstSync ? 90 : 7
|
||||
if (isFirstSync) {
|
||||
console.log(`[bank-sync-cron] First sync for connection ${connection.id}, using ${lookbackDays}-day lookback`)
|
||||
}
|
||||
const fromDate = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function BankFileColumnMappingStep({
|
||||
const [balanceCol, setBalanceCol] = useState<number>(-1)
|
||||
const [delimiter, setDelimiter] = useState<string>(',')
|
||||
const [decimalSep, setDecimalSep] = useState<',' | '.'>(',')
|
||||
const [dateFormat, setDateFormat] = useState<string>('YYYY-MM-DD')
|
||||
|
||||
const isValid = dateCol >= 0 && descCol >= 0 && amountCol >= 0
|
||||
|
||||
@@ -58,7 +59,7 @@ export default function BankFileColumnMappingStep({
|
||||
delimiter,
|
||||
decimal_separator: decimalSep,
|
||||
skip_rows: 1, // Skip header
|
||||
date_format: 'YYYY-MM-DD',
|
||||
date_format: dateFormat,
|
||||
}
|
||||
onConfirm(mapping)
|
||||
}
|
||||
@@ -78,8 +79,8 @@ export default function BankFileColumnMappingStep({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Delimiter and decimal settings */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Delimiter, decimal, and date format settings */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Avgränsare</Label>
|
||||
<Select value={delimiter} onValueChange={(v) => { if (v) setDelimiter(v) }}>
|
||||
@@ -105,6 +106,20 @@ export default function BankFileColumnMappingStep({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Datumformat</Label>
|
||||
<Select value={dateFormat} onValueChange={(v) => { if (v) setDateFormat(v) }}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
|
||||
<SelectItem value="DD.MM.YYYY">DD.MM.YYYY</SelectItem>
|
||||
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
|
||||
<SelectItem value="YYYYMMDD">YYYYMMDD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required column mappings */}
|
||||
|
||||
@@ -40,12 +40,19 @@ interface JournalPreviewLine {
|
||||
credit: number
|
||||
}
|
||||
|
||||
function getOutputVatAccount(rate: number): string {
|
||||
if (rate === 0.12) return '2624'
|
||||
if (rate === 0.06) return '2634'
|
||||
return '2614'
|
||||
}
|
||||
|
||||
function buildJournalPreview(
|
||||
items: ReviewLineItem[],
|
||||
subtotal: number,
|
||||
totalVat: number,
|
||||
total: number,
|
||||
reverseCharge: boolean,
|
||||
supplierType?: string,
|
||||
): JournalPreviewLine[] {
|
||||
const lines: JournalPreviewLine[] = []
|
||||
|
||||
@@ -67,21 +74,35 @@ function buildJournalPreview(
|
||||
}
|
||||
|
||||
if (reverseCharge) {
|
||||
// EU reverse charge: fiktiv moms
|
||||
const vatRate = 0.25
|
||||
const fiktivVat = Math.round(subtotal * vatRate * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2645',
|
||||
description: 'Beräknad ingående moms',
|
||||
debit: fiktivVat,
|
||||
credit: 0,
|
||||
})
|
||||
lines.push({
|
||||
account_number: '2614',
|
||||
description: 'Utgående moms omvänd',
|
||||
debit: 0,
|
||||
credit: fiktivVat,
|
||||
})
|
||||
// Reverse charge: fiktiv moms per VAT rate (matches engine groupVatByRate logic)
|
||||
const isDomesticRC = supplierType === 'swedish_business'
|
||||
const inputAccount = isDomesticRC ? '2647' : '2645'
|
||||
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
if (item.vat_rate > 0) {
|
||||
const current = vatByRate.get(item.vat_rate) || 0
|
||||
vatByRate.set(item.vat_rate, current + Math.round(item.amount * 100) / 100)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [rate, netAmount] of vatByRate) {
|
||||
const fiktivVat = Math.round(netAmount * rate * 100) / 100
|
||||
const outputAccount = getOutputVatAccount(rate)
|
||||
lines.push({
|
||||
account_number: inputAccount,
|
||||
description: inputAccount,
|
||||
debit: fiktivVat,
|
||||
credit: 0,
|
||||
})
|
||||
lines.push({
|
||||
account_number: outputAccount,
|
||||
description: outputAccount,
|
||||
debit: 0,
|
||||
credit: fiktivVat,
|
||||
})
|
||||
}
|
||||
|
||||
// Credit: 2440 at subtotal (no real VAT for reverse charge)
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
@@ -113,8 +134,11 @@ function buildJournalPreview(
|
||||
const ACCOUNT_LABELS: Record<string, string> = {
|
||||
'2440': 'Leverantörsskulder',
|
||||
'2641': 'Ingående moms',
|
||||
'2645': 'Beräknad ingående moms',
|
||||
'2614': 'Utg. moms omvänd skattskyldighet',
|
||||
'2645': 'Beräknad ing. moms förvärv utlandet',
|
||||
'2647': 'Beräknad ing. moms omvänd i Sverige',
|
||||
'2614': 'Utg. moms omvänd 25%',
|
||||
'2624': 'Utg. moms omvänd 12%',
|
||||
'2634': 'Utg. moms omvänd 6%',
|
||||
}
|
||||
|
||||
export function SupplierInvoiceReviewContent({
|
||||
@@ -132,7 +156,7 @@ export function SupplierInvoiceReviewContent({
|
||||
totalVat,
|
||||
total,
|
||||
}: SupplierInvoiceReviewContentProps) {
|
||||
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge)
|
||||
const journalLines = buildJournalPreview(items, subtotal, totalVat, total, reverseCharge, supplier.supplier_type)
|
||||
const totalDebit = journalLines.reduce((sum, l) => sum + l.debit, 0)
|
||||
const totalCredit = journalLines.reduce((sum, l) => sum + l.credit, 0)
|
||||
|
||||
|
||||
@@ -22,9 +22,12 @@ const ACCOUNT_NAMES: Record<string, string> = {
|
||||
'2611': 'Utg. moms 25%',
|
||||
'2621': 'Utg. moms 12%',
|
||||
'2631': 'Utg. moms 6%',
|
||||
'2614': 'Utg. moms omvänd',
|
||||
'2614': 'Utg. moms omvänd 25%',
|
||||
'2624': 'Utg. moms omvänd 12%',
|
||||
'2634': 'Utg. moms omvänd 6%',
|
||||
'2641': 'Ing. moms',
|
||||
'2645': 'Beräknad ing. moms',
|
||||
'2645': 'Beräknad ing. moms förvärv utlandet',
|
||||
'2647': 'Beräknad ing. moms omvänd i Sverige',
|
||||
'2731': 'Arbetsgivaravgifter',
|
||||
'2893': 'Skuld till ägare',
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
describe('normalizeDate', () => {
|
||||
// Pass-through for canonical format
|
||||
it('passes through YYYY-MM-DD', () => {
|
||||
expect(normalizeDate('2024-01-15')).toBe('2024-01-15')
|
||||
expect(normalizeDate('2026-12-31')).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
// European dot format
|
||||
it('normalizes DD.MM.YYYY', () => {
|
||||
expect(normalizeDate('15.01.2024')).toBe('2024-01-15')
|
||||
expect(normalizeDate('31.12.2026')).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
it('normalizes D.M.YYYY (single-digit day/month)', () => {
|
||||
expect(normalizeDate('5.1.2024')).toBe('2024-01-05')
|
||||
expect(normalizeDate('9.3.2025')).toBe('2025-03-09')
|
||||
})
|
||||
|
||||
// European slash format
|
||||
it('normalizes DD/MM/YYYY', () => {
|
||||
expect(normalizeDate('15/01/2024')).toBe('2024-01-15')
|
||||
expect(normalizeDate('31/12/2026')).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
it('normalizes D/M/YYYY (single-digit)', () => {
|
||||
expect(normalizeDate('5/1/2024')).toBe('2024-01-05')
|
||||
})
|
||||
|
||||
// US format with hint
|
||||
it('normalizes MM/DD/YYYY when hint is provided', () => {
|
||||
expect(normalizeDate('01/15/2024', 'MM/DD/YYYY')).toBe('2024-01-15')
|
||||
expect(normalizeDate('12/31/2026', 'MM/DD/YYYY')).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
// Compact format
|
||||
it('normalizes YYYYMMDD', () => {
|
||||
expect(normalizeDate('20240115')).toBe('2024-01-15')
|
||||
expect(normalizeDate('20261231')).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
// Slash year-first format
|
||||
it('normalizes YYYY/MM/DD', () => {
|
||||
expect(normalizeDate('2024/01/15')).toBe('2024-01-15')
|
||||
expect(normalizeDate('2026/12/31')).toBe('2026-12-31')
|
||||
})
|
||||
|
||||
// Whitespace handling
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeDate(' 2024-01-15 ')).toBe('2024-01-15')
|
||||
expect(normalizeDate(' 15.01.2024 ')).toBe('2024-01-15')
|
||||
})
|
||||
|
||||
// Invalid dates
|
||||
it('returns null for empty/null/undefined', () => {
|
||||
expect(normalizeDate('')).toBeNull()
|
||||
expect(normalizeDate(null)).toBeNull()
|
||||
expect(normalizeDate(undefined)).toBeNull()
|
||||
expect(normalizeDate(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unrecognized formats', () => {
|
||||
expect(normalizeDate('Jan 15, 2024')).toBeNull()
|
||||
expect(normalizeDate('15-Jan-2024')).toBeNull()
|
||||
expect(normalizeDate('abc')).toBeNull()
|
||||
expect(normalizeDate('2024')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for invalid month', () => {
|
||||
expect(normalizeDate('2024-13-01')).toBeNull()
|
||||
expect(normalizeDate('2024-00-01')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for invalid day', () => {
|
||||
expect(normalizeDate('2024-02-30')).toBeNull() // Feb 30 doesn't exist
|
||||
expect(normalizeDate('2024-04-31')).toBeNull() // Apr has 30 days
|
||||
expect(normalizeDate('2024-01-32')).toBeNull()
|
||||
expect(normalizeDate('2024-01-00')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles leap year correctly', () => {
|
||||
expect(normalizeDate('2024-02-29')).toBe('2024-02-29') // 2024 is leap year
|
||||
expect(normalizeDate('2023-02-29')).toBeNull() // 2023 is not
|
||||
})
|
||||
|
||||
it('returns null for years out of range', () => {
|
||||
expect(normalizeDate('1899-01-01')).toBeNull()
|
||||
expect(normalizeDate('2101-01-01')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1151,7 +1151,7 @@ describe('parseBankFile — explicit format override', () => {
|
||||
expect(result.format_name).toBe('Unknown')
|
||||
expect(result.transactions).toHaveLength(0)
|
||||
expect(result.issues).toHaveLength(1)
|
||||
expect(result.issues[0].message).toContain('Could not auto-detect')
|
||||
expect(result.issues[0].message).toContain('Kunde inte identifiera bankformat')
|
||||
})
|
||||
|
||||
it('can force generic_csv format by explicit ID', () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Date normalization for bank file imports.
|
||||
*
|
||||
* Converts common Swedish/European date formats to canonical YYYY-MM-DD.
|
||||
* Used by all bank-specific parsers and the generic CSV fallback.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a date string to YYYY-MM-DD.
|
||||
*
|
||||
* Supported formats:
|
||||
* - YYYY-MM-DD (pass-through)
|
||||
* - YYYY/MM/DD
|
||||
* - YYYYMMDD
|
||||
* - DD.MM.YYYY / D.M.YYYY
|
||||
* - DD/MM/YYYY / D/M/YYYY
|
||||
*
|
||||
* The `hint` parameter disambiguates DD/MM vs MM/DD when using slash separators.
|
||||
* Default assumption is DD/MM (European convention) since this is a Swedish app.
|
||||
*
|
||||
* Returns the canonical YYYY-MM-DD string, or null if unparseable.
|
||||
*/
|
||||
export function normalizeDate(raw: string | undefined | null, hint?: string): string | null {
|
||||
if (!raw) return null
|
||||
const s = raw.trim()
|
||||
if (!s) return null
|
||||
|
||||
let year: number
|
||||
let month: number
|
||||
let day: number
|
||||
|
||||
// YYYY-MM-DD
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
||||
const [y, m, d] = s.split('-').map(Number)
|
||||
year = y; month = m; day = d
|
||||
|
||||
// YYYY/MM/DD
|
||||
} else if (/^\d{4}\/\d{2}\/\d{2}$/.test(s)) {
|
||||
const [y, m, d] = s.split('/').map(Number)
|
||||
year = y; month = m; day = d
|
||||
|
||||
// YYYYMMDD
|
||||
} else if (/^\d{8}$/.test(s)) {
|
||||
year = parseInt(s.substring(0, 4), 10)
|
||||
month = parseInt(s.substring(4, 6), 10)
|
||||
day = parseInt(s.substring(6, 8), 10)
|
||||
|
||||
// DD.MM.YYYY or D.M.YYYY
|
||||
} else if (/^\d{1,2}\.\d{1,2}\.\d{4}$/.test(s)) {
|
||||
const parts = s.split('.').map(Number)
|
||||
day = parts[0]; month = parts[1]; year = parts[2]
|
||||
|
||||
// DD/MM/YYYY or D/M/YYYY (or MM/DD/YYYY based on hint)
|
||||
} else if (/^\d{1,2}\/\d{1,2}\/\d{4}$/.test(s)) {
|
||||
const parts = s.split('/').map(Number)
|
||||
if (hint === 'MM/DD/YYYY') {
|
||||
month = parts[0]; day = parts[1]; year = parts[2]
|
||||
} else {
|
||||
// Default: DD/MM/YYYY (European)
|
||||
day = parts[0]; month = parts[1]; year = parts[2]
|
||||
}
|
||||
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate ranges
|
||||
if (year < 1900 || year > 2100) return null
|
||||
if (month < 1 || month > 12) return null
|
||||
if (day < 1 || day > 31) return null
|
||||
|
||||
// Check day is valid for the given month
|
||||
const maxDay = new Date(year, month, 0).getDate()
|
||||
if (day > maxDay) return null
|
||||
|
||||
const mm = String(month).padStart(2, '0')
|
||||
const dd = String(day).padStart(2, '0')
|
||||
return `${year}-${mm}-${dd}`
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue, GenericCSVColumnMapping } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { parseCSVLine } from './nordea'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
/**
|
||||
* Parse a generic CSV with user-provided column mapping
|
||||
@@ -26,6 +27,32 @@ export function parseGenericCSV(
|
||||
// Skip configured number of header/metadata rows
|
||||
const startRow = mapping.skip_rows
|
||||
|
||||
// Detect decimal separator mismatch by sampling amount column
|
||||
const sampleSize = Math.min(lines.length - startRow, 20)
|
||||
let commaPattern = 0
|
||||
let periodPattern = 0
|
||||
for (let s = startRow; s < startRow + sampleSize && s < lines.length; s++) {
|
||||
const sampleLine = lines[s]?.trim()
|
||||
if (!sampleLine) continue
|
||||
const sampleFields = parseCSVLine(sampleLine, mapping.delimiter).map(f => f.trim().replace(/^"|"$/g, ''))
|
||||
const amtStr = sampleFields[mapping.amount] || ''
|
||||
if (/\d,\d{1,2}$/.test(amtStr)) commaPattern++
|
||||
if (/\d\.\d{1,2}$/.test(amtStr)) periodPattern++
|
||||
}
|
||||
if (mapping.decimal_separator === ',' && periodPattern > commaPattern && periodPattern >= 3) {
|
||||
issues.push({
|
||||
row: 0,
|
||||
message: 'Decimalavgränsare verkar vara punkt (.) men komma (,) är valt. Kontrollera inställningen.',
|
||||
severity: 'warning',
|
||||
})
|
||||
} else if (mapping.decimal_separator === '.' && commaPattern > periodPattern && commaPattern >= 3) {
|
||||
issues.push({
|
||||
row: 0,
|
||||
message: 'Decimalavgränsare verkar vara komma (,) men punkt (.) är valt. Kontrollera inställningen.',
|
||||
severity: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = startRow; i < lines.length; i++) {
|
||||
const line = lines[i].trim()
|
||||
if (!line) continue
|
||||
@@ -54,6 +81,10 @@ export function parseGenericCSV(
|
||||
const balanceStr = mapping.balance !== undefined ? fields[mapping.balance] : undefined
|
||||
|
||||
if (!dateStr || !amountStr) {
|
||||
const missing = []
|
||||
if (!dateStr) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -72,10 +103,10 @@ export function parseGenericCSV(
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse date - expect YYYY-MM-DD
|
||||
const date = dateStr.trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
issues.push({ row: i + 1, message: `Invalid date format: ${date} (expected YYYY-MM-DD)`, severity: 'warning' })
|
||||
// Normalize date from multiple formats to YYYY-MM-DD
|
||||
const date = normalizeDate(dateStr, mapping.date_format)
|
||||
if (!date) {
|
||||
issues.push({ row: i + 1, message: `Ogiltigt datumformat: ${dateStr.trim()}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
@@ -91,6 +92,10 @@ export const handelsbankenFormat: BankFileFormat = {
|
||||
}
|
||||
|
||||
if (!date || !amountStr) {
|
||||
const missing = []
|
||||
if (!date) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -102,7 +107,8 @@ export const handelsbankenFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -111,7 +117,7 @@ export const handelsbankenFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
@@ -135,6 +136,10 @@ export const icaBankenFormat: BankFileFormat = {
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
const missing = []
|
||||
if (!date) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -146,7 +151,8 @@ export const icaBankenFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -155,7 +161,7 @@ export const icaBankenFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
@@ -123,6 +124,10 @@ export const lansforsakringarFormat: BankFileFormat = {
|
||||
const balanceStr = balanceIdx >= 0 && balanceIdx < fields.length ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
const missing = []
|
||||
if (!date) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -134,7 +139,8 @@ export const lansforsakringarFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!DATE_RE.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -143,7 +149,7 @@ export const lansforsakringarFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description: description.trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
function parseLunarAmount(value: string): number {
|
||||
@@ -90,6 +91,10 @@ export const lunarFormat: BankFileFormat = {
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
const missing = []
|
||||
if (!date) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -101,7 +106,8 @@ export const lunarFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -110,7 +116,7 @@ export const lunarFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseLunarAmount(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
@@ -158,11 +159,8 @@ export const nordeaBusinessFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize YYYY/MM/DD → YYYY-MM-DD (Format D variant)
|
||||
const normalizedDate = date.includes('/') ? date.replace(/\//g, '-') : date
|
||||
|
||||
// Validate date format (YYYY-MM-DD)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalizedDate)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
// Swedish format: "1 234,56" or "-1 234,56"
|
||||
@@ -77,8 +78,8 @@ export const nordeaFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate date format
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date?.trim())) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -87,7 +88,7 @@ export const nordeaFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date: date.trim(),
|
||||
date: normalizedDate,
|
||||
description: description?.trim() || 'Unknown',
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
@@ -95,7 +96,8 @@ export const sebFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -104,7 +106,7 @@ export const sebFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description: description.trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
|
||||
function parseCommaDecimal(value: string): number {
|
||||
const cleaned = value.replace(/\s/g, '').replace(',', '.')
|
||||
@@ -96,6 +97,10 @@ export const skandiaFormat: BankFileFormat = {
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
const missing = []
|
||||
if (!date) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -107,7 +112,8 @@ export const skandiaFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -116,7 +122,7 @@ export const skandiaFormat: BankFileFormat = {
|
||||
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description: (description || 'Unknown').trim(),
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
|
||||
import { prepareContent } from '../encoding'
|
||||
import { normalizeDate } from '../date-utils'
|
||||
import { parseCSVLine } from './nordea'
|
||||
|
||||
/**
|
||||
@@ -137,6 +138,10 @@ export const swedbankFormat: BankFileFormat = {
|
||||
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
|
||||
|
||||
if (!date || !amountStr) {
|
||||
const missing = []
|
||||
if (!date) missing.push('datum')
|
||||
if (!amountStr) missing.push('belopp')
|
||||
issues.push({ row: i + 1, message: `Saknar ${missing.join(' och ')}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
}
|
||||
@@ -149,7 +154,8 @@ export const swedbankFormat: BankFileFormat = {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
const normalizedDate = normalizeDate(date)
|
||||
if (!normalizedDate) {
|
||||
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
|
||||
skippedRows++
|
||||
continue
|
||||
@@ -163,7 +169,7 @@ export const swedbankFormat: BankFileFormat = {
|
||||
: reference || textDesc || 'Unknown'
|
||||
|
||||
transactions.push({
|
||||
date,
|
||||
date: normalizedDate,
|
||||
description,
|
||||
amount,
|
||||
currency: 'SEK',
|
||||
|
||||
@@ -98,6 +98,11 @@ export function parseBankFile(
|
||||
} else {
|
||||
format = detectFileFormat(content, filename) || undefined
|
||||
if (!format) {
|
||||
// Build diagnostic message listing which formats were tried
|
||||
const tried = FORMATS
|
||||
.filter(f => f.id !== 'generic_csv')
|
||||
.map(f => f.name)
|
||||
const firstLine = content.split('\n')[0]?.substring(0, 80) || ''
|
||||
return {
|
||||
format: 'generic_csv',
|
||||
format_name: 'Unknown',
|
||||
@@ -106,7 +111,7 @@ export function parseBankFile(
|
||||
date_to: null,
|
||||
issues: [{
|
||||
row: 0,
|
||||
message: 'Could not auto-detect file format. Please select your bank manually.',
|
||||
message: `Kunde inte identifiera bankformat. Testade: ${tried.join(', ')}. Första raden: "${firstLine}". Välj bank manuellt eller använd "Annan CSV".`,
|
||||
severity: 'error',
|
||||
}],
|
||||
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
|
||||
|
||||
Reference in New Issue
Block a user