feat(worklist): unified pending-work counts + vampire-transaction fix (#673)

* feat(worklist): unified pending-work counts in lib/worklist

One source of truth for every "Att göra" count, shared by the sidebar
badges, the home page (PR 3), and eventually the MCP list tools. Each
category documents its pending/done predicate in types.ts; counts are
cheap head-only queries that soft-fail to 0 so a broken badge can never
take down a layout. listSuggestedMatches() returns confirmable
transaction↔invoice match rows for the upcoming one-click confirm UI.

GET /api/worklist/counts exposes the aggregate for client refetch.

Partial index on transactions (company_id WHERE unbooked, not ignored)
— the badge predicate runs on every dashboard render — and a
company-scoped index on invoice_inbox_items, which was only indexed by
user_id from before the multi-tenant refactor.

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

* fix(transactions): unify unbooked predicate, add Ignorera action

The "vampire transactions" fix. Three surfaces counted "unbooked"
differently — the sidebar badge included ignored rows (is_business IS
NULL only), the transactions page count and inbox list disagreed with
each other — so old transactions kept haunting the inbox with inflated
badges after every sync. All three now share lib/worklist's canonical
predicate: is_business IS NULL AND is_ignored = false.

The transactions page also gains "Ignorera transaktionen…" in the
booking dialog (mirroring BankReconciliationView's flow: confirm →
ignore → Ångra toast), so non-business strays can finally be cleared
from the worklist without fabricating a verifikation. Recovery remains
on Rapporter → Bankavstämning under "Ignorerade transaktioner".

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

* fix(worklist): address PR #673 review — exact missing-underlag count, tenant-tagged logs

Review findings:
- countVerifikatMissingDocument subtracted set SIZES, which both let
  documents on non-document-requiring entries (e.g. VAT settlements)
  shrink the count and silently truncated at the PostgREST row cap on
  large document histories. Now an exact per-entry set difference over
  fetchAllRows-paginated id-only reads. (greptile, swedish-compliance)
- logAndZero gains companyId as a structured log field so repeated
  count failures can be correlated to a tenant. (compliance-swarm V16)

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

* fix(worklist): address PR #673 review round 2 — chunked .in(), BFL-aware ignore copy

- countInboxDocuments: dedupe + chunk the document-id .in() filter at 150
  ids per request — PostgREST serialises it into the GET query string, so
  a large inbox could exceed proxy URL limits (HTTP 414) and silently
  zero the badge via the error branch. (greptile)
- Ignorera confirm dialog now states the BFL boundary: only for
  non-affärshändelser (duplicates, own-account transfers) — real
  purchases and payments must be booked. Persisting a structured ignore
  reason is noted as follow-up. (swedish-compliance)
- New tests: chunked counting sums across batches; a mid-stream
  pagination error in countVerifikatMissingDocument throws (fetchAllRows
  propagates page errors) and soft-fails to a logged 0 — never a count
  computed from a silently truncated set. (swedish-compliance)

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-05 10:11:37 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cac692e293
commit f59da07fc0
10 changed files with 987 additions and 20 deletions
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('../categories', () => ({
countUnbookedTransactions: vi.fn().mockResolvedValue(4),
countInboxDocuments: vi.fn().mockResolvedValue(6),
countSuggestedMatches: vi.fn().mockResolvedValue(2),
countSupplierInvoicesAwaitingApproval: vi.fn().mockResolvedValue(1),
countVerifikatMissingDocument: vi.fn().mockResolvedValue(3),
countOverdueInvoices: vi.fn().mockResolvedValue(5),
countDeadlinesNeedingAction: vi.fn().mockResolvedValue(1),
countPendingOperations: vi.fn().mockResolvedValue(2),
}))
import { getWorklistCounts } from '../aggregate'
const supabase = {} as SupabaseClient
beforeEach(() => {
vi.clearAllMocks()
})
describe('getWorklistCounts', () => {
it('aggregates every category', async () => {
const { counts } = await getWorklistCounts(supabase, 'company-1')
expect(counts).toEqual({
book_transaction: 4,
inbox_document: 6,
suggested_match: 2,
supplier_invoice_approval: 1,
verifikat_missing_document: 3,
overdue_invoice: 5,
deadline_action: 1,
pending_operations: 2,
})
})
it('excludes suggested_match from the total (subset of book_transaction)', async () => {
const { total } = await getWorklistCounts(supabase, 'company-1')
// 4 + 6 + 1 + 3 + 5 + 1 + 2 — without the 2 suggested matches.
expect(total).toBe(22)
})
})
+241
View File
@@ -0,0 +1,241 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
countDeadlinesNeedingAction,
countInboxDocuments,
countOverdueInvoices,
countPendingOperations,
countSuggestedMatches,
countSupplierInvoicesAwaitingApproval,
countUnbookedTransactions,
countVerifikatMissingDocument,
listSuggestedMatches,
} from '../categories'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const supabase = mockSupabase as unknown as SupabaseClient
const COMPANY = 'company-1'
beforeEach(() => {
vi.clearAllMocks()
reset()
})
describe('countUnbookedTransactions', () => {
it('returns the head count from transactions', async () => {
enqueue({ count: 4 })
await expect(countUnbookedTransactions(supabase, COMPANY)).resolves.toBe(4)
expect(mockSupabase.from).toHaveBeenCalledWith('transactions')
})
it('soft-fails to 0 on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(countUnbookedTransactions(supabase, COMPANY)).resolves.toBe(0)
})
})
describe('countInboxDocuments', () => {
it('counts only items whose document is still unlinked', async () => {
enqueue({
data: [
{ id: 'i1', document_id: 'd1' },
{ id: 'i2', document_id: 'd2' },
{ id: 'i3', document_id: 'd3' },
],
})
enqueue({ count: 2 }) // one of the three docs is already linked elsewhere
await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(2)
expect(mockSupabase.from).toHaveBeenCalledWith('invoice_inbox_items')
expect(mockSupabase.from).toHaveBeenCalledWith('document_attachments')
})
it('returns 0 without a document query when no unconsumed items exist', async () => {
enqueue({ data: [] })
await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(0)
expect(mockSupabase.from).not.toHaveBeenCalledWith('document_attachments')
})
it('chunks the document id filter so large inboxes stay under URL limits', async () => {
// 200 deduped ids → two .in() chunks of 150 + 50, counts summed.
enqueue({
data: Array.from({ length: 200 }, (_, i) => ({
id: `item-${i}`,
document_id: `doc-${i}`,
})),
})
enqueue({ count: 140 })
enqueue({ count: 45 })
await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(185)
// 1 inbox query + 2 chunked document queries.
expect(mockSupabase.from).toHaveBeenCalledTimes(3)
})
it('soft-fails to 0 on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(countInboxDocuments(supabase, COMPANY)).resolves.toBe(0)
})
})
describe('countVerifikatMissingDocument', () => {
it('counts posted document-requiring entries with neither document nor exemption', async () => {
// 6 posted entries: je-1 documented+exempt, je-2 documented, je-3 exempt
// → je-4, je-5, je-6 missing.
enqueue({
data: [
{ id: 'je-1' },
{ id: 'je-2' },
{ id: 'je-3' },
{ id: 'je-4' },
{ id: 'je-5' },
{ id: 'je-6' },
],
})
enqueue({
data: [
{ journal_entry_id: 'je-1' },
{ journal_entry_id: 'je-1' }, // second doc on the same entry — still one entry
{ journal_entry_id: 'je-2' },
],
})
enqueue({
data: [{ journal_entry_id: 'je-1' }, { journal_entry_id: 'je-3' }],
})
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(3)
})
it('ignores documents attached to entries outside the document-requiring set', async () => {
// The doc on je-99 (e.g. a VAT-settlement entry) must not shrink the count.
enqueue({ data: [{ id: 'je-1' }] })
enqueue({ data: [{ journal_entry_id: 'je-99' }] })
enqueue({ data: [] })
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(1)
})
it('soft-fails to 0 when a paginated read errors', async () => {
enqueue({ error: { message: 'boom' } })
enqueue({ data: [] })
enqueue({ data: [] })
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0)
})
it('soft-fails to 0 (never a silent partial) when pagination errors mid-stream', async () => {
// First page of entries is full (1000 = fetchAllRows page size), so a
// second page is requested and errors. fetchAllRows must throw — the
// count drops to a logged 0 rather than computing from a truncated set.
enqueue({
data: Array.from({ length: 1000 }, (_, i) => ({ id: `je-${i}` })),
})
enqueue({ data: [] }) // document_attachments page 1
enqueue({ data: [] }) // exemptions page 1
enqueue({ error: { message: 'mid-stream failure' } }) // entries page 2
await expect(countVerifikatMissingDocument(supabase, COMPANY)).resolves.toBe(0)
})
})
describe('simple head counts', () => {
it.each([
['countSuggestedMatches', countSuggestedMatches, 'transactions'],
['countSupplierInvoicesAwaitingApproval', countSupplierInvoicesAwaitingApproval, 'supplier_invoices'],
['countOverdueInvoices', countOverdueInvoices, 'invoices'],
['countDeadlinesNeedingAction', countDeadlinesNeedingAction, 'deadlines'],
['countPendingOperations', countPendingOperations, 'pending_operations'],
] as const)('%s returns the count and targets the right table', async (_name, fn, table) => {
enqueue({ count: 3 })
await expect(fn(supabase, COMPANY)).resolves.toBe(3)
expect(mockSupabase.from).toHaveBeenCalledWith(table)
})
})
describe('listSuggestedMatches', () => {
it('maps invoice and supplier-invoice hints to confirmable rows', async () => {
enqueue({
data: [
{
id: 'tx-1',
date: '2026-06-01',
description: 'ICA BANKEN',
amount: 423,
currency: 'SEK',
potential_invoice_id: 'inv-1',
potential_supplier_invoice_id: null,
},
{
id: 'tx-2',
date: '2026-05-30',
description: 'TELIA',
amount: -549,
currency: 'SEK',
potential_invoice_id: null,
potential_supplier_invoice_id: 'sinv-1',
},
],
})
enqueue({
data: [
{ id: 'inv-1', invoice_number: 'F-2026-12', total: 423, customer: { name: 'Kund AB' } },
],
})
enqueue({
data: [
{
id: 'sinv-1',
supplier_invoice_number: 'TEL-99',
total: 549,
supplier: { name: 'Telia AB' },
},
],
})
const matches = await listSuggestedMatches(supabase, COMPANY)
expect(matches).toEqual([
{
transaction_id: 'tx-1',
transaction_date: '2026-06-01',
transaction_description: 'ICA BANKEN',
transaction_amount: 423,
transaction_currency: 'SEK',
kind: 'invoice',
candidate_id: 'inv-1',
candidate_number: 'F-2026-12',
counterparty_name: 'Kund AB',
candidate_total: 423,
},
{
transaction_id: 'tx-2',
transaction_date: '2026-05-30',
transaction_description: 'TELIA',
transaction_amount: -549,
transaction_currency: 'SEK',
kind: 'supplier_invoice',
candidate_id: 'sinv-1',
candidate_number: 'TEL-99',
counterparty_name: 'Telia AB',
candidate_total: 549,
},
])
})
it('drops rows whose hinted candidate no longer exists', async () => {
enqueue({
data: [
{
id: 'tx-1',
date: '2026-06-01',
description: 'X',
amount: 100,
currency: 'SEK',
potential_invoice_id: 'inv-gone',
potential_supplier_invoice_id: null,
},
],
})
enqueue({ data: [] }) // invoice lookup finds nothing (deleted candidate)
await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([])
})
it('returns [] on transaction query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(listSuggestedMatches(supabase, COMPANY)).resolves.toEqual([])
})
})