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
+65 -34
View File
@@ -4,6 +4,12 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
import {
buildAccountIndex,
searchAccounts,
type SearchableAccount,
type AccountSearchItem,
} from '@/lib/bookkeeping/account-search'
import type { BASAccount } from '@/types'
interface AccountComboboxProps {
@@ -19,51 +25,60 @@ interface AccountComboboxProps {
// dropdown's empty state. The current search string is passed so the caller
// can prefill the create dialog.
onCreateAccount?: (prefill: string) => void
// The full BAS catalogue. When provided, accounts not yet in `accounts`
// (the company's active chart) become searchable by name and are surfaced
// with the `notActivatedLabel` marker; picking one activates it at commit
// via the existing ACCOUNTS_NOT_IN_CHART rail.
catalog?: SearchableAccount[]
// Label shown next to catalogue-only (not-yet-activated) accounts. Defaults
// to Swedish; bilingual hosts pass a localized string.
notActivatedLabel?: string
// Extra classes merged into the trigger Input — callers pass `h-8` for dense
// table rows, omit it to use the default Input height.
className?: string
// Optional callback ref to the underlying <input>, invoked alongside the
// internal one. Lets a parent imperatively focus the field (e.g. auto-advance
// to the next konteringsrad's account on Enter — see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
}
const MAX_RESULTS = 50
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, className }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// Attach the internal ref (used for focus bookkeeping) and forward the element
// to any external callback ref the parent passed.
const setInputRef = useCallback((el: HTMLInputElement | null) => {
internalInputRef.current = el
inputRef?.(el)
}, [inputRef])
// Sync external value changes into the search field
useEffect(() => {
setSearch(value)
}, [value])
// Filter accounts based on search input
const filteredAccounts = useMemo(() => {
if (!search) return accounts.slice(0, MAX_RESULTS)
// Index the active chart + the full BAS catalogue once per source change.
// Searching it per keystroke is then just substring checks over pre-folded
// haystacks (number + name + description, diacritics stripped).
const accountIndex = useMemo(
() => buildAccountIndex({ active: accounts, catalog }),
[accounts, catalog]
)
const trimmed = search.trim()
if (!trimmed) return accounts.slice(0, MAX_RESULTS)
const startsWithDigit = /^\d/.test(trimmed)
if (startsWithDigit) {
return accounts
.filter((a) => a.account_number.startsWith(trimmed))
.slice(0, MAX_RESULTS)
}
const lowerSearch = trimmed.toLowerCase()
return accounts
.filter((a) => a.account_name.toLowerCase().includes(lowerSearch))
.slice(0, MAX_RESULTS)
}, [accounts, search])
const filteredAccounts = useMemo(
() => searchAccounts(accountIndex, search),
[accountIndex, search]
)
// Group filtered accounts by class
const groupedAccounts = useMemo(() => {
const groups: { className: string; accounts: BASAccount[] }[] = []
const groupMap = new Map<string, BASAccount[]>()
const groups: { className: string; accounts: AccountSearchItem[] }[] = []
const groupMap = new Map<string, AccountSearchItem[]>()
for (const account of filteredAccounts) {
const className = getAccountClassName(account.account_class)
@@ -163,8 +178,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
if (/^\d{4}$/.test(newValue)) {
onChange(newValue)
// Only treat as a commit when the value newly becomes this account, so
// editing an already-committed number doesn't keep stealing focus.
if (newValue !== value) onCommit?.(newValue)
// editing an already-committed number doesn't keep stealing focus. On
// commit, close the dropdown too — focus advances to the amount field, so
// a lingering open list would just cover the rows below.
if (newValue !== value) {
onCommit?.(newValue)
setIsOpen(false)
return
}
}
if (!isOpen) {
setIsOpen(true)
@@ -176,6 +197,9 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}
const handleBlur = () => {
// Close the dropdown as soon as focus leaves, so it never lingers open over
// the rows below when focus advances via keyboard (Enter/Tab).
setIsOpen(false)
// Small delay to allow dropdown click to fire first. Keep any 4-digit
// numeric value even if it's not in the currently-active chart — the
// submit handler will prompt to activate it.
@@ -190,7 +214,7 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
ref={setInputRef}
value={search}
onChange={handleInputChange}
onFocus={handleFocus}
@@ -213,12 +237,12 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
<div className="sticky top-0 px-2 py-1.5 text-xs font-semibold text-muted-foreground bg-muted border-b border-input">
{group.className}
</div>
{group.accounts.map((account) => {
const flatIndex = flatList.indexOf(account)
{group.accounts.map((item) => {
const flatIndex = flatList.indexOf(item)
const isHighlighted = flatIndex === highlightedIndex
return (
<button
key={account.account_number}
key={item.account_number}
type="button"
data-highlighted={isHighlighted}
className={`w-full text-left px-2 py-1.5 text-sm cursor-pointer flex items-baseline gap-2 ${
@@ -226,12 +250,19 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}`}
onMouseDown={(e) => {
e.preventDefault()
selectAccount(account.account_number)
selectAccount(item.account_number)
}}
onMouseEnter={() => setHighlightedIndex(flatIndex)}
>
<span className="font-mono shrink-0">{account.account_number}</span>
<span className="flex-1 min-w-0 break-words">{account.account_name}</span>
<span className={`font-mono shrink-0 ${item.isActive ? '' : 'text-muted-foreground'}`}>
{item.account_number}
</span>
<span className="flex-1 min-w-0 break-words">{item.account_name}</span>
{!item.isActive && (
<span className="shrink-0 self-center text-[11px] text-muted-foreground whitespace-nowrap">
{notActivatedLabel}
</span>
)}
</button>
)
})}
@@ -0,0 +1,159 @@
'use client'
import { useMemo, useState, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
import OpeningBalanceRowEditor, {
type EditableRow,
type OpeningBalanceEditorState,
} from '@/components/import/OpeningBalanceRowEditor'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
/** The currently-linked, posted opening-balance verifikat being corrected. */
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
onCorrected: () => void
}
let seedIdCounter = 0
// Map the booked IB's lines into editable rows. account_name isn't stored on
// the line, so resolve it from BAS for display (cosmetic — only account_number
// + amounts are sent on save).
function seedRowsFromEntry(entry: JournalEntry): EditableRow[] {
const lines = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
return lines.map((l) => {
const bas = BAS_REFERENCE.find((a) => a.account_number === l.account_number)
return {
id: l.id || `seed_${++seedIdCounter}`,
account_number: l.account_number,
account_name: bas?.account_name ?? '',
debit_amount: Number(l.debit_amount) || 0,
credit_amount: Number(l.credit_amount) || 0,
validation_errors: [],
bas_match: bas?.account_name ?? null,
}
})
}
/**
* Inline correction of an already-booked opening-balance verifikat. The user
* edits the IB's lines directly; on save we POST to
* /api/import/opening-balance/correct, which (BFL-compliant) stornoes the old
* IB, books a corrected one, and relinks the period to it. Works regardless of
* how the IB was created (SIE import, CSV/Excel import, or year-end carry).
*/
export default function CorrectOpeningBalanceDialog({
entry,
open,
onOpenChange,
onCorrected,
}: Props) {
const { toast } = useToast()
const initialRows = useMemo(() => seedRowsFromEntry(entry), [entry])
const [state, setState] = useState<OpeningBalanceEditorState | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleSubmit = useCallback(async () => {
if (!state?.canSubmit || isSubmitting) return
setIsSubmitting(true)
try {
const lines = state.rows
.filter((r) => r.debit_amount > 0 || r.credit_amount > 0)
.map((r) => ({
account_number: r.account_number,
debit_amount: r.debit_amount,
credit_amount: r.credit_amount,
}))
const res = await fetch('/api/import/opening-balance/correct', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fiscal_period_id: entry.fiscal_period_id, lines }),
})
const result = await res.json()
if (!res.ok) {
const err = new Error('Failed to correct opening balances') as Error & {
body?: unknown
status?: number
}
err.body = result
err.status = res.status
throw err
}
toast({
title: 'Ingående balanser korrigerade',
description: 'Den gamla IB-verifikationen stornades och en ny bokfördes.',
})
onOpenChange(false)
onCorrected()
} catch (err) {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: 'Kunde inte korrigera ingående balanser',
description: getErrorMessage(anyErr.body ?? err, {
context: 'journal_entry',
statusCode: anyErr.status,
}),
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}, [state, isSubmitting, entry.fiscal_period_id, toast, onOpenChange, onCorrected])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Korrigera ingående balanser</DialogTitle>
<DialogDescription>
Ändra beloppen nedan och spara. Den befintliga IB-verifikationen (
{formatVoucher(entry)}) makuleras och en ny bokförs med de korrigerade beloppen.
</DialogDescription>
</DialogHeader>
{/* Storno explanation — a booked verifikat can't be edited in place */}
<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">
En bokförd verifikation kan inte ändras direkt (Bokföringslagen). När du sparar stornas
den gamla IB-verifikationen och en ny bokförs — båda sparas som en spårbar rättelse.
</p>
</div>
<OpeningBalanceRowEditor initialRows={initialRows} onChange={setState} />
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={!state?.canSubmit || isSubmitting}>
{isSubmitting ? 'Sparar...' : 'Korrigera ingående balanser'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,189 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { ExternalLink, FileText } from 'lucide-react'
import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
/**
* Side-by-side document viewer used while booking manually, so the user can
* read the figures off a receipt/invoice while filling in the journal entry.
*
* Renders by document id through the same-origin inline proxy
* (/api/documents/:id/inline). PDFs use <object type="application/pdf"> rather
* than <iframe> — Chrome intermittently blocks PDFs in a frame even with a
* permissive CSP (see the note in AttachmentPreviewSheet), and <object>
* invokes the PDF plugin directly. Images use <img>.
*
* Unlike AttachmentPreviewSheet this is keyed off a document id, not a
* journal_entry_id — during manual booking the entry does not exist yet.
*/
function isImageType(type: string | null, fileName?: string | null): boolean {
if (type?.startsWith('image/')) return true
// Legacy uploads sometimes leave mime_type null or application/octet-stream —
// fall back to the filename extension.
if (type === null || type === 'application/octet-stream') {
return /\.(jpe?g|png|gif|webp|svg)$/i.test(fileName ?? '')
}
return false
}
function isPdfType(type: string | null, fileName?: string | null): boolean {
if (type === 'application/pdf') return true
if (type === null || type === 'application/octet-stream') {
return fileName?.toLowerCase().endsWith('.pdf') ?? false
}
return false
}
interface DocumentViewerPaneProps {
/** Document id. Bytes are served via the same-origin inline proxy. */
documentId?: string | null
/** Pre-known mime type. When omitted it's fetched from /api/documents/:id. */
mime?: string | null
/** Pre-known signed URL for the "open in new tab" link. Optional. */
downloadUrl?: string | null
/** Optional filename — used for mime sniffing on legacy/octet-stream files. */
fileName?: string | null
className?: string
}
export default function DocumentViewerPane({
documentId,
mime: mimeProp = null,
downloadUrl: downloadUrlProp = null,
fileName = null,
className,
}: DocumentViewerPaneProps) {
const t = useTranslations('document_viewer')
// Fetched metadata is tagged with the document id it belongs to, so a stale
// response for a previously-shown document is ignored rather than flashed.
const [fetched, setFetched] = useState<
{ id: string; mime: string | null; url: string | null } | null
>(null)
// Resolve mime / download_url from the documents API only when the caller did
// not supply a mime (e.g. a pre-linked transaction document). When a mime is
// provided (fresh upload, inbox preview) we skip the round-trip entirely.
// setState happens only inside the async callbacks — never synchronously in
// the effect body — to avoid cascading renders (react-hooks/set-state-in-effect).
useEffect(() => {
if (!documentId || mimeProp) return
let cancelled = false
fetch(`/api/documents/${documentId}`)
.then((r) => (r.ok ? r.json() : null))
.then((body) => {
if (cancelled) return
setFetched({
id: documentId,
mime: body?.data?.mime_type ?? null,
url: body?.data?.download_url ?? null,
})
})
.catch(() => {
// preview is best-effort — record an empty result so we stop "loading"
if (!cancelled) setFetched({ id: documentId, mime: null, url: null })
})
return () => {
cancelled = true
}
}, [documentId, mimeProp])
const fetchedForThis = fetched?.id === documentId ? fetched : null
const mime = mimeProp ?? fetchedForThis?.mime ?? null
const downloadUrl = downloadUrlProp ?? fetchedForThis?.url ?? null
const loadingMeta = !!documentId && !mimeProp && !fetchedForThis
if (!documentId) {
return (
<div
className={cn(
'flex h-full w-full items-center justify-center rounded-lg border bg-muted/20 text-sm text-muted-foreground',
className,
)}
>
<FileText className="mr-2 h-5 w-5" />
{t('empty')}
</div>
)
}
const inlineSrc = `/api/documents/${documentId}/inline`
const newTabHref = downloadUrl ?? inlineSrc
const showAsImage = isImageType(mime, fileName)
const showAsPdf = isPdfType(mime, fileName)
return (
<div
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-lg border bg-muted/20',
className,
)}
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
<span className="truncate text-xs text-muted-foreground">
{fileName ?? t('header_label')}
</span>
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('open_in_new_tab')}
</a>
</div>
<div className="min-h-0 flex-1 overflow-auto bg-background">
{loadingMeta ? (
<div className="p-3">
<Skeleton className="h-full min-h-[40vh] w-full rounded-md" />
</div>
) : showAsPdf ? (
<object
data={inlineSrc}
type="application/pdf"
aria-label={fileName ?? t('header_label')}
className="h-full w-full"
>
<div className="flex h-full w-full items-center justify-center p-4 text-center text-sm text-muted-foreground">
{t('not_previewable')}
{' — '}
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="ml-1 underline"
>
{t('open_in_new_tab')}
</a>
</div>
</object>
) : showAsImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={inlineSrc}
alt={fileName ?? t('header_label')}
className="mx-auto max-w-full object-contain"
/>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-4 text-center text-sm text-muted-foreground">
<FileText className="h-6 w-6" />
<span>{t('not_previewable')}</span>
<a
href={newTabHref}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t('open_in_new_tab')}
</a>
</div>
)}
</div>
</div>
)
}
+53 -3
View File
@@ -16,6 +16,7 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
@@ -124,6 +125,10 @@ export default function JournalEntryForm({
const [showNoDocWarning, setShowNoDocWarning] = useState(false)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
// Full BAS catalogue (static reference data, fetched once per session). Lets
// the account picker surface standard accounts the company hasn't activated
// yet; picking one activates it at commit via the existing rail.
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
const [entryCurrency, setEntryCurrency] = useState<Currency>('SEK')
const [exchangeRate, setExchangeRate] = useState('')
const [isFetchingRate, setIsFetchingRate] = useState(false)
@@ -147,6 +152,8 @@ export default function JournalEntryForm({
// cards + desktop table); we focus whichever one is actually visible.
const desktopDebitRefs = useRef<(HTMLInputElement | null)[]>([])
const mobileDebitRefs = useRef<(HTMLInputElement | null)[]>([])
// Confirm button in the inline (bare) review, focused on open so Enter posts.
const bareConfirmRef = useRef<HTMLButtonElement>(null)
const isForeign = entryCurrency !== 'SEK'
@@ -185,6 +192,7 @@ export default function JournalEntryForm({
useEffect(() => {
fetchPeriods()
fetchAccounts()
loadBasCatalog().then(setCatalog).catch(() => {/* search degrades to the active chart */})
// Fetch default voucher series from company settings — prefer the
// per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'.
@@ -363,7 +371,11 @@ export default function JournalEntryForm({
// surprising when splitting across several lines. The balancing amount is
// now opt-in via double-clicking a debit/credit field (handleFillBalance).
if (field === 'account_number' && value) {
const account = accounts.find((a) => a.account_number === value)
// Fall back to the BAS catalogue so the description still auto-fills when
// the chosen account isn't in the active chart yet.
const account =
accounts.find((a) => a.account_number === value) ??
catalog.find((a) => a.account_number === value)
if (account) {
updated[index].line_description = account.account_name
// Fortnox-style: seed the verifikationstext from the first row's account
@@ -429,6 +441,14 @@ export default function JournalEntryForm({
})
}, [lines])
// Inline (bare) review: move focus to the confirm button when it opens so
// Enter posts — parity with the ConfirmationDialog's autoFocusConfirm.
useEffect(() => {
if (bare && showReview) {
requestAnimationFrame(() => bareConfirmRef.current?.focus())
}
}, [bare, showReview])
// Only lines with both an account and a non-zero amount end up in the submit
// payload (see the filter in handleConfirm). Compute totals and balance from
// those same lines so the enable-gate matches what the API will actually see.
@@ -530,6 +550,31 @@ export default function JournalEntryForm({
setShowReview(true)
}
// Whether an Enter should open the review — mirrors the review button's
// enable gate exactly, so Enter never submits something the button wouldn't.
const canSubmitReview = () =>
isBalanced &&
!!description &&
!!selectedPeriod &&
!periodMismatch &&
!isUploading &&
canWrite &&
!isSubmitting &&
!isSavingDraft
// Enter anywhere in the form = "Granska & skapa": opens the review exactly as
// the button does, from any field. Navigation is Tab's job. Two Enter
// exceptions stay intact: the account combobox (it calls preventDefault to
// select the highlighted account — we skip when defaultPrevented) and the
// internal-note textarea (newlines). The inline review owns its own Enter.
const handleFormKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Enter') return
if (e.defaultPrevented || showReview) return
if ((e.target as HTMLElement).tagName === 'TEXTAREA') return
e.preventDefault()
if (canSubmitReview()) handleReview()
}
// Inner submit: builds payload, POSTs, throws a structured error on failure
// (so the activation hook can intercept ACCOUNTS_NOT_IN_CHART).
const postJournalEntry = useCallback(async () => {
@@ -829,7 +874,7 @@ export default function JournalEntryForm({
<Button variant="outline" onClick={() => setShowReview(false)} disabled={isSubmitting}>
{t('review_back')}
</Button>
<Button onClick={handleConfirm} disabled={isSubmitting}>
<Button ref={bareConfirmRef} onClick={handleConfirm} disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{/* No underlag attached → explicit acknowledgement, equivalent to the
blocking "Bokför utan underlag" dialog in the non-bare flow (BFL
@@ -843,7 +888,7 @@ export default function JournalEntryForm({
)
const formContent = (
<div className="space-y-4">
<div className="space-y-4" onKeyDown={handleFormKeyDown}>
{bare && showReview ? reviewPanel : (
<>
{/* Verifikat metadata — compact bar on top (Fortnox-style). Date, series
@@ -1041,6 +1086,8 @@ export default function JournalEntryForm({
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
notActivatedLabel={t('account_not_activated')}
onChange={(num) => updateLine(index, 'account_number', num)}
onCommit={() => focusDebit(index)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
@@ -1158,6 +1205,8 @@ export default function JournalEntryForm({
<AccountCombobox
value={line.account_number}
accounts={accounts}
catalog={catalog}
notActivatedLabel={t('account_not_activated')}
onChange={(num) => updateLine(index, 'account_number', num)}
onCommit={() => focusDebit(index)}
onCreateAccount={(prefill) => handleOpenCreateAccount(index, prefill)}
@@ -1378,6 +1427,7 @@ export default function JournalEntryForm({
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
autoFocusConfirm
title={
!embedded && nextVoucherNumber != null
? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) })
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Copy, Loader2 } from 'lucide-react'
import { Copy, Loader2, MessageCircle } from 'lucide-react'
import {
Dialog,
DialogContent,
@@ -9,6 +9,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import { useAgentSheet } from '@/components/agent/AgentSheetProvider'
export interface CopyPrefill {
sourceId: string
@@ -42,16 +43,19 @@ export default function NewJournalEntryDialog({
isLoading,
}: Props) {
const t = useTranslations('bookkeeping')
const { openAgentSheet, identity } = useAgentSheet()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A half-typed verifikat must survive an accidental click on the
// backdrop (easy to do across multiple windows/screens). Closing is
// explicit — the header X or Cancel. This also stops nested popovers
// (AccountCombobox, date pickers) and the form's own confirm dialogs
// from collapsing the parent when they portal outside it.
// A half-typed verifikat must survive an accidental backdrop click or a
// stray Escape (easy to hit across multiple windows/screens, or when you
// only meant to dismiss a combobox dropdown). Closing is explicit — the
// header X. This also stops nested popovers (AccountCombobox, date
// pickers) and the form's own confirm dialogs from collapsing the parent
// when they portal outside it.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
@@ -59,6 +63,24 @@ export default function NewJournalEntryDialog({
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
{identity.isVerified && !copyPrefill && (
// Hand off to the assistant: it reads the underlag (the figures the
// user often can't see), suggests accounts, and stages a balanced
// verifikat to approve — no copy-paste. Close the modal first so its
// focus trap doesn't fight the (non-modal) agent sheet.
<button
type="button"
onClick={() => {
onOpenChange(false)
openAgentSheet({ intentId: 'verifikation.draft', contextRef: 'verifikation:new' })
}}
className="inline-flex w-fit items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" />
{t('ask_assistant_handoff')}
</button>
)}
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />