fix(import): let a skattekontoutdrag that does not sum through a confirm gate (#1675)

* fix(import): let a skattekontoutdrag that does not sum through a confirm gate

The skattekonto file parser refused any statement where ingående saldo plus
händelser did not equal utgående saldo with a bare 400 and no figures. A
real export hit it on 2026-08-18 and the user had no way forward, and the
logs carried nothing to diagnose it with. Nothing is booked at import and
the dedup contract makes a later complete re-import safe, so refusing the
file only blocked the rows that WERE readable.

- Parser: report events_sum / sum_difference / unreadable_amount_rows
  instead of just a boolean; reduce several marker pairs to the earliest
  opening and latest closing (per-year sections, newest-first files); read
  a marker saldo from a trailing running-saldo column when the belopp cell
  is empty; accept U+2212 and dash lookalikes as minus and a leading plus.
- Route: no longer 400s on sum_valid=false; logs the figures (amounts and
  counts, never row text) so the next report is diagnosable. Zero readable
  rows still refuses. SKATTEKONTO_FILE_SUM_MISMATCH removed (unused).
- Preview: an "Utdraget summerar inte" card with ingående, händelser,
  ingående+händelser, utgående and differens plus a confirm checkbox that
  gates the import button, mirroring the orgnr-mismatch gate. A one-line
  note explains that nothing is booked at import and that events already
  carrying a 1630 verifikat are offered as a link, not a second booking.

Verified end to end in the sandbox: gate renders, import proceeds after
confirmation, rows land on /skattekonto with Matcha/Bokför.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(import): round the derived händelser total and fall back to the date cell for an invalid marker date

Review nits on #1675.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-18 10:33:51 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 2b5b813b7a
commit 387e1fb7f1
10 changed files with 314 additions and 42 deletions
-7
View File
@@ -1723,13 +1723,6 @@ const SKATTEKONTO_FILE: Record<string, StructuredErrorEntry> = {
message_en:
'The file was not recognized as a tax account statement. Download the account events from Skatteverket and try again.',
},
SKATTEKONTO_FILE_SUM_MISMATCH: {
httpStatus: 400,
message_sv:
'Utdraget summerar inte: ingående saldo plus händelser stämmer inte med utgående saldo. Filen kan vara ofullständig.',
message_en:
'The statement does not sum: opening balance plus events does not equal the closing balance. The file may be incomplete.',
},
SKATTEKONTO_FILE_NO_ROWS: {
httpStatus: 400,
message_sv: 'Kontoutdraget innehåller inga händelser att importera.',
@@ -98,7 +98,12 @@ describe('parseSkattekontoFile: modern export', () => {
it('parses all transaction rows', () => {
expect(result.rows).toHaveLength(9)
expect(result.stats).toEqual({ total_rows: 9, parsed_rows: 9, skipped_rows: 0 })
expect(result.stats).toEqual({
total_rows: 9,
parsed_rows: 9,
skipped_rows: 0,
unreadable_amount_rows: 0,
})
expect(result.variant).toBe('csv')
})
@@ -126,6 +131,8 @@ describe('parseSkattekontoFile: modern export', () => {
it('validates the sum invariant', () => {
expect(result.sum_valid).toBe(true)
expect(result.events_sum).toBe(35087)
expect(result.sum_difference).toBe(0)
expect(result.issues.filter((i) => i.severity === 'error')).toHaveLength(0)
})
@@ -170,16 +177,94 @@ describe('parseSkattekontoFile: robustness', () => {
expect(result.issues.some((i) => i.severity === 'error')).toBe(true)
})
it('flags a sum mismatch as an error', () => {
it('flags a sum mismatch as an error and reports the gap', () => {
const truncated = MODERN_CSV.replace(
'"2026-07-28";"Inbetalning bokförd 260727";"35 000"\r\n',
'',
)
const result = parseSkattekontoFile(truncated, MODERN_FILENAME)
expect(result.sum_valid).toBe(false)
expect(result.events_sum).toBe(87)
expect(result.sum_difference).toBe(35000)
expect(result.rows).toHaveLength(8)
expect(result.issues.some((i) => i.severity === 'error')).toBe(true)
})
it('counts dated rows with unreadable amounts as missing from the sum', () => {
const garbledRow = MODERN_CSV.replace(
'"2026-07-28";"Inbetalning bokförd 260727";"35 000"',
'"2026-07-28";"Inbetalning bokförd 260727";"trasigt"',
)
const result = parseSkattekontoFile(garbledRow, MODERN_FILENAME)
expect(result.sum_valid).toBe(false)
expect(result.stats.unreadable_amount_rows).toBe(1)
expect(result.stats.skipped_rows).toBe(1)
const error = result.issues.find((i) => i.severity === 'error')
expect(error?.message).toContain('oläsbart belopp')
})
it('reads a typographic minus and an explicit plus sign', () => {
const typographic = [
'"2026-06-06";"Kostnadsränta";"−10"',
'"2026-06-07";"Kostnadsränta";"–10"',
'"2026-07-11";"Inbetalning bokförd 260710";"+24 000"',
].join('\n')
const result = parseSkattekontoFile(typographic, 'export.csv')
expect(result.rows.map((r) => r.belopp)).toEqual([-10, -10, 24000])
expect(result.stats.skipped_rows).toBe(0)
})
it('reads marker saldo from a trailing running-saldo column', () => {
const withSaldoColumn = [
'"Testbolaget AB";"556677-8899";"";""',
'"";"Ingående saldo 2026-05-03";"";"-500"',
'"2026-06-06";"Kostnadsränta";"-10";"-510"',
'"2026-07-11";"Inbetalning bokförd 260710";"24 000";"23 490"',
'"";"Utgående saldo 2026-08-01";"";"23 490"',
].join('\r\n')
const result = parseSkattekontoFile(withSaldoColumn, 'export.csv')
expect(result.opening_saldo).toBe(-500)
expect(result.closing_saldo).toBe(23490)
expect(result.rows.map((r) => r.belopp)).toEqual([-10, 24000])
expect(result.sum_valid).toBe(true)
})
it('checks a multi-section statement from the earliest opening to the latest closing', () => {
const multiYear = [
'"Testbolaget AB";"556677-8899";""',
'"";"Ingående saldo 2025-01-01";"100"',
'"2025-03-12";"Debiterad preliminärskatt";"-8 000"',
'"2025-03-14";"Inbetalning bokförd 250313";"8 000"',
'"";"Utgående saldo 2025-12-31";"100"',
'"";"Ingående saldo 2026-01-01";"100"',
'"2026-02-12";"Debiterad preliminärskatt";"-9 000"',
'"2026-02-13";"Inbetalning bokförd 260212";"9 500"',
'"";"Utgående saldo 2026-08-01";"600"',
].join('\r\n')
const result = parseSkattekontoFile(multiYear, 'export.csv')
expect(result.opening_saldo).toBe(100)
expect(result.closing_saldo).toBe(600)
expect(result.rows).toHaveLength(4)
expect(result.sum_valid).toBe(true)
})
it('orders markers by their own date when the file lists newest first', () => {
const newestFirst = [
'"";"Utgående saldo 2026-08-01";"600"',
'"2026-02-13";"Inbetalning bokförd 260212";"9 500"',
'"2026-02-12";"Debiterad preliminärskatt";"-9 000"',
'"";"Ingående saldo 2026-01-01";"100"',
'"";"Utgående saldo 2025-12-31";"100"',
'"2025-03-14";"Inbetalning bokförd 250313";"8 000"',
'"2025-03-12";"Debiterad preliminärskatt";"-8 000"',
'"";"Ingående saldo 2025-01-01";"100"',
].join('\r\n')
const result = parseSkattekontoFile(newestFirst, 'export.csv')
expect(result.opening_saldo).toBe(100)
expect(result.closing_saldo).toBe(600)
expect(result.sum_valid).toBe(true)
})
it('skips malformed rows with warnings', () => {
const withBad = [
'"2026-06-06";"Kostnadsränta";"-10"',
+80 -18
View File
@@ -20,7 +20,9 @@
* Secondary tolerance: legacy `.skv` text exports from the retired
* e-service. Same date;text;amount row shape but possibly unquoted, without
* the name/orgnr header, and sometimes with a trailing running-saldo column,
* which is ignored.
* which is ignored for event rows (a marker row whose belopp cell is empty
* takes its saldo from that column instead). Several marker pairs (one per
* year or page) are reduced to the earliest opening and latest closing.
*/
import { roundOre } from '@/lib/money'
@@ -57,19 +59,44 @@ const SKV_VOCABULARY = [
/**
* Parse a skattekonto amount: whole kronor or comma decimals, space/nbsp
* thousands separators, optional trailing "kr". Returns null on non-amounts.
* thousands separators, optional trailing "kr", optional explicit "+".
* Typographic minus variants (U+2212 MINUS SIGN, the CLDR sv-SE default,
* plus hyphen/dash lookalikes) count as a minus. Returns null on non-amounts.
*/
function parseAmount(value: string): number | null {
const cleaned = value
// \s covers regular space, nbsp (U+00A0) and narrow nbsp (U+202F).
.replace(/\s/g, '')
.replace(/kr$/i, '')
// U+2212 minus sign, U+2010..U+2013 hyphen/dash lookalikes.
.replace(/^[\u2212\u2010-\u2013]/, '-')
.replace(/^\+/, '')
.replace(',', '.')
if (cleaned === '' || cleaned === '-') return null
if (!/^-?\d+(\.\d+)?$/.test(cleaned)) return null
return roundOre(parseFloat(cleaned))
}
/**
* Amount of a saldo marker row. The Kontoutdrag export puts it in the
* belopp column; a layout with a trailing running-saldo column leaves belopp
* empty and carries the saldo in the last column. Take the last readable
* amount at or after the belopp column.
*/
function parseMarkerAmount(cells: string[]): number | null {
for (let i = cells.length - 1; i >= 2; i--) {
const amount = parseAmount(cells[i])
if (amount !== null) return amount
}
return null
}
/** Date written into a marker text ("Ingående saldo 2026-05-03"), if any. */
function markerDate(text: string, dateCell: string): string | null {
const inText = /(\d{4}-\d{2}-\d{2})/.exec(text)
return (inText ? normalizeDate(inText[1]) : null) ?? normalizeDate(dateCell)
}
function splitRow(line: string): string[] {
return parseCSVLine(line, ';').map((cell) => cell.trim())
}
@@ -136,11 +163,17 @@ export function parseSkattekontoFile(
const issues: SkattekontoFileParseIssue[] = []
let companyName: string | null = null
let orgNumber: string | null = null
let openingSaldo: number | null = null
let closingSaldo: number | null = null
// A statement can carry several marker pairs (one per year or per page).
// The statement-level check runs from the earliest opening to the latest
// closing; intermediate pairs cancel out. Order by the marker's own date,
// falling back to file order for undated markers.
let opening: { saldo: number; date: string | null; seq: number } | null = null
let closing: { saldo: number; date: string | null; seq: number } | null = null
let sawSaldoMarker = false
let markerSeq = 0
let totalRows = 0
let skippedRows = 0
let unreadableAmountRows = 0
const seenContent = new Map<string, number>()
@@ -163,17 +196,20 @@ export function parseSkattekontoFile(
const markerText = cells[1] ?? ''
if (OPENING_MARKER_RE.test(markerText) || CLOSING_MARKER_RE.test(markerText)) {
sawSaldoMarker = true
const amount = parseAmount(cells[2] ?? '')
const amount = parseMarkerAmount(cells)
if (amount === null) {
issues.push({
row: i + 1,
message: `Kunde inte läsa saldobeloppet: ${cells[2] ?? ''}`,
severity: 'warning',
})
} else if (OPENING_MARKER_RE.test(markerText)) {
openingSaldo = amount
} else {
closingSaldo = amount
continue
}
const marker = { saldo: amount, date: markerDate(markerText, cells[0] ?? ''), seq: markerSeq++ }
if (OPENING_MARKER_RE.test(markerText)) {
if (!opening || isEarlierMarker(marker, opening)) opening = marker
} else if (!closing || isEarlierMarker(closing, marker)) {
closing = marker
}
continue
}
@@ -206,6 +242,8 @@ export function parseSkattekontoFile(
severity: 'warning',
})
skippedRows++
// A dated event we could not read is money missing from the sum check.
unreadableAmountRows++
continue
}
@@ -223,20 +261,32 @@ export function parseSkattekontoFile(
rows.push({ transaktionsdatum: date, transaktionstext: text, belopp, raw_line: line })
}
// Integrity: the statement must sum. A mismatch means a truncated or
// hand-edited file: surfaced as an error so the route refuses the import.
// A file that HAS saldo markers but not both valid balances is equally
// suspect (cut off before "Utgående saldo", or a garbled amount): fail it
// rather than silently skipping the check. Only marker-less legacy files
// legitimately have no balances to check (sum_valid stays null).
// Integrity: a complete statement sums (opening + events = closing). A
// mismatch means a truncated, filtered or hand-edited file, or dated rows
// whose amount we could not read. It is reported as an error-severity
// issue with the figures; the import route no longer refuses the file on
// it (the preview shows the gap and asks the user to confirm), because
// every parsed row is still a real event that is reviewed before booking
// and re-importing a complete file later dedups. A file that HAS saldo
// markers but not both readable balances is flagged the same way. Only
// marker-less legacy files legitimately have nothing to check
// (sum_valid stays null).
const openingSaldo = opening?.saldo ?? null
const closingSaldo = closing?.saldo ?? null
let sumValid: boolean | null = null
let eventsSum: number | null = null
let sumDifference: number | null = null
if (openingSaldo !== null && closingSaldo !== null) {
const sum = rows.reduce((acc, row) => roundOre(acc + row.belopp), openingSaldo)
sumValid = Math.abs(sum - closingSaldo) < 0.005
eventsSum = rows.reduce((acc, row) => roundOre(acc + row.belopp), openingSaldo)
sumDifference = roundOre(closingSaldo - eventsSum)
sumValid = Math.abs(sumDifference) < 0.005
if (!sumValid) {
issues.push({
row: 0,
message: `Ingående saldo plus transaktioner (${sum}) stämmer inte med utgående saldo (${closingSaldo})`,
message:
unreadableAmountRows > 0
? `Ingående saldo plus händelser (${eventsSum}) stämmer inte med utgående saldo (${closingSaldo}); ${unreadableAmountRows} rader med oläsbart belopp saknas i summan`
: `Ingående saldo plus händelser (${eventsSum}) stämmer inte med utgående saldo (${closingSaldo}); differens ${sumDifference}`,
severity: 'error',
})
}
@@ -262,11 +312,23 @@ export function parseSkattekontoFile(
opening_saldo: openingSaldo,
closing_saldo: closingSaldo,
sum_valid: sumValid,
events_sum: eventsSum,
sum_difference: sumDifference,
issues,
stats: {
total_rows: totalRows,
parsed_rows: rows.length,
skipped_rows: skippedRows,
unreadable_amount_rows: unreadableAmountRows,
},
}
}
/** Earlier by marker date when both are dated; otherwise by file order. */
function isEarlierMarker(
a: { date: string | null; seq: number },
b: { date: string | null; seq: number },
): boolean {
if (a.date && b.date && a.date !== b.date) return a.date < b.date
return a.seq < b.seq
}
+10 -2
View File
@@ -38,14 +38,22 @@ export interface SkattekontoFileParseResult {
closing_saldo: number | null
/**
* opening_saldo + sum(rows) === closing_saldo, checked when both markers
* exist. False means the file is truncated or hand-edited and must not be
* imported silently. Null when the file carries no saldo markers.
* exist. False means the file is truncated, filtered, hand-edited or has
* dated rows we could not read: the preview surfaces the gap and asks the
* user to confirm before importing. Null when the file carries no saldo
* markers.
*/
sum_valid: boolean | null
/** opening_saldo + sum(rows); null when the check could not run. */
events_sum: number | null
/** closing_saldo - events_sum; 0 on a consistent statement, null when unchecked. */
sum_difference: number | null
issues: SkattekontoFileParseIssue[]
stats: {
total_rows: number
parsed_rows: number
skipped_rows: number
/** Dated rows skipped for an unreadable amount: money missing from the sum check. */
unreadable_amount_rows: number
}
}