diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx
index f43cf70b..0b4cc376 100644
--- a/components/dashboard/AttGoraSection.tsx
+++ b/components/dashboard/AttGoraSection.tsx
@@ -200,7 +200,11 @@ export default function AttGoraSection({
}
const showInboxDocuments = hasAi && counts.inbox_document > 0
- const bokforRows = counts.book_transaction > 0 || showInboxDocuments || matches.length > 0
+ const bokforRows =
+ counts.book_transaction > 0 ||
+ counts.book_skattekonto > 0 ||
+ showInboxDocuments ||
+ matches.length > 0
const granskaRows =
counts.supplier_invoice_approval > 0 ||
counts.verifikat_missing_document > 0 ||
@@ -277,6 +281,14 @@ export default function AttGoraSection({
count={counts.book_transaction}
/>
)}
+ {counts.book_skattekonto > 0 && (
+
+ )}
{matches.length > 0 && (
diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx
index b9ccbbc9..c7bc9541 100644
--- a/components/dashboard/DashboardNav.tsx
+++ b/components/dashboard/DashboardNav.tsx
@@ -509,9 +509,10 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
useEffect(() => {
if (!company?.id) return
- // Realtime keeps the badges live; a trailing debounce collapses event
- // bursts (bulk booking / bulk approvals emit one event per row) into a
- // single SWR revalidation instead of a request stampede.
+ // Realtime keeps the badges live (bank rows, skattekonto rows, staged
+ // operations); a trailing debounce collapses event bursts (bulk booking
+ // / bulk approvals emit one event per row) into a single SWR
+ // revalidation instead of a request stampede.
let debounce: ReturnType | null = null
const queueRefresh = () => {
if (debounce) clearTimeout(debounce)
@@ -530,6 +531,16 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
},
queueRefresh,
)
+ .on(
+ 'postgres_changes',
+ {
+ event: '*',
+ schema: 'public',
+ table: 'skattekonto_transactions',
+ filter: `company_id=eq.${company.id}`,
+ },
+ queueRefresh,
+ )
.on(
'postgres_changes',
{
diff --git a/lib/hooks/use-worklist-badges.ts b/lib/hooks/use-worklist-badges.ts
index 92de811f..2f1cb6a4 100644
--- a/lib/hooks/use-worklist-badges.ts
+++ b/lib/hooks/use-worklist-badges.ts
@@ -4,7 +4,12 @@ import useSWR from 'swr'
import { createClient } from '@/lib/supabase/client'
export interface WorklistBadges {
- /** Unbooked bank transactions: same predicate as lib/worklist countUnbookedTransactions. */
+ /**
+ * Rows waiting in the Transaktioner inbox: unbooked bank transactions
+ * (lib/worklist countUnbookedTransactions) plus unbooked skattekonto rows
+ * (countUnbookedSkattekontoRows). The badge sits on /transactions, which
+ * lists both, so the number must cover both (#2180).
+ */
uncategorized: number
/** Agent-staged operations awaiting review: same predicate as countPendingOperations. */
pendingOperations: number
@@ -26,13 +31,20 @@ export function useWorklistBadges(companyId: string | null | undefined) {
companyId ? ['worklist-badges', companyId] : null,
async ([, id]: [string, string]) => {
const supabase = createClient()
- const [tx, ops] = await Promise.all([
+ const [tx, skv, ops] = await Promise.all([
supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', id)
.is('is_business', null)
.eq('is_ignored', false),
+ supabase
+ .from('skattekonto_transactions')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', id)
+ .eq('status', 'booked')
+ .is('journal_entry_id', null)
+ .eq('is_ignored', false),
supabase
.from('pending_operations')
.select('id', { count: 'exact', head: true })
@@ -40,7 +52,7 @@ export function useWorklistBadges(companyId: string | null | undefined) {
.eq('status', 'pending'),
])
return {
- uncategorized: tx.error ? 0 : (tx.count ?? 0),
+ uncategorized: (tx.error ? 0 : (tx.count ?? 0)) + (skv.error ? 0 : (skv.count ?? 0)),
pendingOperations: ops.error ? 0 : (ops.count ?? 0),
}
},
diff --git a/lib/worklist/__tests__/aggregate.test.ts b/lib/worklist/__tests__/aggregate.test.ts
index ca3ce88f..ce4d5c12 100644
--- a/lib/worklist/__tests__/aggregate.test.ts
+++ b/lib/worklist/__tests__/aggregate.test.ts
@@ -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)
})
})
diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts
index 850b078f..89363cfa 100644
--- a/lib/worklist/__tests__/categories.test.ts
+++ b/lib/worklist/__tests__/categories.test.ts
@@ -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({
diff --git a/lib/worklist/aggregate.ts b/lib/worklist/aggregate.ts
index daddaa5d..31b1133c 100644
--- a/lib/worklist/aggregate.ts
+++ b/lib/worklist/aggregate.ts
@@ -9,6 +9,7 @@ import {
countReconciliationDue,
countSuggestedMatches,
countSupplierInvoicesAwaitingApproval,
+ countUnbookedSkattekontoRows,
countUnbookedTransactions,
countVerifikatMissingDocument,
} from './categories'
@@ -40,6 +41,7 @@ export async function getWorklistCounts(
): Promise {
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 +
diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts
index 984cf678..a1284af7 100644
--- a/lib/worklist/categories.ts
+++ b/lib/worklist/categories.ts
@@ -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 {
+ 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
diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts
index eb2c707f..44cf80e2 100644
--- a/lib/worklist/types.ts
+++ b/lib/worklist/types.ts
@@ -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
diff --git a/messages/en.json b/messages/en.json
index 48eda726..984db25c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6534,6 +6534,7 @@
"band_bevaka": "Monitor",
"row_book_transactions": "Transactions to record",
"row_book_transactions_stale": "{count} older than 14 days",
+ "row_book_skattekonto": "Tax account events to record",
"row_inbox_documents": "Documents to handle",
"row_inbox_documents_detail": "Match to a transaction or record directly",
"row_supplier_approval": "Supplier invoices to approve",
diff --git a/messages/sv.json b/messages/sv.json
index ff980177..08e80003 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -6534,6 +6534,7 @@
"band_bevaka": "Bevaka",
"row_book_transactions": "Bokföra transaktioner",
"row_book_transactions_stale": "{count} äldre än 14 dagar",
+ "row_book_skattekonto": "Bokföra skattekontohändelser",
"row_inbox_documents": "Underlag att hantera",
"row_inbox_documents_detail": "Matcha mot transaktion eller bokför direkt",
"row_supplier_approval": "Leverantörsfakturor att attestera",
diff --git a/supabase/migrations/20260903120000_skattekonto_transactions_realtime_publication.sql b/supabase/migrations/20260903120000_skattekonto_transactions_realtime_publication.sql
new file mode 100644
index 00000000..063555e6
--- /dev/null
+++ b/supabase/migrations/20260903120000_skattekonto_transactions_realtime_publication.sql
@@ -0,0 +1,27 @@
+-- Migration: stream skattekonto_transactions changes via Supabase realtime
+--
+-- The sidebar "Att göra" badge now counts unbooked skattekonto rows next to
+-- unbooked bank transactions (#2180), and DashboardNav listens to
+-- postgres_changes on public.skattekonto_transactions so a booked or
+-- ignored tax-account row drops the badge without a manual refresh, the
+-- same way public.transactions already does (20260629180000).
+--
+-- RLS already scopes skattekonto_transactions to the user's companies, so
+-- realtime only delivers rows the current user is allowed to read.
+--
+-- Idempotent so preview branches or partial re-applies do not fail if the
+-- publication already includes the table.
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1
+ FROM pg_publication_tables
+ WHERE pubname = 'supabase_realtime'
+ AND schemaname = 'public'
+ AND tablename = 'skattekonto_transactions'
+ ) THEN
+ ALTER PUBLICATION supabase_realtime ADD TABLE public.skattekonto_transactions;
+ END IF;
+END $$;
+
+NOTIFY pgrst, 'reload schema';