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
parent 238cbe13f9
commit b71f2bf425
26 changed files with 1754 additions and 88 deletions
+2
View File
@@ -1607,6 +1607,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-05] Utlägg becomes an answer, not a page: the Underlag pane asks "Vem betalade?" (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims; the person owed surfaces as a Betala row in Att göra (lib/worklist expense_payout, one item per person) and the Utlägg nav row is gated on existing claims like Körjournal. Chosen over a fourth item in the Bokföring split button (that menu is three ways to type one verifikat, not a list of document kinds) and over keeping the two-step wizard as the entry point: a kvitto paid with a private card differs from any other purchase only in the credit account, and 93 percent of companies on prod are owner-only, for whom a module for that one bit is the wrong shape. Phase 2 (bank-driven repayment, open items shared with leverantörsfakturor, via lön) and phase 3 (retire the wizard, per-person list under Löner) are filed as follow-ups.
[2026-09-05] Cross-tab company guard (WL-09) stays a blocking two-exit dialog, founder re-confirmed today after a forensic pass on a real firing (a switch made elsewhere under the same login, no server-side or agent path involved): auto-follow, per-tab company scoping and a reads-continue banner were offered and declined. Only change: the dialog now names the company the other tab switched to (resolved from the memberships the shell already ships to the client, no request), so the two exits read as a choice between two named companies instead of a named one and "the new one".
[2026-09-05] Björn Lundén connect: a 403 whose body says "out of allowed scope for service provider" is mapped to its own BL_INTEGRATION_NOT_ACTIVATED verdict (the key is right, the company never activated the integration) instead of the generic "leverantören avvisade autentiseringen"; live-verified against a real customer key, where every read endpoint answered exactly that while a made-up key answered 500. Root cause of every failed BL connect in prod (10 consents, only BL's own sandbox company ever got tokens): the integration is still a sandbox listing at BL, so no real company can activate it. Chose a message that names the fix (activate in Lundify, else SIE) over hiding the provider state; the Lundify activation-redirect flow and document/line-level fetching are filed as follow-ups rather than built blind before BL releases the integration.
[2026-09-05] Utlägg phase 2, repayment from the bank line: create_expense_payout_batch takes an optional bank transaction and, under the same row locks, requires an unbooked SEK outflow equal to the claims' total and stamps it (journal_entry_id, is_business, reconciliation_method) in the transaction that posts the verifikat and flips the claims to paid. The pairing bank line ↔ person is computed at read time from the open claims (exact öre match on one person's total, ambiguous totals skipped), surfaced as a suggested match on Hem and as the inbox row's primary button, instead of a new hint column on transactions: the candidate pool is the handful of open claims, so a column written at ingest would add schema and a stale-pointer class for no saving. Old 6-parameter RPC signature dropped in the same migration so a 6-argument call cannot become ambiguous. "Betala ut" on /expenses stays for companies paying from an account without a feed. "Via lön" and the supplier-form control are split into their own issues rather than stretched into this PR.
[2026-09-06] Utlägg gaps closed on the phase 2 PR: (1) a manual "Matcha mot utlägg" picker in the inbox row menu for outflows the exact-amount pairing missed, offered only while the company has open claims; the sum of picked receipts must equal the row to the öre and the same RPC books it, so a partial receipt can never be marked paid (partial payout inside the RPC was considered and left out: it changes money logic for a rare case, rounding a transfer up is not solved). (2) A foreign receipt (currency other than SEK) defaults VAT to 0 in the Underlag dialog with a note: foreign VAT is not deductible on 2641, and the wizard's seller-country step is gone from this path. (3) Enskild firma: a claim on 2018 is egen insättning, not a debt; it is excluded from Att göra, the attention resource, suggestions and the picker, and a payout for it debits 2013 (eget uttag) instead of 2018.
[2026-09-05] SIE precheck refuses a closed or locked containing year up front (conflict verdict with the remedy: Öppna igen / Lås upp) instead of letting the voucher RPC fail with the trigger text; the årsredovisning warns when the comparison year has no entries instead of deriving BR comparatives from the IB voucher: derivation would hide that the RR comparatives are still unknown, and manual/IB comparatives after a migration are a product decision (follow-up issue).
[2026-09-06] Recurring invoice month phase (yearly in February, quarterly Feb/May/Aug/Nov) is exposed as a first/next invoice date (start_date on create, next_run_date on update; web dialog + MCP), not the reporter's "months offset" dropdown: an offset is a derived value relative to now that changes meaning when the interval changes, while the date maps one-to-one onto the next_run_date column that already anchors the phase, so no migration and no per-interval range rules. A date off the day_of_month grid is refused (400) instead of normalized, because the cron advances from the due date and an off-grid first run would drift back to day_of_month on the second run.
[2026-09-06] Voucher series names live on the existing Verifikationsserier list in settings, not a separate group: the list already enumerates the letters in use, and a name belongs next to the letter it names. Rows are the union of used, configured and named letters so a freshly assigned series can be named before its first verifikat.
+164 -4
View File
@@ -50,6 +50,11 @@ import type {
PotentialRotRutPayout,
} from '@/components/transactions/transaction-types'
import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching'
import {
groupExpenseClaimsByPerson,
matchTransactionsToExpensePayouts,
} from '@/lib/expenses/expense-payout-candidates'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
import { SuggestionReviewList } from '@/components/transactions/SuggestionReviewList'
import {
isSourceFilter,
@@ -101,6 +106,8 @@ const TransactionForm = dynamic(() => import('@/components/transactions/Transact
const BatchCategorySelector = dynamic(() => import('@/components/transactions/BatchCategorySelector'), { loading: DialogLoadingSkeleton })
const InvoiceMatchDialog = dynamic(() => import('@/components/transactions/InvoiceMatchDialog'), { loading: DialogLoadingSkeleton })
const RotRutPayoutMatchDialog = dynamic(() => import('@/components/transactions/RotRutPayoutMatchDialog'), { loading: DialogLoadingSkeleton })
const ExpensePayoutMatchDialog = dynamic(() => import('@/components/transactions/ExpensePayoutMatchDialog'), { loading: DialogLoadingSkeleton })
const ExpenseClaimPickerDialog = dynamic(() => import('@/components/transactions/ExpenseClaimPickerDialog'), { loading: DialogLoadingSkeleton })
const MatchVoucherDialog = dynamic(
() => import('@/components/transactions/MatchVoucherDialog').then((module) => module.MatchVoucherDialog),
{ loading: DialogLoadingSkeleton },
@@ -193,6 +200,34 @@ function buildSupplierInvoiceMap(
}, {})
}
// Pair unbooked outflows with the person whose registered utlägg they repay
// in full (lib/expenses/expense-payout-candidates). Read-time: the open claims
// are the candidate pool, so this is one small query and nothing for the
// companies without any. Non-fatal like fetchPotentialMatches.
async function fetchExpensePayoutMatches(
supabase: SupabaseClient,
companyId: string | null,
rows: { id: string; amount: number; currency: string | null; is_business: boolean | null; journal_entry_id: string | null }[],
): 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) {
console.error('[fetchExpensePayoutMatches] expense_claims query failed', error)
return { byTransaction: out, people: [] }
}
const people = groupExpenseClaimsByPerson(
(data ?? []) as Parameters<typeof groupExpenseClaimsByPerson>[0],
)
for (const [txId, m] of matchTransactionsToExpensePayouts(rows, people)) out.set(txId, m.person)
return { byTransaction: out, people }
}
// Fetch the potential invoice/supplier-invoice matches referenced by a page
// of transactions in one parallel round trip. A single-query PostgREST embed
// on potential_supplier_invoice_id is blocked until that FK exists in the
@@ -401,6 +436,13 @@ export default function TransactionsPage() {
// ROT/RUT payout confirm (Skatteverkets utbetalning for an open begäran):
// its own dialog, same selectedTransaction / isConfirmingMatch plumbing.
const [rotRutMatchDialogOpen, setRotRutMatchDialogOpen] = useState(false)
// Utlägg repayment confirm (a transfer covering one person's registered
// claims): own dialog, same selectedTransaction / isConfirmingMatch plumbing.
const [expensePayoutDialogOpen, setExpensePayoutDialogOpen] = useState(false)
// Manual "Matcha mot utlägg" for an outflow the exact-amount pairing missed.
// Offered only while the company has open claims (set by the list fetch).
const [matchExpenseTx, setMatchExpenseTx] = useState<TransactionWithInvoice | null>(null)
const [hasOpenExpenseClaims, setHasOpenExpenseClaims] = useState(false)
// Booking dialog (journal entry form)
const [bookingDialogOpen, setBookingDialogOpen] = useState(false)
@@ -674,8 +716,8 @@ export default function TransactionsPage() {
// animation still finishes instead of being cut to a jump.
.filter((t) => (t.is_business === null && !t.is_ignored) || exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice || a.potential_rot_rut_payout ? 1 : 0
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice || b.potential_rot_rut_payout ? 1 : 0
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice || a.potential_rot_rut_payout || a.potential_expense_payout ? 1 : 0
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice || b.potential_rot_rut_payout || b.potential_expense_payout ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
}),
@@ -1122,7 +1164,12 @@ export default function TransactionsPage() {
const windowIds = new Set(rows.map((r) => r.id))
const olderPending = (pendingRows ?? []).filter((r) => !windowIds.has(r.id))
const allRows = [...rows, ...olderPending].sort((a, b) => b.date.localeCompare(a.date))
const { invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap } = await fetchPotentialMatches(supabase, allRows)
const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap }, expensePayouts] = await Promise.all([
fetchPotentialMatches(supabase, allRows),
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.
@@ -1130,6 +1177,7 @@ export default function TransactionsPage() {
const transactionsWithInvoices: TransactionWithInvoice[] = allRows.map((t) => ({
...t,
potential_expense_payout: expensePayoutMap.get(t.id),
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
@@ -1210,7 +1258,11 @@ export default function TransactionsPage() {
setPagedThroughDate(txData.length >= PAGE_SIZE ? txData[txData.length - 1].date : null)
setHasMore(txData.length >= PAGE_SIZE)
const { invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap } = await fetchPotentialMatches(supabase, txData)
const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutMap }, expensePayouts] = await Promise.all([
fetchPotentialMatches(supabase, txData),
fetchExpensePayoutMatches(supabase, companyId, txData),
])
const expensePayoutMap = expensePayouts.byTransaction
// Same staleness rule after the enrichment await: the offsets above were
// written under this generation, but a newer fetch has already reset them.
@@ -1221,6 +1273,7 @@ export default function TransactionsPage() {
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
...t,
potential_expense_payout: expensePayoutMap.get(t.id),
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
@@ -2231,6 +2284,85 @@ export default function TransactionsPage() {
}
}
async function handleConfirmExpensePayoutMatch() {
if (!selectedTransaction?.potential_expense_payout) return
const person = selectedTransaction.potential_expense_payout
setIsConfirmingMatch(true)
try {
const response = await fetch(
`/api/transactions/${selectedTransaction.id}/match-expense-payout`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claim_ids: person.claim_ids }),
},
)
const result = await response.json()
if (!response.ok) {
toast({
title: t('expense_payout_match_failed_title'),
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsConfirmingMatch(false)
return
}
toast({
title: t('expense_payout_matched_title'),
description: t('expense_payout_matched_description', { name: person.claimant_name }),
})
setExpensePayoutDialogOpen(false)
applyExpensePayoutBooked(selectedTransaction.id, result.journal_entry_id, person.key)
} finally {
setIsConfirmingMatch(false)
}
}
// After a transfer booked one person's utlägg (one-click or via the
// picker): the row leaves the inbox and every other row that suggested the
// 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))
setTimeout(() => {
setTransactions((prev) =>
prev.map((tx) => {
if (tx.id === transactionId) {
return {
...tx,
potential_expense_payout: undefined,
is_business: true,
category: 'expense_other' as TransactionCategory,
journal_entry_id: journalEntryId,
}
}
if (tx.potential_expense_payout?.key === personKey) {
return { ...tx, potential_expense_payout: undefined }
}
return tx
}),
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(transactionId)
return next
})
setSelectedTransaction(null)
}, 350)
}
function handleExpenseClaimsPicked(transactionId: string, journalEntryId: string, personKey: string) {
const person = matchExpenseTx?.potential_expense_payout
toast({
title: t('expense_payout_matched_title'),
description: person
? t('expense_payout_matched_description', { name: person.claimant_name })
: undefined,
})
setMatchExpenseTx(null)
applyExpensePayoutBooked(transactionId, journalEntryId, personKey)
}
async function handleConfirmRotRutPayoutMatch() {
if (!selectedTransaction?.potential_rot_rut_payout) return
const request = selectedTransaction.potential_rot_rut_payout
@@ -3573,6 +3705,14 @@ export default function TransactionsPage() {
setRotRutMatchDialogOpen(true)
return
}
if (
!transaction.potential_invoice &&
!transaction.potential_supplier_invoice &&
transaction.potential_expense_payout
) {
setExpensePayoutDialogOpen(true)
return
}
setMatchDialogOpen(true)
}
@@ -4116,6 +4256,7 @@ export default function TransactionsPage() {
onOpenMatchInvoicePicker={openInvoiceMatchPicker}
onOpenSplitMatch={openSplitMatchDialog}
onOpenMatchVoucher={openMatchVoucherDialog}
onOpenMatchExpense={hasOpenExpenseClaims ? setMatchExpenseTx : undefined}
onOpenAttachDocument={openAttachDocumentDialog}
onDetachDocument={handleDetachDocument}
onOpenCategoryDialog={openCategoryDialog}
@@ -4250,6 +4391,25 @@ export default function TransactionsPage() {
/>
)}
{expensePayoutDialogOpen && (
<ExpensePayoutMatchDialog
open
onOpenChange={setExpensePayoutDialogOpen}
transaction={selectedTransaction}
isConfirming={isConfirmingMatch}
onConfirm={handleConfirmExpensePayoutMatch}
/>
)}
{matchExpenseTx && (
<ExpenseClaimPickerDialog
open
onOpenChange={(o) => { if (!o) setMatchExpenseTx(null) }}
transaction={matchExpenseTx}
onMatched={handleExpenseClaimsPicked}
/>
)}
{matchVoucherTx && (
<MatchVoucherDialog
open
+1 -22
View File
@@ -9,31 +9,10 @@ import {
createPayoutBatch,
listPayoutBatches,
} from '@/lib/expenses/expense-claims-service'
import { PAYOUT_ERROR_MESSAGES } from '@/lib/expenses/payout-error-messages'
ensureInitialized()
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 },
}
export const GET = withRouteContext('expense_claims.payouts.list', async (_request, { supabase, companyId }) => {
const batches = await listPayoutBatches(supabase, companyId)
@@ -0,0 +1,202 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
const mockCreatePayoutBatch = vi.fn()
vi.mock('@/lib/expenses/expense-claims-service', () => ({
createPayoutBatch: (...args: unknown[]) => mockCreatePayoutBatch(...args),
}))
const mockResolveSettlementAccount = vi.fn()
vi.mock('@/lib/bookkeeping/settlement-account', () => ({
resolveSettlementAccount: (...args: unknown[]) => mockResolveSettlementAccount(...args),
}))
const mockHasLiveLink = vi.fn()
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: (...args: unknown[]) => mockHasLiveLink(...args),
}))
import { POST } from '../route'
const TX_ID = '11111111-1111-4111-8111-111111111111'
const CLAIM_A = '22222222-2222-4222-8222-222222222222'
const CLAIM_B = '33333333-3333-4333-8333-333333333333'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const routeParams = createMockRouteParams({ id: TX_ID })
function makeReq(body: unknown = { claim_ids: [CLAIM_A, CLAIM_B] }) {
return createMockRequest(`/api/transactions/${TX_ID}/match-expense-payout`, {
method: 'POST',
body,
})
}
function makeTxRow(overrides: Record<string, unknown> = {}) {
return {
id: TX_ID,
date: '2026-09-10',
amount: -1596,
currency: 'SEK',
journal_entry_id: null,
cash_account_id: 'ca-1',
transaction_voucher_links: [],
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
mockResolveSettlementAccount.mockResolvedValue('1930')
mockHasLiveLink.mockResolvedValue(false)
mockCreatePayoutBatch.mockResolvedValue({
ok: true,
batch_id: 'batch-1',
journal_entry_id: 'je-1',
voucher_number: 12,
total_sek: 1596,
claim_count: 2,
})
})
describe('POST /api/transactions/[id]/match-expense-payout', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeReq(), routeParams)
expect(response.status).toBe(401)
expect(mockCreatePayoutBatch).not.toHaveBeenCalled()
})
it('returns 400 on an invalid body', async () => {
const response = await POST(makeReq({ claim_ids: [] }), routeParams)
expect(response.status).toBe(400)
expect(mockCreatePayoutBatch).not.toHaveBeenCalled()
})
it('returns 404 when the transaction is not in the company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
})
it('refuses an income row', async () => {
enqueue({ data: makeTxRow({ amount: 1596 }) })
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('EXPENSE_PAYOUT_MATCH_NOT_EXPENSE')
expect(mockCreatePayoutBatch).not.toHaveBeenCalled()
})
it('refuses a non-SEK row', async () => {
enqueue({ data: makeTxRow({ currency: 'EUR' }) })
const response = await POST(makeReq(), routeParams)
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(body.error.code).toBe('EXPENSE_PAYOUT_MATCH_CURRENCY')
})
it('refuses a row that is already booked (live pointer or bank_line junction)', async () => {
enqueue({ data: makeTxRow({ journal_entry_id: 'je-old' }) })
mockHasLiveLink.mockResolvedValue(true)
let response = await POST(makeReq(), routeParams)
let parsed = await parseJsonResponse<{ error: { code: string } }>(response)
expect(parsed.status).toBe(400)
expect(parsed.body.error.code).toBe('EXPENSE_PAYOUT_MATCH_TX_ALREADY_LINKED')
reset()
enqueue({
data: makeTxRow({
transaction_voucher_links: [{ journal_entry_id: 'je-bulk', role: 'bank_line' }],
}),
})
response = await POST(makeReq(), routeParams)
parsed = await parseJsonResponse<{ error: { code: string } }>(response)
expect(parsed.body.error.code).toBe('EXPENSE_PAYOUT_MATCH_TX_ALREADY_LINKED')
expect(mockCreatePayoutBatch).not.toHaveBeenCalled()
})
it('books the payout from the bank row: its date, its cash account, linked in the RPC', async () => {
enqueue({ data: makeTxRow() })
mockResolveSettlementAccount.mockResolvedValue('1920')
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string
batch_id: string
category: string
}>(response)
expect(status).toBe(200)
expect(body).toMatchObject({
success: true,
journal_entry_id: 'je-1',
batch_id: 'batch-1',
category: 'expense_other',
})
expect(mockResolveSettlementAccount).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'ca-1',
expect.anything(),
)
expect(mockCreatePayoutBatch).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', {
claim_ids: [CLAIM_A, CLAIM_B],
payout_date: '2026-09-10',
cash_account: '1920',
transaction_id: TX_ID,
})
})
it('maps an amount mismatch onto the structured envelope', async () => {
enqueue({ data: makeTxRow() })
mockCreatePayoutBatch.mockResolvedValue({ ok: false, code: 'TX_AMOUNT_MISMATCH' })
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('EXPENSE_PAYOUT_MATCH_AMOUNT')
})
it('maps service refusals onto their user-facing message and status', async () => {
enqueue({ data: makeTxRow() })
mockCreatePayoutBatch.mockResolvedValue({ ok: false, code: 'ALREADY_PAID' })
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: string; code: string }>(response)
expect(status).toBe(409)
expect(body.code).toBe('ALREADY_PAID')
expect(body.error).toContain('redan utbetalt')
})
})
@@ -0,0 +1,159 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { MatchExpensePayoutSchema } from '@/lib/api/schemas'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { bookkeepingErrorResponse } from '@/lib/bookkeeping/errors'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { createPayoutBatch } from '@/lib/expenses/expense-claims-service'
import { PAYOUT_ERROR_MESSAGES } from '@/lib/expenses/payout-error-messages'
import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-expense-payout
*
* Book an outgoing bank row as the repayment of one person's registered
* utlägg:
*
* Debit 2893 / 2820 / 2018 (the claims' liability account) [|tx.amount|]
* Credit 19xx (the transaction's cash account) [|tx.amount|]
*
* Same booking as POST /api/expense-claims/payouts, but the amount, date and
* bank account come from the bank row, and the row is linked to the voucher
* inside the same RPC transaction: the transfer can never be booked twice
* (once by "Betala ut", once by categorising the bank row). The RPC requires
* the claims' total to equal the transfer to the öre.
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.match_expense_payout',
async (request, ctx, { params }) => {
const { id: transactionId } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, MatchExpensePayoutSchema, {
log,
operation: 'transaction.match_expense_payout',
})
if (!validation.success) return validation.response
const { claim_ids: claimIds } = validation.data
const txLog = log.child({ transactionId, claimCount: claimIds.length })
// transaction_voucher_links rides along: a row bulk-booked into a
// samlingsverifikat carries journal_entry_id = NULL and must still refuse.
const { data: transactionRow, error: fetchTxError } = await supabase
.from('transactions')
.select(
'id, date, amount, currency, journal_entry_id, cash_account_id, transaction_voucher_links(journal_entry_id, role)',
)
.eq('id', transactionId)
.eq('company_id', companyId!)
.single()
if (fetchTxError || !transactionRow) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId })
}
const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as {
id: string
date: string
amount: number
currency: string | null
journal_entry_id: string | null
cash_account_id: string | null
transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null
}
if (!(transaction.amount < 0)) {
return errorResponseFromCode('EXPENSE_PAYOUT_MATCH_NOT_EXPENSE', txLog, {
requestId,
details: { amount: transaction.amount },
})
}
if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') {
return errorResponseFromCode('EXPENSE_PAYOUT_MATCH_CURRENCY', txLog, {
requestId,
details: { currency: transaction.currency },
})
}
// Only a LIVE (posted) pointer or a bank_line junction row blocks: a
// pointer left behind by a storno reads as "utan koppling" in the UI and
// must stay matchable (same predicate as link-journal-entry, issue #988).
// The RPC re-checks under its row lock; this is the early, readable answer.
if (
hasBankLineJunctionRow(junctionLinks) ||
(await hasLiveJournalEntryLink(supabase, companyId!, transaction.journal_entry_id))
) {
return errorResponseFromCode('EXPENSE_PAYOUT_MATCH_TX_ALREADY_LINKED', txLog, {
requestId,
details: { existingJournalEntryId: transaction.journal_entry_id },
})
}
// Credit the cash account THIS transaction belongs to, never a
// company-wide default (mirrors match-supplier-invoice).
const cashAccount = await resolveSettlementAccount(
supabase,
companyId!,
transaction.cash_account_id,
txLog,
)
try {
const result = await createPayoutBatch(supabase, companyId!, user.id, {
claim_ids: claimIds,
payout_date: transaction.date,
cash_account: cashAccount,
transaction_id: transactionId,
})
if (!result.ok) {
if (result.code === 'TX_AMOUNT_MISMATCH') {
return errorResponseFromCode('EXPENSE_PAYOUT_MATCH_AMOUNT', txLog, {
requestId,
details: { amount: transaction.amount },
})
}
const mapped = PAYOUT_ERROR_MESSAGES[result.code] ?? {
message: 'Utbetalningen kunde inte bokföras.',
status: 500,
}
if (mapped.status >= 500) {
txLog.error('expense payout from bank transaction failed', new Error(result.detail ?? result.code))
}
return NextResponse.json({ error: mapped.message, code: result.code }, { status: mapped.status })
}
txLog.info('expense payout matched from bank transaction', {
userId: user.id,
journalEntryId: result.journal_entry_id,
batchId: result.batch_id,
totalSek: result.total_sek,
})
return NextResponse.json({
success: true,
journal_entry_id: result.journal_entry_id,
batch_id: result.batch_id,
voucher_number: result.voucher_number,
total_sek: result.total_sek,
claim_count: result.claim_count,
category: 'expense_other',
})
} catch (err) {
const typed = bookkeepingErrorResponse(err)
if (typed) return typed
txLog.error('failed to match expense payout', err as Error)
return NextResponse.json(
{ error: getErrorMessage(err, { context: 'journal_entry' }) },
{ status: 500 },
)
}
},
{ requireWrite: true },
)
+9 -3
View File
@@ -166,13 +166,17 @@ export default function AttGoraSection({
? `/api/transactions/${match.transaction_id}/match-invoice`
: match.kind === 'rot_rut_payout'
? `/api/transactions/${match.transaction_id}/match-rot-rut-payout`
: `/api/transactions/${match.transaction_id}/match-supplier-invoice`
: match.kind === 'expense_payout'
? `/api/transactions/${match.transaction_id}/match-expense-payout`
: `/api/transactions/${match.transaction_id}/match-supplier-invoice`
const body =
match.kind === 'invoice'
? { invoice_id: match.candidate_id }
: match.kind === 'rot_rut_payout'
? { request_id: match.candidate_id }
: { supplier_invoice_id: match.candidate_id }
: match.kind === 'expense_payout'
? { claim_ids: match.claim_ids ?? [] }
: { supplier_invoice_id: match.candidate_id }
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -335,7 +339,9 @@ export default function AttGoraSection({
? t('suggested_kind_invoice')
: match.kind === 'rot_rut_payout'
? t('suggested_kind_rot_rut_payout')
: t('suggested_kind_supplier_invoice')}
: match.kind === 'expense_payout'
? t('suggested_kind_expense_payout')
: t('suggested_kind_supplier_invoice')}
{match.candidate_number ? ` ${match.candidate_number}` : ''}
{match.counterparty_name ? ` · ${match.counterparty_name}` : ''}
{' · '}
@@ -3116,15 +3116,20 @@ export function PayerChoiceSelect({
accountingMethod: AccountingMethod
}) {
const t = useTranslations('inbox_workspace')
// An enskild firma owner makes an egen insättning, not a loan to the
// company: no debt, nothing to pay out, so the help line says so.
const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma'
// Företaget carries no help line: the button under it ("Matcha mot
// transaktion") already says what happens. The other answers name the
// liability the company takes on, which is the consequence worth reading.
const helpKey = (choice: PayerChoice): string | null =>
choice === 'company'
? null
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
: choice === 'owner' && isEf
? 'payer_help_owner_ef'
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
const selectedHelp = helpKey(value)
return (
<div className="space-y-1.5">
@@ -103,6 +103,11 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const [employeesLoaded, setEmployeesLoaded] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
// A foreign receipt carries VAT the company cannot deduct on 2641: the whole
// amount is cost. The wizard asked for the seller's country; here the
// currency is the signal, and the note under the preview says so.
const isForeign = currency !== 'SEK'
const isEf = entityType === 'enskild_firma'
// Reset per open so a previous underlag's numbers never carry over.
useEffect(() => {
@@ -112,10 +117,10 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
const total = data?.totals?.total
const vat = data?.totals?.vatAmount
setAmountInput(total != null && total > 0 ? String(roundOre(total)).replace('.', ',') : '')
setVatInput(vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
setExpenseAccount('')
setEmployeeId('')
}, [open, item.id, data])
}, [open, item.id, data, isForeign])
useEffect(() => {
if (!open || payer !== 'employee' || employeesLoaded) return
@@ -221,7 +226,9 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
<DialogTitle>{t('expense_dialog_title')}</DialogTitle>
<DialogDescription>
{payer === 'owner'
? t('expense_dialog_help_owner', { account: liabilityAccount })
? isEf
? t('expense_dialog_help_owner_ef')
: t('expense_dialog_help_owner', { account: liabilityAccount })
: t('expense_dialog_help_employee')}
</DialogDescription>
</DialogHeader>
@@ -321,10 +328,12 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
{vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''}
{` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}
</p>
{claimantName && (
<p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
{payer === 'owner' && isEf ? (
<p>{t('expense_outcome_ef')}</p>
) : (
claimantName && <p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
)}
{currency !== 'SEK' && <p>{t('expense_fx_note')}</p>}
{isForeign && <p>{t('expense_fx_note')}</p>}
</div>
)}
</div>
@@ -0,0 +1,224 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import { groupExpenseClaimsByPerson } from '@/lib/expenses/expense-payout-candidates'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
import type { TransactionWithInvoice } from './transaction-types'
interface ClaimRow {
id: string
employee_id: string | null
claimant_name: string
liability_account: string
amount_sek: number | string
expense_date: string
description: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
onMatched: (transactionId: string, journalEntryId: string, personKey: string) => void
}
/**
* Manual "Matcha mot utlägg": for an outflow that the exact-amount suggestion
* did not pair (two receipts of three were paid, or the amount covers a
* subset), the user picks the person and the receipts the transfer covers.
* The sum must equal the transfer to the öre: the same RPC as the one-click
* path books it, so a partial receipt can never be marked paid.
*/
export default function ExpenseClaimPickerDialog({ open, onOpenChange, transaction, onMatched }: Props) {
const t = useTranslations('tx_expense_claim_picker')
const { toast } = useToast()
const [claims, setClaims] = useState<ClaimRow[]>([])
const [loading, setLoading] = useState(false)
const [personKey, setPersonKey] = useState<string>('')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [submitting, setSubmitting] = useState(false)
const transferOre = transaction ? Math.round(Math.abs(transaction.amount) * 100) : 0
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
setSelected(new Set())
fetch('/api/expense-claims?status=registered')
.then((res) => (res.ok ? res.json() : { data: [] }))
.then((json) => {
if (cancelled) return
const rows = ((json?.data ?? []) as ClaimRow[]).filter((r) => r.liability_account !== '2018')
setClaims(rows)
const people = groupExpenseClaimsByPerson(rows)
// Preselect the person (and all their receipts) when one person's
// total equals the transfer; otherwise the first person, nothing ticked.
const exact = people.find((p) => Math.round(p.total_sek * 100) === transferOre)
const first = exact ?? people[0]
setPersonKey(first?.key ?? '')
setSelected(new Set(exact ? exact.claim_ids : []))
})
.catch(() => {
if (!cancelled) setClaims([])
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, transferOre])
const people: ExpensePayoutDue[] = useMemo(() => groupExpenseClaimsByPerson(claims), [claims])
const personClaims = useMemo(
() =>
claims
.filter((c) => (c.employee_id ?? `owner:${c.claimant_name}`) === personKey)
.sort((a, b) => a.expense_date.localeCompare(b.expense_date)),
[claims, personKey],
)
const selectedOre = personClaims
.filter((c) => selected.has(c.id))
.reduce((sum, c) => sum + Math.round((Number(c.amount_sek) || 0) * 100), 0)
const diff = roundOre((selectedOre - transferOre) / 100)
const canConfirm = !submitting && !loading && selected.size > 0 && selectedOre === transferOre
const toggle = (id: string, checked: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const handleConfirm = async () => {
if (!transaction || !canConfirm) return
setSubmitting(true)
try {
const res = await fetch(`/api/transactions/${transaction.id}/match-expense-payout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ claim_ids: [...selected] }),
})
const result = await res.json().catch(() => ({}))
if (!res.ok) {
toast({
title: t('failed_title'),
description: getErrorMessage(result, { context: 'transaction', statusCode: res.status }),
variant: 'destructive',
})
return
}
onMatched(transaction.id, result.journal_entry_id, personKey)
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
</div>
) : people.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('no_claims')}</p>
) : (
<div className="space-y-4">
{people.length > 1 && (
<div className="space-y-1.5">
<Label>{t('person_label')}</Label>
<Select
value={personKey}
onValueChange={(next) => {
setPersonKey(next)
setSelected(new Set())
}}
>
<SelectTrigger className="h-9 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{people.map((p) => (
<SelectItem key={p.key} value={p.key}>
{p.claimant_name} · {formatCurrency(p.total_sek)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="rounded-lg border border-border">
{personClaims.map((c) => (
<label
key={c.id}
className="flex cursor-pointer items-center gap-3 border-b border-border px-3 py-2.5 text-[13px] last:border-b-0 hover:bg-secondary/30"
>
<Checkbox
checked={selected.has(c.id)}
onCheckedChange={(checked) => toggle(c.id, checked === true)}
disabled={submitting}
/>
<span className="w-[86px] shrink-0 tabular-nums text-muted-foreground">{formatDate(c.expense_date)}</span>
<span className="min-w-0 flex-1 truncate">{c.description}</span>
<span className="shrink-0 tabular-nums">{formatCurrency(Number(c.amount_sek))}</span>
</label>
))}
</div>
<div className="flex items-baseline justify-between text-xs text-muted-foreground tabular-nums">
<span>{t('selected_sum', { amount: formatCurrency(selectedOre / 100) })}</span>
<span>{t('transfer_sum', { amount: formatCurrency(transferOre / 100) })}</span>
</div>
{selected.size > 0 && diff !== 0 && (
<p className="text-xs text-attn tabular-nums">{t('diff', { amount: formatCurrency(diff) })}</p>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
{t('cancel')}
</Button>
<Button onClick={handleConfirm} disabled={!canConfirm}>
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,90 @@
'use client'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Loader2 } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface ExpensePayoutMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
/** Row carrying `potential_expense_payout`; the dialog renders nothing without it. */
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: () => void
}
/**
* Confirm dialog for booking an outgoing bank row as the repayment of one
* person's registered utlägg: the person's liability account is debited,
* the row's cash account credited, the claims flip to paid and the row is
* linked to the voucher, all in one RPC call. Two legs, amount from the bank
* row, so everything to approve is known up front (convention 10).
*/
export default function ExpensePayoutMatchDialog({
open,
onOpenChange,
transaction,
isConfirming,
onConfirm,
}: ExpensePayoutMatchDialogProps) {
const t = useTranslations('tx_expense_payout_match')
const match = transaction?.potential_expense_payout ?? null
const currency = transaction?.currency || 'SEK'
const amount = transaction ? Math.abs(transaction.amount) : 0
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription>{t('description')}</DialogDescription>
</DialogHeader>
{transaction && match && (
<div className="space-y-4">
<div className="rounded-lg border border-border p-4 space-y-1">
<p className="font-medium">{transaction.description}</p>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground tabular-nums">{formatDate(transaction.date)}</span>
<span className="font-medium tabular-nums">{formatCurrency(-amount, currency)}</span>
</div>
</div>
<div className="rounded-lg border border-border p-4 space-y-1 text-sm">
<p className="font-medium">{match.claimant_name}</p>
<p className="text-muted-foreground">
{match.claim_count === 1
? t('claims_one', { date: formatDate(match.oldest_expense_date) })
: t('claims_other', { count: match.claim_count, date: formatDate(match.oldest_expense_date) })}
</p>
<p className="text-xs text-muted-foreground tabular-nums pt-2 border-t border-border">
{match.liability_account} D {formatCurrency(amount, 'SEK')} · 19xx K {formatCurrency(amount, 'SEK')}
</p>
<p className="text-xs text-muted-foreground">{t('outcome')}</p>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
{t('cancel')}
</Button>
<Button onClick={onConfirm} disabled={isConfirming || !match}>
{isConfirming && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -72,6 +72,10 @@ interface TransactionInboxCardProps {
/** Open the existing-verifikat matcher: link the bank tx to an already-booked
* voucher (salary, Fortnox import, manual entry) with no new bokföring. */
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
/** Open the utlägg picker: book this outflow as the repayment of chosen
* registered claims (sum must equal the row). Passed only while the company
* has open claims, so the item never shows for the companies without any. */
onOpenMatchExpense?: (transaction: TransactionWithInvoice) => void
/** Open the attach-underlag dialog: pin an inbox document or a fresh upload
* to the transaction (the tx→doc mirror of the Documents view's matcher). */
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
@@ -116,6 +120,7 @@ export default function TransactionInboxCard({
onOpenMatchInvoicePicker,
onOpenSplitMatch,
onOpenMatchVoucher,
onOpenMatchExpense,
onOpenAttachDocument,
onDetachDocument,
onOpenCategoryDialog,
@@ -184,6 +189,9 @@ export default function TransactionInboxCard({
// Skatteverkets ROT/RUT-utbetalning for an open begäran: same 1-click
// shortcut as an invoice match, confirmed in its own dialog.
const hasRotRutPayoutMatch = !!transaction.potential_rot_rut_payout && !transaction.journal_entry_id
// A transfer that repays one person's registered utlägg to the öre: same
// 1-click shortcut, confirmed in its own dialog.
const hasExpensePayoutMatch = !!transaction.potential_expense_payout && !transaction.journal_entry_id
const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id
const selectable = isUncategorized && canWrite
// Unbooked rows are still actionable (match, split, edit, categorize): that
@@ -207,7 +215,9 @@ export default function TransactionInboxCard({
})
: hasRotRutPayoutMatch
? t('match_rot_rut_payout_btn', { name: transaction.potential_rot_rut_payout!.name })
: null
: hasExpensePayoutMatch
? t('match_expense_payout_btn', { name: transaction.potential_expense_payout!.claimant_name })
: null
// Primary action: invoice/supplier-invoice match keeps the 1-click
// shortcut; otherwise the user opens the template picker. Rendered as the
@@ -221,7 +231,7 @@ export default function TransactionInboxCard({
// Manual invoice-match affordance. Hidden once an auto-detected match is
// already shown as the primary button: having both makes the row noisy.
const showInvoiceMatchButton =
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch && !hasRotRutPayoutMatch
isUnbooked && !hasInvoiceMatch && !hasSupplierInvoiceMatch && !hasRotRutPayoutMatch && !hasExpensePayoutMatch
const invoiceMatchLabel = isIncome
? 'Matcha mot kundfaktura'
@@ -238,6 +248,7 @@ export default function TransactionInboxCard({
// invoice match was auto-detected: the user may want to point the bank line at
// an existing salary/Fortnox/manual voucher instead of confirming a payment.
const showMatchVoucherItem = isUnbooked && !!onOpenMatchVoucher
const showMatchExpenseItem = isUnbooked && !isIncome && !!onOpenMatchExpense && !hasExpensePayoutMatch
// "Matcha mot underlag": pin an inbox doc / fresh upload to the tx. The
// tx→doc mirror of the Documents view's "Matcha mot transaktion".
const showAttachDocumentItem = isUnbooked && canWrite && !!onOpenAttachDocument
@@ -260,7 +271,7 @@ export default function TransactionInboxCard({
const showIgnoreItem = isUnbooked && isImportedTransaction(transaction) && !!onIgnore
const showDeleteItem = canDelete && !!onDelete
const showOverflowMenu =
showInvoiceMatchButton || showMatchVoucherItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem || showIgnoreItem || showDeleteItem
showInvoiceMatchButton || showMatchVoucherItem || showMatchExpenseItem || showAttachDocumentItem || showSplitItem || showEditItem || showMoveAccountItem || showIgnoreItem || showDeleteItem
// Pre-migration history row (ISO dates compare lexically): most likely
// corresponds to an already-imported verifikat, so it carries a quiet
@@ -444,6 +455,17 @@ export default function TransactionInboxCard({
{t('match_voucher_btn')}
</DropdownMenuItem>
)}
{showMatchExpenseItem && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
onOpenMatchExpense!(transaction)
}}
>
<FileSearch className="h-4 w-4" />
{t('match_expense_btn')}
</DropdownMenuItem>
)}
{showAttachDocumentItem && (
<DropdownMenuItem
onClick={(e) => {
@@ -1,5 +1,6 @@
import type { Transaction, TransactionCategory, Invoice, Customer, SupplierInvoice, VatTreatment } from '@/types'
import type { RotRutPayoutRequestCandidate } from '@/lib/invoices/rot-rut-payout-matching'
import type { ExpensePayoutDue } from '@/lib/worklist/types'
/** Open ROT/RUT begäran hung onto an income row as a match suggestion, with
* the invoices it covers (so the user sees which fakturor the payout settles). */
@@ -23,6 +24,10 @@ export interface TransactionWithInvoice extends Transaction {
potential_supplier_invoice?: SupplierInvoice
potential_rot_rut_payout?: PotentialRotRutPayout
potential_voucher?: PotentialVoucher
/** The person whose registered utlägg this outflow repays in full
* (lib/expenses/expense-payout-candidates): computed at read time, no
* hint column. Present only while their claims are still registered. */
potential_expense_payout?: ExpensePayoutDue
}
// Page view modes. 'review' is the migrator surface: rows whose sweep
+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[]
}
/**
+32 -2
View File
@@ -2915,6 +2915,8 @@
"invoices_help": "Invoice due dates"
},
"tx_inbox_card": {
"match_expense_btn": "Match to expense claims",
"match_expense_payout_btn": "Book expense reimbursement to {name}",
"skv_counterpart_label": "Possible 1930↔1630 transfer.",
"skv_counterpart_body": "There is a skattekonto event on {date} that matches: post this voucher first, then link the skattekonto row to the same voucher instead of posting it twice.",
"match_invoice_btn": "Match invoice {number}",
@@ -3309,6 +3311,9 @@
"mixed_currency_note": "Documents in different currencies cannot be summed into a single amount. Each document is still booked separately, against its matched bank transaction and the amount the bank actually settled in SEK."
},
"inbox_workspace": {
"payer_help_owner_ef": "Booked as an owner contribution (2018). Nothing to pay out.",
"expense_dialog_help_owner_ef": "Cost and VAT are booked now as an owner contribution on account 2018. The firm owes you nothing: taking money out later is an owner withdrawal.",
"expense_outcome_ef": "No row in To do: an owner contribution is not something to pay out.",
"payer_question": "Who paid?",
"payer_company": "The company",
"payer_owner": "Me, privately",
@@ -3333,7 +3338,7 @@
"expense_vat": "VAT",
"expense_account": "Expense account",
"expense_outcome_att_gora": "Lands in To do: Pay out expenses to {name}.",
"expense_fx_note": "Booked in SEK at the Riksbank rate for the date.",
"expense_fx_note": "Foreign receipt: the VAT is not deducted, the whole amount is booked as cost in SEK at the Riksbank rate for the date.",
"expense_cancel": "Cancel",
"expense_confirm": "Book",
"expense_booked_title": "Expense booked",
@@ -6013,6 +6018,9 @@
"load_failed": "Could not load the brand. Try reloading the page."
},
"transactions": {
"expense_payout_matched_title": "Expense reimbursement booked",
"expense_payout_matched_description": "The transfer to {name} was booked and the claims marked as paid.",
"expense_payout_match_failed_title": "Could not book the reimbursement",
"counterparty_suggestion_gone_title": "That counterparty is no longer available",
"counterparty_suggestion_gone_description": "The suggestion was refreshed. Close the dialog, open it again and pick the counterparty once more.",
"page_title": "Transactions",
@@ -6689,6 +6697,7 @@
"dismiss": "Hide"
},
"dashboard": {
"suggested_kind_expense_payout": "Expense reimbursement",
"band_betala": "Pay",
"row_expense_payout": "Pay out expenses to {name}",
"row_expense_payout_detail_one": "1 receipt · {date}",
@@ -8783,5 +8792,26 @@
"attn_create": "Create suggestions",
"auto_created_title": "{count} suggestions created from the books",
"auto_created_description": "Counterparts your vouchers name that are not in the register. Add them, or hide the ones that do not belong here."
}
},
"tx_expense_payout_match": {
"title": "Book expense reimbursement",
"description": "The transfer is booked against the person's liability account, the claims are marked as paid and the transaction is linked to the voucher.",
"claims_one": "1 receipt · {date}",
"claims_other": "{count} receipts · oldest {date}",
"outcome": "The debt to the person becomes 0 kr and the row leaves To do.",
"cancel": "Cancel",
"confirm": "Book the reimbursement"
},
"tx_expense_claim_picker": {
"title": "Match to expense claims",
"description": "Pick the claims this transfer covers. The sum must equal the amount on the row exactly.",
"person_label": "Person",
"no_claims": "No open expense claims to match.",
"selected_sum": "Selected claims: {amount}",
"transfer_sum": "Transfer: {amount}",
"diff": "Difference: {amount}",
"cancel": "Cancel",
"confirm": "Book the reimbursement",
"failed_title": "Could not book the reimbursement"
}
}
+32 -2
View File
@@ -2915,6 +2915,8 @@
"invoices_help": "Förfallodatum för fakturor"
},
"tx_inbox_card": {
"match_expense_btn": "Matcha mot utlägg",
"match_expense_payout_btn": "Bokför återbetalning av utlägg till {name}",
"skv_counterpart_label": "Möjlig 1930↔1630-överföring.",
"skv_counterpart_body": "Det finns en skattekonto-händelse den {date} som matchar: bokför detta verifikat först, koppla sedan skattekonto-raden mot samma verifikat istället för att bokföra två gånger.",
"match_invoice_btn": "Matcha Faktura {number}",
@@ -3309,6 +3311,9 @@
"mixed_currency_note": "Underlag i olika valutor kan inte summeras till ett belopp. Varje underlag bokförs ändå var för sig, mot sin matchade banktransaktion och det belopp banken faktiskt drog i SEK."
},
"inbox_workspace": {
"payer_help_owner_ef": "Bokförs som egen insättning (2018). Inget att betala ut.",
"expense_dialog_help_owner_ef": "Kostnad och moms bokförs nu som egen insättning på konto 2018. Firman har ingen skuld till dig: tar du ut pengar senare är det ett eget uttag.",
"expense_outcome_ef": "Ingen rad i Att göra: en egen insättning är inget som ska betalas ut.",
"payer_question": "Vem betalade?",
"payer_company": "Företaget",
"payer_owner": "Jag, privat",
@@ -3333,7 +3338,7 @@
"expense_vat": "Moms",
"expense_account": "Kostnadskonto",
"expense_outcome_att_gora": "Hamnar i Att göra: Betala ut utlägg till {name}.",
"expense_fx_note": "Bokförs i SEK med Riksbankens kurs för datumet.",
"expense_fx_note": "Utländskt kvitto: momsen dras inte av, hela beloppet bokförs som kostnad i SEK med Riksbankens kurs för datumet.",
"expense_cancel": "Avbryt",
"expense_confirm": "Bokför",
"expense_booked_title": "Utlägget är bokfört",
@@ -6013,6 +6018,9 @@
"load_failed": "Kunde inte hämta varumärket. Prova att ladda om sidan."
},
"transactions": {
"expense_payout_matched_title": "Återbetalning av utlägg bokförd",
"expense_payout_matched_description": "Överföringen till {name} bokfördes och utläggen markerades som utbetalda.",
"expense_payout_match_failed_title": "Kunde inte bokföra återbetalningen",
"counterparty_suggestion_gone_title": "Motparten är inte längre tillgänglig",
"counterparty_suggestion_gone_description": "Förslaget hann uppdateras. Stäng rutan, öppna den igen och välj motparten på nytt.",
"page_title": "Transaktioner",
@@ -6689,6 +6697,7 @@
"dismiss": "Dölj"
},
"dashboard": {
"suggested_kind_expense_payout": "Återbetalning utlägg",
"band_betala": "Betala",
"row_expense_payout": "Betala ut utlägg till {name}",
"row_expense_payout_detail_one": "1 kvitto · {date}",
@@ -8783,5 +8792,26 @@
"attn_create": "Skapa förslag",
"auto_created_title": "{count} förslag skapade från bokföringen",
"auto_created_description": "Motparter som dina verifikat namnger men som inte finns i registret. Lägg upp dem, eller dölj de som inte hör hemma här."
}
},
"tx_expense_payout_match": {
"title": "Bokför återbetalning av utlägg",
"description": "Överföringen bokförs mot personens skuldkonto, utläggen markeras som utbetalda och transaktionen kopplas till verifikatet.",
"claims_one": "1 kvitto · {date}",
"claims_other": "{count} kvitton · äldsta {date}",
"outcome": "Skulden till personen blir 0 kr och raden försvinner från Att göra.",
"cancel": "Avbryt",
"confirm": "Bokför återbetalningen"
},
"tx_expense_claim_picker": {
"title": "Matcha mot utlägg",
"description": "Välj de utlägg som överföringen täcker. Summan måste stämma exakt med beloppet på raden.",
"person_label": "Person",
"no_claims": "Inga öppna utlägg att matcha.",
"selected_sum": "Valda utlägg: {amount}",
"transfer_sum": "Överföring: {amount}",
"diff": "Skillnad: {amount}",
"cancel": "Avbryt",
"confirm": "Bokför återbetalningen",
"failed_title": "Kunde inte bokföra återbetalningen"
}
}
@@ -0,0 +1,275 @@
-- Repayment of utlägg from the bank line.
--
-- create_expense_payout_batch gains p_transaction_id: when the caller books a
-- reimbursement FROM an unbooked bank transaction, the RPC locks that row too,
-- requires it to be an unbooked SEK outflow of exactly the claims' total, and
-- stamps it (journal_entry_id, is_business, reconciliation_method) in the same
-- transaction as the verifikat and the claims' status flip. The bank line and
-- the payout can then never be booked twice: once by "Betala ut", once by
-- categorising the bank row.
--
-- Enskild firma: a claim on 2018 (egen insättning) is not a debt, so a payout
-- for it is the owner's eget uttag and debits 2013, never 2018 (the closing
-- references net 2011/2013/2017/2018 into 2010 at year start; the sub-account
-- must say what happened).
--
-- Postgres overloads by signature, so the old 6-parameter function is dropped
-- first: leaving it in place would make a 6-argument call ambiguous against
-- the new signature with its defaulted 7th parameter.
DROP FUNCTION IF EXISTS public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid);
CREATE OR REPLACE FUNCTION public.create_expense_payout_batch(
p_company_id uuid,
p_claim_ids uuid[],
p_payout_date date,
p_cash_account text,
p_notes text DEFAULT NULL,
p_user_id uuid DEFAULT NULL,
p_transaction_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $$
DECLARE
v_caller uuid;
v_ids uuid[];
v_claim record;
v_count integer := 0;
v_first boolean := true;
v_employee_id uuid;
v_claimant_name text;
v_claimant_key text;
v_liability text;
v_total numeric(15,2) := 0;
v_period_id uuid;
v_period_locked_at timestamptz;
v_series text := 'A';
v_series_raw text;
v_batch_id uuid := gen_random_uuid();
v_je_id uuid := gen_random_uuid();
v_voucher_number integer;
v_desc text;
v_marked integer;
v_tx record;
v_tx_updated integer;
v_debit text;
BEGIN
IF auth.role() = 'service_role' THEN
v_caller := COALESCE(p_user_id, auth.uid());
ELSE
v_caller := auth.uid();
END IF;
IF v_caller IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
-- Same gate as the expense tables' write policies (owner/admin/member);
-- SECURITY DEFINER bypasses RLS, so the check has to be explicit.
IF NOT EXISTS (
SELECT 1 FROM public.company_members cm
WHERE cm.company_id = p_company_id
AND cm.user_id = v_caller
AND cm.role IN ('owner', 'admin', 'member')
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'FORBIDDEN');
END IF;
SELECT ARRAY(SELECT DISTINCT unnest(p_claim_ids)) INTO v_ids;
IF v_ids IS NULL OR cardinality(v_ids) = 0 THEN
RETURN jsonb_build_object('ok', false, 'code', 'NO_CLAIMS');
END IF;
IF p_cash_account IS NULL OR p_cash_account !~ '^19[0-9]{2}$' THEN
RETURN jsonb_build_object('ok', false, 'code', 'INVALID_CASH_ACCOUNT');
END IF;
-- Lock the claims. A concurrent caller for any of the same rows queues on
-- this lock and, once this transaction commits, reads them as 'paid'.
FOR v_claim IN
SELECT ec.id, ec.status, ec.employee_id, ec.claimant_name, ec.liability_account, ec.amount_sek
FROM public.expense_claims ec
WHERE ec.id = ANY(v_ids)
AND ec.company_id = p_company_id
ORDER BY ec.id
FOR UPDATE
LOOP
v_count := v_count + 1;
IF v_claim.status <> 'registered' THEN
RETURN jsonb_build_object('ok', false, 'code', 'ALREADY_PAID',
'details', jsonb_build_object('claim_id', v_claim.id));
END IF;
IF v_first THEN
v_employee_id := v_claim.employee_id;
v_claimant_name := v_claim.claimant_name;
v_claimant_key := COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name)));
v_liability := v_claim.liability_account;
v_first := false;
ELSE
IF COALESCE(v_claim.employee_id::text, 'name:' || lower(btrim(v_claim.claimant_name))) <> v_claimant_key THEN
RETURN jsonb_build_object('ok', false, 'code', 'MIXED_CLAIMANTS');
END IF;
IF v_claim.liability_account <> v_liability THEN
RETURN jsonb_build_object('ok', false, 'code', 'MIXED_LIABILITY');
END IF;
END IF;
v_total := v_total + v_claim.amount_sek;
END LOOP;
IF v_count <> cardinality(v_ids) THEN
RETURN jsonb_build_object('ok', false, 'code', 'CLAIMS_NOT_FOUND');
END IF;
-- Bank-line mode: the transfer that repays these claims. Locked with the
-- claims so a concurrent categorisation of the same row waits and then
-- sees it booked. The amount must equal the claims exactly (öre): a partial
-- transfer is a different payout, chosen by a different set of claims.
IF p_transaction_id IS NOT NULL THEN
SELECT t.id, t.amount, t.currency, t.date
INTO v_tx
FROM public.transactions t
WHERE t.id = p_transaction_id
AND t.company_id = p_company_id
FOR UPDATE;
IF v_tx.id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_NOT_FOUND');
END IF;
IF public.is_transaction_booked(v_tx.id) THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_ALREADY_BOOKED');
END IF;
IF upper(COALESCE(v_tx.currency, 'SEK')) <> 'SEK' THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_CURRENCY',
'details', jsonb_build_object('currency', v_tx.currency));
END IF;
IF v_tx.amount >= 0 OR round(-v_tx.amount, 2) <> v_total THEN
RETURN jsonb_build_object('ok', false, 'code', 'TX_AMOUNT_MISMATCH',
'details', jsonb_build_object('transaction_amount', v_tx.amount, 'claims_total', v_total));
END IF;
END IF;
-- Open fiscal year covering the payout date (mirrors engine.findFiscalPeriod).
SELECT fp.id, fp.locked_at
INTO v_period_id, v_period_locked_at
FROM public.fiscal_periods fp
WHERE fp.company_id = p_company_id
AND fp.period_start <= p_payout_date
AND fp.period_end >= p_payout_date
AND fp.is_closed = false
ORDER BY fp.period_start DESC
LIMIT 1;
IF v_period_id IS NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'FISCAL_PERIOD_NOT_FOUND');
END IF;
IF v_period_locked_at IS NOT NULL THEN
RETURN jsonb_build_object('ok', false, 'code', 'PERIOD_LOCKED',
'details', jsonb_build_object('fiscal_period_id', v_period_id));
END IF;
IF NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts a
WHERE a.company_id = p_company_id
AND a.account_number = p_cash_account
AND COALESCE(a.is_active, true)
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
'details', jsonb_build_object('account', p_cash_account));
END IF;
v_debit := CASE WHEN v_liability = '2018' THEN '2013' ELSE v_liability END;
IF NOT EXISTS (
SELECT 1 FROM public.chart_of_accounts a
WHERE a.company_id = p_company_id
AND a.account_number = v_debit
AND COALESCE(a.is_active, true)
) THEN
RETURN jsonb_build_object('ok', false, 'code', 'ACCOUNT_NOT_IN_CHART',
'details', jsonb_build_object('account', v_debit));
END IF;
-- Voucher series: the per-source-type default from company_settings, 'A'
-- otherwise (mirrors resolveDefaultSeriesForSource).
SELECT cs.default_voucher_series_per_source_type ->> 'expense_payout'
INTO v_series_raw
FROM public.company_settings cs
WHERE cs.company_id = p_company_id;
IF v_series_raw ~ '^[A-Z]$' THEN
v_series := v_series_raw;
END IF;
v_desc := 'Utbetalning utlägg: ' || v_claimant_name || ' (' || v_count || ' st)';
INSERT INTO public.expense_payout_batches
(id, company_id, user_id, employee_id, claimant_name, payout_date,
cash_account, liability_account, total_sek, notes)
VALUES
(v_batch_id, p_company_id, v_caller, v_employee_id, v_claimant_name, p_payout_date,
p_cash_account, v_liability, v_total, p_notes);
INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, source_id, status)
VALUES
(v_je_id, v_caller, p_company_id, v_period_id, 0, v_series,
p_payout_date, v_desc, 'expense_payout', v_batch_id, 'draft');
INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount, currency, sort_order, line_description)
VALUES
(v_je_id, v_debit, v_total, 0, 'SEK', 0, v_desc),
(v_je_id, p_cash_account, 0, v_total, 'SEK', 1, v_desc);
SELECT voucher_number INTO v_voucher_number
FROM public.commit_journal_entry(p_company_id, v_je_id);
UPDATE public.expense_payout_batches
SET journal_entry_id = v_je_id
WHERE id = v_batch_id AND company_id = p_company_id;
UPDATE public.expense_claims
SET status = 'paid', payout_batch_id = v_batch_id
WHERE id = ANY(v_ids)
AND company_id = p_company_id
AND status = 'registered';
GET DIAGNOSTICS v_marked = ROW_COUNT;
IF v_marked <> cardinality(v_ids) THEN
-- Cannot happen while the rows are locked above; if it ever does, the
-- exception rolls back the batch and the verifikat together.
RAISE EXCEPTION 'create_expense_payout_batch: marked % of % claims paid', v_marked, cardinality(v_ids);
END IF;
IF p_transaction_id IS NOT NULL THEN
-- Same stamp as the bulk-book RPCs: the 1:1 pointer plus is_business, so
-- every "unbooked" predicate (inbox, worklist, badges) drops the row.
UPDATE public.transactions
SET journal_entry_id = v_je_id,
is_business = TRUE,
reconciliation_method = 'manual',
updated_at = now()
WHERE id = p_transaction_id
AND company_id = p_company_id
AND journal_entry_id IS NULL;
GET DIAGNOSTICS v_tx_updated = ROW_COUNT;
IF v_tx_updated <> 1 THEN
RAISE EXCEPTION 'create_expense_payout_batch: transaction % could not be linked', p_transaction_id;
END IF;
END IF;
RETURN jsonb_build_object(
'ok', true,
'batch_id', v_batch_id,
'journal_entry_id', v_je_id,
'voucher_number', v_voucher_number,
'total_sek', v_total,
'claim_count', cardinality(v_ids),
'transaction_id', p_transaction_id
);
END;
$$;
REVOKE ALL ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid, uuid) TO authenticated, service_role;
COMMENT ON FUNCTION public.create_expense_payout_batch(uuid, uuid[], date, text, text, uuid, uuid) IS
'Books one reimbursement transfer for N registered expense claims atomically: locks the claims (and the bank transaction when given), posts liability -> cash via commit_journal_entry, marks the claims paid and links the transaction.';
NOTIFY pgrst, 'reload schema';
+102 -7
View File
@@ -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 },
])
})
})