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:
co-authored by
Claude Opus 4.6
parent
4fbfadb2b7
commit
fe137346df
@@ -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 })
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user