From 41a5728ca7c59d72b1b9472578eb43a7cf9548c4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sat, 5 Sep 2026 16:43:53 +0200 Subject: [PATCH] fix(expenses): review follow-ups from #2317 (#2326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../general/InvoiceInboxWorkspace.tsx | 5 +++ .../general/RegisterExpenseDialog.tsx | 7 ++- .../general/mcp-server/resources/attention.ts | 2 +- lib/worklist/categories.ts | 45 ++++++++++--------- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index bb887929..bed5de8c 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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(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() }} /> )} diff --git a/components/extensions/general/RegisterExpenseDialog.tsx b/components/extensions/general/RegisterExpenseDialog.tsx index ffa4ac30..c002b667 100644 --- a/components/extensions/general/RegisterExpenseDialog.tsx +++ b/components/extensions/general/RegisterExpenseDialog.tsx @@ -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 { diff --git a/extensions/general/mcp-server/resources/attention.ts b/extensions/general/mcp-server/resources/attention.ts index 08f58945..d1224784 100644 --- a/extensions/general/mcp-server/resources/attention.ts +++ b/extensions/general/mcp-server/resources/attention.ts @@ -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.', }, }) } diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index 5b6480d0..ff459556 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -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 { - 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<{ + 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(({ 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() + 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)