feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line
The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).
- create_expense_payout_batch(..., p_transaction_id): old signature dropped
so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ
* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma
- "Matcha mot utlägg" in the inbox row menu: pick the person and the
receipts a transfer covers when the exact-amount pairing missed it. The
picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
from Att göra, the attention resource, suggestions and the picker; a
payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
the dialog says so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
Claude Fable 5.1
Jakob Wennberg
parent
238cbe13f9
commit
b71f2bf425
@@ -13,6 +13,7 @@ import {
|
||||
countUnbookedTransactions,
|
||||
countVerifikatMissingDocument,
|
||||
listExpensePayoutsDue,
|
||||
listExpensePayoutSuggestions,
|
||||
listSuggestedMatches,
|
||||
} from '../categories'
|
||||
import {
|
||||
@@ -545,11 +546,11 @@ 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' },
|
||||
{ id: 'c1', employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: '1240.00', expense_date: '2026-09-03' },
|
||||
{ id: 'c2', employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', amount_sek: 1196, expense_date: '2026-09-02' },
|
||||
{ id: 'c3', 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' },
|
||||
{ id: 'c4', employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: 0.1, expense_date: '2026-09-07' },
|
||||
],
|
||||
})
|
||||
const people = await listExpensePayoutsDue(supabase, COMPANY)
|
||||
@@ -562,6 +563,7 @@ describe('listExpensePayoutsDue', () => {
|
||||
claimant_name: 'Anna Berg',
|
||||
liability_account: '2820',
|
||||
claim_count: 2,
|
||||
claim_ids: ['c2', 'c3'],
|
||||
total_sek: 1596,
|
||||
oldest_expense_date: '2026-09-02',
|
||||
},
|
||||
@@ -571,6 +573,7 @@ describe('listExpensePayoutsDue', () => {
|
||||
claimant_name: 'Jakob',
|
||||
liability_account: '2893',
|
||||
claim_count: 2,
|
||||
claim_ids: ['c1', 'c4'],
|
||||
// 1240 + 0.1 in öre-safe arithmetic, never 1240.1000000000001.
|
||||
total_sek: 1240.1,
|
||||
oldest_expense_date: '2026-09-03',
|
||||
@@ -583,3 +586,46 @@ describe('listExpensePayoutsDue', () => {
|
||||
await expect(listExpensePayoutsDue(supabase, COMPANY)).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('listExpensePayoutSuggestions', () => {
|
||||
it('pairs an unbooked SEK outflow with the person whose open total it equals', async () => {
|
||||
// Open claims: Anna 1 596 (two receipts), owner 1 240.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'c2', employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', amount_sek: 1196, expense_date: '2026-09-02' },
|
||||
{ id: 'c3', employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', amount_sek: 400, expense_date: '2026-09-06' },
|
||||
{ id: 'c1', employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: 1240, expense_date: '2026-09-03' },
|
||||
],
|
||||
})
|
||||
// Unbooked outflows: one repays Anna exactly, one is a different amount.
|
||||
enqueue({
|
||||
data: [
|
||||
{ id: 'tx-1', date: '2026-09-10', description: 'Överföring Anna Berg', amount: -1596, currency: 'SEK', is_business: null, journal_entry_id: null },
|
||||
{ id: 'tx-2', date: '2026-09-10', description: 'Telia', amount: -2450, currency: 'SEK', is_business: null, journal_entry_id: null },
|
||||
],
|
||||
})
|
||||
const out = await listExpensePayoutSuggestions(supabase, COMPANY)
|
||||
expect(findCalls('transactions', 'in')).toContainEqual(['amount', [-1596, -1240]])
|
||||
expect(out).toEqual([
|
||||
{
|
||||
transaction_id: 'tx-1',
|
||||
transaction_date: '2026-09-10',
|
||||
transaction_description: 'Överföring Anna Berg',
|
||||
transaction_amount: -1596,
|
||||
transaction_currency: 'SEK',
|
||||
kind: 'expense_payout',
|
||||
candidate_id: 'emp-1',
|
||||
candidate_number: null,
|
||||
counterparty_name: 'Anna Berg',
|
||||
candidate_total: 1596,
|
||||
claim_ids: ['c2', 'c3'],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does nothing for a company without open claims', async () => {
|
||||
enqueue({ data: [] })
|
||||
await expect(listExpensePayoutSuggestions(supabase, COMPANY)).resolves.toEqual([])
|
||||
expect(mockSupabase.from).not.toHaveBeenCalledWith('transactions')
|
||||
})
|
||||
})
|
||||
|
||||
+75
-29
@@ -11,8 +11,11 @@
|
||||
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 { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
import {
|
||||
groupExpenseClaimsByPerson,
|
||||
matchTransactionsToExpensePayouts,
|
||||
} from '@/lib/expenses/expense-payout-candidates'
|
||||
import {
|
||||
MATCHABLE_INVOICE_STATUSES,
|
||||
MATCHABLE_SUPPLIER_INVOICE_STATUSES,
|
||||
@@ -498,6 +501,15 @@ export async function listSuggestedMatches(
|
||||
// Hint pointing at a deleted, foreign or already-settled candidate → drop
|
||||
// the row rather than render an unconfirmable suggestion.
|
||||
}
|
||||
// Transfers that repay one person's registered utlägg. No hint column: the
|
||||
// pairing is recomputed from the open claims (cheap, and empty for the
|
||||
// companies without any). Confirm endpoint:
|
||||
// kind 'expense_payout' → POST /api/transactions/{id}/match-expense-payout
|
||||
const expenseMatches = await listExpensePayoutSuggestions(supabase, companyId, limit)
|
||||
const seen = new Set(matches.map((m) => m.transaction_id))
|
||||
for (const m of expenseMatches) {
|
||||
if (!seen.has(m.transaction_id)) matches.push(m)
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
@@ -576,7 +588,7 @@ export async function listExpensePayoutsDue(
|
||||
companyId: string,
|
||||
): Promise<ExpensePayoutDue[]> {
|
||||
type ClaimRow = {
|
||||
id?: string
|
||||
id: string
|
||||
employee_id: string | null
|
||||
claimant_name: string
|
||||
liability_account: string
|
||||
@@ -601,33 +613,7 @@ export async function listExpensePayoutsDue(
|
||||
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)
|
||||
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,
|
||||
)
|
||||
return groupExpenseClaimsByPerson(rows)
|
||||
}
|
||||
|
||||
/** Number of people owed for unpaid utlägg (see listExpensePayoutsDue). */
|
||||
@@ -637,3 +623,63 @@ export async function countExpensePayoutsDue(
|
||||
): Promise<number> {
|
||||
return (await listExpensePayoutsDue(supabase, companyId)).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Unbooked SEK outflows whose amount equals one person's outstanding utlägg
|
||||
* to the öre. Read-time pairing over the open claims: the candidate pool is
|
||||
* empty for most companies, so this costs one head-count-sized query and
|
||||
* nothing else there. See lib/expenses/expense-payout-candidates.ts for the
|
||||
* matching rule.
|
||||
*/
|
||||
export async function listExpensePayoutSuggestions(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
limit = 20,
|
||||
): Promise<SuggestedMatch[]> {
|
||||
const people = await listExpensePayoutsDue(supabase, companyId)
|
||||
if (people.length === 0) return []
|
||||
const amounts = [...new Set(people.map((p) => -p.total_sek))]
|
||||
const { data, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, is_business, journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.is('is_business', null)
|
||||
.eq('is_ignored', false)
|
||||
.in('amount', amounts)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit)
|
||||
if (error) {
|
||||
log.error('worklist listExpensePayoutSuggestions failed', { companyId, reason: error.message })
|
||||
return []
|
||||
}
|
||||
type TxRow = {
|
||||
id: string
|
||||
date: string
|
||||
description: string | null
|
||||
amount: number
|
||||
currency: string | null
|
||||
is_business: boolean | null
|
||||
journal_entry_id: string | null
|
||||
}
|
||||
const txs = (data ?? []) as TxRow[]
|
||||
const paired = matchTransactionsToExpensePayouts(txs, people)
|
||||
const out: SuggestedMatch[] = []
|
||||
for (const tx of txs) {
|
||||
const m = paired.get(tx.id)
|
||||
if (!m) continue
|
||||
out.push({
|
||||
transaction_id: tx.id,
|
||||
transaction_date: tx.date,
|
||||
transaction_description: tx.description ?? '',
|
||||
transaction_amount: tx.amount,
|
||||
transaction_currency: tx.currency ?? 'SEK',
|
||||
kind: 'expense_payout',
|
||||
candidate_id: m.person.key,
|
||||
candidate_number: null,
|
||||
counterparty_name: m.person.claimant_name,
|
||||
candidate_total: m.person.total_sek,
|
||||
claim_ids: m.person.claim_ids,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface ExpensePayoutDue {
|
||||
/** 2893 (AB owner), 2018 (EF owner) or 2820 (employee). */
|
||||
liability_account: string
|
||||
claim_count: number
|
||||
/** The registered claims behind the total, in expense_date order. */
|
||||
claim_ids: string[]
|
||||
total_sek: number
|
||||
/** ISO date of the oldest unpaid claim. */
|
||||
oldest_expense_date: string
|
||||
@@ -156,14 +158,18 @@ export interface SuggestedMatch {
|
||||
transaction_currency: string
|
||||
/**
|
||||
* Which match endpoint confirms it: match-invoice, match-supplier-invoice,
|
||||
* or match-rot-rut-payout (Skatteverkets utbetalning for an open begäran;
|
||||
* candidate_number is then the request name).
|
||||
* match-rot-rut-payout (Skatteverkets utbetalning for an open begäran;
|
||||
* candidate_number is then the request name), or match-expense-payout (a
|
||||
* transfer repaying one person's registered utlägg; candidate_id is the
|
||||
* person key and claim_ids carries the claims the transfer covers).
|
||||
*/
|
||||
kind: 'invoice' | 'supplier_invoice' | 'rot_rut_payout'
|
||||
kind: 'invoice' | 'supplier_invoice' | 'rot_rut_payout' | 'expense_payout'
|
||||
candidate_id: string
|
||||
candidate_number: string | null
|
||||
counterparty_name: string | null
|
||||
candidate_total: number | null
|
||||
/** expense_payout only: the registered claims this transfer pays. */
|
||||
claim_ids?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user