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,9 +142,11 @@ describe('createDraftEntry — cancelled status on line-insert failure', () => {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
in: vi.fn().mockResolvedValue({
|
||||
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
|
||||
error: null,
|
||||
in: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -194,9 +196,11 @@ describe('createDraftEntry — date/period cross-validation', () => {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
in: vi.fn().mockResolvedValue({
|
||||
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
|
||||
error: null,
|
||||
in: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [{ account_number: '1930', id: 'acc-1' }, { account_number: '3001', id: 'acc-2' }],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -172,12 +172,14 @@ describe('createJournalEntry orphan draft cleanup', () => {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
in: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ account_number: '1930', id: 'acc-1930' },
|
||||
{ account_number: '1510', id: 'acc-1510' },
|
||||
],
|
||||
error: null,
|
||||
in: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ account_number: '1930', id: 'acc-1930' },
|
||||
{ account_number: '1510', id: 'acc-1510' },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -262,12 +264,14 @@ describe('createJournalEntry orphan draft cleanup', () => {
|
||||
return {
|
||||
select: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockReturnValue({
|
||||
in: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ account_number: '1930', id: 'acc-1930' },
|
||||
{ account_number: '1510', id: 'acc-1510' },
|
||||
],
|
||||
error: null,
|
||||
in: vi.fn().mockReturnValue({
|
||||
eq: vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ account_number: '1930', id: 'acc-1930' },
|
||||
{ account_number: '1510', id: 'acc-1510' },
|
||||
],
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
+28
-12
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
@@ -54,21 +55,37 @@ export async function getNextVoucherNumber(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve account IDs from account numbers for a company
|
||||
* Resolve account IDs from account numbers for a company.
|
||||
*
|
||||
* By default only active accounts are returned — inactive / never-added
|
||||
* accounts surface as "missing" so callers throw AccountsNotInChartError.
|
||||
*
|
||||
* Pass `{ includeInactive: true }` for reversals: the accounts on an already-
|
||||
* committed entry were legitimately active at commit time, and BFL 5 kap 5§
|
||||
* requires storno to be possible even if a user has since deactivated one of
|
||||
* those accounts. Blocking the reversal would leave the original entry
|
||||
* uncorrected with no audit trail.
|
||||
*/
|
||||
async function resolveAccountIds(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
lines: CreateJournalEntryLineInput[]
|
||||
lines: CreateJournalEntryLineInput[],
|
||||
options: { includeInactive?: boolean } = {}
|
||||
): Promise<Map<string, string>> {
|
||||
const accountNumbers = [...new Set(lines.map((l) => l.account_number))]
|
||||
|
||||
const { data: accounts, error } = await supabase
|
||||
let query = supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, account_number')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
if (!options.includeInactive) {
|
||||
query = query.eq('is_active', true)
|
||||
}
|
||||
|
||||
const { data: accounts, error } = await query
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to resolve account IDs: ${error.message}`)
|
||||
}
|
||||
@@ -178,9 +195,7 @@ export async function createDraftEntry(
|
||||
const allAccountNumbers = [...new Set(input.lines.map(l => l.account_number))]
|
||||
const missingAccounts = allAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingAccounts.length > 0) {
|
||||
throw new Error(
|
||||
`Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}`
|
||||
)
|
||||
throw new AccountsNotInChartError(missingAccounts)
|
||||
}
|
||||
|
||||
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
|
||||
@@ -382,16 +397,17 @@ export async function reverseEntry(
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines)
|
||||
// Resolve account IDs — include inactive rows. The accounts on the
|
||||
// original committed entry were active at commit time; if the user has
|
||||
// since toggled one off, the storno must still be allowed to go through
|
||||
// (BFL 5 kap 5§). Only a truly missing chart row (rare: would require
|
||||
// the row to have been deleted) still throws AccountsNotInChartError.
|
||||
const accountIdMap = await resolveAccountIds(supabase, companyId, reversedLines, { includeInactive: true })
|
||||
|
||||
// Validate all account numbers resolved to IDs
|
||||
const reversalAccountNumbers = [...new Set(reversedLines.map(l => l.account_number))]
|
||||
const missingReversalAccounts = reversalAccountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingReversalAccounts.length > 0) {
|
||||
throw new Error(
|
||||
`Account(s) not found in chart of accounts: ${missingReversalAccounts.join(', ')}`
|
||||
)
|
||||
throw new AccountsNotInChartError(missingReversalAccounts)
|
||||
}
|
||||
|
||||
// Create reversal entry with reverses_id link
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export const ACCOUNTS_NOT_IN_CHART = 'ACCOUNTS_NOT_IN_CHART' as const
|
||||
|
||||
export class AccountsNotInChartError extends Error {
|
||||
readonly code = ACCOUNTS_NOT_IN_CHART
|
||||
readonly accountNumbers: string[]
|
||||
|
||||
constructor(accountNumbers: string[]) {
|
||||
const sorted = [...new Set(accountNumbers)].sort()
|
||||
super(`Accounts not enabled in chart of accounts: ${sorted.join(', ')}`)
|
||||
this.name = 'AccountsNotInChartError'
|
||||
this.accountNumbers = sorted
|
||||
}
|
||||
}
|
||||
|
||||
export function isAccountsNotInChartError(err: unknown): err is AccountsNotInChartError {
|
||||
return err instanceof AccountsNotInChartError
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structured 400 response for AccountsNotInChartError.
|
||||
* API routes catch the typed error and return this so the frontend can
|
||||
* open ActivateAccountsDialog and retry the request after activation.
|
||||
*/
|
||||
export function accountsNotInChartResponse(err: AccountsNotInChartError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
code: err.code,
|
||||
message: `Följande konton behöver aktiveras: ${err.accountNumbers.join(', ')}`,
|
||||
account_numbers: err.accountNumbers,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import { validateBalance, getNextVoucherNumber, getSwedishLocalDate } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError } from '@/lib/bookkeeping/errors'
|
||||
|
||||
/**
|
||||
* Storno Service - 3-step correction flow per Bokföringslagen
|
||||
@@ -161,12 +162,13 @@ export async function correctEntry(
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Resolve account IDs for corrected lines
|
||||
// Resolve account IDs for corrected lines — only active rows count
|
||||
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, account_number')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_active', true)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
const accountIdMap = new Map<string, string>()
|
||||
@@ -177,9 +179,7 @@ export async function correctEntry(
|
||||
// Validate all account numbers resolved to IDs
|
||||
const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num))
|
||||
if (missingAccounts.length > 0) {
|
||||
throw new Error(
|
||||
`Account(s) not found in chart of accounts: ${missingAccounts.join(', ')}`
|
||||
)
|
||||
throw new AccountsNotInChartError(missingAccounts)
|
||||
}
|
||||
|
||||
const { data: newEntry, error: correctedError } = await supabase
|
||||
|
||||
@@ -199,6 +199,18 @@ export function getErrorMessage(
|
||||
if (typeof error === 'object' && error !== null) {
|
||||
const obj = error as Record<string, unknown>
|
||||
|
||||
// Structured application error: { error: { code, message, ... } }
|
||||
if (typeof obj.error === 'object' && obj.error !== null) {
|
||||
const structured = obj.error as { code?: unknown; message?: unknown; account_numbers?: unknown }
|
||||
if (structured.code === 'ACCOUNTS_NOT_IN_CHART' && Array.isArray(structured.account_numbers)) {
|
||||
const numbers = structured.account_numbers as string[]
|
||||
return `Följande konton behöver aktiveras: ${numbers.join(', ')}`
|
||||
}
|
||||
if (typeof structured.message === 'string' && structured.message.trim()) {
|
||||
return structured.message
|
||||
}
|
||||
}
|
||||
|
||||
// Try Zod validation errors
|
||||
const zodMessage = tryParseZodErrors(obj)
|
||||
if (zodMessage) return zodMessage
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Wraps an async submit function so that responses of the form
|
||||
* `{ error: { code: 'ACCOUNTS_NOT_IN_CHART', account_numbers } }` trigger a
|
||||
* dialog-and-retry flow instead of surfacing as a generic error.
|
||||
*
|
||||
* The consumer renders <ActivateAccountsDialog> bound to `dialog.open`,
|
||||
* `dialog.accountNumbers`, `confirm`, and `cancel`. On confirm the missing
|
||||
* accounts are activated and the original submit runs again automatically.
|
||||
*
|
||||
* The submit fn should throw with a cause object containing the parsed
|
||||
* response body so we can read the structured error. Convention:
|
||||
* throw Object.assign(new Error(body.error.message), { body, status })
|
||||
*/
|
||||
|
||||
interface DialogState {
|
||||
open: boolean
|
||||
accountNumbers: string[]
|
||||
}
|
||||
|
||||
interface StructuredError {
|
||||
code?: string
|
||||
account_numbers?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
function extractAccountsNotInChart(err: unknown): string[] | null {
|
||||
if (typeof err !== 'object' || err === null) return null
|
||||
const anyErr = err as { body?: { error?: StructuredError } }
|
||||
const structured = anyErr.body?.error
|
||||
if (structured?.code === 'ACCOUNTS_NOT_IN_CHART' && Array.isArray(structured.account_numbers)) {
|
||||
return structured.account_numbers
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function useSubmitWithAccountActivation<T>(
|
||||
submit: () => Promise<T>
|
||||
) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, accountNumbers: [] })
|
||||
const [pendingResolve, setPendingResolve] = useState<null | ((value: T | null) => void)>(null)
|
||||
const [pendingReject, setPendingReject] = useState<null | ((err: unknown) => void)>(null)
|
||||
|
||||
const runSubmit = useCallback(async (): Promise<T> => {
|
||||
try {
|
||||
return await submit()
|
||||
} catch (err) {
|
||||
const missing = extractAccountsNotInChart(err)
|
||||
if (!missing) throw err
|
||||
|
||||
// Open dialog and wait for the user to confirm or cancel.
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
setPendingResolve(() => (value: T | null) => {
|
||||
if (value === null) reject(new Error('cancelled'))
|
||||
else resolve(value)
|
||||
})
|
||||
setPendingReject(() => reject)
|
||||
setDialog({ open: true, accountNumbers: missing })
|
||||
})
|
||||
}
|
||||
}, [submit])
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
const numbers = dialog.accountNumbers
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_numbers: numbers }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw Object.assign(new Error('Kunde inte aktivera konton'), { body, status: res.status })
|
||||
}
|
||||
// Retry the original submit
|
||||
const result = await submit()
|
||||
setDialog({ open: false, accountNumbers: [] })
|
||||
pendingResolve?.(result as T | null)
|
||||
setPendingResolve(null)
|
||||
setPendingReject(null)
|
||||
} catch (err) {
|
||||
setDialog({ open: false, accountNumbers: [] })
|
||||
pendingReject?.(err)
|
||||
setPendingResolve(null)
|
||||
setPendingReject(null)
|
||||
}
|
||||
}, [dialog.accountNumbers, submit, pendingResolve, pendingReject])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setDialog({ open: false, accountNumbers: [] })
|
||||
pendingReject?.(new Error('cancelled'))
|
||||
setPendingResolve(null)
|
||||
setPendingReject(null)
|
||||
}, [pendingReject])
|
||||
|
||||
return { runSubmit, dialog, confirm, cancel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Small helper: parse a Response and throw a shaped error that
|
||||
* useSubmitWithAccountActivation can recognize. Callers use this inside
|
||||
* their submit fn so the hook can pick up ACCOUNTS_NOT_IN_CHART.
|
||||
*/
|
||||
export async function throwOnStructuredError(response: Response): Promise<unknown> {
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(extractMessage(body) || `HTTP ${response.status}`), {
|
||||
body,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function extractMessage(body: unknown): string | null {
|
||||
if (typeof body !== 'object' || body === null) return null
|
||||
const obj = body as { error?: unknown }
|
||||
if (typeof obj.error === 'string') return obj.error
|
||||
if (typeof obj.error === 'object' && obj.error !== null) {
|
||||
const e = obj.error as { message?: unknown }
|
||||
if (typeof e.message === 'string') return e.message
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user