feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)

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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-05 15:57:14 +02:00
committed by GitHub
parent bff44e5757
commit cbe5580886
17 changed files with 866 additions and 86 deletions
+1
View File
@@ -1602,3 +1602,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
+16 -3
View File
@@ -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({
<AttGoraSection
worklist={worklist}
suggestedMatches={suggestedMatches.slice(0, 5)}
expensePayouts={expensePayouts}
expiringBankConnections={expiringBankConnections}
emptyLedger={emptyLedger}
hasActiveBankConnection={hasActiveBankConnection}
+2
View File
@@ -301,6 +301,7 @@ export default async function DashboardLayout({
])
const hasWebshop = navFlags.hasWebshop
const hasMileageTrips = navFlags.hasMileageTrips
const hasExpenseClaims = navFlags.hasExpenseClaims
const canonicalDomain = (() => {
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}
+41 -5
View File
@@ -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({
</div>
)}
{betalaRows && (
<div>
<BandHeader>{t('band_betala')}</BandHeader>
<div>
{expensePayouts.map((p) => (
<WorklistRow
key={p.key}
href="/expenses"
icon={HandCoins}
label={t('row_expense_payout', { name: p.claimant_name })}
detail={
p.claim_count === 1
? t('row_expense_payout_detail_one', { date: formatDate(p.oldest_expense_date) })
: t('row_expense_payout_detail_other', {
count: p.claim_count,
date: formatDate(p.oldest_expense_date),
})
}
count={p.claim_count}
badge={
<span className="text-xs tabular-nums text-muted-foreground">
{formatCurrency(p.total_sek)}
</span>
}
/>
))}
</div>
</div>
)}
{granskaRows && (
<div>
<BandHeader>{t('band_granska')}</BandHeader>
+14 -3
View File
@@ -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<Exclude<GroupKey, 'top'>, 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.
@@ -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<ExpensePayer | null>(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 && (
<RegisterExpenseDialog
open
onOpenChange={(next) => {
if (!next) setRegisterExpensePayer(null)
}}
item={selected}
payer={registerExpensePayer}
onSuccess={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
}}
/>
)}
{selected && (
<NewSupplierInvoiceDialog
open={createSupplierInvoiceOpen}
@@ -3053,6 +3067,68 @@ function ProposedBooking({
)
}
// ── Vem betalade? ────────────────────────────────────────────
/**
* How an unmatched underlag gets booked, phrased as who paid for it.
* 'company' → match the bank line; 'unpaid' → supplier invoice (2440);
* 'owner' / 'employee' → utlägg against the person's liability account.
*/
export type PayerChoice = 'company' | 'unpaid' | ExpensePayer
const PAYER_ORDER: PayerChoice[] = ['company', 'owner', 'employee', 'unpaid']
export function PayerChoiceList({
value,
onChange,
accountingMethod,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => 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 (
<div className="space-y-1.5">
<p className="text-[13px] font-medium">{t('payer_question')}</p>
<div role="radiogroup" aria-label={t('payer_question')} className="rounded-lg border border-border">
{PAYER_ORDER.map((choice) => {
const selected = choice === value
return (
<button
key={choice}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange(choice)}
className={cn(
'flex w-full items-start gap-3 border-b border-border px-3 py-2.5 text-left transition-colors duration-150 last:border-b-0 hover:bg-secondary/30 first:rounded-t-lg last:rounded-b-lg',
selected && 'bg-secondary/40',
)}
>
<span
aria-hidden
className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border bg-background',
selected ? 'border-primary' : 'border-input',
)}
>
{selected && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
</span>
<span className="min-w-0">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
<span className="block text-xs text-muted-foreground">{t(helpKey(choice))}</span>
</span>
</button>
)
})}
</div>
</div>
)
}
// ── 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<void>
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<PayerChoice>(() =>
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. */}
<Button
variant="default"
size="sm"
className="w-full"
onClick={onMatchTransaction}
{/* Unmatched state: one question decides the booking path. Every
answer keeps BFL 5 kap 6-7 § intact (the underlag is booked
as a verifikat, never forced into a supplier invoice), and the
verifikat editor stays reachable below as the escape hatch. */}
<PayerChoiceList value={payer} onChange={setPayer} accountingMethod={accountingMethod} />
{payer === 'company' ? (
<Button variant="default" size="sm" className="w-full" onClick={onMatchTransaction}>
Matcha mot transaktion
</Button>
) : payer === 'unpaid' ? (
<Button variant="default" size="sm" className="w-full" onClick={onCreateSupplierInvoice}>
Skapa leverantörsfaktura
</Button>
) : (
<Button variant="default" size="sm" className="w-full" onClick={() => onRegisterExpense(payer)}>
{t('payer_book_expense')}
</Button>
)}
<button
type="button"
onClick={onBookDirect}
className="w-full text-xs text-muted-foreground hover:text-foreground hover:underline pt-1"
>
Matcha mot transaktion
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between">
Andra sätt att bokföra
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-64">
<DropdownMenuItem
onClick={onCreateSupplierInvoice}
className="flex flex-col items-start gap-1"
>
<span>Skapa leverantörsfaktura</span>
<span className="text-xs text-muted-foreground">
För leverantörsskulder du vill följa (periodisering).
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onRegisterExpense}
className="flex flex-col items-start gap-1"
>
<span>Registrera som utlägg</span>
<span className="text-xs text-muted-foreground">
För köp du eller en anställd betalat privat.
</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onBookDirect}
className="flex flex-col items-start gap-1"
>
<span>Bokför som verifikat</span>
<span className="text-xs text-muted-foreground">
För underlag som inte är en leverantörsfaktura (bankavgift, utlägg).
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{t('payer_open_editor')}
</button>
</>
)}
<Button
@@ -0,0 +1,339 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { useAccounts } from '@/lib/reference-data/hooks'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants'
import type { InvoiceExtractionResult } from '@/types'
/**
* Who paid for the underlag out of their own pocket. The owner's liability
* account follows the entity type (2893 skuld till ägare in an AB, 2018 egen
* insättning in an enskild firma); an employee is always 2820.
*/
export type ExpensePayer = 'owner' | 'employee'
interface InboxItemLike {
id: string
document_id: string | null
extracted_data: InvoiceExtractionResult | null
}
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
item: InboxItemLike
payer: ExpensePayer
/** Re-read the item after the claim posted its verifikat. */
onSuccess: () => void | Promise<void>
}
const OWNER_FALLBACK_NAME = 'Ägare'
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
function parseAmount(raw: string): number {
const n = parseFloat(raw.replace(/\s/g, '').replace(',', '.'))
return Number.isFinite(n) ? n : 0
}
/**
* RegisterExpenseDialog: the confirm step behind "Vem betalade? Jag, privat /
* En anställd" in the Underlag pane. One screen, prefilled from the
* extraction: who, cost account, amount and VAT, then the outcome spelled out
* before anything posts (design convention 10). Posts through
* POST /api/expense-claims, which books cost + moms against the person's
* liability account and stamps the inbox item as booked.
*/
export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, onSuccess }: Props) {
const t = useTranslations('inbox_workspace')
const { toast } = useToast()
const { accounts } = useAccounts()
const entityType = useCompanyOptional()?.company?.entity_type ?? null
const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893'
const liabilityAccount = payer === 'owner' ? ownerLiability : '2820'
const data = item.extracted_data
const [description, setDescription] = useState('')
const [expenseDate, setExpenseDate] = useState(todayIso())
const [amountInput, setAmountInput] = useState('')
const [vatInput, setVatInput] = useState('')
const [expenseAccount, setExpenseAccount] = useState('')
const [ownerName, setOwnerName] = useState('')
const [employeeId, setEmployeeId] = useState('')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
// Reset per open so a previous underlag's numbers never carry over.
useEffect(() => {
if (!open) return
setDescription(data?.supplier?.name?.trim() || '')
setExpenseDate(data?.invoice?.invoiceDate || todayIso())
const total = data?.totals?.total
const vat = data?.totals?.vatAmount
setAmountInput(total != null && total > 0 ? String(roundOre(total)).replace('.', ',') : '')
setVatInput(vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
}, [open, item.id, data])
useEffect(() => {
if (!open || payer !== 'employee' || employeesLoaded) return
fetch('/api/salary/employees')
.then((res) => (res.ok ? res.json() : null))
.then((json) => setEmployees((json?.data ?? []) as EmployeeOption[]))
.catch(() => setEmployees([]))
.finally(() => setEmployeesLoaded(true))
}, [open, payer, employeesLoaded])
const amount = parseAmount(amountInput)
const vatAmount = parseAmount(vatInput)
const net = roundOre(amount - vatAmount)
const employee = employees.find((e) => e.id === employeeId) ?? null
const claimantName =
payer === 'owner'
? ownerName.trim() || OWNER_FALLBACK_NAME
: employee
? `${employee.first_name} ${employee.last_name}`.trim()
: ''
const canSubmit =
!isSubmitting &&
description.trim().length > 0 &&
ISO_DATE_RE.test(expenseDate) &&
amount > 0 &&
vatAmount >= 0 &&
vatAmount < amount &&
ACCOUNT_NUMBER_RE.test(expenseAccount) &&
/^[4-8]/.test(expenseAccount) &&
(payer === 'owner' || !!employeeId)
const accountName = useMemo(
() => accounts.find((a) => a.account_number === expenseAccount)?.account_name ?? '',
[accounts, expenseAccount],
)
const handleSubmit = useCallback(async () => {
if (!canSubmit) return
setIsSubmitting(true)
try {
const body: Record<string, unknown> = {
description: description.trim(),
expense_date: expenseDate,
amount,
vat_amount: vatAmount,
currency,
expense_account: expenseAccount,
inbox_item_id: item.id,
document_id: item.document_id ?? undefined,
}
if (payer === 'owner') body.claimant_name = claimantName
else body.employee_id = employeeId
const res = await fetch('/api/expense-claims', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const json = (await res.json().catch(() => ({}))) as {
data?: { journal_entry_id?: string | null }
error?: unknown
}
if (!res.ok) {
toast({
title: t('expense_failed_title'),
description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }),
variant: 'destructive',
})
return
}
toast({
title: t('expense_booked_title'),
description: t('expense_booked_description', { name: claimantName }),
})
await onSuccess()
onOpenChange(false)
} finally {
setIsSubmitting(false)
}
}, [
canSubmit,
description,
expenseDate,
amount,
vatAmount,
currency,
expenseAccount,
item.id,
item.document_id,
payer,
claimantName,
employeeId,
toast,
t,
onSuccess,
onOpenChange,
])
return (
<Dialog open={open} onOpenChange={(next) => !isSubmitting && onOpenChange(next)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('expense_dialog_title')}</DialogTitle>
<DialogDescription>
{payer === 'owner'
? t('expense_dialog_help_owner', { account: liabilityAccount })
: t('expense_dialog_help_employee')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{payer === 'owner' ? (
<div className="space-y-1.5">
<Label htmlFor="re-owner">{t('expense_owner_name')}</Label>
<Input
id="re-owner"
value={ownerName}
onChange={(e) => setOwnerName(e.target.value)}
placeholder={OWNER_FALLBACK_NAME}
disabled={isSubmitting}
/>
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="re-employee">{t('expense_employee')}</Label>
<Select value={employeeId} onValueChange={setEmployeeId} disabled={isSubmitting}>
<SelectTrigger id="re-employee">
<SelectValue
placeholder={employeesLoaded && employees.length === 0 ? t('expense_no_employees') : t('expense_pick_employee')}
/>
</SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.first_name} {e.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="re-description">{t('expense_description')}</Label>
<Input
id="re-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label htmlFor="re-date">{t('expense_date')}</Label>
<Input
id="re-date"
type="date"
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="re-amount">{t('expense_amount', { currency })}</Label>
<Input
id="re-amount"
inputMode="decimal"
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
disabled={isSubmitting}
className="tabular-nums"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="re-vat">{t('expense_vat')}</Label>
<Input
id="re-vat"
inputMode="decimal"
value={vatInput}
onChange={(e) => setVatInput(e.target.value)}
disabled={isSubmitting}
className="tabular-nums"
/>
</div>
</div>
<div className="space-y-1.5">
<Label>{t('expense_account')}</Label>
<AccountCombobox
value={expenseAccount}
accounts={accounts}
onChange={setExpenseAccount}
disabled={isSubmitting}
selectedName={accountName}
/>
</div>
{amount > 0 && vatAmount < amount && (
<div className="rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground space-y-1">
<p className="tabular-nums">
{expenseAccount || '____'} D {formatCurrency(net, currency)}
{vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''}
{` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}
</p>
{claimantName && (
<p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
)}
{currency !== 'SEK' && <p>{t('expense_fx_note')}</p>}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t('expense_cancel')}
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('expense_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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) {
+38 -7
View File
@@ -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'])
})
})
+28 -4
View File
@@ -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<DashboardNavFlags> {
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<boolean> {
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<DashboardNavFlags> {
): Promise<Omit<DashboardNavFlags, 'hasExpenseClaims'>> {
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),
+15 -2
View File
@@ -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)
})
})
+44
View File
@@ -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([])
})
})
+15 -2
View File
@@ -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<SuggestedMatch[]>
/**
* 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<ExpensePayoutDue[]>
}
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,
}
}
+73 -1
View File
@@ -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<ExpensePayoutDue[]> {
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<string, ExpensePayoutDue>()
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<number> {
return (await listExpensePayoutsDue(supabase, companyId)).length
}
+29
View File
@@ -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:<claimant_name>` 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<WorklistCategory, number>
/**
+35
View File
@@ -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",
+35
View File
@@ -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",