* 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>
255 lines
7.9 KiB
TypeScript
255 lines
7.9 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
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'
|
|
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* POST /api/import/opening-balance/execute
|
|
*
|
|
* Creates an opening balance journal entry from user-confirmed lines.
|
|
* Auto-activates BAS accounts not yet in the company's chart.
|
|
*/
|
|
export async function POST(request: Request) {
|
|
const supabase = await createClient()
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
const writeCheck = await requireWritePermission(supabase, user.id)
|
|
if (!writeCheck.ok) return writeCheck.response
|
|
|
|
const result = await validateBody(request, OpeningBalanceExecuteSchema)
|
|
if (!result.success) return result.response
|
|
|
|
const { fiscal_period_id, lines } = result.data
|
|
|
|
let companyId: string
|
|
try {
|
|
companyId = await requireCompanyId(supabase, user.id)
|
|
} catch {
|
|
return NextResponse.json({ error: 'Inget aktivt företag' }, { status: 400 })
|
|
}
|
|
|
|
try {
|
|
// 1. Verify fiscal period exists, belongs to company, and is not closed/locked
|
|
const { data: period, error: periodError } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('*')
|
|
.eq('id', fiscal_period_id)
|
|
.eq('company_id', companyId)
|
|
.single()
|
|
|
|
if (periodError || !period) {
|
|
return NextResponse.json(
|
|
{ error: 'Räkenskapsperioden hittades inte' },
|
|
{ status: 404 },
|
|
)
|
|
}
|
|
|
|
if (period.is_closed) {
|
|
return NextResponse.json(
|
|
{ error: 'Räkenskapsperioden är stängd' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
if (period.locked_at) {
|
|
return NextResponse.json(
|
|
{ error: 'Räkenskapsperioden är låst' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// 2. Check if period already has opening balances
|
|
if (period.opening_balances_set) {
|
|
return NextResponse.json(
|
|
{
|
|
error: 'Räkenskapsperioden har redan ingående balanser',
|
|
existing_entry_id: period.opening_balance_entry_id,
|
|
},
|
|
{ status: 409 },
|
|
)
|
|
}
|
|
|
|
// 3. Filter out zero-amount lines and validate no P&L accounts
|
|
const validLines = lines.filter((l) => l.debit_amount > 0 || l.credit_amount > 0)
|
|
|
|
if (validLines.length < 2) {
|
|
return NextResponse.json(
|
|
{ error: 'Minst två rader med belopp krävs' },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// Reject class 3-8 accounts
|
|
const pnlAccounts = validLines
|
|
.map((l) => l.account_number)
|
|
.filter((num) => {
|
|
const cls = parseInt(num.charAt(0), 10)
|
|
return cls >= 3 && cls <= 8
|
|
})
|
|
|
|
if (pnlAccounts.length > 0) {
|
|
return NextResponse.json(
|
|
{
|
|
error: `Resultatkonton (klass 3-8) kan inte användas i ingående balanser: ${pnlAccounts.slice(0, 5).join(', ')}`,
|
|
},
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// 4. Verify balance
|
|
let totalDebit = 0
|
|
let totalCredit = 0
|
|
for (const line of validLines) {
|
|
totalDebit = Math.round((totalDebit + line.debit_amount) * 100) / 100
|
|
totalCredit = Math.round((totalCredit + line.credit_amount) * 100) / 100
|
|
}
|
|
|
|
const diff = Math.round((totalDebit - totalCredit) * 100) / 100
|
|
if (Math.abs(diff) >= 0.01) {
|
|
return NextResponse.json(
|
|
{ error: `Debet och kredit balanserar inte — differens: ${diff.toFixed(2)} SEK` },
|
|
{ status: 400 },
|
|
)
|
|
}
|
|
|
|
// 5. Auto-activate BAS accounts not in company's chart
|
|
const accountNumbers = [...new Set(validLines.map((l) => l.account_number))]
|
|
|
|
const existingAccounts = await fetchAllRows(({ from, to }) =>
|
|
supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number')
|
|
.eq('company_id', companyId)
|
|
.range(from, to),
|
|
)
|
|
|
|
const existingNumbers = new Set(existingAccounts.map((a) => a.account_number))
|
|
const accountsToActivate = accountNumbers
|
|
.filter((num) => !existingNumbers.has(num))
|
|
.map((num) => {
|
|
const ref = getBASReference(num)
|
|
|
|
if (ref) {
|
|
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),
|
|
}
|
|
}
|
|
|
|
// Derive metadata from account number
|
|
const accountClass = parseInt(num.charAt(0), 10)
|
|
const accountGroup = num.substring(0, 2)
|
|
const accountType =
|
|
accountClass === 1 ? 'asset'
|
|
: accountClass === 2 ? 'liability'
|
|
: accountClass === 3 ? 'revenue'
|
|
: 'expense'
|
|
const normalBalance = accountClass <= 1 || accountClass >= 4 ? 'debit' : 'credit'
|
|
|
|
return {
|
|
user_id: user.id,
|
|
company_id: companyId,
|
|
account_number: num,
|
|
account_name: `Konto ${num}`,
|
|
account_class: accountClass,
|
|
account_group: accountGroup,
|
|
account_type: accountType,
|
|
normal_balance: normalBalance,
|
|
plan_type: 'full_bas' as const,
|
|
is_active: true,
|
|
is_system_account: false,
|
|
description: `Konto ${num}`,
|
|
sru_code: null,
|
|
sort_order: parseInt(num),
|
|
}
|
|
})
|
|
|
|
if (accountsToActivate.length > 0) {
|
|
const { error: activateError } = await supabase
|
|
.from('chart_of_accounts')
|
|
.insert(accountsToActivate)
|
|
|
|
if (activateError) {
|
|
console.error('Failed to activate accounts:', activateError)
|
|
return NextResponse.json(
|
|
{ error: 'Kunde inte aktivera konton i kontoplanen' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|
|
|
|
// 6. Create journal entry via engine
|
|
const entryLines: CreateJournalEntryLineInput[] = validLines.map((line) => ({
|
|
account_number: line.account_number,
|
|
debit_amount: line.debit_amount,
|
|
credit_amount: line.credit_amount,
|
|
line_description: `IB ${line.account_number}`,
|
|
}))
|
|
|
|
const entry = await createJournalEntry(supabase, companyId, user.id, {
|
|
fiscal_period_id,
|
|
entry_date: period.period_start,
|
|
description: 'Ingående balanser (Excel-import)',
|
|
source_type: 'opening_balance',
|
|
voucher_series: 'A',
|
|
lines: entryLines,
|
|
})
|
|
|
|
// 7. Update fiscal period
|
|
await supabase
|
|
.from('fiscal_periods')
|
|
.update({
|
|
opening_balance_entry_id: entry.id,
|
|
opening_balances_set: true,
|
|
})
|
|
.eq('id', fiscal_period_id)
|
|
.eq('company_id', companyId)
|
|
|
|
return NextResponse.json({
|
|
data: {
|
|
success: true,
|
|
journal_entry_id: entry.id,
|
|
fiscal_period_id,
|
|
lines_created: entryLines.length,
|
|
total_debit: totalDebit,
|
|
total_credit: totalCredit,
|
|
},
|
|
})
|
|
} 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' },
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
}
|