fix(worklist): count unbooked skattekonto rows in the Att göra badge and Hem list (#2180) (#2227)

The Transaktioner inbox lists unbooked skattekonto rows next to unbooked
bank rows, but the sidebar badge and the Hem "Att göra" list counted bank
rows only, so a migrated tax account waited in the inbox unseen.

- lib/worklist: new category book_skattekonto with the inbox's own
  predicate (status = 'booked', no verifikat, not ignored); aggregate
  includes it in counts and total.
- Hem: a "Bokföra skattekontohändelser" row under Bokför, deep-linking to
  /transactions?source=skatteverket.
- Sidebar badge hook: the same third head-count query, summed into the
  /transactions badge.
- DashboardNav subscribes to skattekonto_transactions realtime changes;
  migration adds the table to the supabase_realtime publication so a
  booked or ignored row drops the badge without a manual refresh.

Closes #2180


Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 17:17:41 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent aeff998b90
commit 1e91c126ff
11 changed files with 136 additions and 9 deletions
+4 -2
View File
@@ -3,6 +3,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('../categories', () => ({
countUnbookedTransactions: vi.fn().mockResolvedValue(4),
countUnbookedSkattekontoRows: vi.fn().mockResolvedValue(7),
countInboxDocuments: vi.fn().mockResolvedValue(6),
countSuggestedMatches: vi.fn().mockResolvedValue(2),
countSupplierInvoicesAwaitingApproval: vi.fn().mockResolvedValue(1),
@@ -26,6 +27,7 @@ describe('getWorklistCounts', () => {
const { counts } = await getWorklistCounts(supabase, 'company-1')
expect(counts).toEqual({
book_transaction: 4,
book_skattekonto: 7,
inbox_document: 6,
suggested_match: 2,
supplier_invoice_approval: 1,
@@ -49,7 +51,7 @@ describe('getWorklistCounts', () => {
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 + 1, without the 2 suggested matches.
expect(total).toBe(23)
// 4 + 7 + 6 + 1 + 3 + 5 + 1 + 2 + 1, without the 2 suggested matches.
expect(total).toBe(30)
})
})
+20
View File
@@ -9,6 +9,7 @@ import {
countReconciliationDue,
countSuggestedMatches,
countSupplierInvoicesAwaitingApproval,
countUnbookedSkattekontoRows,
countUnbookedTransactions,
countVerifikatMissingDocument,
listSuggestedMatches,
@@ -40,6 +41,25 @@ describe('countUnbookedTransactions', () => {
})
})
describe('countUnbookedSkattekontoRows', () => {
it('counts only settled, unbooked, non-ignored skattekonto rows', async () => {
enqueue({ count: 3 })
await expect(countUnbookedSkattekontoRows(supabase, COMPANY)).resolves.toBe(3)
expect(mockSupabase.from).toHaveBeenCalledWith('skattekonto_transactions')
// Same predicate as the Transaktioner inbox: Skatteverket status 'booked'
// (upcoming charges have nothing to book), no verifikat, not ignored.
const eqCalls = findCalls('skattekonto_transactions', 'eq')
expect(eqCalls).toContainEqual(['status', 'booked'])
expect(eqCalls).toContainEqual(['is_ignored', false])
expect(findCall('skattekonto_transactions', 'is')).toEqual(['journal_entry_id', null])
})
it('soft-fails to 0 on query error', async () => {
enqueue({ error: { message: 'boom' } })
await expect(countUnbookedSkattekontoRows(supabase, COMPANY)).resolves.toBe(0)
})
})
describe('countInboxDocuments', () => {
it('counts only items whose document is still unlinked', async () => {
enqueue({
+5
View File
@@ -9,6 +9,7 @@ import {
countReconciliationDue,
countSuggestedMatches,
countSupplierInvoicesAwaitingApproval,
countUnbookedSkattekontoRows,
countUnbookedTransactions,
countVerifikatMissingDocument,
} from './categories'
@@ -40,6 +41,7 @@ export async function getWorklistCounts(
): Promise<WorklistCounts> {
const [
bookTransaction,
bookSkattekonto,
inboxDocument,
suggestedMatch,
supplierInvoiceApproval,
@@ -50,6 +52,7 @@ export async function getWorklistCounts(
reconciliationDue,
] = await Promise.all([
countUnbookedTransactions(supabase, companyId),
countUnbookedSkattekontoRows(supabase, companyId),
countInboxDocuments(supabase, companyId),
options.suggestedMatches
? Promise.resolve(options.suggestedMatches).then((m) => m.length)
@@ -65,6 +68,7 @@ export async function getWorklistCounts(
return {
counts: {
book_transaction: bookTransaction,
book_skattekonto: bookSkattekonto,
inbox_document: inboxDocument,
suggested_match: suggestedMatch,
supplier_invoice_approval: supplierInvoiceApproval,
@@ -76,6 +80,7 @@ export async function getWorklistCounts(
},
total:
bookTransaction +
bookSkattekonto +
inboxDocument +
supplierInvoiceApproval +
verifikatMissingDocument +
+22
View File
@@ -67,6 +67,28 @@ export async function countUnbookedTransactions(
return count ?? 0
}
/**
* Unbooked skattekonto rows: every Skatteverket-side event the Transaktioner
* inbox lists (status = 'booked', i.e. "tidigare") that has no verifikat and
* was not ignored. `status` is Skatteverket's status, not booking status:
* 'upcoming' rows are future charges with nothing to book. journal_entry_id
* is the booked marker here (unlike bank transactions, which use is_business).
*/
export async function countUnbookedSkattekontoRows(
supabase: SupabaseClient,
companyId: string,
): Promise<number> {
const { count, error } = await supabase
.from('skattekonto_transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('status', 'booked')
.is('journal_entry_id', null)
.eq('is_ignored', false)
if (error) return logAndZero('book_skattekonto', companyId, error)
return count ?? 0
}
/**
* Unconsumed inbox documents. Mirrors /api/documents/inbox-available:
* items with a file that have not become a supplier invoice, a journal
+14
View File
@@ -24,6 +24,20 @@ export const WORKLIST_CATEGORIES = [
* keep journal_entry_id NULL (see lib/transactions/is-booked.ts).
*/
'book_transaction',
/**
* Unbooked skattekonto rows ("N st skattekontohändelser att bokföra").
* Pending: skattekonto_transactions with status = 'booked' (Skatteverket's
* "tidigare": the event has happened on the tax account),
* journal_entry_id IS NULL and is_ignored = false. Rows with
* status = 'upcoming' are future charges with nothing to book
* yet and never reach the Transaktioner inbox, so they are not
* pending work either.
* Done: the skattekonto booking flows set journal_entry_id (here it IS
* the booked marker, unlike bank transactions), or the user
* ignores the row (is_ignored = true). Same predicate as the
* Transaktioner inbox's Skatteverket rows.
*/
'book_skattekonto',
/**
* Unconsumed documents in the inbox ("N st underlag att hantera").
* Pending: invoice_inbox_items with a document and no