Files
accounted/lib/hooks/use-submit-with-account-activation.ts
T
Jakob Wennberg adf58a51c0 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>
2026-04-22 09:58:54 +02:00

128 lines
4.3 KiB
TypeScript

'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
}