fix(expenses): review follow-ups from #2333 (#2352)

- The owner's claimant key is trimmed and lower-cased, the same rule the
  payout RPC applies, so "Jakob" and "jakob " are one person with one
  Att göra row and one exact-amount match.
- The inbox pages through every registered claim (fetchAllRows) before
  pairing, so a long backlog can never understate a person's debt.
- A foreign receipt's VAT field is locked at 0 and 0 is what is submitted.
- Transport failures in the one-click and picker confirms show the
  destructive toast instead of failing silently.
- The open-claims flag is set only after the stale-fetch guard, and a
  payout match decrements the inbox count like every other row exit.
- Test: reset the live-link mock before the bank_line junction case.
- Wording: "Återbetalning av utlägg".

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-06 20:19:18 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 1a725c121a
commit 3b9daf2606
10 changed files with 66 additions and 24 deletions
+24 -11
View File
@@ -212,19 +212,25 @@ async function fetchExpensePayoutMatches(
): Promise<{ byTransaction: Map<string, ExpensePayoutDue>; people: ExpensePayoutDue[] }> {
const out = new Map<string, ExpensePayoutDue>()
if (!companyId || rows.length === 0) return { byTransaction: out, people: [] }
const { data, error } = await 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 })
if (error) {
// Every registered claim, paged past the PostgREST row cap: a truncated set
// would understate a person's debt and suggest a payout for part of it.
let claimRows: Parameters<typeof groupExpenseClaimsByPerson>[0]
try {
claimRows = 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 (error) {
console.error('[fetchExpensePayoutMatches] expense_claims query failed', error)
return { byTransaction: out, people: [] }
}
const people = groupExpenseClaimsByPerson(
(data ?? []) as Parameters<typeof groupExpenseClaimsByPerson>[0],
)
const people = groupExpenseClaimsByPerson(claimRows)
for (const [txId, m] of matchTransactionsToExpensePayouts(rows, people)) out.set(txId, m.person)
return { byTransaction: out, people }
}
@@ -1170,11 +1176,11 @@ export default function TransactionsPage() {
fetchExpensePayoutMatches(supabase, companyId, allRows),
])
const expensePayoutMap = expensePayouts.byTransaction
setHasOpenExpenseClaims(expensePayouts.people.length > 0)
// Re-check after the second await: a scope change during the match
// enrichment must also discard this response.
if (fetchGenerationRef.current !== generation) return
setHasOpenExpenseClaims(expensePayouts.people.length > 0)
const transactionsWithInvoices: TransactionWithInvoice[] = allRows.map((t) => ({
...t,
@@ -2329,6 +2335,12 @@ export default function TransactionsPage() {
})
setExpensePayoutDialogOpen(false)
applyExpensePayoutBooked(selectedTransaction.id, result.journal_entry_id, person.key)
} catch (error) {
toast({
title: t('expense_payout_match_failed_title'),
description: getErrorMessage(error, { context: 'transaction' }),
variant: 'destructive',
})
} finally {
setIsConfirmingMatch(false)
}
@@ -2339,6 +2351,7 @@ export default function TransactionsPage() {
// same person drops its suggestion, since that person is now paid.
function applyExpensePayoutBooked(transactionId: string, journalEntryId: string, personKey: string) {
setExitingIds((prev) => new Set(prev).add(transactionId))
setTotalUncategorizedCount((prev) => Math.max(0, (prev ?? 1) - 1))
setTimeout(() => {
setTransactions((prev) =>
prev.map((tx) => {
@@ -137,6 +137,7 @@ describe('POST /api/transactions/[id]/match-expense-payout', () => {
expect(parsed.body.error.code).toBe('EXPENSE_PAYOUT_MATCH_TX_ALREADY_LINKED')
reset()
mockHasLiveLink.mockResolvedValue(false)
enqueue({
data: makeTxRow({
transaction_voucher_links: [{ journal_entry_id: 'je-bulk', role: 'bank_line' }],