fix(mcp): paginate trial-balance and VAT aggregations (1000-row truncation) (#806)
* fix(mcp): paginate trial-balance and VAT aggregations (1000-row truncation) The gnubok_get_trial_balance tool and computeVatReport each ran an unbounded journal_entry_lines aggregation. PostgREST caps an unpaginated .select() at 1000 rows, so any period with >1000 entry lines silently truncated: wrong per-account sums and a false "not balanced" trial balance, and an under-reported momsdeklaration for yearly or busy quarterly VAT periods. - get_trial_balance now delegates to the canonical generateTrialBalance (lib/reports), which paginates via fetchAllRows and rolls opening balances forward, also fixing a latent bug where the tool ignored IB. - computeVatReport now paginates its line fetch via fetchAllRows. The library fixed this class of bug in #79; these two MCP paths kept their own copies that were never updated. Signed-off-by: Jonas Hagberg <jonas@lindan.se> * fix(mcp): non-null assert periodId in generateTrialBalance call Fixes the core-only TS build error (string | undefined not assignable to string). periodId is guaranteed defined by the !period guard above; mirrors the existing periodId! call later in the file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): support .range() in computeVatReport mock for paginated query computeVatReport now fetches journal_entry_lines via fetchAllRows (.range), but the hand-rolled mock terminated at .lte(). Move the terminal to .range() so the 8 VAT-aggregation tests exercise the paginated path. Test-only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Jonas Hagberg <jonas@lindan.se> Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
Jakob Wennberg
parent
e4a9fdb4a6
commit
37ee125b9b
@@ -17,12 +17,14 @@ interface MockLine {
|
||||
|
||||
function mockSupabaseWithLines(lines: MockLine[]) {
|
||||
// Build a chain that matches the call path in computeVatReport:
|
||||
// .from('journal_entry_lines').select(...).eq(...).in(...).gte(...).lte(...)
|
||||
// The terminal `.lte()` returns `{ data, error }`.
|
||||
// .from('journal_entry_lines').select(...).eq(...).in(...).gte(...).lte(...).range(from, to)
|
||||
// computeVatReport now paginates via fetchAllRows, so the terminal call is
|
||||
// `.range(from, to)`. Returning all lines on the first page (always < the
|
||||
// 1000-row PAGE_SIZE for these fixtures) makes fetchAllRows stop after one page.
|
||||
const terminal = { data: lines, error: null }
|
||||
const chain: Record<string, () => unknown> = {}
|
||||
// Terminal awaitable: vitest awaits the last call; .lte() returns the data.
|
||||
chain.lte = () => terminal
|
||||
chain.range = () => terminal
|
||||
chain.lte = () => chain
|
||||
chain.gte = () => chain
|
||||
chain.in = () => chain
|
||||
chain.eq = () => chain
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
calculateVatLiability,
|
||||
} from '@/lib/reports/kpi'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import { generateARLedger } from '@/lib/reports/ar-ledger'
|
||||
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
|
||||
import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets'
|
||||
@@ -1035,18 +1036,26 @@ export async function computeVatReport(
|
||||
endDate = `${year}-12-31`
|
||||
}
|
||||
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', startDate)
|
||||
.lte('journal_entries.entry_date', endDate)
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
// Paginate. An unbounded .select() caps at PostgREST's 1000-row default,
|
||||
// which silently truncates a yearly (or busy quarterly) VAT period with
|
||||
// >1000 entry lines and under-reports the momsdeklaration.
|
||||
const lines = await fetchAllRows<{
|
||||
account_number: string
|
||||
debit_amount: number
|
||||
credit_amount: number
|
||||
}>(({ from, to }) =>
|
||||
supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
.gte('journal_entries.entry_date', startDate)
|
||||
.lte('journal_entries.entry_date', endDate)
|
||||
.range(from, to)
|
||||
)
|
||||
|
||||
const accountTotals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines ?? []) {
|
||||
for (const line of lines) {
|
||||
const acc = line.account_number
|
||||
const existing = accountTotals.get(acc) ?? { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
@@ -3396,47 +3405,26 @@ export const tools: McpTool[] = [
|
||||
|
||||
if (!period) throw new Error('Fiscal period not found.')
|
||||
|
||||
// Aggregate journal entry lines
|
||||
const { data: lines, error } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)')
|
||||
.eq('journal_entries.company_id', companyId)
|
||||
.eq('journal_entries.fiscal_period_id', periodId)
|
||||
.in('journal_entries.status', ['posted', 'reversed'])
|
||||
// Delegate to the canonical, paginated trial-balance builder. The
|
||||
// previous inline query had no pagination, so PostgREST's 1000-row
|
||||
// default silently truncated any period with >1000 entry lines (wrong
|
||||
// sums, false "not balanced"), and it ignored opening balances.
|
||||
// generateTrialBalance paginates and rolls IB forward.
|
||||
const trialBalance = await generateTrialBalance(supabase, companyId, periodId!)
|
||||
|
||||
if (error) throw new Error(`Database error: ${error.message}`)
|
||||
|
||||
// Get account names
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name]))
|
||||
|
||||
// Aggregate by account
|
||||
const totals = new Map<string, { debit: number; credit: number }>()
|
||||
for (const line of lines ?? []) {
|
||||
const acc = line.account_number
|
||||
const existing = totals.get(acc) ?? { debit: 0, credit: 0 }
|
||||
existing.debit += Number(line.debit_amount) || 0
|
||||
existing.credit += Number(line.credit_amount) || 0
|
||||
totals.set(acc, existing)
|
||||
}
|
||||
|
||||
const rows = Array.from(totals.entries())
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([accNum, t]) => {
|
||||
const net = Math.round((t.debit - t.credit) * 100) / 100
|
||||
const rows = trialBalance.rows
|
||||
.map((r) => {
|
||||
const net = Math.round((r.closing_debit - r.closing_credit) * 100) / 100
|
||||
return {
|
||||
account_number: accNum,
|
||||
account_name: accountMap.get(accNum) ?? accNum,
|
||||
period_debit: Math.round(t.debit * 100) / 100,
|
||||
period_credit: Math.round(t.credit * 100) / 100,
|
||||
account_number: r.account_number,
|
||||
account_name: r.account_name,
|
||||
period_debit: r.period_debit,
|
||||
period_credit: r.period_credit,
|
||||
closing_debit: net > 0 ? net : 0,
|
||||
closing_credit: net < 0 ? Math.abs(net) : 0,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.account_number.localeCompare(b.account_number))
|
||||
|
||||
const totalDebit = Math.round(rows.reduce((s, r) => s + r.closing_debit, 0) * 100) / 100
|
||||
const totalCredit = Math.round(rows.reduce((s, r) => s + r.closing_credit, 0) * 100) / 100
|
||||
|
||||
Reference in New Issue
Block a user