From 951bdb4e660b282ac5e2b777d1dded08f9c21e09 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 23 May 2026 09:21:31 +0200 Subject: [PATCH] feat(bookkeeping): show per-account saldo on journal entry form (#562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bookkeeping): show per-account saldo on journal entry form Adds a "Saldo" column to the journal entry form so bookkeepers can see the current balance of each account as of the entry date while drafting a voucher. Useful context for booking bank withdrawals, VAT clearings, and other balance-sensitive operations. - New GET /api/bookkeeping/account-balances?accounts=...&as_of=... returns per-account net (debit - credit) over posted entries up to and including the requested date. Batched in chunks of 200 entry IDs to stay under PostgREST IN-list limits. - JournalEntryForm fetches balances debounced 150ms on changes to the set of selected account numbers or the entry date; carries forward previously-known values so the cell doesn't flash to a skeleton on re-fetch. - Saldo is reference-only: it reflects "balance before this entry" and intentionally ignores the draft lines the user is currently editing. - Renders in both desktop (table column) and mobile (per-line caption) layouts. Tabular-nums, muted, right-aligned. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bookkeeping): correct saldo semantics — IB + period-only, BS vs P&L Address Swedish compliance review on PR #562: 1. P&L accounts (class 3-8) no longer show a since-inception cumulative sum. They reset each räkenskapsår per BFNAR 2013:2; the saldo now reflects current-period activity only, matching trial-balance semantics. BS accounts (class 1-2) continue to include IB. 2. Opening balances are now sourced via the canonical getOpeningBalances() helper, which reads the explicit opening_balance_entry_id set by year-end closing or SIE import. Previously, summing journal_entry_lines from inception returned 0 for SIE-imported companies whose IB lives in a separate entry that the old query happened to include — and the wrong value once year-end ran and an OB entry was set without exclusion logic. 3. Relabel "Saldo" -> "Saldo (före)" / "Balance (before)" so the UI communicates that the figure excludes the draft being edited (BFNAR 2013:2 kap 8 self-documentation requirement). 4. Stop forwarding raw Supabase error.message to the client; log server-side via the structured logger and return a generic 'Internal server error' to avoid leaking schema details. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bookkeeping): reject future as_of dates on account-balances endpoint Both compliance reviewers on PR #562 flagged this independently: a future as_of date would include posted entries dated after today in the activity window, producing a misleading "balance before this entry" hint that could drive incorrect verifikat entries (swedish-compliance-review-bot) or be used for future-date probing (SOC 2 PI1.1, GDPR Art.25(2)). - AccountBalancesQuerySchema.as_of now refines to <= today. - JournalEntryForm collapses the loading skeleton to 0 on any non-OK response so the saldo column doesn't get stuck spinning when a user enters a future entry_date (which the form's separate period validation already handles). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bookkeeping): compare as_of guard against Europe/Stockholm date swedish-compliance-review-bot caught this on the previous fix: the future-date guard used new Date().toISOString().slice(0, 10), which is UTC. Between 00:00–02:00 CET (or 00:00–03:00 CEST), a Swedish bookkeeper's local "today" is one day ahead of UTC, so entering their Stockholm-local date would be rejected as a future date. Compare against Europe/Stockholm-local date via toLocaleDateString ('sv-SE'), which renders YYYY-MM-DD natively, so string comparison remains correct across DST. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/bookkeeping/account-balances/route.ts | 161 ++++++++++++++++++ components/bookkeeping/JournalEntryForm.tsx | 99 ++++++++++- lib/api/schemas.ts | 16 ++ messages/en.json | 2 + messages/sv.json | 2 + 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 app/api/bookkeeping/account-balances/route.ts diff --git a/app/api/bookkeeping/account-balances/route.ts b/app/api/bookkeeping/account-balances/route.ts new file mode 100644 index 00000000..ae06165e --- /dev/null +++ b/app/api/bookkeeping/account-balances/route.ts @@ -0,0 +1,161 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { requireCompanyId } from '@/lib/company/context' +import { validateQuery } from '@/lib/api/validate' +import { AccountBalancesQuerySchema } from '@/lib/api/schemas' +import { getOpeningBalances } from '@/lib/reports/opening-balances' +import { fetchAllRows } from '@/lib/supabase/fetch-all' +import { createLogger } from '@/lib/logger' + +const log = createLogger('api.bookkeeping.account-balances') + +/** + * Per-account saldo as of a date. Used by the journal-entry form to show + * each account's balance before the draft entry is posted. + * + * Mirrors the trial-balance model: + * - Balance-sheet accounts (class 1-2): IB + period activity through as_of. + * - P&L accounts (class 3-8): period activity only (P&L resets + * each räkenskapsår; carrying a + * since-inception sum would violate + * BFNAR 2013:2). + * + * IB is sourced via getOpeningBalances() so SIE-imported and year-end-closed + * companies behave identically. The opening-balance entry is excluded from + * period activity to avoid double-counting its lines. + */ +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 params = validateQuery(request, AccountBalancesQuerySchema) + if (!params.success) return params.response + const { accounts, as_of } = params.data + + const companyId = await requireCompanyId(supabase, user.id) + + // Find the fiscal period containing as_of (any state — we want a reference + // saldo even for closed/locked periods). + const { data: period, error: periodError } = await supabase + .from('fiscal_periods') + .select('id, period_start, period_end, opening_balance_entry_id') + .eq('company_id', companyId) + .lte('period_start', as_of) + .gte('period_end', as_of) + .order('period_start', { ascending: false }) + .limit(1) + .maybeSingle() + + if (periodError) { + log.error('fiscal period lookup failed', { companyId, as_of, error: periodError.message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + // No period anchor → no meaningful IB, return zeros so the UI degrades cleanly. + if (!period) { + return NextResponse.json({ + data: accounts.map((account_number) => ({ account_number, balance: 0 })), + }) + } + + const { data: coaRows, error: coaError } = await supabase + .from('chart_of_accounts') + .select('account_number, account_class') + .eq('company_id', companyId) + .in('account_number', accounts) + + if (coaError) { + log.error('chart of accounts lookup failed', { companyId, error: coaError.message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + const accountClass = new Map() + for (const row of coaRows ?? []) { + accountClass.set(row.account_number, row.account_class) + } + + let openingBalances: Map + let obEntryId: string | null + try { + const result = await getOpeningBalances(supabase, companyId, { + period_start: period.period_start, + opening_balance_entry_id: period.opening_balance_entry_id, + }) + openingBalances = result.balances + obEntryId = result.obEntryId + } catch (err) { + log.error('opening-balance computation failed', { + companyId, + period_id: period.id, + error: err instanceof Error ? err.message : String(err), + }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + // Sum activity from period_start through as_of, excluding the OB entry + // (its lines are already in openingBalances). + let lines: Array<{ account_number: string; debit_amount: number; credit_amount: number }> + try { + lines = await fetchAllRows<{ + account_number: string + debit_amount: number + credit_amount: number + }>(({ from, to }) => { + let query = supabase + .from('journal_entry_lines') + .select( + 'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)' + ) + .eq('journal_entries.company_id', companyId) + .in('account_number', accounts) + .in('journal_entries.status', ['posted', 'reversed']) + .gte('journal_entries.entry_date', period.period_start) + .lte('journal_entries.entry_date', as_of) + + if (obEntryId) { + query = query.neq('journal_entry_id', obEntryId) + } + + return query.range(from, to) + }) + } catch (err) { + log.error('period activity lookup failed', { + companyId, + period_id: period.id, + error: err instanceof Error ? err.message : String(err), + }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } + + const periodActivity = new Map() + for (const line of lines) { + const existing = periodActivity.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + periodActivity.set(line.account_number, existing) + } + + return NextResponse.json({ + data: accounts.map((account_number) => { + // Fall back to inferring class from the first digit for accounts not in + // the company's COA (e.g. system accounts the user typed manually). + const klass = accountClass.get(account_number) ?? (parseInt(account_number[0], 10) || 0) + const isBalanceSheet = klass >= 1 && klass <= 2 + + const ib = isBalanceSheet + ? openingBalances.get(account_number) || { debit: 0, credit: 0 } + : { debit: 0, credit: 0 } + const activity = periodActivity.get(account_number) || { debit: 0, credit: 0 } + + const net = ib.debit - ib.credit + activity.debit - activity.credit + return { + account_number, + balance: Math.round(net * 100) / 100, + } + }), + }) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 6e23a8f6..8ac0ccc7 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -18,6 +18,7 @@ import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker' import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog' import { ActivateAccountsDialog } from '@/components/bookkeeping/ActivateAccountsDialog' +import { Skeleton } from '@/components/ui/skeleton' import { useSubmitWithAccountActivation, throwOnStructuredError, @@ -102,6 +103,9 @@ export default function JournalEntryForm({ const [foreignAmount, setForeignAmount] = useState('') const [periodMismatch, setPeriodMismatch] = useState<'no_period' | 'wrong_period' | null>(null) const [showCreatePeriod, setShowCreatePeriod] = useState(false) + // Per-account saldo as of entryDate, keyed by account_number. + // undefined = not fetched, null = fetch in flight. + const [accountBalances, setAccountBalances] = useState>({}) const isForeign = entryCurrency !== 'SEK' @@ -212,6 +216,76 @@ export default function JournalEntryForm({ } }, [entryCurrency, fetchRate]) + // Stable key of selected account numbers across all lines, sorted + deduped. + // Only valid 4-digit BAS account numbers are included. + const accountsKey = useMemo( + () => + Array.from( + new Set(lines.map((l) => l.account_number).filter((a) => /^\d{4}$/.test(a))) + ) + .sort() + .join(','), + [lines] + ) + + // Fetch per-account saldo as of entryDate for the accounts currently on the + // form. Balances are reference-only ("saldo before this entry") — they ignore + // the draft lines the user is typing, by design. + useEffect(() => { + if (!accountsKey) { + setAccountBalances({}) + return + } + const accountList = accountsKey.split(',') + // Carry forward any previously-known balances for these accounts so the + // value doesn't blank out on re-fetch; mark genuinely new accounts as + // loading (null). + setAccountBalances((prev) => { + const next: Record = {} + for (const a of accountList) next[a] = a in prev ? prev[a] : null + return next + }) + + let cancelled = false + const handle = setTimeout(async () => { + try { + const qs = new URLSearchParams({ accounts: accountsKey, as_of: entryDate }) + const res = await fetch(`/api/bookkeeping/account-balances?${qs}`) + if (!res.ok) { + // 4xx (e.g. future entryDate rejected by Zod) or 5xx: collapse the + // loading skeleton so the column doesn't get stuck. Saldo is a + // reference value, not authoritative — showing 0 here is preferable + // to an indefinite spinner. + if (cancelled) return + setAccountBalances((prev) => { + const next = { ...prev } + for (const a of accountList) { + if (next[a] == null) next[a] = 0 + } + return next + }) + return + } + const body = (await res.json()) as { + data: Array<{ account_number: string; balance: number }> + } + if (cancelled) return + setAccountBalances((prev) => { + const next = { ...prev } + for (const row of body.data) next[row.account_number] = row.balance + return next + }) + } catch { + // Reference value — failure is non-fatal, just leave previous state. + } + }, 150) + + return () => { + cancelled = true + clearTimeout(handle) + } + }, [accountsKey, entryDate]) + const addLine = () => { setLines([...lines, { ...BLANK_LINE }]) } @@ -691,6 +765,17 @@ export default function JournalEntryForm({ /> + {/^\d{4}$/.test(line.account_number) && ( +
+ {accountBalances[line.account_number] === null || accountBalances[line.account_number] === undefined ? ( + + ) : ( + + {t('saldo_label')} {formatCurrency(accountBalances[line.account_number] as number)} + + )} +
+ )} ))} @@ -733,6 +818,7 @@ export default function JournalEntryForm({ {t('col_description')} {t('col_debit')} {t('col_credit')} + {t('col_saldo')} @@ -778,6 +864,16 @@ export default function JournalEntryForm({ step="0.01" /> + + {(() => { + if (!/^\d{4}$/.test(line.account_number)) return null + const bal = accountBalances[line.account_number] + if (bal === null || bal === undefined) { + return + } + return formatCurrency(bal) + })()} +