Bug/open banking flow (#854)

* fix(enable-banking): pin Mobile BankID (decoupled) auth_method so Handelsbanken corporate connects

We never sent auth_method to Enable Banking, so it fell back to the ASPSP's
visible default — REDIRECT for Handelsbanken. For Handelsbanken *corporate*
PSUs the redirect flow does not support Mobile BankID, so authorization failed
right after the user approved in the BankID app. Mobile BankID at Handelsbanken
is a DECOUPLED method flagged hidden_method=true, which Enable Banking only uses
when requested explicitly.

Resolve the bank's preferred auth method before /auth: query the ASPSP's
auth_methods and pick the DECOUPLED (Mobile BankID) method when present,
otherwise leave auth_method unset so banks that already work are untouched.
The method name is read dynamically per psu_type, so it is robust across
sandbox/production naming.

- api-client: add approach/hidden_method to AuthMethod, fix ASPSP.auth_methods
  field name (was available_auth_methods, never populated), add
  getPreferredAuthMethod(), thread optional authMethod through startAuthorization
- index: resolve authMethod in /connect and pass it on both fresh + reconnect
- tests: cover method selection and request-body shaping

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(invoice-inbox): clean up bulk-selection toolbar UI

Redesign the selection toolbar shown when inbox items are checked:
one solid primary "Bokför valda" button with outlined secondary
actions ("Fråga assistenten", "Ta bort") and a plain selection
count. Removes the redundant "Avmarkera" button (users uncheck the
still-visible box), fixes label clipping, and gives the toolbar more
breathing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(entitlements): bypass paywall in local development

Add isPaywallBypassed() so all gated capabilities are testable locally
without a subscription. Fires only on NODE_ENV=development (npm run dev)
or an explicit DISABLE_PAYWALL=true escape hatch — production builds run
under NODE_ENV=production and the entitlement suite runs under 'test',
so both keep exercising the real gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(tic): resolve enskild firma bolagsuppgifter via 12-digit personnummer

TIC's Lens search is fuzzy and only resolves an enskild firma from the 12-digit (century-prefixed) personnummer; a 10-digit form fuzzy-matched an unrelated entity. Expand personnummer to 12 digits before querying and reject hits whose registration number is unrelated to the request. Add a "Hämta" action to the settings Bolagsuppgifter panel to (re)fetch on demand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): implement categorize core for bank transaction categorization

- Added `categorize-core.ts` to handle categorization of bank transactions, supporting single and bulk operations.
- Introduced `categorizeMatchedTransaction` and `bulkBookMatchedInboxItems` functions for transaction processing.
- Implemented fiscal period validation and duplicate booking detection.
- Enhanced logging and error handling for transaction categorization.

feat(scripts): add diagnostic script for Handelsbanken ASPSP metadata

- Created `check-handelsbanken-aspsp.mjs` to fetch and display available authentication methods for Handelsbanken.
- Outputs metadata for business and personal PSU types, including default authentication methods.

fix(migrations): increase statement timeout for SIE bulk delete operations

- Updated `20260629160000_sie_bulk_delete_statement_timeout.sql` to set a longer statement timeout for bulk delete RPCs to prevent cancellations during large imports.

feat(migrations): add bulk book inbox items to pending operations

- Expanded `pending_operations` table to include `bulk_book_inbox_items` operation type in `20260630120000_pending_operations_add_bulk_book_inbox_items.sql`.
- Supports bulk booking of matched inbox items against bank transactions.

test(pg): add tests for replace_period_opening_balance_link RPC

- Implemented tests in `replace-period-opening-balance-link.pg.test.ts` to validate the functionality of the opening-balance correction flow.
- Ensured immutability of opening balance links and proper handling of posted vs. non-posted entries.

* fix(sie-export): update journal entries and lines handling in SIE export tests

* fix(migrations): resolve version collision on 20260629160000

The SIE bulk-delete statement_timeout migration shared version
20260629160000 with journal_entries_list_series_filter (merged from
main via #798/#823), causing a schema_migrations_pkey duplicate key
error on apply. Rename the branch's migration to 20260629160100.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compliance): resolve compliance-swarm + review findings

- opening-balance/correct: compensating rollback for the non-atomic
  storno+rebook so a mid-sequence failure never leaves two posted OB
  entries (ASVS V2.3); durable audit event on every failure path
  (V16); reference the original verifikationsnummer in the corrected
  entry per BFL 5 kap 5§; document that requireWrite already enforces
  write-role + membership (V8.2.1 was a false positive)
- reports sources routes: validate the cursor date component as ISO
  (/^\d{4}-\d{2}-\d{2}$/) before use, 400 on malformed (ASVS V1.2),
  applied to both the VAT-declaration and trial-balance routes
- AgentSessionList: await the rename PATCH, revert the optimistic
  title and toast on failure (ASVS V4.5)
- bank booking: exclude same-batch siblings from the booking-time
  duplicate guard so bulk-booking distinct same-(date,amount)
  transactions no longer false-positives; pre-existing duplicate
  detection is preserved
- BulkBookInboxDialog: drop the unsafe currency-based reverse_charge
  default, add an omvänd skattskyldighet advisory, and type VAT
  options to the backend VatTreatment union
- OpeningBalanceRowEditor: hold onChange in a ref (synced in effect,
  not during render) so an unstable callback can't cause a render loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-01 18:13:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2da9c71eb3
commit f63d3e3100
83 changed files with 6769 additions and 1360 deletions
+15 -371
View File
@@ -1,24 +1,13 @@
'use client'
import { useState, useMemo, useCallback, useRef } from 'react'
import Fuse from 'fuse.js'
import { useMemo, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import type { ParsedOpeningBalanceRow } from '@/lib/import/opening-balance/types'
interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
} from './OpeningBalanceRowEditor'
interface OpeningBalanceEditStepProps {
rows: ParsedOpeningBalanceRow[]
@@ -26,37 +15,6 @@ interface OpeningBalanceEditStepProps {
onBack: () => void
}
// Filter BAS reference to balance sheet accounts only (class 1-2) for primary suggestions
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<typeof BAS_REFERENCE[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
@@ -67,28 +25,9 @@ export default function OpeningBalanceEditStep({
onContinue,
onBack,
}: OpeningBalanceEditStepProps) {
const [rows, setRows] = useState<EditableRow[]>(() => {
// Defense-in-depth dedup: if the parser ever leaks duplicates by
// account_number, collapse them here before the user sees them. Union
// validation_errors so a warning surfaced only on the later row isn't
// silently dropped during the merge.
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
const seedRows = useMemo<EditableRow[]>(
() =>
initialRows.map((r) => ({
id: generateId(),
account_number: r.account_number,
account_name: r.account_name,
@@ -96,323 +35,28 @@ export default function OpeningBalanceEditStep({
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
})
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Compute totals
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
// Validation
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canContinue = totals.isBalanced && !hasErrors && rows.length >= 2
// Autocomplete results
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
// If the query is numeric, search all accounts; otherwise prefer balance sheet
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
})),
[initialRows],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
return (
<Card>
<CardHeader>
<CardTitle>Granska och redigera</CardTitle>
<CardDescription>
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort
och ändra rader. Debet och kredit måste balansera innan du kan fortsätta.
Kontrollera att kontonummer och belopp stämmer. Du kan lägga till, ta bort och ändra
rader. Debet och kredit måste balansera innan du kan fortsätta.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id &&
autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">
{row.account_name}
</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
<OpeningBalanceRowEditor initialRows={seedRows} onChange={setState} />
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK.
Kontrollera beloppen innan du fortsätter.
</p>
</div>
)}
{/* Navigation */}
<div className="flex justify-between pt-2">
<Button variant="ghost" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={() => onContinue(rows)} disabled={!canContinue}>
<Button onClick={() => state && onContinue(state.rows)} disabled={!state?.canSubmit}>
Fortsätt
</Button>
</div>
+19 -14
View File
@@ -18,7 +18,9 @@ interface EditableRow {
interface OpeningBalancePeriodStepProps {
rows: EditableRow[]
onExecute: (fiscalPeriodId: string) => void
/** `replace` is true when the selected period already has opening balances
* (the existing IB verifikat will be stornoed and replaced). */
onExecute: (fiscalPeriodId: string, replace: boolean) => void
onBack: () => void
isLoading: boolean
error: string | null
@@ -71,22 +73,23 @@ export default function OpeningBalancePeriodStep({
}, [])
const selectedPeriod = periods.find((p) => p.id === selectedPeriodId)
const periodHasOB = selectedPeriod?.opening_balances_set
const periodHasOB = !!selectedPeriod?.opening_balances_set
const periodIsClosed = selectedPeriod?.is_closed
const periodIsLocked = !!selectedPeriod?.locked_at
// A period that already has IB can still be corrected, as long as it is open
// and unlocked — the existing IB verifikat is stornoed and replaced.
const canExecute =
selectedPeriodId &&
!periodHasOB &&
!!selectedPeriodId &&
!periodIsClosed &&
!periodIsLocked &&
!isLoading
const handleExecute = useCallback(() => {
if (canExecute) {
onExecute(selectedPeriodId)
onExecute(selectedPeriodId, periodHasOB)
}
}, [canExecute, selectedPeriodId, onExecute])
}, [canExecute, selectedPeriodId, periodHasOB, onExecute])
return (
<Card>
@@ -131,13 +134,13 @@ export default function OpeningBalancePeriodStep({
)}
</div>
{/* Warnings */}
{periodHasOB && (
<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">
Denna period har redan ingående balanser. Ta bort den befintliga verifikationen
(via stornering) innan du importerar nya.
{/* Replace notice — selecting a period that already has IB corrects it */}
{periodHasOB && !periodIsClosed && !periodIsLocked && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Denna period har redan ingående balanser. Om du fortsätter makuleras (stornas) den
befintliga IB-verifikationen och en ny bokförs med beloppen nedan.
</p>
</div>
)}
@@ -180,8 +183,10 @@ export default function OpeningBalancePeriodStep({
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Bokför...
{periodHasOB ? 'Ersätter...' : 'Bokför...'}
</>
) : periodHasOB ? (
'Ersätt ingående balanser'
) : (
'Bokför ingående balanser'
)}
@@ -15,6 +15,7 @@ export default function OpeningBalanceResultStep({
result,
onNewImport,
}: OpeningBalanceResultStepProps) {
const isCorrection = !!result.reversed_entry_id
return (
<Card>
<CardHeader>
@@ -26,7 +27,9 @@ export default function OpeningBalanceResultStep({
)}
<CardTitle>
{result.success
? 'Ingående balanser bokförda'
? isCorrection
? 'Ingående balanser korrigerade'
: 'Ingående balanser bokförda'
: 'Importen misslyckades'}
</CardTitle>
</div>
@@ -0,0 +1,429 @@
'use client'
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
import Fuse from 'fuse.js'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Plus, Trash2, AlertTriangle, Scale } from 'lucide-react'
import { cn } from '@/lib/utils'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
export interface EditableRow {
id: string
account_number: string
account_name: string
debit_amount: number
credit_amount: number
validation_errors: string[]
bas_match: string | null
}
export interface OpeningBalanceEditorState {
rows: EditableRow[]
totals: { debit: number; credit: number; diff: number; isBalanced: boolean }
/** Balanced, no row errors, and at least two lines — safe to book/correct. */
canSubmit: boolean
}
interface OpeningBalanceRowEditorProps {
/** Seed rows. Read once on mount (later prop changes are ignored — remount
* via `key` or a conditional render to reset the grid). */
initialRows: EditableRow[]
/** Fires whenever rows / totals / validity change. Held in a ref internally,
* so it does NOT need to be referentially stable — passing an inline arrow
* is safe and will not cause an update-loop. */
onChange: (state: OpeningBalanceEditorState) => void
}
// Balance-sheet accounts (class 1-2) drive the primary suggestions; numeric
// queries fall back to the full chart.
const BALANCE_SHEET_ACCOUNTS = BAS_REFERENCE.filter(
(a) => a.account_class === 1 || a.account_class === 2,
)
const ALL_BAS_ACCOUNTS = BAS_REFERENCE
let fuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getFuse() {
if (!fuseInstance) {
fuseInstance = new Fuse(ALL_BAS_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return fuseInstance
}
let balanceFuseInstance: Fuse<(typeof BAS_REFERENCE)[0]> | null = null
function getBalanceFuse() {
if (!balanceFuseInstance) {
balanceFuseInstance = new Fuse(BALANCE_SHEET_ACCOUNTS, {
keys: ['account_number', 'account_name'],
threshold: 0.3,
includeScore: true,
})
}
return balanceFuseInstance
}
let idCounter = 0
function generateId() {
return `row_${++idCounter}_${Date.now()}`
}
// Defense-in-depth dedup: if a seed source ever leaks duplicate accounts,
// collapse them before the user sees them. Union validation_errors so a
// warning surfaced only on the later row isn't silently dropped on merge.
function dedupeRows(initialRows: EditableRow[]): EditableRow[] {
const byAccount = new Map<string, EditableRow>()
for (const r of initialRows) {
const key = r.account_number.replace(/\D/g, '')
const existing = byAccount.get(key)
if (existing) {
existing.debit_amount = Math.round((existing.debit_amount + r.debit_amount) * 100) / 100
existing.credit_amount = Math.round((existing.credit_amount + r.credit_amount) * 100) / 100
if (!existing.account_name && r.account_name) existing.account_name = r.account_name
if (r.validation_errors?.length) {
const seen = new Set(existing.validation_errors)
for (const err of r.validation_errors) {
if (!seen.has(err)) existing.validation_errors.push(err)
}
}
continue
}
byAccount.set(key, {
id: r.id || generateId(),
account_number: r.account_number,
account_name: r.account_name,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
validation_errors: [...r.validation_errors],
bas_match: r.bas_match,
})
}
return Array.from(byAccount.values())
}
/**
* The opening-balance line grid: editable account / debet / kredit rows with
* BAS autocomplete, a live debit-credit balance check, blocking of resultat-
* konton (class 3-8), and a "round to 2099" auto-balance for sub-1-SEK drift.
*
* Stateless about WHY it's editing — the wizard seeds it from a parsed file,
* the verifikat correction dialog seeds it from the booked IB's lines. Both
* read the current rows + validity through `onChange`.
*/
export default function OpeningBalanceRowEditor({
initialRows,
onChange,
}: OpeningBalanceRowEditorProps) {
const [rows, setRows] = useState<EditableRow[]>(() => dedupeRows(initialRows))
const [activeAutocomplete, setActiveAutocomplete] = useState<string | null>(null)
const [autocompleteQuery, setAutocompleteQuery] = useState('')
const autocompleteRef = useRef<HTMLDivElement>(null)
// Hold onChange in a ref so an unstable (inline) callback identity can't
// retrigger the notifying effect below. Synced after each commit rather than
// during render (react-hooks/refs: refs must not be written while rendering).
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
const totals = useMemo(() => {
let debit = 0
let credit = 0
for (const row of rows) {
debit = Math.round((debit + row.debit_amount) * 100) / 100
credit = Math.round((credit + row.credit_amount) * 100) / 100
}
const diff = Math.round((debit - credit) * 100) / 100
return { debit, credit, diff, isBalanced: Math.abs(diff) < 0.01 }
}, [rows])
const hasErrors = useMemo(() => {
return rows.some((r) => {
if (!/^\d{4}$/.test(r.account_number)) return true
if (r.debit_amount === 0 && r.credit_amount === 0) return true
if (r.validation_errors.length > 0) return true
return false
})
}, [rows])
const canSubmit = totals.isBalanced && !hasErrors && rows.length >= 2
// Push state up via the ref so an unstable `onChange` identity can't retrigger
// this effect. Memoised `totals` means it only fires when the user actually
// edits a row, never in a loop.
useEffect(() => {
onChangeRef.current({ rows, totals, canSubmit })
}, [rows, totals, canSubmit])
const autocompleteResults = useMemo(() => {
if (!autocompleteQuery || autocompleteQuery.length < 1) return []
const isNumeric = /^\d+$/.test(autocompleteQuery)
const fuse = isNumeric ? getFuse() : getBalanceFuse()
return fuse.search(autocompleteQuery, { limit: 8 }).map((r) => r.item)
}, [autocompleteQuery])
const updateRow = useCallback((id: string, updates: Partial<EditableRow>) => {
setRows((prev) =>
prev.map((r) => {
if (r.id !== id) return r
const updated = { ...r, ...updates }
// Re-validate
const errors: string[] = []
if (!/^\d{4}$/.test(updated.account_number)) {
errors.push('Ogiltigt kontonummer')
}
const cls = parseInt(updated.account_number.charAt(0), 10)
if (cls >= 3 && cls <= 8) {
errors.push(`Resultatkonto (klass ${cls})`)
}
updated.validation_errors = errors
return updated
}),
)
}, [])
const deleteRow = useCallback((id: string) => {
setRows((prev) => prev.filter((r) => r.id !== id))
}, [])
const addRow = useCallback(() => {
setRows((prev) => [
...prev,
{
id: generateId(),
account_number: '',
account_name: '',
debit_amount: 0,
credit_amount: 0,
validation_errors: ['Ogiltigt kontonummer'],
bas_match: null,
},
])
}, [])
const selectAutocompleteItem = useCallback(
(rowId: string, account: (typeof BAS_REFERENCE)[0]) => {
updateRow(rowId, {
account_number: account.account_number,
account_name: account.account_name,
bas_match: account.account_name,
})
setActiveAutocomplete(null)
setAutocompleteQuery('')
},
[updateRow],
)
const handleAutoBalance = useCallback(() => {
if (Math.abs(totals.diff) > 1) return // Only auto-balance ≤ 1 SEK
if (totals.isBalanced) return
const adjustmentRow: EditableRow = {
id: generateId(),
account_number: '2099',
account_name: 'Årets resultat',
debit_amount: totals.diff > 0 ? 0 : Math.abs(totals.diff),
credit_amount: totals.diff > 0 ? totals.diff : 0,
validation_errors: [],
bas_match: 'Årets resultat',
}
setRows((prev) => [...prev, adjustmentRow])
}, [totals])
return (
<div className="space-y-4">
{/* Table */}
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="px-3 py-2 text-left w-28">Konto</th>
<th className="px-3 py-2 text-left">Kontonamn</th>
<th className="px-3 py-2 text-right w-32">Debet</th>
<th className="px-3 py-2 text-right w-32">Kredit</th>
<th className="px-3 py-2 w-10" />
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className={cn(
'border-b last:border-0',
row.validation_errors.length > 0 && 'bg-destructive/5',
)}
>
<td className="px-3 py-1.5 relative">
<Input
value={row.account_number}
onChange={(e) => {
const val = e.target.value.replace(/[^0-9]/g, '').slice(0, 4)
updateRow(row.id, { account_number: val })
setActiveAutocomplete(row.id)
setAutocompleteQuery(val)
}}
onFocus={() => {
setActiveAutocomplete(row.id)
setAutocompleteQuery(row.account_number)
}}
onBlur={() => {
// Delay to allow click on autocomplete items
setTimeout(() => setActiveAutocomplete(null), 200)
}}
placeholder="1930"
className="h-8 font-mono tabular-nums w-20"
maxLength={4}
/>
{/* Autocomplete dropdown */}
{activeAutocomplete === row.id && autocompleteResults.length > 0 && (
<div
ref={autocompleteRef}
className="absolute z-50 top-full left-3 mt-1 w-72 max-h-48 overflow-y-auto rounded-md border bg-popover shadow-md"
>
{autocompleteResults.map((item) => (
<button
key={item.account_number}
className="flex items-center gap-2 w-full px-3 py-1.5 text-left text-sm hover:bg-accent transition-colors"
onMouseDown={(e) => {
e.preventDefault()
selectAutocompleteItem(row.id, item)
}}
>
<span className="font-mono text-muted-foreground tabular-nums">
{item.account_number}
</span>
<span className="truncate">{item.account_name}</span>
</button>
))}
</div>
)}
</td>
<td className="px-3 py-1.5">
<div className="flex items-center gap-2">
<span className="text-sm truncate max-w-xs">{row.account_name}</span>
{row.validation_errors.length > 0 && (
<span
className="text-destructive shrink-0"
title={row.validation_errors.join(', ')}
>
<AlertTriangle className="h-3.5 w-3.5" />
</span>
)}
</div>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.debit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
debit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Input
type="number"
value={row.credit_amount || ''}
onChange={(e) =>
updateRow(row.id, {
credit_amount: Math.round(parseFloat(e.target.value || '0') * 100) / 100,
})
}
placeholder="0,00"
className="h-8 text-right tabular-nums w-28"
min={0}
step={0.01}
/>
</td>
<td className="px-3 py-1.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => deleteRow(row.id)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 font-medium">
<td className="px-3 py-2" colSpan={2}>
Summa
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.debit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{totals.credit.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</td>
<td />
</tr>
{!totals.isBalanced && (
<tr className="text-destructive">
<td className="px-3 py-1 text-sm" colSpan={2}>
Differens
</td>
<td className="px-3 py-1 text-right tabular-nums text-sm" colSpan={2}>
{totals.diff.toLocaleString('sv-SE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
SEK
</td>
<td />
</tr>
)}
</tfoot>
</table>
</div>
{/* Actions row */}
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={addRow}>
<Plus className="h-3.5 w-3.5 mr-1.5" />
Lägg till rad
</Button>
{!totals.isBalanced && Math.abs(totals.diff) <= 1 && Math.abs(totals.diff) >= 0.01 && (
<Button variant="outline" size="sm" onClick={handleAutoBalance}>
<Scale className="h-3.5 w-3.5 mr-1.5" />
Avrunda ({totals.diff > 0 ? '+' : ''}
{totals.diff.toFixed(2)} till 2099)
</Button>
)}
</div>
{/* Warnings */}
{!totals.isBalanced && Math.abs(totals.diff) > 1 && (
<div className="flex items-start gap-3 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3">
<AlertTriangle className="h-4 w-4 text-warning mt-0.5 shrink-0" />
<p className="text-sm text-warning">
Debet och kredit balanserar inte. Differens:{' '}
{totals.diff.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} SEK. Kontrollera
beloppen innan du fortsätter.
</p>
</div>
)}
</div>
)
}
+47 -3
View File
@@ -1,8 +1,10 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { formatCurrency } from '@/lib/utils'
import {
Building2,
@@ -39,9 +41,20 @@ export default function SIEPreviewStep({
const errors = issues.filter((i) => i.severity === 'error')
const warnings = issues.filter((i) => i.severity === 'warning')
// Opening-balance imbalance. The importer plugs any diff > 0.01 to 2099, but a
// diff under ~1 SEK is genuine öresavrundning. Anything larger is a real
// imbalance (incomplete export — missing liabilities / unappropriated prior-year
// result) that would silently book a bogus amount to 2099. Mirrors the importer's
// own `fileImbalance > 1.00` "serious" threshold (lib/import/sie-import.ts).
const ibDiff = Math.round((preview.trialBalance.totalDebit - preview.trialBalance.totalCredit) * 100) / 100
const significantImbalance = !preview.trialBalance.isBalanced && Math.abs(ibDiff) > 1
const [ackImbalance, setAckImbalance] = useState(false)
// Only block on actual parsing errors, not unmapped accounts
// (users need to proceed to mapping step to fix unmapped accounts)
// (users need to proceed to mapping step to fix unmapped accounts).
// A significant IB imbalance is a soft block — the user must acknowledge it.
const hasBlockingErrors = errors.length > 0
const blockContinue = hasBlockingErrors || (significantImbalance && !ackImbalance)
return (
<div className="space-y-6">
@@ -164,11 +177,42 @@ export default function SIEPreviewStep({
<Badge variant="success">Balanserar</Badge>
) : (
<Badge variant="secondary">
Diff: {formatCurrency(preview.trialBalance.totalDebit - preview.trialBalance.totalCredit)}
Diff: {formatCurrency(ibDiff)}
</Badge>
)}
</div>
</div>
{/* Significant imbalance — explain + require acknowledgement before continuing */}
{significantImbalance && (
<div className="mt-4 space-y-3 rounded-lg border border-warning/40 bg-warning/5 px-4 py-3">
<div className="flex items-start gap-2 text-sm">
<AlertCircle className="h-4 w-4 text-warning mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="font-medium text-warning">
Ingående balanser balanserar inte ({formatCurrency(Math.abs(ibDiff))})
</p>
<p className="text-muted-foreground">
Det betyder oftast att exporten från ditt bokföringssystem är ofullständig —
t.ex. att skulder saknas eller att föregående års resultat inte är disponerat.
Om du fortsätter bokförs differensen på konto 2099 (Årets resultat), vilket
nästan alltid blir fel. Vi rekommenderar att du rättar exporten i källsystemet
och laddar upp filen på nytt.
</p>
</div>
</div>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={ackImbalance}
onCheckedChange={(v) => setAckImbalance(v === true)}
className="mt-0.5"
/>
<span className="text-muted-foreground">
Jag förstår att differensen bokförs på 2099 och vill fortsätta ändå.
</span>
</label>
</div>
)}
</CardContent>
</Card>
@@ -343,7 +387,7 @@ export default function SIEPreviewStep({
<Button variant="outline" className="min-h-11" onClick={onBack}>
Tillbaka
</Button>
<Button className="min-h-11" onClick={onContinue} disabled={hasBlockingErrors}>
<Button className="min-h-11" onClick={onContinue} disabled={blockContinue}>
{preview.mappingStatus.lowConfidence > 0 || preview.mappingStatus.unmapped > 0
? 'Granska mappningar'
: 'Fortsätt'}