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:
co-authored by
Claude Opus 4.7
parent
78c91e00e4
commit
951bdb4e66
@@ -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<string, number>()
|
||||
for (const row of coaRows ?? []) {
|
||||
accountClass.set(row.account_number, row.account_class)
|
||||
}
|
||||
|
||||
let openingBalances: Map<string, { debit: number; credit: number }>
|
||||
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<string, { debit: number; credit: number }>()
|
||||
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,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -675,6 +675,22 @@ export const ReportPeriodQuerySchema = z.object({
|
||||
month: z.coerce.number().int().min(1).max(12).optional(),
|
||||
})
|
||||
|
||||
export const AccountBalancesQuerySchema = z.object({
|
||||
accounts: z
|
||||
.string()
|
||||
.transform((s) => s.split(',').map((a) => a.trim()).filter(Boolean))
|
||||
.pipe(z.array(accountNumber).min(1).max(50)),
|
||||
// Reject future dates — a saldo "as of tomorrow" would include unposted
|
||||
// future entries (if any) and mislead the bookkeeper about the true
|
||||
// pre-entry state of the ledger. Compared in Europe/Stockholm so a Swedish
|
||||
// bookkeeper working in the 00:00–02:00 CET window (after midnight UTC has
|
||||
// not yet passed) isn't rejected for entering their local today's date.
|
||||
as_of: isoDate.refine(
|
||||
(d) => d <= new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Stockholm' }),
|
||||
{ message: 'as_of cannot be in the future' },
|
||||
),
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// VAT validation schemas
|
||||
// ============================================================
|
||||
|
||||
@@ -2692,6 +2692,8 @@
|
||||
"col_description": "Description",
|
||||
"col_debit": "Debit",
|
||||
"col_credit": "Credit",
|
||||
"col_saldo": "Balance (before)",
|
||||
"saldo_label": "Balance (before)",
|
||||
"line_description_placeholder": "Line text...",
|
||||
"sum": "Total",
|
||||
"sum_d": "D: {amount}",
|
||||
|
||||
@@ -2692,6 +2692,8 @@
|
||||
"col_description": "Beskrivning",
|
||||
"col_debit": "Debet",
|
||||
"col_credit": "Kredit",
|
||||
"col_saldo": "Saldo (före)",
|
||||
"saldo_label": "Saldo (före)",
|
||||
"line_description_placeholder": "Radtext...",
|
||||
"sum": "Summa",
|
||||
"sum_d": "D: {amount}",
|
||||
|
||||
Reference in New Issue
Block a user