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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
bff44e5757
commit
cbe5580886
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user