fix: prevent silent data truncation in report generators (#257)

* fix: prevent silent data truncation in report generators

Supabase PostgREST silently truncates queries at 1000 rows. Several
report generators used bare .select() without fetchAllRows(), causing
incomplete financial data — a BFL compliance violation.

Wrapped 10 queries across 7 files with fetchAllRows():
- monthly-breakdown: journal_entry_lines (easily >1000/period)
- ar-ledger: unpaid invoices
- supplier-ledger: unpaid supplier invoices
- salary-journal: salary_run_employees (+ optimized with !inner join)
- vacation-liability: employees + salary_run_employees
- full-archive-export: document_attachments + journal_entry IDs
- ingest.ts: supplier invoices for auto-matching

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Greptile review — try/catch and client-side safety checks

- Wrap full-archive-export document fetch in try/catch to match the
  VAT section pattern — a failed document query should not prevent
  the rest of the archive from being generated.
- Restore client-side year/status safety checks in salary-journal and
  vacation-liability as defense-in-depth against PostgREST !inner
  filter regressions, per BFL lönejournal and BFNAR 2016:10 compliance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-16 15:46:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 4fbfadb2b7
commit fe137346df
10 changed files with 217 additions and 167 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() { function makeBuilder() {
const b: Record<string, unknown> = {} const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) { for (const m of ['select', 'eq', 'in', 'range']) {
b[m] = vi.fn().mockReturnValue(b) b[m] = vi.fn().mockReturnValue(b)
} }
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+71 -65
View File
@@ -54,11 +54,13 @@ describe('generateMonthlyBreakdown', () => {
select: () => ({ select: () => ({
eq: () => ({ eq: () => ({
eq: () => ({ eq: () => ({
eq: () => eq: () => ({
Promise.resolve({ range: () =>
data: [], Promise.resolve({
error: null, data: [],
}), error: null,
}),
}),
}), }),
}), }),
}), }),
@@ -96,36 +98,38 @@ describe('generateMonthlyBreakdown', () => {
select: () => ({ select: () => ({
eq: () => ({ eq: () => ({
eq: () => ({ eq: () => ({
eq: () => eq: () => ({
Promise.resolve({ range: () =>
data: [ Promise.resolve({
{ data: [
account_number: '3001', {
debit_amount: 0, account_number: '3001',
credit_amount: 10000, debit_amount: 0,
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 10000,
}, journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
{ },
account_number: '5010', {
debit_amount: 3000, account_number: '5010',
credit_amount: 0, debit_amount: 3000,
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 0,
}, journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
{ },
account_number: '3001', {
debit_amount: 0, account_number: '3001',
credit_amount: 5000, debit_amount: 0,
journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 5000,
}, journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
{ },
account_number: '6200', {
debit_amount: 1500, account_number: '6200',
credit_amount: 0, debit_amount: 1500,
journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 0,
}, journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
], },
error: null, ],
}), error: null,
}),
}),
}), }),
}), }),
}), }),
@@ -175,36 +179,38 @@ describe('generateMonthlyBreakdown', () => {
select: () => ({ select: () => ({
eq: () => ({ eq: () => ({
eq: () => ({ eq: () => ({
eq: () => eq: () => ({
Promise.resolve({ range: () =>
data: [ Promise.resolve({
{ data: [
account_number: '1930', {
debit_amount: 10000, account_number: '1930',
credit_amount: 0, debit_amount: 10000,
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 0,
}, journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
{ },
account_number: '2611', {
debit_amount: 0, account_number: '2611',
credit_amount: 2500, debit_amount: 0,
journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 2500,
}, journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
{ },
account_number: '8400', {
debit_amount: 500, account_number: '8400',
credit_amount: 0, debit_amount: 500,
journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 0,
}, journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
{ },
account_number: '8300', {
debit_amount: 0, account_number: '8300',
credit_amount: 200, debit_amount: 0,
journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, credit_amount: 200,
}, journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' },
], },
error: null, ],
}), error: null,
}),
}),
}), }),
}), }),
}), }),
@@ -9,7 +9,7 @@ let results: Array<{ data?: unknown; error?: unknown }>
function makeBuilder() { function makeBuilder() {
const b: Record<string, unknown> = {} const b: Record<string, unknown> = {}
for (const m of ['select', 'eq', 'in']) { for (const m of ['select', 'eq', 'in', 'range']) {
b[m] = vi.fn().mockReturnValue(b) b[m] = vi.fn().mockReturnValue(b)
} }
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }) b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
+13 -7
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface ARInvoiceDetail { export interface ARInvoiceDetail {
invoice_id: string invoice_id: string
@@ -44,13 +45,18 @@ export async function generateARLedger(
const refDate = asOfDate ? new Date(asOfDate) : new Date() const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/sent/overdue invoices with customer info // Fetch all unpaid/sent/overdue invoices with customer info
const { data: invoices, error } = await supabase // eslint-disable-next-line @typescript-eslint/no-explicit-any
.from('invoices') let invoices: any[]
.select('*, customer:customers(id, name)') try {
.eq('company_id', companyId) invoices = await fetchAllRows(({ from, to }) =>
.in('status', ['sent', 'overdue']) supabase
.from('invoices')
if (error || !invoices) { .select('*, customer:customers(id, name)')
.eq('company_id', companyId)
.in('status', ['sent', 'overdue'])
.range(from, to)
)
} catch {
return { return {
entries: [], entries: [],
total_outstanding: 0, total_outstanding: 0,
+31 -14
View File
@@ -8,6 +8,7 @@ import { generateGeneralLedger } from './general-ledger'
import { generateJournalRegister } from './journal-register' import { generateJournalRegister } from './journal-register'
import { calculateVatDeclaration } from './vat-declaration' import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service' import { getAuditLog } from '@/lib/core/audit/audit-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { AuditLogEntry } from '@/types' import type { AuditLogEntry } from '@/types'
export interface FullArchiveOptions { export interface FullArchiveOptions {
@@ -117,22 +118,35 @@ export async function generateFullArchive(
const manifest: DocumentManifestEntry[] = [] const manifest: DocumentManifestEntry[] = []
// Fetch document attachments linked to journal entries in this period // Fetch document attachments linked to journal entries in this period
const { data: documents } = await supabase // Wrapped in try/catch to match VAT section — a failed document fetch
.from('document_attachments') // should not prevent the rest of the archive from being generated.
.select('id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes') try {
.eq('company_id', companyId) const documents = await fetchAllRows<{
.not('journal_entry_id', 'is', null) id: string; file_name: string; storage_path: string; journal_entry_id: string | null
sha256_hash: string; version: number; digitization_date: string | null
if (documents && documents.length > 0) { upload_source: string | null; mime_type: string | null; file_size_bytes: number | null
// Filter to entries in this period }>(({ from, to }) =>
const { data: periodEntryIds } = await supabase supabase
.from('journal_entries') .from('document_attachments')
.select('id') .select('id, file_name, storage_path, journal_entry_id, sha256_hash, version, digitization_date, upload_source, mime_type, file_size_bytes')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('fiscal_period_id', period_id) .not('journal_entry_id', 'is', null)
.in('status', ['posted', 'reversed']) .range(from, to)
)
const periodEntryIdSet = new Set((periodEntryIds || []).map((e: { id: string }) => e.id)) if (documents.length > 0) {
// Filter to entries in this period
const periodEntryIds = await fetchAllRows<{ id: string }>(({ from, to }) =>
supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', period_id)
.in('status', ['posted', 'reversed'])
.range(from, to)
)
const periodEntryIdSet = new Set(periodEntryIds.map((e) => e.id))
const periodDocuments = documents.filter( const periodDocuments = documents.filter(
(d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id) (d: { journal_entry_id: string | null }) => d.journal_entry_id && periodEntryIdSet.has(d.journal_entry_id)
) )
@@ -182,6 +196,9 @@ export async function generateFullArchive(
} }
} }
} }
} catch {
// Document fetch failed — archive will still contain reports and audit trail
}
dokument.file('manifest.json', JSON.stringify(manifest, null, 2)) dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
} }
+25 -19
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface MonthlyBreakdownMonth { export interface MonthlyBreakdownMonth {
label: string label: string
@@ -42,24 +43,29 @@ export async function generateMonthlyBreakdown(
} }
// Get all posted journal entry lines for this period with their entry dates // Get all posted journal entry lines for this period with their entry dates
const { data: lines, error: linesError } = await supabase // eslint-disable-next-line @typescript-eslint/no-explicit-any
.from('journal_entry_lines') let lines: any[]
.select(` try {
account_number, lines = await fetchAllRows(({ from, to }) =>
debit_amount, supabase
credit_amount, .from('journal_entry_lines')
journal_entry:journal_entries!inner( .select(`
entry_date, account_number,
status, debit_amount,
company_id, credit_amount,
fiscal_period_id journal_entry:journal_entries!inner(
) entry_date,
`) status,
.eq('journal_entries.fiscal_period_id', fiscalPeriodId) company_id,
.eq('journal_entries.company_id', companyId) fiscal_period_id
.eq('journal_entries.status', 'posted') )
`)
if (linesError || !lines) { .eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.range(from, to)
)
} catch {
return { months: [] } return { months: [] }
} }
@@ -81,7 +87,7 @@ export async function generateMonthlyBreakdown(
} }
for (const line of lines) { for (const line of lines) {
const entry = line.journal_entry as unknown as { const entry = line.journal_entry as {
entry_date: string entry_date: string
status: string status: string
company_id: string company_id: string
+20 -19
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/** /**
* Lönejournal — Monthly/annual per-employee salary register. * Lönejournal — Monthly/annual per-employee salary register.
@@ -57,29 +58,29 @@ export async function generateSalaryJournal(
monthFrom?: number, monthFrom?: number,
monthTo?: number monthTo?: number
): Promise<SalaryJournalReport> { ): Promise<SalaryJournalReport> {
const query = supabase // Use !inner join to filter server-side by year and status, avoiding
.from('salary_run_employees') // fetching all salary_run_employees across all years.
.select(` // eslint-disable-next-line @typescript-eslint/no-explicit-any
*, const data: any[] = await fetchAllRows(({ from, to }) =>
employee:employees(id, first_name, last_name, personnummer_last4, employment_type), supabase
salary_run:salary_runs(period_year, period_month, payment_date, status) .from('salary_run_employees')
`) .select(`
.eq('company_id', companyId) *,
employee:employees(id, first_name, last_name, personnummer_last4, employment_type),
salary_run:salary_runs!inner(period_year, period_month, payment_date, status)
`)
.eq('company_id', companyId)
.eq('salary_runs.period_year', year)
.eq('salary_runs.status', 'booked')
.order('created_at')
.range(from, to)
)
// We need to filter by the salary_run's period_year, which requires a join filter const rows: SalaryJournalRow[] = data
// Supabase doesn't support filtering on joined columns directly in .eq(),
// so we fetch all and filter client-side for the year
const { data, error } = await query.order('created_at')
if (error) {
throw new Error(`Failed to generate salary journal: ${error.message}`)
}
const rows: SalaryJournalRow[] = (data || [])
.filter(sre => { .filter(sre => {
const run = sre.salary_run as { period_year: number; period_month: number; status: string } | null const run = sre.salary_run as { period_year: number; period_month: number; status: string } | null
if (!run || run.period_year !== year) return false if (!run || run.period_year !== year) return false
if (run.status !== 'booked') return false // Only booked runs for BFL-compliant lönejournal if (run.status !== 'booked') return false
if (monthFrom && run.period_month < monthFrom) return false if (monthFrom && run.period_month < monthFrom) return false
if (monthTo && run.period_month > monthTo) return false if (monthTo && run.period_month > monthTo) return false
return true return true
+13 -7
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface SupplierLedgerEntry { export interface SupplierLedgerEntry {
supplier_id: string supplier_id: string
@@ -30,13 +31,18 @@ export async function generateSupplierLedger(
const refDate = asOfDate ? new Date(asOfDate) : new Date() const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/partially_paid supplier invoices // Fetch all unpaid/partially_paid supplier invoices
const { data: invoices, error } = await supabase // eslint-disable-next-line @typescript-eslint/no-explicit-any
.from('supplier_invoices') let invoices: any[]
.select('*, supplier:suppliers(id, name)') try {
.eq('company_id', companyId) invoices = await fetchAllRows(({ from, to }) =>
.in('status', ['registered', 'approved', 'partially_paid', 'overdue']) supabase
.from('supplier_invoices')
if (error || !invoices) { .select('*, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
.range(from, to)
)
} catch {
return { return {
entries: [], entries: [],
total_outstanding: 0, total_outstanding: 0,
+32 -26
View File
@@ -1,4 +1,5 @@
import type { SupabaseClient } from '@supabase/supabase-js' import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
/** /**
* Semesterlöneskuld — Vacation liability report per BFNAR 2016:10. * Semesterlöneskuld — Vacation liability report per BFNAR 2016:10.
@@ -50,32 +51,37 @@ export async function generateVacationLiability(
const r = (x: number) => Math.round(x * 100) / 100 const r = (x: number) => Math.round(x * 100) / 100
// Load active employees // Load active employees
const { data: employees, error: empError } = await supabase const employees = await fetchAllRows(({ from, to }) =>
.from('employees') supabase
.select('id, first_name, last_name, personnummer_last4, vacation_rule, vacation_days_per_year, vacation_days_saved') .from('employees')
.eq('company_id', companyId) .select('id, first_name, last_name, personnummer_last4, vacation_rule, vacation_days_per_year, vacation_days_saved')
.eq('is_active', true) .eq('company_id', companyId)
.order('last_name') .eq('is_active', true)
.order('last_name')
.range(from, to)
)
if (empError) throw new Error(`Failed to load employees: ${empError.message}`) // Load salary run employees for booked runs this year (server-side filtered via !inner join)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bookedForYear: any[] = await fetchAllRows(({ from, to }) =>
supabase
.from('salary_run_employees')
.select(`
employee_id,
vacation_accrual,
vacation_accrual_avgifter,
avgifter_rate,
vacation_days_taken,
salary_run:salary_runs!inner(period_year, status)
`)
.eq('company_id', companyId)
.eq('salary_runs.period_year', year)
.eq('salary_runs.status', 'booked')
.range(from, to)
)
// Load all salary run employees for booked runs this year // Client-side safety check: ensure server-side !inner filter was applied
const { data: runEmployees, error: sreError } = await supabase const verifiedBookedForYear = bookedForYear.filter(sre => {
.from('salary_run_employees')
.select(`
employee_id,
vacation_accrual,
vacation_accrual_avgifter,
avgifter_rate,
vacation_days_taken,
salary_run:salary_runs!inner(period_year, status)
`)
.eq('company_id', companyId)
if (sreError) throw new Error(`Failed to load salary run data: ${sreError.message}`)
// Filter to booked runs for the year
const bookedForYear = (runEmployees || []).filter(sre => {
const run = sre.salary_run as unknown as { period_year: number; status: string } | null const run = sre.salary_run as unknown as { period_year: number; status: string } | null
return run && run.period_year === year && run.status === 'booked' return run && run.period_year === year && run.status === 'booked'
}) })
@@ -88,7 +94,7 @@ export async function generateVacationLiability(
lastRate: number lastRate: number
}>() }>()
for (const sre of bookedForYear) { for (const sre of verifiedBookedForYear) {
const current = accrualsByEmployee.get(sre.employee_id) || { const current = accrualsByEmployee.get(sre.employee_id) || {
totalAccrual: 0, totalAvgifter: 0, totalDaysTaken: 0, lastRate: 0.3142, totalAccrual: 0, totalAvgifter: 0, totalDaysTaken: 0, lastRate: 0.3142,
} }
@@ -99,7 +105,7 @@ export async function generateVacationLiability(
accrualsByEmployee.set(sre.employee_id, current) accrualsByEmployee.set(sre.employee_id, current)
} }
const rows: VacationLiabilityRow[] = (employees || []).map(emp => { const rows: VacationLiabilityRow[] = employees.map(emp => {
const accruals = accrualsByEmployee.get(emp.id) const accruals = accrualsByEmployee.get(emp.id)
const accruedAmount = r(accruals?.totalAccrual || 0) const accruedAmount = r(accruals?.totalAccrual || 0)
const accruedAvgifter = r(accruals?.totalAvgifter || 0) const accruedAvgifter = r(accruals?.totalAvgifter || 0)
+10 -8
View File
@@ -7,6 +7,7 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation' import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
import { fetchMultipleRates } from '@/lib/currency/riksbanken' import { fetchMultipleRates } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log' import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation' import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types' import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
@@ -135,14 +136,15 @@ export async function ingestTransactions(
// Pre-fetch unpaid supplier invoices for expense matching (non-critical) // Pre-fetch unpaid supplier invoices for expense matching (non-critical)
try { try {
const { data } = await supabase unpaidSupplierInvoices = await fetchAllRows<SupplierInvoice>(({ from, to }) =>
.from('supplier_invoices') supabase
.select('*, supplier:suppliers(*)') .from('supplier_invoices')
.eq('company_id', companyId) .select('*, supplier:suppliers(*)')
.in('status', ['registered', 'approved']) .eq('company_id', companyId)
.gt('remaining_amount', 0) .in('status', ['registered', 'approved'])
.gt('remaining_amount', 0)
if (data) unpaidSupplierInvoices = data as SupplierInvoice[] .range(from, to)
)
} catch { } catch {
// Non-critical — supplier invoice matching will be skipped // Non-critical — supplier invoice matching will be skipped
} }