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:
Jakob Wennberg
2026-04-22 09:58:54 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 4cd0a55761
commit adf58a51c0
27 changed files with 789 additions and 172 deletions
+88 -51
View File
@@ -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 }
@@ -6,6 +6,7 @@ import { OpeningBalanceExecuteSchema } from '@/lib/api/schemas'
import { requireWritePermission } from '@/lib/auth/require-write'
import { requireCompanyId } from '@/lib/company/context'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { CreateJournalEntryLineInput } from '@/types'
@@ -241,6 +242,9 @@ export async function POST(request: Request) {
},
})
} catch (error) {
if (error instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(error)
}
console.error('Opening balance execute error:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Importen misslyckades' },
+4
View File
@@ -5,6 +5,7 @@ import {
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
@@ -160,6 +161,9 @@ export async function POST(
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create payment journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra betalningen' },
+99 -26
View File
@@ -16,6 +16,7 @@ import {
createInvoiceJournalEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -26,6 +27,7 @@ import { uploadDocument } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import type {
Transaction,
TransactionCategory,
@@ -43,6 +45,51 @@ const log = createLogger('pending-operations/commit')
ensureInitialized()
/**
* Record a best-effort processing_history breadcrumb when the invoice's
* accrual journal entry couldn't be booked. The pending-operation itself
* still succeeds (invoice email delivered / status set), but the JE is
* missing — which in the accrual case means revenue (3001) and utgående
* moms (2611) are unposted for the period, understating the momsdeklaration.
*
* The event makes the gap visible and actionable: an operator or bokföring
* consultant can query processing_history for `InvoiceJournalEntrySkipped`
* and re-book the missing verifikation (via the activation dialog or
* manually) before the momsdeklaration is filed. Swallows its own errors
* to preserve the non-blocking contract with the caller.
*/
async function recordSkippedInvoiceJournalEntry(
invoiceId: string,
companyId: string,
userId: string,
operation: 'send_invoice' | 'mark_invoice_sent',
err: unknown
): Promise<void> {
try {
const reasonCode = err instanceof AccountsNotInChartError
? 'accounts_not_in_chart'
: 'journal_entry_error'
const accountNumbers = err instanceof AccountsNotInChartError ? err.accountNumbers : undefined
await appendProcessingHistory({
companyId,
correlationId: invoiceId,
aggregateType: 'System',
aggregateId: invoiceId,
eventType: 'InvoiceJournalEntrySkipped',
payload: {
invoice_id: invoiceId,
operation,
reason_code: reasonCode,
...(accountNumbers ? { account_numbers: accountNumbers } : {}),
},
actor: { type: 'user', id: userId },
occurredAt: new Date(),
})
} catch (historyErr) {
log.warn('Failed to append InvoiceJournalEntrySkipped to processing_history', historyErr)
}
}
/**
* Ensure a fiscal period exists for the given date, create one if needed.
* Same logic as app/api/transactions/[id]/categorize/route.ts
@@ -173,6 +220,7 @@ async function commitCategorizeTransaction(
journalEntryId = journalEntry.id
}
} catch (err) {
if (err instanceof AccountsNotInChartError) throw err
log.error('Failed to create journal entry:', err)
return { error: err instanceof Error ? err.message : 'Failed to create journal entry', status: 500 }
}
@@ -579,7 +627,18 @@ async function commitSendInvoice(
createdJournalEntryId = je.id
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
}
} catch { /* non-blocking */ }
} catch (err) {
// Non-blocking: the invoice is already sent by this point and the user
// can retry the journal entry separately. Re-throwing here would mean
// the email has already gone out but the outer 400 would report
// failure — worst of both worlds.
//
// Record a processing_history breadcrumb so the missing verifikation
// surfaces in audit trails and the momsdeklaration gap is actionable
// rather than silent. TODO: wire ActivateAccountsDialog into this
// pending-op flow, then re-introduce blocking for ACCOUNTS_NOT_IN_CHART.
await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'send_invoice', err)
}
}
if (isRealInvoice) {
@@ -647,7 +706,13 @@ async function commitMarkInvoiceSent(
journalEntryId = je.id
await supabase.from('invoices').update({ journal_entry_id: je.id }).eq('id', invoiceId)
}
} catch { /* non-blocking */ }
} catch (err) {
// Non-blocking: invoice is already marked as sent. Record a
// processing_history breadcrumb so the missing JE is visible in audit
// trails. TODO: wire ActivateAccountsDialog into this pending-op flow,
// then re-introduce blocking for ACCOUNTS_NOT_IN_CHART here.
await recordSkippedInvoiceJournalEntry(invoiceId, companyId, userId, 'mark_invoice_sent', err)
}
}
return { data: { status: 'sent', journal_entry_id: journalEntryId } }
@@ -722,6 +787,7 @@ async function commitMatchTransactionInvoice(
journalEntryId = je?.id ?? null
}
} catch (err) {
if (err instanceof AccountsNotInChartError) throw err
log.error('Failed to create match journal entry:', err)
}
@@ -822,30 +888,37 @@ export async function POST(
// Execute based on operation type
let result: { data?: Record<string, unknown>; error?: string; status?: number }
switch (pendingOp.operation_type) {
case 'categorize_transaction':
result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params)
break
case 'create_customer':
result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params)
break
case 'mark_invoice_paid':
result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params)
break
case 'send_invoice':
result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email)
break
case 'mark_invoice_sent':
result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params)
break
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params)
break
default:
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
try {
switch (pendingOp.operation_type) {
case 'categorize_transaction':
result = await commitCategorizeTransaction(supabase, user.id, companyId, pendingOp.params)
break
case 'create_customer':
result = await commitCreateCustomer(supabase, user.id, companyId, pendingOp.params)
break
case 'create_invoice':
result = await commitCreateInvoice(supabase, user.id, companyId, pendingOp.params)
break
case 'mark_invoice_paid':
result = await commitMarkInvoicePaid(supabase, user.id, companyId, pendingOp.params)
break
case 'send_invoice':
result = await commitSendInvoice(supabase, user.id, companyId, pendingOp.params, user.email)
break
case 'mark_invoice_sent':
result = await commitMarkInvoiceSent(supabase, user.id, companyId, pendingOp.params)
break
case 'match_transaction_invoice':
result = await commitMatchTransactionInvoice(supabase, user.id, companyId, pendingOp.params)
break
default:
return NextResponse.json({ error: 'Unknown operation type' }, { status: 400 })
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
throw err
}
if (result.error) {
@@ -4,6 +4,7 @@ import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
ensureInitialized()
@@ -61,6 +62,9 @@ export async function POST(
try {
await reverseEntry(supabase, companyId, user.id, entryId)
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
// Entry may already be reversed — continue
const msg = err instanceof Error ? err.message : ''
if (!msg.includes('already reversed')) {
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
@@ -131,7 +132,20 @@ export async function POST(
.eq('id', creditNote.id)
}
} catch (err) {
// Roll back the just-inserted credit note (items cascade-delete) on
// any JE failure. A creditfaktura row without a corresponding reversal
// JE would leave ingående moms overstated for the period — same
// momsdeklaration-integrity concern as the POST-route rollback.
await supabase.from('supplier_invoices').delete().eq('id', creditNote.id).eq('company_id', companyId)
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create credit note journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra kreditfakturan — försök igen eller ändra datum om perioden är låst.' },
{ status: 500 }
)
}
}
@@ -6,6 +6,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
@@ -98,6 +99,9 @@ export async function POST(
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create payment journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra betalningen' },
+15
View File
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas'
@@ -194,7 +195,21 @@ export async function POST(request: Request) {
.eq('id', invoice.id)
}
} catch (err) {
// Roll back the just-inserted supplier invoice (+ items via ON DELETE
// CASCADE) on any JE failure so we never leave a supplier_invoices row
// that has no registration JE. Under accrual method an orphan row
// means leverantörsskuld (2440) and ingående moms (2641) go unposted,
// which silently understates the momsdeklaration for the period.
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create registration journal entry:', err)
return NextResponse.json(
{ error: 'Kunde inte bokföra leverantörsfakturan — försök igen eller ändra datum om perioden är låst.' },
{ status: 500 }
)
}
}
+4
View File
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { BookTransactionSchema } from '@/lib/api/schemas'
import { requireCompanyId } from '@/lib/company/context'
@@ -65,6 +66,9 @@ export async function POST(
lines,
})
} 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 }
@@ -5,6 +5,7 @@ import {
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -121,6 +122,9 @@ export async function POST(
newState: { journal_entry_id: null },
})
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to storno conflicting journal entry:', err)
return NextResponse.json(
{ error: 'Failed to reverse conflicting journal entry' },
@@ -200,6 +204,9 @@ export async function POST(
journalEntryId = journalEntry?.id ?? null
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create payment journal entry:', err)
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
// Continue - we still want to update the invoice and transaction
@@ -4,6 +4,7 @@ import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { AccountsNotInChartError, accountsNotInChartResponse } from '@/lib/bookkeeping/errors'
import { validateBody } from '@/lib/api/validate'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import { logMatchEvent } from '@/lib/invoices/match-log'
@@ -127,6 +128,9 @@ export async function POST(
if (journalEntry) journalEntryId = journalEntry.id
}
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
console.error('Failed to create payment journal entry:', err)
}
@@ -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'
@@ -60,6 +61,9 @@ export async function POST(
try {
await reverseEntry(supabase, companyId, user.id, transaction.journal_entry_id)
} catch (err) {
if (err instanceof AccountsNotInChartError) {
return accountsNotInChartResponse(err)
}
const message = err instanceof Error ? err.message : 'Reversal failed'
return NextResponse.json({ error: message }, { status: 500 })
}