feat(bookkeeping): show per-account saldo on journal entry form (#562)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-23 09:21:31 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 78c91e00e4
commit 951bdb4e66
5 changed files with 279 additions and 1 deletions
+98 -1
View File
@@ -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<Record<string, number | null>>({})
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<string, number | null> = {}
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({
/>
</div>
</div>
{/^\d{4}$/.test(line.account_number) && (
<div className="flex justify-end text-xs text-muted-foreground tabular-nums pt-0.5">
{accountBalances[line.account_number] === null || accountBalances[line.account_number] === undefined ? (
<Skeleton className="h-3 w-20" />
) : (
<span>
{t('saldo_label')} {formatCurrency(accountBalances[line.account_number] as number)}
</span>
)}
</div>
)}
</div>
))}
@@ -733,6 +818,7 @@ export default function JournalEntryForm({
<th className="py-2 px-1">{t('col_description')}</th>
<th className="py-2 w-32 px-1 text-right">{t('col_debit')}</th>
<th className="py-2 w-32 px-1 text-right">{t('col_credit')}</th>
<th className="py-2 w-28 px-1 text-right">{t('col_saldo')}</th>
<th className="py-2 w-10"></th>
</tr>
</thead>
@@ -778,6 +864,16 @@ export default function JournalEntryForm({
step="0.01"
/>
</td>
<td className="py-1.5 px-1 text-right tabular-nums text-muted-foreground">
{(() => {
if (!/^\d{4}$/.test(line.account_number)) return null
const bal = accountBalances[line.account_number]
if (bal === null || bal === undefined) {
return <Skeleton className="h-4 w-20 ml-auto" />
}
return formatCurrency(bal)
})()}
</td>
<td className="py-1.5">
<Button
variant="ghost"
@@ -812,6 +908,7 @@ export default function JournalEntryForm({
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
</td>
<td></td>
<td></td>
</tr>
</tfoot>
</table>