fix(mcp): exclude VAT contra accounts from ledger-context dominant pick (#932)

Found by the switch-on check (calling gnubok_get_agent_briefing on real
prod data): counterparty patterns for reverse-charge foreign SaaS
(Google/ngrok/Supabase) reported dominant_account 2614 (reverse-charge
output VAT) instead of 5420 (software expense). The dominant_account CTE in
get_ledger_usage_stats excluded only 19xx, so on a reverse-charge booking
(expense + 2645 + 2614 + 1930) the three non-bank accounts tie at equal
counts and the account_number ascending tiebreak picks the low VAT number.

Migration 20260708110000 CREATE OR REPLACEs the function to also exclude
26xx (always moms in BAS, never characterizes a counterparty). Loan/tax
counterparties booking to 23xx/24xx/25xx/27xx stay eligible. supplier_patterns
is unaffected (it aggregates supplier_invoice_items.account_number, expense
only). Regression pg test asserts 5420 over 2614 and was confirmed to fail on
the old function.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-08 15:04:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent a3c6566caf
commit fddc58f624
3 changed files with 300 additions and 4 deletions
+42 -3
View File
@@ -288,6 +288,30 @@ describe('get_ledger_usage_stats', () => {
// Outside the window: must not count.
await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'OLD VENDOR', category: 'expense_other', date: '2026-01-05', expenseAccount: '4010', voucherNumber: 7 })
// Reverse-charge EU purchase (foreign SaaS): lines are expense (5420) +
// calc input VAT (2645) + calc output VAT (2614) + bank (1930), tying at
// equal counts. The dominant contra must be the expense 5420, NOT the low
// VAT number 2614 that the account_number tiebreak would otherwise pick
// (regression for 20260708110000; observed on prod as 2614).
let rcVoucher = 20
for (const d of ['2026-05-20', '2026-06-18']) {
const rcEntry = await insertDraftJournalEntry({
userId, companyId, fiscalPeriodId,
entryDate: d, status: 'posted',
voucherNumber: rcVoucher++, sourceType: 'bank_transaction',
})
await insertLines(rcEntry, [
{ account: '5420', debit: 500, credit: 0 },
{ account: '2645', debit: 125, credit: 0 },
{ account: '2614', debit: 0, credit: 125 },
{ account: '1930', debit: 0, credit: 500 },
])
await insertBookedTransaction({
companyId, userId, journalEntryId: rcEntry,
merchantName: 'GOOGLE WO', category: 'expense_software', date: d,
})
}
// Suppliers: Telia with 3 consistent invoices (one of them multi-line,
// which must not outvote), one credit note and one reversed invoice that
// must both be excluded; Blandat with a 1/2 split staying below any
@@ -327,12 +351,17 @@ describe('get_ledger_usage_stats', () => {
stats.account_usage.map((a) => [a.account_number, a]),
)
// 6 posted in-window bank entries each carry a 1930 line; the storno's
// 1930 line is excluded by source_type.
expect(byAccount['1930'].postings).toBe(6)
// 8 posted in-window bank entries each carry a 1930 line (6 simple + 2
// reverse-charge); the storno's 1930 line is excluded by source_type.
expect(byAccount['1930'].postings).toBe(8)
expect(byAccount['6570'].postings).toBe(4)
expect(byAccount['5810'].postings).toBe(2)
expect(byAccount['5810'].last_used).toBe('2026-06-20')
// The reverse-charge fixture's expense and VAT lines all appear in
// account_usage (which does not exclude 26xx: it answers "what accounts
// are used", not "what characterizes a counterparty").
expect(byAccount['5420'].postings).toBe(2)
expect(byAccount['2614'].postings).toBe(2)
// Neither the reversed original nor its storno may credit 4010 postings,
// and the draft line and out-of-window account are absent.
@@ -374,6 +403,16 @@ describe('get_ledger_usage_stats', () => {
).toBeUndefined()
})
it('picks the expense over a VAT contra for reverse-charge bookings', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const google = stats.counterparty_patterns.find((p) => p.counterparty_key === 'google')
expect(google).toBeDefined()
expect(google!.occurrences).toBe(2)
// 5420/2645/2614 tie at equal counts; excluding 26xx leaves the expense.
// Without the fix the account_number tiebreak would return 2614.
expect(google!.dominant_account_number).toBe('5420')
})
it('orders counterparties by occurrences descending', async () => {
const stats = await callRpc(companyId, '2026-04-01')
const occurrences = stats.counterparty_patterns.map((p) => p.occurrences)