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",