fix(expenses): review follow-ups from #2317 (#2326)

- The Utlägg nav row is computed server-side, so the first booked claim
  now refreshes the App Router tree instead of staying hidden until a
  full reload.
- The dialog's default date is the local calendar date; toISOString() is
  UTC and dated a receipt booked after midnight CEST to the previous day.
- listExpensePayoutsDue pages through every registered claim with
  fetchAllRows instead of stopping at 500 rows: a person omitted or a
  total understated there is money the company owes someone.
- The attention resource's payout instruction names the liability
  account per row (2893 / 2018 / 2820) instead of only 2893/2820.


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 16:43:53 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent eb2ae1da17
commit 41a5728ca7
4 changed files with 36 additions and 23 deletions
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -495,6 +496,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
const [bookDirectOpen, setBookDirectOpen] = useState(false)
// "Vem betalade?" answered with a person: the utlägg confirm step.
const [registerExpensePayer, setRegisterExpensePayer] = useState<ExpensePayer | null>(null)
// The layout computes the Utlägg nav gate (hasExpenseClaims) on the server;
// the first booked claim must re-run it or the row stays hidden until reload.
const router = useRouter()
// 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)
@@ -2244,6 +2248,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
payer={registerExpensePayer}
onSuccess={async () => {
await Promise.all([fetchItems(), handleSelect(selected.id)])
router.refresh()
}}
/>
)}
@@ -62,7 +62,12 @@ interface Props {
const OWNER_FALLBACK_NAME = 'Ägare'
function todayIso(): string {
return new Date().toISOString().slice(0, 10)
// Local calendar date: toISOString() is UTC and would date a receipt booked
// after midnight CEST to the previous day (wrong period, wrong FX rate).
const now = new Date()
const mm = String(now.getMonth() + 1).padStart(2, '0')
const dd = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${mm}-${dd}`
}
function parseAmount(raw: string): number {
@@ -439,7 +439,7 @@ export const attentionResource: McpResource = {
})),
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.',
'Betala ut från företagskontot och bokför utbetalningen: debitera radens liability_account (2893 ägare i AB, 2018 ägare i enskild firma, 2820 anställd) mot 19xx K, via /expenses eller POST /api/expense-claims/payouts.',
},
})
}
+24 -21
View File
@@ -12,6 +12,7 @@ import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matc
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
MATCHABLE_INVOICE_STATUSES,
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
@@ -564,13 +565,6 @@ 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
@@ -581,25 +575,34 @@ 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<{
type ClaimRow = {
id?: string
employee_id: string | null
claimant_name: string
liability_account: string
amount_sek: number | string
expense_date: string
}>) {
}
let rows: ClaimRow[]
try {
// Every registered claim, paginated past the PostgREST row cap: a person
// omitted or a total understated here is money the company owes someone.
rows = await fetchAllRows<ClaimRow>(({ from, to }) =>
supabase
.from('expense_claims')
.select('id, employee_id, claimant_name, liability_account, amount_sek, expense_date')
.eq('company_id', companyId)
.eq('status', 'registered')
.order('expense_date', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
)
} catch (err) {
logAndZero('expense_payout', companyId, err as { message?: string })
return []
}
const byPerson = new Map<string, ExpensePayoutDue>()
for (const row of rows) {
const key = row.employee_id ?? `owner:${row.claimant_name}`
const amount = Number(row.amount_sek) || 0
const existing = byPerson.get(key)