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:
Jakob Wennberg
2026-09-06 18:16:51 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 238cbe13f9
commit b71f2bf425
26 changed files with 1754 additions and 88 deletions
+5
View File
@@ -2166,6 +2166,11 @@ export const MatchRotRutPayoutSchema = z.object({
request_id: uuid,
})
/** Bank outflow → the registered utlägg it repays (one person). */
export const MatchExpensePayoutSchema = z.object({
claim_ids: z.array(uuid).min(1).max(200),
})
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
// Same purpose as MatchInvoiceSchema.lines: user-edited rows override
+20
View File
@@ -995,6 +995,26 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
message_en:
'The payout was booked but the transaction could not be linked to the voucher. Link it via "Match against existing voucher".',
},
EXPENSE_PAYOUT_MATCH_NOT_EXPENSE: {
httpStatus: 400,
message_sv: 'Endast utbetalningar kan matchas mot utlägg.',
message_en: 'Only outgoing transactions can be matched to expense claims.',
},
EXPENSE_PAYOUT_MATCH_TX_ALREADY_LINKED: {
httpStatus: 400,
message_sv: 'Transaktionen är redan bokförd eller kopplad till en verifikation.',
message_en: 'The transaction is already booked or linked to a journal entry.',
},
EXPENSE_PAYOUT_MATCH_CURRENCY: {
httpStatus: 400,
message_sv: 'Utlägg betalas ut i SEK och transaktionen har en annan valuta.',
message_en: 'Expense claims are reimbursed in SEK; the transaction is in another currency.',
},
EXPENSE_PAYOUT_MATCH_AMOUNT: {
httpStatus: 400,
message_sv: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.',
message_en: 'The amount does not match the selected expense claims. Pick the claims this transfer covers.',
},
ROT_RUT_FILE_CREATE_FAILED: {
httpStatus: 500,
message_sv: 'Filen kunde inte skapas.',
@@ -423,12 +423,47 @@ describe('createPayoutBatch', () => {
p_cash_account: '1935',
p_notes: 'Septemberutlägg',
p_user_id: USER,
p_transaction_id: null,
})
// No journal write happens outside the RPC.
expect(createJournalEntryMock).not.toHaveBeenCalled()
expect(reverseEntryMock).not.toHaveBeenCalled()
})
it('forwards the bank transaction so the RPC links it in the same transaction', async () => {
enqueue({
data: { ok: true, batch_id: 'batch-2', journal_entry_id: 'je-3', voucher_number: 8, total_sek: 1596, claim_count: 2 },
})
const result = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c2', 'c3'],
payout_date: '2026-09-10',
cash_account: '1930',
transaction_id: 'tx-1',
})
expect(result).toMatchObject({ ok: true, batch_id: 'batch-2', journal_entry_id: 'je-3' })
expect(rpcCalls()[0][1]).toMatchObject({ p_transaction_id: 'tx-1', p_payout_date: '2026-09-10' })
})
it('echoes the bank-line refusals (amount mismatch, already booked) as typed codes', async () => {
enqueue({ data: { ok: false, code: 'TX_AMOUNT_MISMATCH', details: { transaction_amount: -1500, claims_total: 1596 } } })
const mismatch = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c2'],
payout_date: '2026-09-10',
cash_account: '1930',
transaction_id: 'tx-1',
})
expect(mismatch).toMatchObject({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
enqueue({ data: { ok: false, code: 'TX_ALREADY_BOOKED' } })
const booked = await createPayoutBatch(sb, COMPANY, USER, {
claim_ids: ['c2'],
payout_date: '2026-09-10',
cash_account: '1930',
transaction_id: 'tx-1',
})
expect(booked).toMatchObject({ ok: false, code: 'TX_ALREADY_BOOKED' })
})
it('echoes a refusal code from the RPC (claims already paid by a concurrent request)', async () => {
enqueue({ data: { ok: false, code: 'ALREADY_PAID', details: { claim_id: 'c1' } } })
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest'
import {
groupExpenseClaimsByPerson,
matchTransactionsToExpensePayouts,
} from '../expense-payout-candidates'
const anna = { key: 'emp-1', employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', claim_count: 2, claim_ids: ['c2', 'c3'], total_sek: 1596, oldest_expense_date: '2026-09-02' }
const owner = { key: 'owner:Jakob', employee_id: null, claimant_name: 'Jakob', liability_account: '2893', claim_count: 1, claim_ids: ['c1'], total_sek: 1240, oldest_expense_date: '2026-09-03' }
describe('groupExpenseClaimsByPerson', () => {
it('sums per person in öre-safe arithmetic and keeps claim ids in date order', () => {
const out = groupExpenseClaimsByPerson([
{ id: 'a', employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: '0.1', expense_date: '2026-09-01' },
{ id: 'b', employee_id: null, claimant_name: 'Jakob', liability_account: '2893', amount_sek: 0.2, expense_date: '2026-09-02' },
])
expect(out).toHaveLength(1)
expect(out[0].total_sek).toBe(0.3)
expect(out[0].claim_ids).toEqual(['a', 'b'])
})
it('leaves an enskild firma owner out: egen insättning is not a debt', () => {
const out = groupExpenseClaimsByPerson([
{ id: 'a', employee_id: null, claimant_name: 'Sara', liability_account: '2018', amount_sek: 500, expense_date: '2026-09-01' },
{ id: 'b', employee_id: 'emp-1', claimant_name: 'Anna Berg', liability_account: '2820', amount_sek: 200, expense_date: '2026-09-02' },
])
expect(out.map((p) => p.key)).toEqual(['emp-1'])
})
})
describe('matchTransactionsToExpensePayouts', () => {
it('pairs an SEK outflow with the one person owed exactly that amount', () => {
const m = matchTransactionsToExpensePayouts(
[{ id: 'tx-1', amount: -1596, currency: 'SEK', is_business: null, journal_entry_id: null }],
[anna, owner],
)
expect(m.get('tx-1')?.person.key).toBe('emp-1')
})
it('ignores inflows, booked rows, foreign currency and near misses', () => {
const m = matchTransactionsToExpensePayouts(
[
{ id: 'in', amount: 1596, currency: 'SEK', is_business: null, journal_entry_id: null },
{ id: 'booked', amount: -1596, currency: 'SEK', is_business: true, journal_entry_id: 'je' },
{ id: 'eur', amount: -1596, currency: 'EUR', is_business: null, journal_entry_id: null },
{ id: 'near', amount: -1596.01, currency: 'SEK', is_business: null, journal_entry_id: null },
],
[anna],
)
expect(m.size).toBe(0)
})
it('skips a total two people share: the amount alone cannot say who', () => {
const twin = { ...owner, key: 'owner:Emil', claimant_name: 'Emil', total_sek: 1596 }
const m = matchTransactionsToExpensePayouts(
[{ id: 'tx-1', amount: -1596, currency: 'SEK', is_business: null, journal_entry_id: null }],
[anna, twin],
)
expect(m.size).toBe(0)
})
})
+15
View File
@@ -500,6 +500,12 @@ export interface CreatePayoutBatchInput {
payout_date: string
cash_account: string
notes?: string
/**
* The unbooked bank transaction that IS this transfer. The RPC then requires
* an SEK outflow of exactly the claims' total and links it to the verifikat
* in the same transaction, so the row can never be booked a second time.
*/
transaction_id?: string
}
export type CreatePayoutBatchFailureCode =
@@ -513,6 +519,10 @@ export type CreatePayoutBatchFailureCode =
| 'ACCOUNT_NOT_IN_CHART'
| 'INVALID_CASH_ACCOUNT'
| 'FORBIDDEN'
| 'TX_NOT_FOUND'
| 'TX_ALREADY_BOOKED'
| 'TX_CURRENCY'
| 'TX_AMOUNT_MISMATCH'
| 'BATCH_INSERT_FAILED'
export type CreatePayoutBatchResult =
@@ -537,6 +547,10 @@ const PAYOUT_RPC_CODES: ReadonlySet<string> = new Set<CreatePayoutBatchFailureCo
'ACCOUNT_NOT_IN_CHART',
'INVALID_CASH_ACCOUNT',
'FORBIDDEN',
'TX_NOT_FOUND',
'TX_ALREADY_BOOKED',
'TX_CURRENCY',
'TX_AMOUNT_MISMATCH',
])
interface PayoutRpcRow {
@@ -581,6 +595,7 @@ export async function createPayoutBatch(
// cookieless service client where auth.uid() is NULL); an authenticated
// caller is pinned to its own auth.uid() by the RPC.
p_user_id: userId,
p_transaction_id: input.transaction_id ?? null,
})
if (error) {
// Period-lock and lock-date triggers surface here as Postgres errors;
+105
View File
@@ -0,0 +1,105 @@
/**
* Bank-driven repayment of utlägg: who is owed, and which unbooked bank
* outflow is the transfer that pays them.
*
* Pure functions on plain rows so the inbox page (browser), the worklist
* (server) and the ingest path can share one definition of "this bank line
* repays that person" without a schema hint column. The match is
* deliberately strict: an SEK outflow whose absolute amount equals ONE
* person's outstanding total to the öre. Two people with the same total make
* the amount ambiguous and produce no suggestion; the user then picks the
* person in the payout dialog.
*/
import { roundOre } from '@/lib/money'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
export interface ExpenseClaimRowForGrouping {
id: string
employee_id: string | null
claimant_name: string
liability_account: string
amount_sek: number | string
expense_date: string
}
/**
* Group registered claims into one item per person, oldest debt first.
*
* Claims on 2018 (an enskild firma owner's egen insättning) are skipped: the
* firm owes its owner nothing, a later withdrawal is eget uttag booked from
* the bank line like any other, so there is no Betala row and no transfer
* to pair. Only 2893 (AB owner) and 2820 (employee) are debts.
*/
export function groupExpenseClaimsByPerson(rows: ExpenseClaimRowForGrouping[]): ExpensePayoutDue[] {
const byPerson = new Map<string, ExpensePayoutDue>()
for (const row of rows) {
if (row.liability_account === '2018') continue
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.claim_ids.push(row.id)
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,
claim_ids: [row.id],
total_sek: roundOre(amount),
oldest_expense_date: row.expense_date,
})
}
}
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,
)
}
export interface MatchableOutflow {
id: string
amount: number
currency?: string | null
is_business?: boolean | null
journal_entry_id?: string | null
}
/** A bank outflow that repays one person's registered utlägg in full. */
export interface ExpensePayoutMatch {
transaction_id: string
person: ExpensePayoutDue
}
/**
* Pair unbooked SEK outflows with the person whose outstanding total they
* equal. Amounts compared in öre. Totals shared by two or more people are
* skipped (ambiguous), as are rows already booked or flagged is_business.
*/
export function matchTransactionsToExpensePayouts(
transactions: MatchableOutflow[],
people: ExpensePayoutDue[],
): Map<string, ExpensePayoutMatch> {
const out = new Map<string, ExpensePayoutMatch>()
if (people.length === 0 || transactions.length === 0) return out
const byOre = new Map<number, ExpensePayoutDue | null>()
for (const p of people) {
const ore = Math.round(p.total_sek * 100)
if (ore <= 0) continue
// null marks an ambiguous total: two people owed the same amount.
byOre.set(ore, byOre.has(ore) ? null : p)
}
for (const tx of transactions) {
if (tx.journal_entry_id || tx.is_business !== null && tx.is_business !== undefined) continue
if ((tx.currency ?? 'SEK').toUpperCase() !== 'SEK') continue
if (!(tx.amount < 0)) continue
const person = byOre.get(Math.round(-tx.amount * 100))
if (person) out.set(tx.id, { transaction_id: tx.id, person })
}
return out
}
+35
View File
@@ -0,0 +1,35 @@
/**
* User-facing Swedish messages for createPayoutBatch refusal codes, shared by
* POST /api/expense-claims/payouts and POST /api/transactions/[id]/match-expense-payout
* so the same refusal reads the same on both surfaces.
*/
export const PAYOUT_ERROR_MESSAGES: Record<string, { message: string; status: number }> = {
NO_CLAIMS: { message: 'Välj minst ett utlägg att betala ut.', status: 400 },
CLAIMS_NOT_FOUND: { message: 'Något av utläggen hittades inte.', status: 404 },
ALREADY_PAID: { message: 'Något av utläggen är redan utbetalt.', status: 409 },
MIXED_CLAIMANTS: {
message: 'En utbetalning kan bara avse en person. Dela upp per person.',
status: 400,
},
MIXED_LIABILITY: {
message: 'Utläggen har olika skuldkonton och kan inte betalas ut tillsammans.',
status: 400,
},
FISCAL_PERIOD_NOT_FOUND: {
message: 'Inget räkenskapsår täcker utbetalningsdatumet.',
status: 400,
},
BATCH_INSERT_FAILED: { message: 'Utbetalningen kunde inte sparas.', status: 500 },
PERIOD_LOCKED: { message: 'Perioden är låst. Lås upp den innan du bokför utbetalningen.', status: 409 },
ACCOUNT_NOT_IN_CHART: { message: 'Kontot finns inte i kontoplanen.', status: 400 },
INVALID_CASH_ACCOUNT: { message: 'Ange ett likvidkonto i 19xx-serien.', status: 400 },
FORBIDDEN: { message: 'Du saknar behörighet att bokföra utbetalningar i det här företaget.', status: 403 },
// Bank-line mode (p_transaction_id): the transfer that repays the claims.
TX_NOT_FOUND: { message: 'Transaktionen hittades inte.', status: 404 },
TX_ALREADY_BOOKED: { message: 'Transaktionen är redan bokförd.', status: 409 },
TX_CURRENCY: { message: 'Utlägg betalas ut i SEK och transaktionen har en annan valuta.', status: 400 },
TX_AMOUNT_MISMATCH: {
message: 'Beloppet stämmer inte med de valda utläggen. Välj de utlägg som överföringen täcker.',
status: 400,
},
}
+50 -4
View File
@@ -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
View File
@@ -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
}
+9 -3
View File
@@ -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[]
}
/**