feat(expenses): repay utlägg from the bank line (#2333)
* feat(expenses): repay utlägg from the bank line
The transfer that repays a person's registered utlägg is now booked from
the bank inbox (or one click on Hem) instead of ahead of it: the payout RPC
takes the unbooked bank transaction, requires an SEK outflow equal to the
claims' total to the öre, posts liability D / 19xx K, marks the claims paid
and links the row in one locked transaction. The same transfer can no
longer be booked twice (once by "Betala ut", once by categorising the row).
- create_expense_payout_batch(..., p_transaction_id): old signature dropped
so a 6-argument call cannot become ambiguous; refusals TX_NOT_FOUND,
TX_ALREADY_BOOKED, TX_CURRENCY, TX_AMOUNT_MISMATCH
- POST /api/transactions/[id]/match-expense-payout { claim_ids }
- lib/expenses/expense-payout-candidates: pure per-person grouping and
outflow pairing (one person per amount; shared totals are skipped)
- Hem suggested matches gain kind 'expense_payout'; the inbox row gets a
primary "Bokför återbetalning av utlägg till {name}" and a two-leg confirm
- PAYOUT_ERROR_MESSAGES shared by both payout routes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ
* feat(expenses): close the utlägg gaps: claim picker, foreign VAT, enskild firma
- "Matcha mot utlägg" in the inbox row menu: pick the person and the
receipts a transfer covers when the exact-amount pairing missed it. The
picked sum must equal the row to the öre; the same RPC books it.
- A foreign receipt defaults VAT to 0 in the Underlag dialog with a note:
foreign VAT is not deductible on 2641.
- Enskild firma: a claim on 2018 is egen insättning, not a debt. Excluded
from Att göra, the attention resource, suggestions and the picker; a
payout for it debits 2013 (eget uttag), never 2018. Copy in the pane and
the dialog says so.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
238cbe13f9
commit
b71f2bf425
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
Reference in New Issue
Block a user