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
@@ -7,9 +7,11 @@ import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
/**
|
||||
* POST /api/bookkeeping/accounts/activate
|
||||
*
|
||||
* Batch-activate BAS accounts for a user.
|
||||
* Accepts { account_numbers: string[] } and creates chart_of_accounts rows from reference data.
|
||||
* Skips any accounts that already exist for the user.
|
||||
* Batch-activate BAS accounts for a user. Accepts { account_numbers: string[] }.
|
||||
* - Inserts rows from BAS reference for accounts not yet in the chart.
|
||||
* - Reactivates (is_active=true) accounts that already exist but are inactive.
|
||||
* - Skips anything already active.
|
||||
* - Returns { activated, reactivated, skipped, unknown } so callers can react.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
@@ -31,62 +33,97 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'account_numbers array required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check which accounts already exist
|
||||
const { data: existing } = await supabase
|
||||
const uniqueNumbers = [...new Set(accountNumbers)]
|
||||
|
||||
// Fetch existing rows with current is_active state
|
||||
const { data: existing, error: fetchError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number')
|
||||
.select('account_number, is_active')
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', accountNumbers)
|
||||
.in('account_number', uniqueNumbers)
|
||||
|
||||
const existingNumbers = new Set((existing || []).map((a) => a.account_number))
|
||||
|
||||
// Build rows for accounts that don't already exist
|
||||
const newAccounts = accountNumbers
|
||||
.filter((num) => !existingNumbers.has(num))
|
||||
.map((num) => {
|
||||
const ref = getBASReference(num)
|
||||
if (!ref) return null
|
||||
|
||||
return {
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
account_number: ref.account_number,
|
||||
account_name: ref.account_name,
|
||||
account_class: ref.account_class,
|
||||
account_group: ref.account_group,
|
||||
account_type: ref.account_type,
|
||||
normal_balance: ref.normal_balance,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: ref.description,
|
||||
sru_code: ref.sru_code,
|
||||
sort_order: parseInt(ref.account_number),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (newAccounts.length === 0) {
|
||||
return NextResponse.json({
|
||||
data: [],
|
||||
message: 'All accounts already activated',
|
||||
activated: 0,
|
||||
skipped: accountNumbers.length,
|
||||
})
|
||||
if (fetchError) {
|
||||
return NextResponse.json({ error: fetchError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(newAccounts)
|
||||
.select()
|
||||
const existingByNumber = new Map<string, boolean>(
|
||||
(existing || []).map((a) => [a.account_number, a.is_active])
|
||||
)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
const toReactivate: string[] = []
|
||||
const toInsert: Array<ReturnType<typeof buildInsertRow>> = []
|
||||
const unknown: string[] = []
|
||||
let skipped = 0
|
||||
|
||||
for (const num of uniqueNumbers) {
|
||||
if (existingByNumber.has(num)) {
|
||||
if (existingByNumber.get(num) === true) {
|
||||
skipped += 1
|
||||
} else {
|
||||
toReactivate.push(num)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const row = buildInsertRow(num, user.id, companyId)
|
||||
if (row) {
|
||||
toInsert.push(row)
|
||||
} else {
|
||||
unknown.push(num)
|
||||
}
|
||||
}
|
||||
|
||||
let reactivatedRows: { account_number: string }[] = []
|
||||
if (toReactivate.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.update({ is_active: true })
|
||||
.eq('company_id', companyId)
|
||||
.in('account_number', toReactivate)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
reactivatedRows = data || []
|
||||
}
|
||||
|
||||
let insertedRows: { account_number: string }[] = []
|
||||
if (toInsert.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.insert(toInsert)
|
||||
.select('account_number')
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
insertedRows = data || []
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data,
|
||||
activated: data?.length || 0,
|
||||
skipped: accountNumbers.length - (data?.length || 0),
|
||||
data: [...insertedRows, ...reactivatedRows],
|
||||
activated: insertedRows.length,
|
||||
reactivated: reactivatedRows.length,
|
||||
skipped,
|
||||
unknown,
|
||||
})
|
||||
}
|
||||
|
||||
function buildInsertRow(accountNumber: string, userId: string, companyId: string) {
|
||||
const ref = getBASReference(accountNumber)
|
||||
if (!ref) return null
|
||||
return {
|
||||
user_id: userId,
|
||||
company_id: companyId,
|
||||
account_number: ref.account_number,
|
||||
account_name: ref.account_name,
|
||||
account_class: ref.account_class,
|
||||
account_group: ref.account_group,
|
||||
account_type: ref.account_type,
|
||||
normal_balance: ref.normal_balance,
|
||||
plan_type: 'full_bas' as const,
|
||||
is_active: true,
|
||||
is_system_account: false,
|
||||
description: ref.description,
|
||||
sru_code: ref.sru_code,
|
||||
sort_order: parseInt(ref.account_number),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* GET /api/bookkeeping/accounts/bas-lookup?numbers=5010,2641
|
||||
*
|
||||
* Returns BAS reference metadata (name, class, type) for a list of account
|
||||
* numbers. Used by ActivateAccountsDialog to render human-readable labels
|
||||
* before the user confirms activation. Unknown numbers are returned with
|
||||
* account_name=null so the UI can flag them as non-BAS.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const raw = searchParams.get('numbers') || ''
|
||||
const numbers = [...new Set(raw.split(',').map((s) => s.trim()).filter(Boolean))]
|
||||
if (numbers.length === 0) {
|
||||
return NextResponse.json({ data: [] })
|
||||
}
|
||||
|
||||
const data = numbers.map((num) => {
|
||||
const ref = getBASReference(num)
|
||||
if (!ref) {
|
||||
return { account_number: num, account_name: null, known: false }
|
||||
}
|
||||
return {
|
||||
account_number: ref.account_number,
|
||||
account_name: ref.account_name,
|
||||
account_class: ref.account_class,
|
||||
account_type: ref.account_type,
|
||||
known: true,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CorrectJournalEntrySchema } from '@/lib/api/schemas'
|
||||
@@ -34,6 +35,9 @@ export async function POST(
|
||||
const result = await correctEntry(supabase, companyId, user.id, id, body.lines)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return accountsNotInChartResponse(err)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to correct entry' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import { requireWritePermission } from '@/lib/auth/require-write'
|
||||
@@ -28,6 +29,9 @@ export async function POST(
|
||||
const reversalEntry = await reverseEntry(supabase, companyId, user.id, id)
|
||||
return NextResponse.json({ data: reversalEntry })
|
||||
} catch (err) {
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return accountsNotInChartResponse(err)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to reverse entry' },
|
||||
{ status: 400 }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { CreateJournalEntrySchema } from '@/lib/api/schemas'
|
||||
@@ -123,6 +124,9 @@ export async function POST(request: Request) {
|
||||
const entry = await createJournalEntry(supabase, companyId, user.id, body)
|
||||
return NextResponse.json({ data: entry })
|
||||
} catch (err) {
|
||||
if (err instanceof AccountsNotInChartError) {
|
||||
return accountsNotInChartResponse(err)
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to create journal entry' },
|
||||
{ status: 400 }
|
||||
|
||||
Reference in New Issue
Block a user