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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
2b5b813b7a
commit
387e1fb7f1
@@ -1047,3 +1047,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-17] 77xx nedskrivningar split per official BAS kopplingstabell in BOTH k2-mapper and ink2-engine (fältkod 7515: 7700-7739, 7750-7789, 7800-7899; 7516: 774x, 779x): agent feedback 2026-07-07 reported the K2 side; the INK2R side and the swedish-sru-filing reference table had the same whole-77xx-to-7516 error, verified against bas.se INK2_P1_intervall-240118.pdf before overriding the skill reference. NE-bilaga mappings deliberately untouched (NE has no separate omsättningstillgångar line).
|
||||
[2026-08-17] MCP feedback loop = local /loop-feedback-triage appending dev_docs/mcp_feedback_digest.md + small PRs, NOT a GitHub-issue digest or Resend email: closes the loops.md backlog item blocked since 07-09 on a "channel decision". Issues stay founder-authorised; the digest is the read surface. gnubok_feedback reply copy no longer promises weekly aggregation (it was never true); tool advertised in server instructions + agent briefing (feedback_channel), where it was previously discoverable only by scanning tools/list.
|
||||
[2026-08-17] Non-IBAN foreign payment accounts (USD/GBP): added generic bank_code + foreign_account_number to InvoicePaymentAccount (JSONB, no migration) instead of per-country fields (routing_number, sort_code, bsb); rule = IBAN OR (bank_code + foreign_account_number + BIC), only for NON_IBAN_CURRENCIES, label per currency. Chosen over a field per country: the Currency union only carries USD/GBP among non-IBAN systems, and one generic pair keeps the PDF/settings/schema surface small; extend NON_IBAN_CURRENCIES + bankCodeLabelKey when AUD/CAD land. Agent feedback 2026-08-03.
|
||||
[2026-08-18] Skattekontoutdrag sum mismatch (opening + events != closing) demoted from a hard 400 to a preview confirm gate showing ingående/händelser/utgående/differens, mirroring the orgnr-mismatch gate: Sebastian's real export was refused on it (2026-08-18) with no way forward and no figures to diagnose; nothing is booked at import and dedup makes a later complete re-import safe, so refusing the file only blocked the rows that WERE readable. Parser also takes the earliest opening / latest closing across several marker pairs, reads a marker saldo from a trailing running-saldo column, and accepts U+2212 / plus-sign amounts; the route logs the figures (amounts and counts, never row text) so the next report is diagnosable from Vercel logs. Kept the hard reject only for zero readable rows.
|
||||
|
||||
@@ -92,14 +92,35 @@ describe('POST /api/import/skattekonto-file/parse', () => {
|
||||
expect(body.error.code).toBe('SKATTEKONTO_FILE_NOT_RECOGNIZED')
|
||||
})
|
||||
|
||||
it('rejects a statement that does not sum', async () => {
|
||||
it('returns a statement that does not sum with the gap for the preview gate', async () => {
|
||||
enqueue({ data: null }) // no prior import
|
||||
enqueue({ data: { org_number: '556677-8899' } }) // company_settings
|
||||
enqueue({ data: [] }) // existing rows page
|
||||
const broken = MODERN_CSV.replace('"23 490"', '"99 999"')
|
||||
const { status, body } = await jsonOf(
|
||||
await POST(makeFileRequest(broken, 'Kontoutdrag 556677-8899 2026-05-03--2026-08-01.csv'), emptyParams),
|
||||
)
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.parse_result.sum_valid).toBe(false)
|
||||
expect(body.data.parse_result.opening_saldo).toBe(-500)
|
||||
expect(body.data.parse_result.events_sum).toBe(23490)
|
||||
expect(body.data.parse_result.closing_saldo).toBe(99999)
|
||||
expect(body.data.parse_result.sum_difference).toBe(76509)
|
||||
// The rows are still returned: nothing is booked at import, so the user
|
||||
// can import the events that ARE in the file and confirm the gap.
|
||||
expect(body.data.parse_result.rows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('still refuses a statement with no readable events', async () => {
|
||||
enqueue({ data: null }) // no prior import
|
||||
const empty = [
|
||||
'"Testbolaget AB";"556677-8899";""',
|
||||
'"";"Ingående saldo 2026-05-03";"-500"',
|
||||
'"";"Utgående saldo 2026-08-01";"-500"',
|
||||
].join('\r\n')
|
||||
const { status, body } = await jsonOf(await POST(makeFileRequest(empty), emptyParams))
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('SKATTEKONTO_FILE_SUM_MISMATCH')
|
||||
expect(body.error.code).toBe('SKATTEKONTO_FILE_NO_ROWS')
|
||||
})
|
||||
|
||||
it('parses a valid statement and partitions against existing rows', async () => {
|
||||
|
||||
@@ -75,19 +75,29 @@ export const POST = withRouteContext(
|
||||
|
||||
const parseResult = parseSkattekontoFile(content, file.name)
|
||||
|
||||
if (parseResult.sum_valid === false) {
|
||||
return errorResponseFromCode('SKATTEKONTO_FILE_SUM_MISMATCH', opLog, {
|
||||
requestId,
|
||||
details: {
|
||||
openingSaldo: parseResult.opening_saldo,
|
||||
closingSaldo: parseResult.closing_saldo,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (parseResult.rows.length === 0) {
|
||||
return errorResponseFromCode('SKATTEKONTO_FILE_NO_ROWS', opLog, { requestId })
|
||||
}
|
||||
|
||||
// A statement that does not sum is no longer refused: the preview shows
|
||||
// the gap and asks for confirmation (see parser.ts). Log the figures so
|
||||
// a report of "utdraget summerar inte" can be diagnosed without the
|
||||
// file: amounts and counts only, never row text.
|
||||
if (parseResult.sum_valid === false) {
|
||||
opLog.warn('skattekonto file does not sum', {
|
||||
openingSaldo: parseResult.opening_saldo,
|
||||
closingSaldo: parseResult.closing_saldo,
|
||||
eventsSum: parseResult.events_sum,
|
||||
sumDifference: parseResult.sum_difference,
|
||||
parsedRows: parseResult.stats.parsed_rows,
|
||||
skippedRows: parseResult.stats.skipped_rows,
|
||||
unreadableAmountRows: parseResult.stats.unreadable_amount_rows,
|
||||
dateFrom: parseResult.date_from,
|
||||
dateTo: parseResult.date_to,
|
||||
variant: parseResult.variant,
|
||||
})
|
||||
}
|
||||
|
||||
// Wrong-company guard: the modern export names its orgnr in the header
|
||||
// row. A mismatch is surfaced for the preview step to confirm, not a
|
||||
// hard block (legacy files have no header at all).
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/components/ui/table'
|
||||
import { ArrowLeft, ArrowRight, AlertTriangle, Calendar, FileText, Scale } from 'lucide-react'
|
||||
import { formatCurrency, cn } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { SkattekontoFileParseResult } from '@/lib/import/skattekonto-file/types'
|
||||
|
||||
interface SkattekontoFilePreviewStepProps {
|
||||
@@ -40,13 +41,33 @@ export default function SkattekontoFilePreviewStep({
|
||||
}: SkattekontoFilePreviewStepProps) {
|
||||
const t = useTranslations('import')
|
||||
const [mismatchConfirmed, setMismatchConfirmed] = useState(false)
|
||||
const { rows, stats, issues, date_from, date_to, closing_saldo } = parseResult
|
||||
const [sumGapConfirmed, setSumGapConfirmed] = useState(false)
|
||||
const {
|
||||
rows,
|
||||
stats,
|
||||
issues,
|
||||
date_from,
|
||||
date_to,
|
||||
opening_saldo,
|
||||
closing_saldo,
|
||||
events_sum,
|
||||
sum_difference,
|
||||
sum_valid,
|
||||
} = parseResult
|
||||
|
||||
const duplicateSet = new Set(duplicateIndexes)
|
||||
const promotionSet = new Set(promotionIndexes)
|
||||
const newCount = rows.length - duplicateIndexes.length
|
||||
const warnings = issues.filter((i) => i.severity !== 'error')
|
||||
const importBlocked = orgNumberMismatch && !mismatchConfirmed
|
||||
// A statement that does not sum (truncated, filtered, unreadable rows) is
|
||||
// a confirm gate, not a block: nothing is booked at import and every event
|
||||
// is reviewed on the skattekonto page, so importing what IS in the file is
|
||||
// safe. The gate exists so the user knows the picture is incomplete.
|
||||
const sumGap = sum_valid === false
|
||||
const sumGapHasFigures =
|
||||
opening_saldo !== null && closing_saldo !== null && events_sum !== null && sum_difference !== null
|
||||
const importBlocked =
|
||||
(orgNumberMismatch && !mismatchConfirmed) || (sumGap && !sumGapConfirmed)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -119,12 +140,61 @@ export default function SkattekontoFilePreviewStep({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{sumGap && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{t('skattekonto_sum_gap_title')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
{sumGapHasFigures ? (
|
||||
<dl className="grid max-w-sm grid-cols-[auto_1fr] gap-x-6 gap-y-1 tabular-nums">
|
||||
<dt className="text-muted-foreground">{t('skattekonto_sum_gap_opening')}</dt>
|
||||
<dd className="text-right">{formatCurrency(opening_saldo)}</dd>
|
||||
<dt className="text-muted-foreground">{t('skattekonto_sum_gap_events')}</dt>
|
||||
<dd className="text-right">{formatCurrency(roundOre(events_sum - opening_saldo))}</dd>
|
||||
<dt className="text-muted-foreground">{t('skattekonto_sum_gap_expected')}</dt>
|
||||
<dd className="text-right">{formatCurrency(events_sum)}</dd>
|
||||
<dt className="text-muted-foreground">{t('skattekonto_sum_gap_closing')}</dt>
|
||||
<dd className="text-right">{formatCurrency(closing_saldo)}</dd>
|
||||
<dt className="font-medium">{t('skattekonto_sum_gap_difference')}</dt>
|
||||
<dd className="text-right font-medium">{formatCurrency(sum_difference)}</dd>
|
||||
</dl>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t('skattekonto_sum_gap_no_saldo')}</p>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
{stats.unreadable_amount_rows > 0
|
||||
? t('skattekonto_sum_gap_body_unreadable', { count: stats.unreadable_amount_rows })
|
||||
: t('skattekonto_sum_gap_body')}
|
||||
</p>
|
||||
<label className="flex cursor-pointer items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded-sm border-border"
|
||||
checked={sumGapConfirmed}
|
||||
onChange={(e) => setSumGapConfirmed(e.target.checked)}
|
||||
/>
|
||||
<span>{t('skattekonto_sum_gap_confirm')}</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{duplicateIndexes.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('skattekonto_duplicates_note', { count: duplicateIndexes.length })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* What happens after import: nothing is booked automatically, and an
|
||||
event that already has a 1630 verifikat (a deposit booked from the
|
||||
bank side) is offered as a link, not a second booking. Answers the
|
||||
"some of these are already booked" question before the click. */}
|
||||
<p className="text-sm text-muted-foreground">{t('skattekonto_after_import_note')}</p>
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="py-3">
|
||||
|
||||
@@ -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"',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7116,6 +7116,17 @@
|
||||
"skattekonto_org_mismatch_title": "The statement belongs to another company",
|
||||
"skattekonto_org_mismatch_body": "The file is a statement for {companyName} ({orgNumber}), which does not match the active company's organisation number. Make sure you have the right company selected before importing.",
|
||||
"skattekonto_org_mismatch_confirm": "I am sure this statement belongs to the active company",
|
||||
"skattekonto_sum_gap_title": "The statement does not sum",
|
||||
"skattekonto_sum_gap_opening": "Opening balance",
|
||||
"skattekonto_sum_gap_events": "Events in the file",
|
||||
"skattekonto_sum_gap_expected": "Opening balance + events",
|
||||
"skattekonto_sum_gap_closing": "Closing balance per the file",
|
||||
"skattekonto_sum_gap_difference": "Difference",
|
||||
"skattekonto_sum_gap_no_saldo": "The file has no readable opening or closing balance, so the statement cannot be reconciled.",
|
||||
"skattekonto_sum_gap_body": "Events are missing from the file: it may have been downloaded with a filter, cut off or edited. You can still import the events that are in it; nothing is booked automatically, and importing a complete statement later fills the gap without duplicates.",
|
||||
"skattekonto_sum_gap_body_unreadable": "{count, plural, one {1 dated row has an amount we could not read and is missing from the sum.} other {# dated rows have amounts we could not read and are missing from the sum.}} You can still import the readable events; nothing is booked automatically. Send us the file and we will teach the import to read it.",
|
||||
"skattekonto_sum_gap_confirm": "I understand the statement is incomplete and want to import the events it contains",
|
||||
"skattekonto_after_import_note": "Nothing is booked at import. The events land on the tax account page; those that already have a voucher on 1630 (for example deposits you booked from the bank side) are flagged as possible duplicates and linked to that voucher instead of being booked again.",
|
||||
"skattekonto_duplicates_note": "{count, plural, one {1 event already exists on the tax account and will be skipped.} other {# events already exist on the tax account and will be skipped.}}",
|
||||
"skattekonto_issues_title": "{count, plural, one {1 row could not be fully read} other {# rows could not be fully read}}",
|
||||
"skattekonto_issue_row": "Row {row}",
|
||||
|
||||
@@ -7116,6 +7116,17 @@
|
||||
"skattekonto_org_mismatch_title": "Utdraget gäller ett annat företag",
|
||||
"skattekonto_org_mismatch_body": "Filen är ett kontoutdrag för {companyName} ({orgNumber}), vilket inte matchar det aktiva företagets organisationsnummer. Kontrollera att du valt rätt företag innan du importerar.",
|
||||
"skattekonto_org_mismatch_confirm": "Jag är säker på att utdraget hör till det aktiva företaget",
|
||||
"skattekonto_sum_gap_title": "Utdraget summerar inte",
|
||||
"skattekonto_sum_gap_opening": "Ingående saldo",
|
||||
"skattekonto_sum_gap_events": "Händelser i filen",
|
||||
"skattekonto_sum_gap_expected": "Ingående saldo + händelser",
|
||||
"skattekonto_sum_gap_closing": "Utgående saldo enligt filen",
|
||||
"skattekonto_sum_gap_difference": "Differens",
|
||||
"skattekonto_sum_gap_no_saldo": "Filen saknar ett läsbart ingående eller utgående saldo, så utdraget kan inte stämmas av.",
|
||||
"skattekonto_sum_gap_body": "Händelser saknas i filen: den kan vara nedladdad med ett filter, avklippt eller redigerad. Du kan importera händelserna som finns med ändå; inget bokförs automatiskt, och en senare import av ett komplett utdrag fyller på det som fattas utan dubbletter.",
|
||||
"skattekonto_sum_gap_body_unreadable": "{count, plural, one {En rad med datum har ett belopp som inte gick att läsa och saknas därför i summan.} other {# rader med datum har belopp som inte gick att läsa och saknas därför i summan.}} Du kan importera de läsbara händelserna ändå; inget bokförs automatiskt. Skicka gärna filen till oss så lär vi importen läsa den.",
|
||||
"skattekonto_sum_gap_confirm": "Jag förstår att utdraget är ofullständigt och vill importera händelserna som finns",
|
||||
"skattekonto_after_import_note": "Inget bokförs vid importen. Händelserna hamnar på skattekontosidan; de som redan har ett verifikat på 1630 (till exempel insättningar du bokfört från banken) markeras som möjlig dubblett och kopplas till det verifikatet i stället för att bokföras en gång till.",
|
||||
"skattekonto_duplicates_note": "{count, plural, one {En händelse finns redan på skattekontot och hoppas över.} other {# händelser finns redan på skattekontot och hoppas över.}}",
|
||||
"skattekonto_issues_title": "{count, plural, one {En rad kunde inte läsas fullt ut} other {# rader kunde inte läsas fullt ut}}",
|
||||
"skattekonto_issue_row": "Rad {row}",
|
||||
|
||||
Reference in New Issue
Block a user