fix(mcp): query_journal defaults to status 'all' so totals equal ledger balances (#1863)

* fix(mcp): query_journal defaults to status 'all' so totals equal ledger balances

Posted-only was the default, but storno bookkeeping keeps both the
reversed original and its posted storno on the account: one-leg sums are
never balances. A customer's agent summed posted-only lines over a
storno-heavy quarter, found phantom VAT residuals on 2614/2641/2645/2647
and asked support to revert correct books.

- default status 'all' (posted + reversed), the same inclusion rule as
  trial balance, GL and SIE export
- explicit 'posted'/'reversed' get status_filter_warning when the
  opposite leg exists in range (entry-level head count, advisory)
- unknown status values now throw instead of matching nothing
- sibling description trims fund the additions within the tools/list
  payload budget

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): make status_filter_warning claim strictly true, hint status:'posted' for tag preview

Review feedback (PR Agent + skeptics): the entry-level opposite-status
count cannot prove the line-filtered totals are wrong, so the warning
now says one-leg totals CAN differ from balances; verb agrees with a
count of one. tag_journal_lines preview hint tells agents to pass
status:'posted' now that query_journal defaults to 'all'. Description
trims keep the tools/list payload inside the budget guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): state the status_filter_warning count scope in the message

CodeRabbit: the opposite-status count applies entry-level filters only,
so say so in the warning instead of letting an account-scoped caller
read the count as account-scoped. Line-level scoping declined as
documented in DECISIONS.md (it would re-run the full line fetch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-24 17:52:32 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 174f315d9c
commit 0a3cb1a31a
3 changed files with 157 additions and 12 deletions
@@ -249,6 +249,7 @@ function makeLineRow(opts: {
entry_notes?: string | null
voucher_number?: number
entry_date?: string
entry_status?: string
}) {
return {
id: opts.id,
@@ -268,7 +269,7 @@ function makeLineRow(opts: {
description: opts.entry_description ?? '',
notes: opts.entry_notes ?? null,
source_type: 'bank_transaction',
status: 'posted',
status: opts.entry_status ?? 'posted',
},
}
}
@@ -1006,3 +1007,102 @@ describe('gnubok_query_journal: accounts argument normalization', () => {
).rejects.toThrow(/capped at 50/)
})
})
describe('gnubok_query_journal: status default and balance integrity', () => {
const tool = () => tools.find((t) => t.name === 'gnubok_query_journal')!
// A storno pair on 2614: the reversed original (credit) and its posted
// storno (debit). Under the ledger inclusion rule they net to zero; a
// posted-only view sees just the storno leg and fabricates a +940.27
// "balance". This is the exact shape that made a customer's agent find
// phantom VAT residuals and demand a revert of correct books.
const stornoPair = () => [
makeLineRow({
id: 'l-orig', account_number: '2614', debit_amount: 0, credit_amount: 940.27,
entry_status: 'reversed', entry_date: '2026-05-31',
}),
makeLineRow({
id: 'l-storno', account_number: '2614', debit_amount: 940.27, credit_amount: 0,
entry_status: 'posted', entry_date: '2026-05-31', voucher_number: 2,
}),
]
it("declares status_filter_warning in the output schema and 'all' as the documented default", () => {
const t = tool()
const out = t.outputSchema as { properties?: Record<string, unknown> }
expect(out.properties?.status_filter_warning).toBeDefined()
const statusProp = (t.inputSchema as {
properties: Record<string, { description?: string }>
}).properties.status
expect(statusProp.description).toMatch(/Default 'all'/)
})
it('defaults to posted + reversed so a storno pair nets to zero (ledger rule)', async () => {
const { supabase, entryQueries } = makeTwoStepTextMock(stornoPair())
const result = (await tool().execute(
{ accounts: ['2614'] },
'company-1', 'user-1', supabase,
)) as {
lines: unknown[]
totals: { net: number }
status_filter_warning?: string
applied_filters: { status: string }
}
expect(result.applied_filters.status).toBe('all')
expect(result.lines).toHaveLength(2)
expect(result.totals.net).toBe(0)
expect(result.status_filter_warning).toBeUndefined()
// The entry pass filters on BOTH statuses; no separate opposite-status
// count query runs on the default path.
const statusFilters = entryQueries().flatMap((q) =>
q.filters.filter((f) => f.column === 'status'),
)
expect(statusFilters).toEqual([
{ op: 'in', column: 'status', value: ['posted', 'reversed'] },
])
})
it("explicit status 'posted' returns one leg and warns that totals are not balances", async () => {
const { supabase, entryQueries } = makeTwoStepTextMock(stornoPair())
const result = (await tool().execute(
{ accounts: ['2614'], status: 'posted' },
'company-1', 'user-1', supabase,
)) as {
lines: Array<{ line_id: string }>
totals: { net: number }
status_filter_warning?: string
}
expect(result.lines.map((l) => l.line_id)).toEqual(['l-storno'])
expect(result.totals.net).toBe(940.27)
expect(result.status_filter_warning).toMatch(/can differ from account balances/)
expect(result.status_filter_warning).toMatch(/status 'all'/)
// Singular/plural agreement: one excluded entry reads "1 entry ... is".
expect(result.status_filter_warning).toMatch(/1 entry with status 'reversed' in the filtered range is excluded/)
// The warning is grounded in a real opposite-status count query.
const oppositeCount = entryQueries().some((q) =>
q.filters.some((f) => f.op === 'eq' && f.column === 'status' && f.value === 'reversed'),
)
expect(oppositeCount).toBe(true)
})
it('omits the warning when the one-leg filter excluded nothing', async () => {
const rows = [
makeLineRow({ id: 'l1', account_number: '2614', debit_amount: 100, entry_status: 'posted' }),
]
const { supabase } = makeTwoStepTextMock(rows)
const result = (await tool().execute(
{ accounts: ['2614'], status: 'posted' },
'company-1', 'user-1', supabase,
)) as { status_filter_warning?: string }
expect(result.status_filter_warning).toBeUndefined()
})
it('rejects an unknown status value instead of matching nothing', async () => {
const { supabase } = makeTwoStepTextMock([])
await expect(
tool().execute({ status: 'draft' }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/status must be/)
})
})