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:
co-authored by
Claude Opus 4.8
parent
cac692e293
commit
f59da07fc0
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user