From cbe55808868301261dd64bd17994400a0cbcd90e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 5 Sep 2026 15:57:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(expenses):=20utl=C3=A4gg=20as=20an=20answe?= =?UTF-8?q?r=20to=20"Vem=20betalade=3F"=20in=20Underlag,=20not=20a=20page?= =?UTF-8?q?=20(#2317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An out-of-pocket purchase differs from any other receipt only in the credit account, so the Underlag pane now asks one question for an unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and the deep link into the two-step wizard. The verifikat editor stays reachable below as the escape hatch (BFL 5 kap 6-7 §). The person owed surfaces in Att göra under a new Betala band, one row per person (lib/worklist expense_payout, counted in the total and exposed to agents through the attention resource). The Utlägg nav row is gated on existing claims, the same hybrid gate as Körjournal, since the entry point for a new utlägg is now the Underlag pane. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + app/(dashboard)/hem-sections.tsx | 19 +- app/(dashboard)/layout.tsx | 2 + components/dashboard/AttGoraSection.tsx | 46 ++- components/dashboard/DashboardNav.tsx | 17 +- .../general/InvoiceInboxWorkspace.tsx | 173 ++++++--- .../general/RegisterExpenseDialog.tsx | 339 ++++++++++++++++++ .../general/mcp-server/resources/attention.ts | 27 +- lib/dashboard/__tests__/nav-flags.test.ts | 45 ++- lib/dashboard/nav-flags.ts | 32 +- lib/worklist/__tests__/aggregate.test.ts | 17 +- lib/worklist/__tests__/categories.test.ts | 44 +++ lib/worklist/aggregate.ts | 17 +- lib/worklist/categories.ts | 74 +++- lib/worklist/types.ts | 29 ++ messages/en.json | 35 ++ messages/sv.json | 35 ++ 17 files changed, 866 insertions(+), 86 deletions(-) create mode 100644 components/extensions/general/RegisterExpenseDialog.tsx diff --git a/DECISIONS.md b/DECISIONS.md index e83e190c..5838a61f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1602,3 +1602,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-05] PR #2305 review: encrypt both OAuth handoff payload columns with AES-256-GCM using a purpose-scoped derivation of the existing server-only service-role secret, matching other extension credential storage. Authenticate the handoff token, consent, initiating user, destination origin and column as additional data; reject plaintext or unreadable payloads after atomic consume. This resolves the at-rest encryption finding without new configuration, dependencies, or edits to the already-applied migration. [2026-09-05] PR #2305 cleanup review: expire provider_otc rows through a service-role cron every five minutes, including abandoned states and encrypted handoffs whose consents remain. Use the existing cron-auth wrapper and generated hosted/self-hosted schedules; the migration is already applied on staging and remains unchanged. [2026-09-05] Supplier and customer pages: the register fills the row's own contact fields (e-mail, phone, postal address, VAT number) when they are empty or still carry what the register said last time, marked "från SCB" by equality with the registry fact, never a value a person typed. Chosen over a read-only fallback because the row is what payment files and documents use; provenance by equality instead of a source column because it needs no schema and a person's edit ends it by itself. Företagsuppgifter keeps only what the register alone knows (status line, industry, seat, size). Agents get the party read-only first: ?expand=party on v1 supplier/customer detail, party_id on list rows, and gnubok_get_party in MCP; the parties resource (suggest, promote, enrich) comes as its own v1 surface next. +[2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups. diff --git a/app/(dashboard)/hem-sections.tsx b/app/(dashboard)/hem-sections.tsx index 1192dd38..4461d652 100644 --- a/app/(dashboard)/hem-sections.tsx +++ b/app/(dashboard)/hem-sections.tsx @@ -3,7 +3,12 @@ import NewUserChecklist from '@/components/onboarding/NewUserChecklist' import AttGoraSection from '@/components/dashboard/AttGoraSection' import ResumePane from '@/components/dashboard/ResumePane' import { HemNotices } from '@/components/dashboard/HemNotices' -import { getWorklistCounts, listSuggestedMatches, SUGGESTED_MATCH_SCAN_CAP } from '@/lib/worklist' +import { + getWorklistCounts, + listExpensePayoutsDue, + listSuggestedMatches, + SUGGESTED_MATCH_SCAN_CAP, +} from '@/lib/worklist' import { listResumeItems } from '@/lib/worklist/resume' import { getCompanyNotices } from '@/lib/notices' import { expiringBankConnectionsFrom } from '@/lib/notices/categories' @@ -191,12 +196,19 @@ export async function HemPanesSection({ // the worklist count is the list's length (it used to scan the same rows // twice). Everything else runs in the same wave. const suggestedMatchesPromise = listSuggestedMatches(supabase, companyId, SUGGESTED_MATCH_SCAN_CAP) - const [worklist, suggestedMatches, resumeItems, bankConnectionsRes, postedEntries] = + // Same pattern for people owed for utlägg: Hem renders one row per person + // and the worklist count is the list's length. + const expensePayoutsPromise = listExpensePayoutsDue(supabase, companyId) + const [worklist, suggestedMatches, expensePayouts, resumeItems, bankConnectionsRes, postedEntries] = await Promise.all([ // Pending-work counts come from lib/worklist: the same source as the // sidebar badges, so the numbers can never diverge. - getWorklistCounts(supabase, companyId, { suggestedMatches: suggestedMatchesPromise }), + getWorklistCounts(supabase, companyId, { + suggestedMatches: suggestedMatchesPromise, + expensePayoutsDue: expensePayoutsPromise, + }), suggestedMatchesPromise, + expensePayoutsPromise, // In-progress work for the Fortsätt pane: pure draft-state derivation. listResumeItems(supabase, companyId, now), supabase.from('bank_connections').select('id, status, consent_expires, bank_name, last_sie_sweep').eq('company_id', companyId).eq('status', 'active'), @@ -229,6 +241,7 @@ export async function HemPanesSection({ { try { @@ -591,6 +592,7 @@ export default async function DashboardLayout({ salesOrdersEnabled={salesOrdersEnabled} hasWebshop={hasWebshop} hasMileage={hasMileage} + hasExpenseClaims={hasExpenseClaims} isSandbox={isSandbox} extensionNavItems={getExtensionNavItems()} userName={userProfile?.full_name ?? null} diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 170e90db..bff3d69d 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -21,6 +21,7 @@ import { ChevronRight, Eye, FileWarning, + HandCoins, Inbox, Landmark, Loader2, @@ -29,14 +30,15 @@ import { ShieldCheck, Stamp, } from 'lucide-react' -import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types' +import type { ExpensePayoutDue, SuggestedMatch, WorklistCounts } from '@/lib/worklist/types' /** * AttGoraSection: the dashboard's unified worklist ("Att göra"). * - * One flat ledger of everything actionable, grouped into three bands by - * session intent: Bokför (the daily loop), Granska & komplettera (close the - * gaps), Bevaka (time-driven). Every count comes from lib/worklist (the same + * One flat ledger of everything actionable, grouped into four bands by + * session intent: Bokför (the daily loop), Betala (money the company owes a + * person for utlägg), Granska & komplettera (close the gaps), Bevaka + * (time-driven). Every count comes from lib/worklist (the same * source as the sidebar badges) so the numbers can never disagree. * * Suggested transaction↔invoice matches render inline with one-click confirm: @@ -53,6 +55,8 @@ interface ExpiringBankConnection { interface AttGoraSectionProps { worklist: WorklistCounts suggestedMatches: SuggestedMatch[] + /** People owed for registered, unpaid utlägg: one Betala row each. */ + expensePayouts?: ExpensePayoutDue[] expiringBankConnections?: ExpiringBankConnection[] /** * True while the setup checklist is open and the company has zero posted @@ -114,6 +118,7 @@ function BandHeader({ children }: { children: React.ReactNode }) { export default function AttGoraSection({ worklist, suggestedMatches, + expensePayouts = [], expiringBankConnections = [], emptyLedger = false, hasActiveBankConnection = true, @@ -209,6 +214,7 @@ export default function AttGoraSection({ counts.book_skattekonto > 0 || showInboxDocuments || matches.length > 0 + const betalaRows = expensePayouts.length > 0 const granskaRows = counts.supplier_invoice_approval > 0 || counts.verifikat_missing_document > 0 || @@ -218,7 +224,7 @@ export default function AttGoraSection({ counts.deadline_action > 0 || counts.reconciliation_due > 0 || expiringBankConnections.length > 0 - const allClear = !bokforRows && !granskaRows && !bevakaRows + const allClear = !bokforRows && !betalaRows && !granskaRows && !bevakaRows // The header total must equal what the section actually shows, computed off // the same visibleWorklistTotal helper as the dashboard KPI tile so the two @@ -380,6 +386,36 @@ export default function AttGoraSection({ )} + {betalaRows && ( +
+ {t('band_betala')} +
+ {expensePayouts.map((p) => ( + + {formatCurrency(p.total_sek)} + + } + /> + ))} +
+
+ )} + {granskaRows && (
{t('band_granska')} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index c9d0eb6a..4ad83578 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -102,6 +102,10 @@ interface DashboardNavProps { // toggle OR existing mileage_trips rows (trips created via API/MCP must // stay reachable). Computed by the dashboard layout. hasMileage?: boolean + // Whether the Utlägg row shows: existing expense_claims rows. New utlägg + // start from the Underlag pane ("Vem betalade?"), so the page only earns a + // rail row once there is a person to pay out. Computed by the layout. + hasExpenseClaims?: boolean isSandbox?: boolean extensionNavItems?: ExtensionNavItem[] // Signed-in user's full name + email: drives the bottom-left account @@ -203,6 +207,9 @@ interface NavItem { // bookkeeping settings toggle (company_settings.mileage_enabled) or already // has trips. UI-visibility gate only; the page and APIs work regardless. requiresMileage?: boolean + // Utlägg row: visible only when the company already has expense claims + // (same "data stays reachable" gate as Körjournal). UI-visibility only. + requiresExpenses?: boolean // Paywall surfaces: hidden unless the active company holds this paid // capability. Cosmetic only, the page and API gates are the real // enforcement; this just keeps the sidebar honest for non-payers. @@ -246,10 +253,12 @@ const navItems: NavItem[] = [ // must still reach its already-imported orders (accounting underlag). { href: '/orders', labelKey: 'webshop_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true }, { href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' }, - // Utlägg: out-of-pocket purchases and their reimbursement batches. The + // Utlägg: out-of-pocket purchases and their reimbursement batches. Hidden + // until a claim exists: a receipt paid privately is registered from the + // Underlag pane, and the person to pay out surfaces in Att göra. The // /expenses route previously redirected to supplier invoices; the nav key // has existed in the nav namespace since then. - { href: '/expenses', labelKey: 'expenses', icon: Receipt, group: 'arbeta' }, + { href: '/expenses', labelKey: 'expenses', icon: Receipt, group: 'arbeta', requiresExpenses: true }, { href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true }, // Körjournal: hidden by default (most companies have no car); shows when // the settings toggle is on or trips already exist (hybrid gate, same @@ -347,7 +356,7 @@ const groupLabelKey: Record, string> = { skatt: 'group_tax', } -export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { +export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, hasExpenseClaims = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) { const pathname = usePathname() const router = useRouter() const supabase = useRealtimeSupabase() @@ -608,6 +617,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa // Körjournal is hidden until the company opts in via the bookkeeping // settings toggle (or trips already exist, e.g. created via MCP). if (item.requiresMileage && !hasMileage) return false + // Utlägg is hidden until a claim exists (registered from Underlag). + if (item.requiresExpenses && !hasExpenseClaims) return false // Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless // the active company holds the capability. The page + API gates enforce // the paywall; this keeps the sidebar from advertising a dead workspace. diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index ae29eaeb..bb887929 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -1,7 +1,6 @@ 'use client' import { useState, useCallback, useEffect, useRef, useMemo } from 'react' -import { useRouter } from 'next/navigation' import { useCompanySettings } from '@/lib/reference-data/hooks' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' @@ -74,6 +73,7 @@ import { type InboxKindFilter, } from '@/lib/documents/inbox-kind' import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog' +import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog' import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog' import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog' // InboxCustomDomainDialog (egen domän) is built but gated off: see @@ -431,7 +431,6 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const { toast } = useToast() - const router = useRouter() const t = useTranslations('inbox_workspace') const tStart = useTranslations('start_cards') const dismissKeyCompanyId = useCompanyOptional()?.company?.id ?? null @@ -494,6 +493,8 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { const [isRotating, setIsRotating] = useState(false) const [isDragging, setIsDragging] = useState(false) const [bookDirectOpen, setBookDirectOpen] = useState(false) + // "Vem betalade?" answered with a person: the utlägg confirm step. + const [registerExpensePayer, setRegisterExpensePayer] = useState(null) // Bulk-book selected underlag (Modell B): the "Bokför valda" selection-bar // action. The dialog filters the selection to bookable items itself. const [bulkBookOpen, setBulkBookOpen] = useState(false) @@ -2155,7 +2156,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { onDelete={() => handleDelete(selected.id)} onBookDirect={() => setBookDirectOpen(true)} onCreateSupplierInvoice={() => setCreateSupplierInvoiceOpen(true)} - onRegisterExpense={() => router.push(`/expenses?new=1&inbox_item=${selected.id}`)} + onRegisterExpense={(payer) => setRegisterExpensePayer(payer)} onMatchTransaction={() => setMatchPickerOpen(true)} onUnmatchTransaction={async () => { const targetId = selected.id @@ -2233,6 +2234,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) { }} /> )} + {selected && registerExpensePayer && ( + { + if (!next) setRegisterExpensePayer(null) + }} + item={selected} + payer={registerExpensePayer} + onSuccess={async () => { + await Promise.all([fetchItems(), handleSelect(selected.id)]) + }} + /> + )} {selected && ( void + accountingMethod: AccountingMethod +}) { + const t = useTranslations('inbox_workspace') + const helpKey = (choice: PayerChoice): string => + choice === 'unpaid' && accountingMethod === 'cash' ? 'payer_help_unpaid_cash' : `payer_help_${choice}` + return ( +
+

{t('payer_question')}

+
+ {PAYER_ORDER.map((choice) => { + const selected = choice === value + return ( + + ) + })} +
+
+ ) +} + // ── Fields rail ────────────────────────────────────────────── function FieldsRail({ @@ -3077,7 +3153,7 @@ function FieldsRail({ onDelete: () => void onBookDirect: () => void onCreateSupplierInvoice: () => void - onRegisterExpense: () => void + onRegisterExpense: (payer: ExpensePayer) => void onMatchTransaction: () => void onUnmatchTransaction: () => Promise onAskAssistant?: (transactionId: string) => void @@ -3135,6 +3211,17 @@ function FieldsRail({ setFieldsExpanded(false) }, [item.id]) const t = useTranslations('inbox_workspace') + // "Vem betalade?": the one question that decides how an unmatched underlag + // is booked. Company money is matched against the bank line; a person's + // money books cost + moms against that person's liability account and puts + // them in Att göra; "ingen ännu" is an unpaid supplier invoice (2440). A + // supplier invoice defaults to unpaid, a receipt to paid by the company. + const [payer, setPayer] = useState(() => + resolvedKind === 'supplier_invoice' ? 'unpaid' : 'company', + ) + useEffect(() => { + setPayer(resolvedKind === 'supplier_invoice' ? 'unpaid' : 'company') + }, [item.id, resolvedKind]) // WhatsApp chat context: verified human answers captured by the intake bot // (photo caption, representation deltagare + syfte, sender note). Rendered @@ -3593,61 +3680,31 @@ function FieldsRail({ ) : ( <> - {/* Unmatched state: the canonical next step is to find the bank - transaction this underlag belongs to. Two escape hatches sit - below it: "Skapa leverantörsfaktura" for users who want - supplier-invoice tracking (accrual flow), and "Bokför som - verifikat" for underlag that aren't a supplier invoice at all - (bank fees, owner expenses, the underlag for a correction). The - latter opens the same BookDirectlyDialog as the matched state, - which works without a bank transaction and lets the user attach - one if they want. Per BFL 5 kap 6-7 § the underlag must be - bookable as a verifikat, not forced into a supplier invoice. */} - + ) : payer === 'unpaid' ? ( + + ) : ( + + )} + - - - - - - - Skapa leverantörsfaktura - - För leverantörsskulder du vill följa (periodisering). - - - - Registrera som utlägg - - För köp du eller en anställd betalat privat. - - - - Bokför som verifikat - - För underlag som inte är en leverantörsfaktura (bankavgift, utlägg). - - - - + {t('payer_open_editor')} + )} + + + + + ) +} diff --git a/extensions/general/mcp-server/resources/attention.ts b/extensions/general/mcp-server/resources/attention.ts index 2d68e6b2..08f58945 100644 --- a/extensions/general/mcp-server/resources/attention.ts +++ b/extensions/general/mcp-server/resources/attention.ts @@ -4,7 +4,7 @@ import { fetchUnlinkedDocuments, UNLINKED_DOCUMENT_SCAN_CAP, } from '@/lib/documents/unlinked-documents' -import { countReconciliationDue } from '@/lib/worklist/categories' +import { countReconciliationDue, listExpensePayoutsDue } from '@/lib/worklist/categories' import { fetchJunctionLinkedTxIds } from '@/lib/reconciliation/bank-reconciliation' import { fetchAllRows } from '@/lib/supabase/fetch-all' @@ -419,6 +419,31 @@ export const attentionResource: McpResource = { }) } + // ── People owed for unpaid utlägg ─────────────────────────────── + // Same predicate as the Att göra Betala band (lib/worklist + // listExpensePayoutsDue): one item per person, not per receipt. + const expensePayouts = await listExpensePayoutsDue(supabase, companyId) + if (expensePayouts.length > 0) { + categories.push({ + key: 'expense_payout', + label_sv: 'Personer med utlägg att betala ut', + severity: 'info', + count: expensePayouts.length, + samples: expensePayouts.slice(0, SAMPLE_LIMIT).map((p) => ({ + claimant_name: p.claimant_name, + employee_id: p.employee_id, + liability_account: p.liability_account, + claim_count: p.claim_count, + total_sek: p.total_sek, + oldest_expense_date: p.oldest_expense_date, + })), + next: { + description: + 'Betala ut från företagskontot och bokför utbetalningen (2893/2820 D mot 19xx K) via /expenses eller POST /api/expense-claims/payouts.', + }, + }) + } + // ── Period lock approaching ───────────────────────────────────── const lockDate = companySettingsRow.data?.bookkeeping_locked_through ?? null if (lockDate && activePeriodRow.data) { diff --git a/lib/dashboard/__tests__/nav-flags.test.ts b/lib/dashboard/__tests__/nav-flags.test.ts index e47bf6c0..e55c87a7 100644 --- a/lib/dashboard/__tests__/nav-flags.test.ts +++ b/lib/dashboard/__tests__/nav-flags.test.ts @@ -23,22 +23,49 @@ function makeSupabase( } describe('getDashboardNavFlags', () => { - it('reads both flags from the RPC row and never touches the tables', async () => { + it('reads both flags from the RPC row and only probes expense_claims beside it', async () => { const { supabase, from, rpc } = makeSupabase({ data: [{ has_webshop: true, has_mileage_trips: false }] }) - expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: true, hasMileageTrips: false }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ + hasWebshop: true, + hasMileageTrips: false, + hasExpenseClaims: false, + }) expect(rpc).toHaveBeenCalledWith('get_dashboard_nav_flags', { p_company_id: 'c1' }) - expect(from).not.toHaveBeenCalled() + // The Utlägg row is gated on existing claims (not part of the RPC): one + // limit-1 probe in the same wave, never the webshop/mileage tables. + expect(from.mock.calls.map((c) => c[0])).toEqual(['expense_claims']) }) it('accepts a single-object payload and treats null flags as false', async () => { const { supabase } = makeSupabase({ data: { has_webshop: null, has_mileage_trips: true } }) - expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: false, hasMileageTrips: true }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ + hasWebshop: false, + hasMileageTrips: true, + hasExpenseClaims: false, + }) + }) + + it('shows the Utlägg row once a claim exists', async () => { + const { supabase } = makeSupabase( + { data: [{ has_webshop: false, has_mileage_trips: false }] }, + { expense_claims: [{ id: 'ec1' }] }, + ) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ + hasWebshop: false, + hasMileageTrips: false, + hasExpenseClaims: true, + }) }) it.each(['PGRST202', '42883', '42501'])('falls back to the four probes when the RPC is unavailable (%s)', async (code) => { const { supabase, from } = makeSupabase({ error: { code } }, { webshop_orders: [{ id: 'o1' }] }) - expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: true, hasMileageTrips: false }) + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ + hasWebshop: true, + hasMileageTrips: false, + hasExpenseClaims: false, + }) expect(from.mock.calls.map((c) => c[0]).sort()).toEqual([ + 'expense_claims', 'mileage_trips', 'shopify_connections', 'webshop_orders', @@ -48,7 +75,11 @@ describe('getDashboardNavFlags', () => { it('degrades to hidden rows on any other error instead of probing', async () => { const { supabase, from } = makeSupabase({ error: { code: '57014', message: 'timeout' } }) - expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ hasWebshop: false, hasMileageTrips: false }) - expect(from).not.toHaveBeenCalled() + expect(await getDashboardNavFlags(supabase, 'c1')).toEqual({ + hasWebshop: false, + hasMileageTrips: false, + hasExpenseClaims: false, + }) + expect(from.mock.calls.map((c) => c[0])).toEqual(['expense_claims']) }) }) diff --git a/lib/dashboard/nav-flags.ts b/lib/dashboard/nav-flags.ts index f2638f37..1d1da82e 100644 --- a/lib/dashboard/nav-flags.ts +++ b/lib/dashboard/nav-flags.ts @@ -5,6 +5,13 @@ export interface DashboardNavFlags { hasWebshop: boolean /** Existing mileage trips (created via UI, API or MCP). */ hasMileageTrips: boolean + /** + * Existing expense claims (utlägg). Gates the Utlägg nav row the same way + * trips gate Körjournal: the entry point for a new utlägg is the Underlag + * pane ("Vem betalade?"), so the page only earns a rail row once there is + * something on it (a person to pay out). + */ + hasExpenseClaims: boolean } const FALLBACK_CODES = new Set(['PGRST202', '42883', '42501']) @@ -24,7 +31,13 @@ export async function getDashboardNavFlags( supabase: SupabaseClient, companyId: string, ): Promise { - const rpc = await supabase.rpc('get_dashboard_nav_flags', { p_company_id: companyId }) + // The expense probe runs beside the RPC rather than inside it: extending + // get_dashboard_nav_flags would need a migration for one limit-1 read, and + // the two waves overlap so the layout pays no extra round trip. + const [rpc, expenseClaims] = await Promise.all([ + supabase.rpc('get_dashboard_nav_flags', { p_company_id: companyId }), + probeExpenseClaims(supabase, companyId), + ]) if (!rpc.error) { const row = (Array.isArray(rpc.data) ? rpc.data[0] : rpc.data) as | { has_webshop?: boolean | null; has_mileage_trips?: boolean | null } @@ -33,19 +46,30 @@ export async function getDashboardNavFlags( return { hasWebshop: row?.has_webshop === true, hasMileageTrips: row?.has_mileage_trips === true, + hasExpenseClaims: expenseClaims, } } if (!FALLBACK_CODES.has(rpc.error.code ?? '')) { - return { hasWebshop: false, hasMileageTrips: false } + return { hasWebshop: false, hasMileageTrips: false, hasExpenseClaims: expenseClaims } } - return getDashboardNavFlagsViaProbes(supabase, companyId) + return { ...(await getDashboardNavFlagsViaProbes(supabase, companyId)), hasExpenseClaims: expenseClaims } +} + +async function probeExpenseClaims(supabase: SupabaseClient, companyId: string): Promise { + const { data, error } = await supabase + .from('expense_claims') + .select('id') + .eq('company_id', companyId) + .limit(1) + // A failed probe hides the row; the page and API work regardless. + return !error && (data?.length ?? 0) > 0 } /** The pre-RPC implementation, kept verbatim as the fallback. */ export async function getDashboardNavFlagsViaProbes( supabase: SupabaseClient, companyId: string, -): Promise { +): Promise> { const [woo, shopify, orders, trips] = await Promise.all([ supabase.from('woocommerce_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), supabase.from('shopify_connections').select('id').eq('company_id', companyId).eq('status', 'active').limit(1), diff --git a/lib/worklist/__tests__/aggregate.test.ts b/lib/worklist/__tests__/aggregate.test.ts index ce4d5c12..10a65ddf 100644 --- a/lib/worklist/__tests__/aggregate.test.ts +++ b/lib/worklist/__tests__/aggregate.test.ts @@ -12,6 +12,7 @@ vi.mock('../categories', () => ({ countDeadlinesNeedingAction: vi.fn().mockResolvedValue(1), countPendingOperations: vi.fn().mockResolvedValue(2), countReconciliationDue: vi.fn().mockResolvedValue(1), + countExpensePayoutsDue: vi.fn().mockResolvedValue(2), })) import { getWorklistCounts } from '../aggregate' @@ -36,6 +37,7 @@ describe('getWorklistCounts', () => { deadline_action: 1, pending_operations: 2, reconciliation_due: 1, + expense_payout: 2, }) }) @@ -49,9 +51,20 @@ describe('getWorklistCounts', () => { expect(countSuggestedMatches).not.toHaveBeenCalled() }) + it('takes the expense-payout count from a caller-supplied list instead of rescanning', async () => { + const { countExpensePayoutsDue } = await import('../categories') + const people = [{ key: 'owner:Anna' }, { key: 'emp-1' }, { key: 'emp-2' }] as never[] + const { counts } = await getWorklistCounts(supabase, 'company-1', { + expensePayoutsDue: Promise.resolve(people), + }) + expect(counts.expense_payout).toBe(3) + expect(countExpensePayoutsDue).not.toHaveBeenCalled() + }) + it('excludes suggested_match from the total (subset of book_transaction)', async () => { const { total } = await getWorklistCounts(supabase, 'company-1') - // 4 + 7 + 6 + 1 + 3 + 5 + 1 + 2 + 1, without the 2 suggested matches. - expect(total).toBe(30) + // 4 + 7 + 6 + 1 + 3 + 5 + 1 + 2 + 1 + 2 (people owed for utlägg), without + // the 2 suggested matches. + expect(total).toBe(32) }) }) diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts index 1ddebce1..d8ad11fd 100644 --- a/lib/worklist/__tests__/categories.test.ts +++ b/lib/worklist/__tests__/categories.test.ts @@ -12,6 +12,7 @@ import { countUnbookedSkattekontoRows, countUnbookedTransactions, countVerifikatMissingDocument, + listExpensePayoutsDue, listSuggestedMatches, } from '../categories' import { @@ -539,3 +540,46 @@ describe('countReconciliationDue', () => { await expect(countReconciliationDue(supabase, COMPANY, TODAY)).resolves.toBe(0) }) }) + +describe('listExpensePayoutsDue', () => { + it('groups registered claims into one item per person, oldest debt first', async () => { + enqueue({ + data: [ + { employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: '1240.00', expense_date: '2026-09-03' }, + { employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', amount_sek: 1196, expense_date: '2026-09-02' }, + { employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', amount_sek: 400, expense_date: '2026-09-06' }, + // Same owner name twice: one person, one transfer. + { employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: 0.1, expense_date: '2026-09-07' }, + ], + }) + const people = await listExpensePayoutsDue(supabase, COMPANY) + expect(mockSupabase.from).toHaveBeenCalledWith('expense_claims') + expect(findCalls('expense_claims', 'eq')).toContainEqual(['status', 'registered']) + expect(people).toEqual([ + { + key: 'emp-1', + employee_id: 'emp-1', + claimant_name: 'Anna Berg', + liability_account: '2820', + claim_count: 2, + total_sek: 1596, + oldest_expense_date: '2026-09-02', + }, + { + key: 'owner:Jakob', + employee_id: null, + claimant_name: 'Jakob', + liability_account: '2893', + claim_count: 2, + // 1240 + 0.1 in öre-safe arithmetic, never 1240.1000000000001. + total_sek: 1240.1, + oldest_expense_date: '2026-09-03', + }, + ]) + }) + + it('soft-fails to an empty list on query error', async () => { + enqueue({ error: { message: 'boom' } }) + await expect(listExpensePayoutsDue(supabase, COMPANY)).resolves.toEqual([]) + }) +}) diff --git a/lib/worklist/aggregate.ts b/lib/worklist/aggregate.ts index 31b1133c..6ea58bf9 100644 --- a/lib/worklist/aggregate.ts +++ b/lib/worklist/aggregate.ts @@ -1,8 +1,9 @@ import type { SupabaseClient } from '@supabase/supabase-js' -import type { SuggestedMatch } from './types' +import type { ExpensePayoutDue, SuggestedMatch } from './types' import type { WorklistCounts } from './types' import { countDeadlinesNeedingAction, + countExpensePayoutsDue, countInboxDocuments, countOverdueInvoices, countPendingOperations, @@ -32,6 +33,12 @@ export interface GetWorklistCountsOptions { * parallel with the other counts. */ suggestedMatches?: SuggestedMatch[] | Promise + /** + * People owed for unpaid utlägg the caller is already fetching (Hem + * renders one row per person): the count is the list's length instead of + * a second scan of expense_claims. + */ + expensePayoutsDue?: ExpensePayoutDue[] | Promise } export async function getWorklistCounts( @@ -50,6 +57,7 @@ export async function getWorklistCounts( deadlineAction, pendingOperations, reconciliationDue, + expensePayout, ] = await Promise.all([ countUnbookedTransactions(supabase, companyId), countUnbookedSkattekontoRows(supabase, companyId), @@ -63,6 +71,9 @@ export async function getWorklistCounts( countDeadlinesNeedingAction(supabase, companyId), countPendingOperations(supabase, companyId), countReconciliationDue(supabase, companyId), + options.expensePayoutsDue + ? Promise.resolve(options.expensePayoutsDue).then((p) => p.length) + : countExpensePayoutsDue(supabase, companyId), ]) return { @@ -77,6 +88,7 @@ export async function getWorklistCounts( deadline_action: deadlineAction, pending_operations: pendingOperations, reconciliation_due: reconciliationDue, + expense_payout: expensePayout, }, total: bookTransaction + @@ -87,6 +99,7 @@ export async function getWorklistCounts( overdueInvoice + deadlineAction + pendingOperations + - reconciliationDue, + reconciliationDue + + expensePayout, } } diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index 7379a655..5b6480d0 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -11,11 +11,12 @@ import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching' import type { SupabaseClient } from '@supabase/supabase-js' import { createLogger } from '@/lib/logger' +import { roundOre } from '@/lib/money' import { MATCHABLE_INVOICE_STATUSES, MATCHABLE_SUPPLIER_INVOICE_STATUSES, } from '@/lib/invoices/matchable-statuses' -import type { SuggestedMatch } from './types' +import type { ExpensePayoutDue, SuggestedMatch } from './types' // Canonical home is lib/worklist/types.ts (dependency-free, client-safe); // re-exported here so existing server-side imports keep working. @@ -562,3 +563,74 @@ export async function countReconciliationDue( return keys.filter((k) => !coveredKeys.has(k)).length } + +/** + * Upper bound on registered-claim rows scanned per company. Claims are + * marked paid in batches, so a backlog beyond this is pathological; the + * list clamps rather than paginating on every home render. + */ +export const EXPENSE_PAYOUT_SCAN_CAP = 500 + +/** + * People owed for registered, unpaid utlägg, newest debt last. The canonical + * "att betala ut" predicate: expense_claims.status = 'registered'. Grouped + * here (not in SQL) because the owner has no employee row: two owner claims + * with the same claimant_name are one person, one transfer. + */ +export async function listExpensePayoutsDue( + supabase: SupabaseClient, + companyId: string, +): Promise { + const { data, error } = await supabase + .from('expense_claims') + .select('employee_id, claimant_name, liability_account, amount_sek, expense_date') + .eq('company_id', companyId) + .eq('status', 'registered') + .order('expense_date', { ascending: true }) + .limit(EXPENSE_PAYOUT_SCAN_CAP) + if (error) { + logAndZero('expense_payout', companyId, error) + return [] + } + const byPerson = new Map() + for (const row of (data ?? []) as Array<{ + employee_id: string | null + claimant_name: string + liability_account: string + amount_sek: number | string + expense_date: string + }>) { + const key = row.employee_id ?? `owner:${row.claimant_name}` + const amount = Number(row.amount_sek) || 0 + const existing = byPerson.get(key) + if (existing) { + existing.claim_count += 1 + existing.total_sek = roundOre(existing.total_sek + amount) + if (row.expense_date < existing.oldest_expense_date) { + existing.oldest_expense_date = row.expense_date + } + } else { + byPerson.set(key, { + key, + employee_id: row.employee_id, + claimant_name: row.claimant_name, + liability_account: row.liability_account, + claim_count: 1, + total_sek: roundOre(amount), + oldest_expense_date: row.expense_date, + }) + } + } + // Oldest debt first: the person who has waited longest tops the list. + return [...byPerson.values()].sort((a, b) => + a.oldest_expense_date < b.oldest_expense_date ? -1 : a.oldest_expense_date > b.oldest_expense_date ? 1 : 0, + ) +} + +/** Number of people owed for unpaid utlägg (see listExpensePayoutsDue). */ +export async function countExpensePayoutsDue( + supabase: SupabaseClient, + companyId: string, +): Promise { + return (await listExpensePayoutsDue(supabase, companyId)).length +} diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts index 5224e35a..95bd8828 100644 --- a/lib/worklist/types.ts +++ b/lib/worklist/types.ts @@ -105,10 +105,39 @@ export const WORKLIST_CATEGORIES = [ * reconcile monthly, not a new chore for everyone. */ 'reconciliation_due', + /** + * People the company owes for out-of-pocket purchases ("Betala ut utlägg + * till Anna"), one item per person. + * Pending: expense_claims.status = 'registered' (booked as cost against a + * person-liability account 2893/2820/2018, nothing paid out yet), + * grouped by employee_id, or by claimant_name for the owner. + * Done: every claim of that person is marked 'paid' (a payout batch + * posted the 1930 leg), or the claim is deleted (storno). + * Counts PEOPLE, not receipts: the action is one transfer per person. + */ + 'expense_payout', ] as const export type WorklistCategory = (typeof WORKLIST_CATEGORIES)[number] +/** + * One person the company owes for registered, unpaid utlägg: the Att göra + * row "Betala ut utlägg till {name}". Grouped server-side by employee_id + * (or claimant_name for the owner, who has no employee row). + */ +export interface ExpensePayoutDue { + /** employee_id, or `owner:` for claims without one. */ + key: string + employee_id: string | null + claimant_name: string + /** 2893 (AB owner), 2018 (EF owner) or 2820 (employee). */ + liability_account: string + claim_count: number + total_sek: number + /** ISO date of the oldest unpaid claim. */ + oldest_expense_date: string +} + export interface WorklistCounts { counts: Record /** diff --git a/messages/en.json b/messages/en.json index afd3e851..1b3d24e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3302,6 +3302,37 @@ "mixed_currency_note": "Documents in different currencies cannot be summed into a single amount. Each document is still booked separately, against its matched bank transaction and the amount the bank actually settled in SEK." }, "inbox_workspace": { + "payer_question": "Who paid?", + "payer_company": "The company", + "payer_help_company": "Card or bank account. Matched against the transaction when it appears.", + "payer_owner": "Me, privately", + "payer_help_owner": "The company owes you the amount. Paid out from the company account later.", + "payer_employee": "An employee", + "payer_help_employee": "The company owes the person the amount (2820). Paid out later.", + "payer_unpaid": "No one yet", + "payer_help_unpaid": "Unpaid invoice. Booked as a supplier liability with a due date.", + "payer_help_unpaid_cash": "Unpaid invoice. Registered as a supplier invoice and booked when the payment shows on the account.", + "payer_book_expense": "Book the expense", + "payer_open_editor": "Open in the voucher editor", + "expense_dialog_title": "Book the expense", + "expense_dialog_help_owner": "Cost and VAT are booked now. The company owes you the amount on account {account} until it is paid out.", + "expense_dialog_help_employee": "Cost and VAT are booked now. The company owes the employee the amount on account 2820 until it is paid out.", + "expense_owner_name": "Your name", + "expense_employee": "Employee", + "expense_pick_employee": "Choose a person", + "expense_no_employees": "No employees registered", + "expense_description": "Description", + "expense_date": "Date", + "expense_amount": "Amount ({currency})", + "expense_vat": "VAT", + "expense_account": "Expense account", + "expense_outcome_att_gora": "Lands in To do: Pay out expenses to {name}.", + "expense_fx_note": "Booked in SEK at the Riksbank rate for the date.", + "expense_cancel": "Cancel", + "expense_confirm": "Book", + "expense_booked_title": "Expense booked", + "expense_booked_description": "The debt to {name} is now in To do.", + "expense_failed_title": "Could not book the expense", "hunt_stop": "Stop", "hunt_reading": "Reading {mailboxes}", "hunt_progress": "pass {pass} · {found} fetched", @@ -6640,6 +6671,10 @@ "dismiss": "Hide" }, "dashboard": { + "band_betala": "Pay", + "row_expense_payout": "Pay out expenses to {name}", + "row_expense_payout_detail_one": "1 receipt · {date}", + "row_expense_payout_detail_other": "{count} receipts · oldest {date}", "skv_promo_title": "Connect Skatteverket", "skv_promo_description": "See your tax account and file VAT and employer declarations directly from here. Connect with BankID in a couple of minutes.", "skv_promo_cta": "Connect", diff --git a/messages/sv.json b/messages/sv.json index f49e277b..fb17e486 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -3302,6 +3302,37 @@ "mixed_currency_note": "Underlag i olika valutor kan inte summeras till ett belopp. Varje underlag bokförs ändå var för sig, mot sin matchade banktransaktion och det belopp banken faktiskt drog i SEK." }, "inbox_workspace": { + "payer_question": "Vem betalade?", + "payer_company": "Företaget", + "payer_help_company": "Kort eller bankkonto. Matchas mot transaktionen när den syns.", + "payer_owner": "Jag, privat", + "payer_help_owner": "Bolaget blir skyldigt dig beloppet. Betalas ut från företagskontot senare.", + "payer_employee": "En anställd", + "payer_help_employee": "Bolaget blir skyldigt personen beloppet (2820). Betalas ut senare.", + "payer_unpaid": "Ingen ännu", + "payer_help_unpaid": "Obetald faktura. Bokförs som leverantörsskuld med förfallodatum.", + "payer_help_unpaid_cash": "Obetald faktura. Registreras som leverantörsfaktura och bokförs när betalningen syns på kontot.", + "payer_book_expense": "Bokför utlägget", + "payer_open_editor": "Öppna i verifikatredigeraren", + "expense_dialog_title": "Bokför utlägget", + "expense_dialog_help_owner": "Kostnad och moms bokförs nu. Bolaget blir skyldigt dig beloppet på konto {account} tills det betalas ut.", + "expense_dialog_help_employee": "Kostnad och moms bokförs nu. Bolaget blir skyldigt den anställda beloppet på konto 2820 tills det betalas ut.", + "expense_owner_name": "Ditt namn", + "expense_employee": "Anställd", + "expense_pick_employee": "Välj person", + "expense_no_employees": "Inga anställda registrerade", + "expense_description": "Beskrivning", + "expense_date": "Datum", + "expense_amount": "Belopp ({currency})", + "expense_vat": "Moms", + "expense_account": "Kostnadskonto", + "expense_outcome_att_gora": "Hamnar i Att göra: Betala ut utlägg till {name}.", + "expense_fx_note": "Bokförs i SEK med Riksbankens kurs för datumet.", + "expense_cancel": "Avbryt", + "expense_confirm": "Bokför", + "expense_booked_title": "Utlägget är bokfört", + "expense_booked_description": "Skulden till {name} finns nu i Att göra.", + "expense_failed_title": "Kunde inte bokföra utlägget", "hunt_stop": "Avbryt", "hunt_reading": "Läser {mailboxes}", "hunt_progress": "omgång {pass} · {found} hämtade", @@ -6640,6 +6671,10 @@ "dismiss": "Dölj" }, "dashboard": { + "band_betala": "Betala", + "row_expense_payout": "Betala ut utlägg till {name}", + "row_expense_payout_detail_one": "1 kvitto · {date}", + "row_expense_payout_detail_other": "{count} kvitton · äldsta {date}", "skv_promo_title": "Koppla Skatteverket", "skv_promo_description": "Se skattekontot och lämna moms- och arbetsgivardeklarationer direkt härifrån. Anslut med BankID på ett par minuter.", "skv_promo_cta": "Anslut",