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
@@ -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,
},
}