Prompt to activate missing BAS accounts at commit (#308)
* feat: prompt to activate missing BAS accounts at commit
Booking to an account not in the active chart previously threw a
generic 400 "Account(s) not found: 5010" and the user had to leave
the form to enable the account via /bookkeeping > BAS-katalog.
- New AccountsNotInChartError thrown from resolveAccountIds in the
engine (and the parallel resolver in core/storno-service). The
query also now filters on is_active=true, so deactivated accounts
are treated the same as never-added ones.
- API routes that call the engine (journal-entries, reverse, correct,
transactions/book + match-invoice + match-supplier-invoice +
uncategorize, invoices/mark-paid, supplier-invoices + mark-paid +
credit, salary/runs/correct, import/opening-balance/execute,
pending-operations/commit) catch the typed error and return a
structured 400: { error: { code: ACCOUNTS_NOT_IN_CHART,
account_numbers, message } }.
- /api/bookkeeping/accounts/activate now also reactivates rows that
already exist but are is_active=false, not only INSERTs. Returns
{ activated, reactivated, skipped, unknown }.
- New GET /api/bookkeeping/accounts/bas-lookup?numbers=... resolves
BAS names client-side so the dialog can show "5010 · Lokalhyra"
without bundling the full 1,276-account catalog.
- ActivateAccountsDialog lists the missing accounts (BAS names + any
unknown non-BAS numbers) and confirms with a single action.
- useSubmitWithAccountActivation wraps an async submit: on
ACCOUNTS_NOT_IN_CHART it opens the dialog, activates on confirm,
then retries the original submit so the user never re-enters data.
- AccountCombobox accepts any 4-digit numeric value, not just items
from the active chart — the activation dialog handles the rest.
- JournalEntryForm wired to the hook + dialog. Other submit surfaces
now surface a clear Swedish message ("Följande konton behöver
aktiveras: …") via getErrorMessage; wiring the dialog into those
is an additive follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: sync CLAUDE.md with current codebase state
Catch-up on growth since the last CLAUDE.md revision:
- Integrations list now includes AWS Bedrock, Upstash Redis,
Google Drive, Recharts, PDF.js, @react-pdf/renderer, xlsx,
fuse.js, ics.
- Extension table reflects cloud-backup enabled; adds
inbox-smart-match and example-logger; reorders to match current
extensions.config.json.
- Updated counts: 36 event types (was 30+), 35 MCP tools (was 26),
~60 tables (was ~47), 118 migrations (was 93), 19 report
endpoints (was 16), 20 report generators (was 17).
- lib/ directory table now covers salary, providers,
company-lookup, processing-history, support.ts; removes the
deleted settings/ subdir.
- App routes table adds /salary/*, /help, /settings/salary,
/settings/backup.
- API endpoints table adds /api/salary/*, /api/support/contact,
/api/account/delete, /api/audit-trail/*, /api/log,
/api/currency/rate, top-level extension routes.
- Tables section adds Salary, Third-party providers, Inbox &
Migration groups; removes salary_payments (replaced by
salary_runs + salary_line_items).
- Skills list updated to enumerate the Swedish domain skills by
name instead of the old single /swedish-bookkeeping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: address PR review feedback on account activation
Seven fixes based on Greptile + Swedish compliance review on #308.
- ActivateAccountsDialog: disable the confirm button when any
entered number isn't a valid BAS account. Previously activation
would succeed for the knowns and the retry would immediately
fail again on the unknowns, giving a confusing double-toast UX.
- pending-operations/commit: revert commitSendInvoice and
commitMarkInvoiceSent to swallow AccountsNotInChartError
silently. The prior PR upgrade made these blocking, which
regressed invoice delivery for users whose AR accounts are
inactive — and since the activation dialog isn't wired into
those flows yet, there's no one-click recovery. The silent
catches now append an InvoiceJournalEntrySkipped event to
processing_history so the missing verifikation is actionable
in audit trails rather than silently understating the
momsdeklaration (revenue / utgående moms unposted).
- engine.reverseEntry: resolve account IDs with includeInactive=true
so storno of an already-committed entry goes through even when
the user has since deactivated one of its accounts. Blocking
the reversal would leave the original entry uncorrected in
violation of BFL 5 kap 5§ (rättelse must be documented). The
default (includeInactive=false) still applies to createDraftEntry
so new bookings to inactive accounts continue to trigger the
activation dialog.
- supplier-invoices POST + credit: roll back the just-inserted
supplier_invoices row (items cascade-delete) on any JE failure,
not only AccountsNotInChartError. An orphan supplier_invoices
row without a registration / credit JE leaves leverantörsskuld
(2440) and ingående moms (2641) unposted — a silent
understatement / overstatement in the momsdeklaration (ML
2023:200 / BFL 5 kap). The catch now returns a clear Swedish
error message for non-activation failures (typically period
lock or DB error) instead of silently logging.
Test mocks for chart_of_accounts updated for the new query chain
(eq.in.eq instead of eq.eq.in after the is_active conditional).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4cd0a55761
commit
adf58a51c0
@@ -142,8 +142,10 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setSearch(newValue)
|
||||
// Only emit valid account numbers to parent
|
||||
if (/^\d{4}$/.test(newValue) && accounts.some(a => a.account_number === newValue)) {
|
||||
// Emit any 4-digit numeric value to the parent. Unknown BAS numbers are
|
||||
// accepted optimistically — the submit-time ActivateAccountsDialog lets
|
||||
// the user activate missing accounts without leaving the form.
|
||||
if (/^\d{4}$/.test(newValue)) {
|
||||
onChange(newValue)
|
||||
}
|
||||
if (!isOpen) {
|
||||
@@ -156,9 +158,12 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
// Small delay to allow dropdown click to fire first
|
||||
// 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.
|
||||
setTimeout(() => {
|
||||
if (!accounts.some(a => a.account_number === search)) {
|
||||
const isFourDigit = /^\d{4}$/.test(search)
|
||||
if (!isFourDigit && !accounts.some(a => a.account_number === search)) {
|
||||
setSearch(value)
|
||||
}
|
||||
}, 150)
|
||||
@@ -223,9 +228,15 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Hittade inget konto som matchar.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Kontot kan behöva aktiveras i din kontoplan.
|
||||
</p>
|
||||
{/^\d{4}$/.test(search.trim()) ? (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Om det är ett giltigt BAS-konto aktiveras det när du bokför.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Kontot kan behöva aktiveras i din kontoplan.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Loader2, Plus } from 'lucide-react'
|
||||
|
||||
export interface ActivateAccountsDialogProps {
|
||||
open: boolean
|
||||
accountNumbers: string[]
|
||||
onConfirm: () => Promise<void> | void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface BasLookupRow {
|
||||
account_number: string
|
||||
account_name: string | null
|
||||
known: boolean
|
||||
}
|
||||
|
||||
export function ActivateAccountsDialog({
|
||||
open,
|
||||
accountNumbers,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ActivateAccountsDialogProps) {
|
||||
const [rows, setRows] = useState<BasLookupRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || accountNumbers.length === 0) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
fetch(`/api/bookkeeping/accounts/bas-lookup?numbers=${encodeURIComponent(accountNumbers.join(','))}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (cancelled) return
|
||||
setRows((body?.data as BasLookupRow[]) || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setRows(accountNumbers.map((n) => ({ account_number: n, account_name: null, known: false })))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, accountNumbers])
|
||||
|
||||
const knownRows = rows.filter((r) => r.known)
|
||||
const unknownRows = rows.filter((r) => !r.known)
|
||||
// Disable confirm when any entered number isn't a valid BAS account: activating
|
||||
// only the knowns would leave the unknowns to fail again on retry.
|
||||
const canConfirm = knownRows.length > 0 && unknownRows.length === 0 && !submitting
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onConfirm()
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) onCancel() }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Aktivera konton</DialogTitle>
|
||||
<DialogDescription>
|
||||
{knownRows.length > 0
|
||||
? 'Följande konton behöver aktiveras i din kontoplan innan bokföringen kan slutföras.'
|
||||
: 'Inga giltiga BAS-konton att aktivera.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Hämtar kontouppgifter...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && knownRows.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border">
|
||||
{knownRows.map((r) => (
|
||||
<li key={r.account_number} className="flex items-baseline gap-3 px-3 py-2">
|
||||
<span className="font-mono text-foreground w-14 shrink-0">{r.account_number}</span>
|
||||
<span className="truncate">{r.account_name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!loading && unknownRows.length > 0 && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<p className="font-medium">Okända konton:</p>
|
||||
<p className="mt-1 font-mono">{unknownRows.map((r) => r.account_number).join(', ')}</p>
|
||||
<p className="mt-1 text-destructive/80">
|
||||
Dessa nummer finns inte i BAS-katalogen och kan inte aktiveras automatiskt. Kontrollera inmatningen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={submitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={!canConfirm}>
|
||||
{submitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Aktiverar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Aktivera och bokför
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,11 @@ import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
|
||||
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
||||
import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog'
|
||||
import {
|
||||
useSubmitWithAccountActivation,
|
||||
throwOnStructuredError,
|
||||
} from '@/lib/hooks/use-submit-with-account-activation'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
@@ -273,9 +278,9 @@ export default function JournalEntryForm({
|
||||
setShowReview(true)
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsSubmitting(true)
|
||||
|
||||
// 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 () => {
|
||||
let currencyMetaApplied = false
|
||||
const entryLines: CreateJournalEntryLineInput[] = lines
|
||||
.filter((l) => l.account_number && (l.debit_amount || l.credit_amount))
|
||||
@@ -287,15 +292,11 @@ export default function JournalEntryForm({
|
||||
line_description: l.line_description || undefined,
|
||||
}
|
||||
|
||||
// Attach currency metadata from pre-populated data (e.g. transaction flow)
|
||||
if (l.currency) {
|
||||
base.currency = l.currency
|
||||
if (l.amount_in_currency != null) base.amount_in_currency = l.amount_in_currency
|
||||
if (l.exchange_rate != null) base.exchange_rate = l.exchange_rate
|
||||
}
|
||||
// Attach currency metadata from the entry-level currency selector (manual flow)
|
||||
// Applied to the first bank/cash account (class 19xx) only
|
||||
else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) {
|
||||
} else if (isForeign && rate > 0 && l.account_number.startsWith('19') && !currencyMetaApplied) {
|
||||
base.currency = entryCurrency
|
||||
base.amount_in_currency = computedForeignAmount
|
||||
base.exchange_rate = rate
|
||||
@@ -306,7 +307,6 @@ export default function JournalEntryForm({
|
||||
})
|
||||
|
||||
const url = submitUrl ?? '/api/bookkeeping/journal-entries'
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -321,17 +321,17 @@ export default function JournalEntryForm({
|
||||
lines: entryLines,
|
||||
}),
|
||||
})
|
||||
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
|
||||
}, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes])
|
||||
|
||||
const result = await res.json()
|
||||
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
|
||||
useSubmitWithAccountActivation(postJournalEntry)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const result = await runSubmit()
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
title: 'Kunde inte skapa verifikation',
|
||||
description: getErrorMessage(result, { context: 'journal_entry', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
// Link uploaded documents to the new journal entry (non-blocking)
|
||||
const journalEntryId = result.data?.id ?? result.journal_entry_id
|
||||
if (journalEntryId && uploadedFiles.length > 0) {
|
||||
const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id)
|
||||
@@ -361,7 +361,6 @@ export default function JournalEntryForm({
|
||||
description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`,
|
||||
})
|
||||
setShowReview(false)
|
||||
// Reset form
|
||||
setDescription('')
|
||||
setNotes('')
|
||||
setUploadedFiles([])
|
||||
@@ -373,9 +372,20 @@ export default function JournalEntryForm({
|
||||
if (journalEntryId) {
|
||||
onEntryCreated?.(journalEntryId)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === 'cancelled') {
|
||||
// User dismissed the activation dialog — no toast needed
|
||||
} else {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte skapa verifikation',
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
const formContent = (
|
||||
@@ -770,6 +780,13 @@ export default function JournalEntryForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ActivateAccountsDialog
|
||||
open={activationDialog.open}
|
||||
accountNumbers={activationDialog.accountNumbers}
|
||||
onConfirm={confirmActivation}
|
||||
onCancel={cancelActivation}
|
||||
/>
|
||||
|
||||
<ConfirmationDialog
|
||||
open={showReview}
|
||||
onOpenChange={setShowReview}
|
||||
|
||||
Reference in New Issue
Block a user