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
@@ -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,
}
}),
})
}