* 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>
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import { eventBus } from '@/lib/events'
|
|
import type {
|
|
CreateJournalEntryLineInput,
|
|
JournalEntry,
|
|
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
|
|
*
|
|
* Swedish bookkeeping law requires that committed entries cannot be modified.
|
|
* To correct an error, you must:
|
|
* 1. Create a storno (reversal) entry that nullifies the original
|
|
* 2. Create a corrected entry with the right data
|
|
* 3. Link all three via reverses_id, reversed_by_id, correction_of_id
|
|
*/
|
|
|
|
/**
|
|
* Cancel a journal entry and delete its lines.
|
|
* Uses status='cancelled' instead of DELETE (DB trigger blocks all DELETEs).
|
|
* Works for both draft→cancelled and posted→cancelled transitions.
|
|
*/
|
|
async function cancelEntry(supabase: SupabaseClient, entryId: string): Promise<void> {
|
|
const { error: statusErr } = await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'cancelled' })
|
|
.eq('id', entryId)
|
|
if (statusErr) {
|
|
console.error(`[storno] cancelEntry: failed to cancel ${entryId}:`, statusErr.message)
|
|
}
|
|
const { error: linesErr } = await supabase
|
|
.from('journal_entry_lines')
|
|
.delete()
|
|
.eq('journal_entry_id', entryId)
|
|
if (linesErr) {
|
|
console.error(`[storno] cancelEntry: failed to delete lines for ${entryId}:`, linesErr.message)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Correct an existing posted journal entry using the storno method.
|
|
*
|
|
* Returns: { reversal, corrected } - the two new entries created
|
|
*/
|
|
export async function correctEntry(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
userId: string,
|
|
originalEntryId: string,
|
|
correctedLines: CreateJournalEntryLineInput[]
|
|
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
|
|
// Validate the corrected lines are balanced
|
|
const balance = validateBalance(correctedLines)
|
|
if (!balance.valid) {
|
|
throw new Error(
|
|
`Corrected entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
|
|
)
|
|
}
|
|
|
|
// Fetch original entry with lines
|
|
const { data: original, error: fetchError } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', originalEntryId)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (fetchError || !original) {
|
|
throw new Error('Original journal entry not found')
|
|
}
|
|
|
|
if (original.status !== 'posted') {
|
|
throw new Error('Can only correct posted entries')
|
|
}
|
|
|
|
const originalLines = (original.lines as JournalEntryLine[]) || []
|
|
|
|
// ===== Step 1: Create storno (reversal) entry =====
|
|
const reversalVoucherNumber = await getNextVoucherNumber(
|
|
supabase,
|
|
companyId,
|
|
original.fiscal_period_id,
|
|
original.voucher_series || 'A'
|
|
)
|
|
|
|
const { data: reversalEntry, error: reversalError } = await supabase
|
|
.from('journal_entries')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: userId,
|
|
fiscal_period_id: original.fiscal_period_id,
|
|
voucher_number: reversalVoucherNumber,
|
|
voucher_series: original.voucher_series || 'A',
|
|
entry_date: getSwedishLocalDate(),
|
|
description: `Storno: ${original.description}`,
|
|
source_type: 'storno',
|
|
reverses_id: originalEntryId,
|
|
status: 'draft',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (reversalError || !reversalEntry) {
|
|
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
|
|
}
|
|
|
|
// Insert reversed lines (swap debit and credit)
|
|
const reversalLineInserts = originalLines.map((line, index) => ({
|
|
journal_entry_id: reversalEntry.id,
|
|
account_number: line.account_number,
|
|
account_id: line.account_id || null,
|
|
debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
|
|
credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
|
|
currency: line.currency || 'SEK',
|
|
amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null,
|
|
exchange_rate: line.exchange_rate || null,
|
|
line_description: `Storno: ${line.line_description || ''}`,
|
|
tax_code: line.tax_code || null,
|
|
cost_center: line.cost_center || null,
|
|
project: line.project || null,
|
|
sort_order: index,
|
|
}))
|
|
|
|
const { error: reversalLinesError } = await supabase
|
|
.from('journal_entry_lines')
|
|
.insert(reversalLineInserts)
|
|
|
|
if (reversalLinesError) {
|
|
await cancelEntry(supabase, reversalEntry.id)
|
|
throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
|
|
}
|
|
|
|
// Post the reversal entry
|
|
const { error: postReversalError } = await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'posted' })
|
|
.eq('id', reversalEntry.id)
|
|
|
|
if (postReversalError) {
|
|
await cancelEntry(supabase, reversalEntry.id)
|
|
throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
|
|
}
|
|
|
|
// NOTE: Original entry is NOT marked as 'reversed' here. We defer that
|
|
// until both the reversal and corrected entries are successfully posted.
|
|
// This avoids the impossible reversed→posted rollback if step 2 fails.
|
|
|
|
// ===== Step 2: Create corrected entry =====
|
|
// If anything in this step fails, cancel the reversal entry.
|
|
// The original entry was never modified, so no rollback needed.
|
|
|
|
let correctedEntry: typeof reversalEntry
|
|
|
|
try {
|
|
const correctedVoucherNumber = await getNextVoucherNumber(
|
|
supabase,
|
|
companyId,
|
|
original.fiscal_period_id,
|
|
original.voucher_series || 'A'
|
|
)
|
|
|
|
// 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>()
|
|
for (const account of accounts || []) {
|
|
accountIdMap.set(account.account_number, account.id)
|
|
}
|
|
|
|
// Validate all account numbers resolved to IDs
|
|
const missingAccounts = accountNumbers.filter(num => !accountIdMap.has(num))
|
|
if (missingAccounts.length > 0) {
|
|
throw new AccountsNotInChartError(missingAccounts)
|
|
}
|
|
|
|
const { data: newEntry, error: correctedError } = await supabase
|
|
.from('journal_entries')
|
|
.insert({
|
|
company_id: companyId,
|
|
user_id: userId,
|
|
fiscal_period_id: original.fiscal_period_id,
|
|
voucher_number: correctedVoucherNumber,
|
|
voucher_series: original.voucher_series || 'A',
|
|
entry_date: getSwedishLocalDate(),
|
|
description: `Rättelse: ${original.description}`,
|
|
source_type: 'correction',
|
|
correction_of_id: originalEntryId,
|
|
status: 'draft',
|
|
})
|
|
.select()
|
|
.single()
|
|
|
|
if (correctedError || !newEntry) {
|
|
throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
|
|
}
|
|
|
|
correctedEntry = newEntry
|
|
|
|
// Insert corrected lines
|
|
const correctedLineInserts = correctedLines.map((line, index) => ({
|
|
journal_entry_id: correctedEntry.id,
|
|
account_number: line.account_number,
|
|
account_id: accountIdMap.get(line.account_number) || null,
|
|
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
|
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
|
currency: line.currency || 'SEK',
|
|
amount_in_currency: line.amount_in_currency
|
|
? Math.round(line.amount_in_currency * 100) / 100
|
|
: null,
|
|
exchange_rate: line.exchange_rate || null,
|
|
line_description: line.line_description || null,
|
|
tax_code: line.tax_code || null,
|
|
cost_center: line.cost_center || null,
|
|
project: line.project || null,
|
|
sort_order: index,
|
|
}))
|
|
|
|
const { error: correctedLinesError } = await supabase
|
|
.from('journal_entry_lines')
|
|
.insert(correctedLineInserts)
|
|
|
|
if (correctedLinesError) {
|
|
await cancelEntry(supabase, correctedEntry.id)
|
|
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
|
|
}
|
|
|
|
// Post the corrected entry
|
|
const { error: postCorrectedError } = await supabase
|
|
.from('journal_entries')
|
|
.update({ status: 'posted' })
|
|
.eq('id', correctedEntry.id)
|
|
|
|
if (postCorrectedError) {
|
|
await cancelEntry(supabase, correctedEntry.id)
|
|
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
|
|
}
|
|
} catch (err) {
|
|
// Cancel the reversal entry (posted → cancelled). Original was never
|
|
// modified so no rollback needed — it's still 'posted'.
|
|
await cancelEntry(supabase, reversalEntry.id)
|
|
throw err
|
|
}
|
|
|
|
// ===== Mark original as reversed (CAS guard: only if still 'posted') =====
|
|
const { data: updatedOriginal, error: casError } = await supabase
|
|
.from('journal_entries')
|
|
.update({
|
|
status: 'reversed',
|
|
reversed_by_id: reversalEntry.id,
|
|
})
|
|
.eq('id', originalEntryId)
|
|
.eq('status', 'posted')
|
|
.select('id')
|
|
|
|
if (casError || !updatedOriginal || updatedOriginal.length === 0) {
|
|
// Concurrent reversal beat us — cancel both our entries
|
|
await cancelEntry(supabase, reversalEntry.id)
|
|
await cancelEntry(supabase, correctedEntry!.id)
|
|
throw new Error('Entry was already reversed by a concurrent operation')
|
|
}
|
|
|
|
// ===== Step 3: Fetch complete entries =====
|
|
const { data: finalReversal } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', reversalEntry.id)
|
|
.single()
|
|
|
|
const { data: finalCorrected } = await supabase
|
|
.from('journal_entries')
|
|
.select('*, lines:journal_entry_lines(*)')
|
|
.eq('id', correctedEntry.id)
|
|
.single()
|
|
|
|
const result = {
|
|
reversal: finalReversal as JournalEntry,
|
|
corrected: finalCorrected as JournalEntry,
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'journal_entry.corrected',
|
|
payload: {
|
|
original: original as JournalEntry,
|
|
storno: result.reversal,
|
|
corrected: result.corrected,
|
|
companyId,
|
|
userId,
|
|
},
|
|
})
|
|
|
|
return result
|
|
}
|