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
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { PoolClient } from 'pg'
|
||||
import { getPool, getClient, withUserContext } from './setup'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
import { seedCompany, insertAuthUser, insertCompanyMember, insertCashAccount, insertTransaction } from './fixtures'
|
||||
|
||||
// pg-real coverage for 20260904171000_expense_payout_batch_rpc:
|
||||
// create_expense_payout_batch books one payout verifikat, links the batch
|
||||
@@ -12,6 +12,7 @@ import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
|
||||
type RpcResult = {
|
||||
ok: boolean
|
||||
code?: string
|
||||
transaction_id?: string | null
|
||||
batch_id?: string
|
||||
journal_entry_id?: string
|
||||
voucher_number?: number
|
||||
@@ -36,14 +37,14 @@ async function insertClaim(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
amountSek: number,
|
||||
overrides: Partial<{ claimantName: string; status: string }> = {},
|
||||
overrides: Partial<{ claimantName: string; status: string; liability: string }> = {},
|
||||
): Promise<string> {
|
||||
const id = randomUUID()
|
||||
await getPool().query(
|
||||
`INSERT INTO public.expense_claims
|
||||
(id, company_id, user_id, claimant_name, description, expense_date, amount_sek, vat_sek, expense_account, liability_account, status)
|
||||
VALUES ($1, $2, $3, $4, 'Kvitto', '2026-08-25', $5, 0, '5410', '2893', $6)`,
|
||||
[id, companyId, userId, overrides.claimantName ?? 'Ägare', amountSek, overrides.status ?? 'registered'],
|
||||
VALUES ($1, $2, $3, $4, 'Kvitto', '2026-08-25', $5, 0, '5410', $7, $6)`,
|
||||
[id, companyId, userId, overrides.claimantName ?? 'Ägare', amountSek, overrides.status ?? 'registered', overrides.liability ?? '2893'],
|
||||
)
|
||||
return id
|
||||
}
|
||||
@@ -52,11 +53,11 @@ async function callRpc(
|
||||
client: PoolClient,
|
||||
companyId: string,
|
||||
claimIds: string[],
|
||||
opts: Partial<{ date: string; cash: string }> = {},
|
||||
opts: Partial<{ date: string; cash: string; transactionId: string }> = {},
|
||||
): Promise<RpcResult> {
|
||||
const { rows } = await client.query<{ r: RpcResult }>(
|
||||
`SELECT public.create_expense_payout_batch($1, $2::uuid[], $3::date, $4) AS r`,
|
||||
[companyId, claimIds, opts.date ?? '2026-08-31', opts.cash ?? '1930'],
|
||||
`SELECT public.create_expense_payout_batch($1, $2::uuid[], $3::date, $4, NULL, NULL, $5::uuid) AS r`,
|
||||
[companyId, claimIds, opts.date ?? '2026-08-31', opts.cash ?? '1930', opts.transactionId ?? null],
|
||||
)
|
||||
return rows[0].r
|
||||
}
|
||||
@@ -282,4 +283,98 @@ describe('create_expense_payout_batch', () => {
|
||||
|
||||
expect(await payoutState(companyId, [owner, other])).toMatchObject({ batches: 0, postedPayouts: 0 })
|
||||
})
|
||||
|
||||
it('books the payout FROM an unbooked bank outflow and links the row in the same transaction', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const c1 = await insertClaim(companyId, userId, 1196)
|
||||
const c2 = await insertClaim(companyId, userId, 400)
|
||||
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const txId = await insertTransaction({
|
||||
companyId,
|
||||
userId,
|
||||
amount: -1596,
|
||||
date: '2026-08-31',
|
||||
description: 'Överföring Ägare',
|
||||
cashAccountId,
|
||||
})
|
||||
|
||||
const result = await asUser(userId, (c) => callRpc(c, companyId, [c1, c2], { transactionId: txId }))
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.transaction_id).toBe(txId)
|
||||
|
||||
const { rows: tx } = await getPool().query(
|
||||
`SELECT journal_entry_id, is_business, reconciliation_method FROM public.transactions WHERE id = $1`,
|
||||
[txId],
|
||||
)
|
||||
expect(tx[0]).toEqual({
|
||||
journal_entry_id: result.journal_entry_id,
|
||||
is_business: true,
|
||||
reconciliation_method: 'manual',
|
||||
})
|
||||
const { rows: claims } = await getPool().query(
|
||||
`SELECT status FROM public.expense_claims WHERE id = ANY($1::uuid[])`,
|
||||
[[c1, c2]],
|
||||
)
|
||||
expect(claims.map((r) => r.status)).toEqual(['paid', 'paid'])
|
||||
const { rows: booked } = await getPool().query(`SELECT public.is_transaction_booked($1) AS b`, [txId])
|
||||
expect(booked[0].b).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a bank row whose amount differs from the claims, or that is already booked, without touching anything', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
const c1 = await insertClaim(companyId, userId, 1240)
|
||||
const cashAccountId = await insertCashAccount({ companyId, ledgerAccount: '1930' })
|
||||
const wrongAmount = await insertTransaction({ companyId, userId, amount: -1200, cashAccountId })
|
||||
|
||||
const mismatch = await asUser(userId, (c) => callRpc(c, companyId, [c1], { transactionId: wrongAmount }))
|
||||
expect(mismatch).toMatchObject({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
|
||||
|
||||
const inflow = await insertTransaction({ companyId, userId, amount: 1240, cashAccountId })
|
||||
const notOutflow = await asUser(userId, (c) => callRpc(c, companyId, [c1], { transactionId: inflow }))
|
||||
expect(notOutflow).toMatchObject({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
|
||||
|
||||
// Book the right row once, then try to book it again: the second call must
|
||||
// see it as booked (the claims are already paid too, but the row check
|
||||
// runs first for a fresh set of claims).
|
||||
const right = await insertTransaction({ companyId, userId, amount: -1240, cashAccountId })
|
||||
const first = await asUser(userId, (c) => callRpc(c, companyId, [c1], { transactionId: right }))
|
||||
expect(first.ok).toBe(true)
|
||||
const c2 = await insertClaim(companyId, userId, 1240)
|
||||
const again = await asUser(userId, (c) => callRpc(c, companyId, [c2], { transactionId: right }))
|
||||
expect(again).toMatchObject({ ok: false, code: 'TX_ALREADY_BOOKED' })
|
||||
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT count(*)::int AS n FROM public.journal_entries WHERE company_id = $1 AND source_type = 'expense_payout'`,
|
||||
[companyId],
|
||||
)
|
||||
expect(rows[0].n).toBe(1)
|
||||
const { rows: c2rows } = await getPool().query(`SELECT status FROM public.expense_claims WHERE id = $1`, [c2])
|
||||
expect(c2rows[0].status).toBe('registered')
|
||||
})
|
||||
|
||||
it('books an enskild firma owner payout as eget uttag on 2013, never 2018', async () => {
|
||||
const { companyId, userId } = await seedCompany()
|
||||
await seedChart(companyId, userId)
|
||||
await getPool().query(
|
||||
`INSERT INTO public.chart_of_accounts
|
||||
(user_id, company_id, account_number, account_name, account_class, account_type, normal_balance, is_active)
|
||||
VALUES ($1, $2, '2018', 'Övriga egna insättningar', 2, 'equity', 'credit', true),
|
||||
($1, $2, '2013', 'Övriga egna uttag', 2, 'equity', 'debit', true)`,
|
||||
[userId, companyId],
|
||||
)
|
||||
const c1 = await insertClaim(companyId, userId, 640, { liability: '2018' })
|
||||
const result = await asUser(userId, (c) => callRpc(c, companyId, [c1]))
|
||||
expect(result.ok).toBe(true)
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT account_number, debit_amount::float AS d, credit_amount::float AS c
|
||||
FROM public.journal_entry_lines WHERE journal_entry_id = $1 ORDER BY sort_order`,
|
||||
[result.journal_entry_id],
|
||||
)
|
||||
expect(rows).toEqual([
|
||||
{ account_number: '2013', d: 640, c: 0 },
|
||||
{ account_number: '1930', d: 0, c: 640 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user