diff --git a/DECISIONS.md b/DECISIONS.md index 1e1328e0..941665d8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1607,6 +1607,8 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 22281dbe..38fb1198 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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; people: ExpensePayoutDue[] }> { + const out = new Map() + 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[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(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 && ( + + )} + + {matchExpenseTx && ( + { if (!o) setMatchExpenseTx(null) }} + transaction={matchExpenseTx} + onMatched={handleExpenseClaimsPicked} + /> + )} + {matchVoucherTx && ( = { - 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) diff --git a/app/api/transactions/[id]/match-expense-payout/__tests__/route.test.ts b/app/api/transactions/[id]/match-expense-payout/__tests__/route.test.ts new file mode 100644 index 00000000..1299b885 --- /dev/null +++ b/app/api/transactions/[id]/match-expense-payout/__tests__/route.test.ts @@ -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 = {}) { + 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') + }) +}) diff --git a/app/api/transactions/[id]/match-expense-payout/route.ts b/app/api/transactions/[id]/match-expense-payout/route.ts new file mode 100644 index 00000000..66f91c62 --- /dev/null +++ b/app/api/transactions/[id]/match-expense-payout/route.ts @@ -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 }, +) diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index bff3d69d..3298cb90 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -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}` : ''} {' · '} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index cddc7e18..ed5920ee 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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 (
diff --git a/components/extensions/general/RegisterExpenseDialog.tsx b/components/extensions/general/RegisterExpenseDialog.tsx index c002b667..cab5b7b4 100644 --- a/components/extensions/general/RegisterExpenseDialog.tsx +++ b/components/extensions/general/RegisterExpenseDialog.tsx @@ -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, {t('expense_dialog_title')} {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')} @@ -321,10 +328,12 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, {vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''} {` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}

- {claimantName && ( -

{t('expense_outcome_att_gora', { name: claimantName })}

+ {payer === 'owner' && isEf ? ( +

{t('expense_outcome_ef')}

+ ) : ( + claimantName &&

{t('expense_outcome_att_gora', { name: claimantName })}

)} - {currency !== 'SEK' &&

{t('expense_fx_note')}

} + {isForeign &&

{t('expense_fx_note')}

}
)} diff --git a/components/transactions/ExpenseClaimPickerDialog.tsx b/components/transactions/ExpenseClaimPickerDialog.tsx new file mode 100644 index 00000000..a7f63e6b --- /dev/null +++ b/components/transactions/ExpenseClaimPickerDialog.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [personKey, setPersonKey] = useState('') + const [selected, setSelected] = useState>(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 ( + !submitting && onOpenChange(next)}> + + + {t('title')} + {t('description')} + + + {loading ? ( +
+ +
+ ) : people.length === 0 ? ( +

{t('no_claims')}

+ ) : ( +
+ {people.length > 1 && ( +
+ + +
+ )} + +
+ {personClaims.map((c) => ( + + ))} +
+ +
+ {t('selected_sum', { amount: formatCurrency(selectedOre / 100) })} + {t('transfer_sum', { amount: formatCurrency(transferOre / 100) })} +
+ {selected.size > 0 && diff !== 0 && ( +

{t('diff', { amount: formatCurrency(diff) })}

+ )} +
+ )} + + + + + +
+
+ ) +} diff --git a/components/transactions/ExpensePayoutMatchDialog.tsx b/components/transactions/ExpensePayoutMatchDialog.tsx new file mode 100644 index 00000000..461c524e --- /dev/null +++ b/components/transactions/ExpensePayoutMatchDialog.tsx @@ -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 ( + + + + {t('title')} + {t('description')} + + + {transaction && match && ( +
+
+

{transaction.description}

+
+ {formatDate(transaction.date)} + {formatCurrency(-amount, currency)} +
+
+ +
+

{match.claimant_name}

+

+ {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) })} +

+

+ {match.liability_account} D {formatCurrency(amount, 'SEK')} · 19xx K {formatCurrency(amount, 'SEK')} +

+

{t('outcome')}

+
+
+ )} + + + + + +
+
+ ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 8b97fb2c..2b203db8 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -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')} )} + {showMatchExpenseItem && ( + { + e.stopPropagation() + onOpenMatchExpense!(transaction) + }} + > + + {t('match_expense_btn')} + + )} {showAttachDocumentItem && ( { diff --git a/components/transactions/transaction-types.ts b/components/transactions/transaction-types.ts index ca4ba667..9254aaa4 100644 --- a/components/transactions/transaction-types.ts +++ b/components/transactions/transaction-types.ts @@ -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 diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 27b9aa6d..42dc7b88 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -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 diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts index 992e68c0..9176df09 100644 --- a/lib/errors/structured-errors.ts +++ b/lib/errors/structured-errors.ts @@ -995,6 +995,26 @@ const INVOICE: Record = { 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.', diff --git a/lib/expenses/__tests__/expense-claims-service.test.ts b/lib/expenses/__tests__/expense-claims-service.test.ts index 50ae179e..4fdaad10 100644 --- a/lib/expenses/__tests__/expense-claims-service.test.ts +++ b/lib/expenses/__tests__/expense-claims-service.test.ts @@ -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' } } }) diff --git a/lib/expenses/__tests__/expense-payout-candidates.test.ts b/lib/expenses/__tests__/expense-payout-candidates.test.ts new file mode 100644 index 00000000..f0a510cc --- /dev/null +++ b/lib/expenses/__tests__/expense-payout-candidates.test.ts @@ -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) + }) +}) diff --git a/lib/expenses/expense-claims-service.ts b/lib/expenses/expense-claims-service.ts index 7a97e579..85734684 100644 --- a/lib/expenses/expense-claims-service.ts +++ b/lib/expenses/expense-claims-service.ts @@ -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 = new Set() + 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 { + const out = new Map() + if (people.length === 0 || transactions.length === 0) return out + const byOre = new Map() + 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 +} diff --git a/lib/expenses/payout-error-messages.ts b/lib/expenses/payout-error-messages.ts new file mode 100644 index 00000000..523d1668 --- /dev/null +++ b/lib/expenses/payout-error-messages.ts @@ -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 = { + 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, + }, +} diff --git a/lib/worklist/__tests__/categories.test.ts b/lib/worklist/__tests__/categories.test.ts index d8ad11fd..17149af0 100644 --- a/lib/worklist/__tests__/categories.test.ts +++ b/lib/worklist/__tests__/categories.test.ts @@ -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') + }) +}) diff --git a/lib/worklist/categories.ts b/lib/worklist/categories.ts index ff459556..52e4aa88 100644 --- a/lib/worklist/categories.ts +++ b/lib/worklist/categories.ts @@ -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 { 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() - 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 { 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 { + 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 +} diff --git a/lib/worklist/types.ts b/lib/worklist/types.ts index 95bd8828..66b8f7dd 100644 --- a/lib/worklist/types.ts +++ b/lib/worklist/types.ts @@ -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[] } /** diff --git a/messages/en.json b/messages/en.json index 8b8f49e7..0a05ab06 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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" + } } diff --git a/messages/sv.json b/messages/sv.json index d139191f..8e36b372 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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" + } } diff --git a/supabase/migrations/20260905183000_expense_payout_from_bank_transaction.sql b/supabase/migrations/20260905183000_expense_payout_from_bank_transaction.sql new file mode 100644 index 00000000..2019b2d5 --- /dev/null +++ b/supabase/migrations/20260905183000_expense_payout_from_bank_transaction.sql @@ -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'; diff --git a/tests/pg/expense-payout-batch-rpc.pg.test.ts b/tests/pg/expense-payout-batch-rpc.pg.test.ts index 377a2a18..bf9b0445 100644 --- a/tests/pg/expense-payout-batch-rpc.pg.test.ts +++ b/tests/pg/expense-payout-batch-rpc.pg.test.ts @@ -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 { 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 { 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 }, + ]) + }) })