diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 363d6206..51c55ada 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -48,8 +48,10 @@ import type { CategorizeHandler, PotentialVoucher, PotentialRotRutPayout, + PotentialRotRutPayoutRequest, } from '@/components/transactions/transaction-types' import { OPEN_ROT_RUT_PAYOUT_STATUSES } from '@/lib/invoices/rot-rut-payout-matching' +import { matchTransactionsToRotRutPayoutSets } from '@/lib/invoices/rot-rut-payout-set-matching' import { groupExpenseClaimsByPerson, matchTransactionsToExpensePayouts, @@ -241,7 +243,15 @@ async function fetchExpensePayoutMatches( // prod schema cache (see DECISIONS.md 2026-07-06). async function fetchPotentialMatches( supabase: SupabaseClient, + companyId: string | null, rows: { + id: string + amount: number + currency: string | null + description?: string | null + merchant_name?: string | null + is_business: boolean | null + journal_entry_id: string | null potential_invoice_id: string | null potential_supplier_invoice_id: string | null potential_rot_rut_payout_request_id?: string | null @@ -251,13 +261,6 @@ async function fetchPotentialMatches( const potentialInvoiceIds = Array.from( new Set(rows.flatMap((t) => (t.potential_invoice_id ? [t.potential_invoice_id] : []))), ) - const potentialRotRutRequestIds = Array.from( - new Set( - rows.flatMap((t) => - t.potential_rot_rut_payout_request_id ? [t.potential_rot_rut_payout_request_id] : [], - ), - ), - ) const potentialSupplierInvoiceIds = Array.from( new Set(rows.flatMap((t) => (t.potential_supplier_invoice_id ? [t.potential_supplier_invoice_id] : []))), ) @@ -280,7 +283,7 @@ async function fetchPotentialMatches( // an unmatchable candidate must not reach the row or the match dialog, which // would otherwise compare the transaction against a 0 kr remaining balance // and call it a partial payment. - const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResults] = await Promise.all([ + const [invoiceResults, supplierInvoiceResults, voucherResults, rotRutResult] = await Promise.all([ Promise.all( chunks(potentialInvoiceIds).map((ids) => supabase @@ -314,21 +317,22 @@ async function fetchPotentialMatches( .eq('status', 'posted'), ), ), - // Open ROT/RUT begäran (Skatteverkets utbetalning). Revalidated like the - // invoice hints: a request settled by another row must not reach the - // dialog. Items ride along so the dialog can list the covered invoices. - Promise.all( - chunks(potentialRotRutRequestIds).map((ids) => - supabase + // Open ROT/RUT begäran (Skatteverkets utbetalning): the company's whole + // open pool, not the hinted ids. It serves both the persisted 1:1 hint + // (revalidated: a request settled by another row must not reach the + // dialog) and the read-time covering set for a row Skatteverket paid + // together with other beslut (#2239). Items ride along so the dialog can + // list the covered invoices. A handful of rows per company. + companyId + ? supabase .from('rot_rut_payout_requests') .select( 'id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id, items:rot_rut_payout_request_items(requested_amount, invoice:invoices(invoice_number))', ) - .in('id', ids) + .eq('company_id', companyId) .in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES]) - .is('settlement_journal_entry_id', null), - ), - ), + .is('settlement_journal_entry_id', null) + : Promise.resolve({ data: null, error: null }), ]) // Non-fatal: the transaction list still renders without match hints, but @@ -342,12 +346,11 @@ async function fetchPotentialMatches( for (const r of voucherResults) { if (r.error) console.error('[fetchPotentialMatches] journal_entries query failed', r.error) } - for (const r of rotRutResults) { - if (r.error) console.error('[fetchPotentialMatches] rot_rut_payout_requests query failed', r.error) + if (rotRutResult.error) { + console.error('[fetchPotentialMatches] rot_rut_payout_requests query failed', rotRutResult.error) } - const rotRutMap: Record = {} - for (const req of rotRutResults.flatMap((r) => (r.data ?? []) as Array<{ + const rotRutRequests: PotentialRotRutPayoutRequest[] = ((rotRutResult.data ?? []) as Array<{ id: string name: string deduction_type: 'rot' | 'rut' @@ -359,20 +362,31 @@ async function fetchPotentialMatches( requested_amount: number | string invoice?: { invoice_number: string | null } | { invoice_number: string | null }[] | null }> | null - }>)) { - rotRutMap[req.id] = { - id: req.id, - name: req.name, - deduction_type: req.deduction_type, - status: req.status, - requested_total: req.requested_total, - decided_total: req.decided_total, - settlement_journal_entry_id: req.settlement_journal_entry_id, - invoices: (req.items ?? []).map((item) => { - const inv = Array.isArray(item.invoice) ? item.invoice[0] : item.invoice - return { invoice_number: inv?.invoice_number ?? null, requested_amount: item.requested_amount } - }), - } + }>).map((req) => ({ + id: req.id, + name: req.name, + deduction_type: req.deduction_type, + status: req.status, + requested_total: req.requested_total, + decided_total: req.decided_total, + settlement_journal_entry_id: req.settlement_journal_entry_id, + invoices: (req.items ?? []).map((item) => { + const inv = Array.isArray(item.invoice) ? item.invoice[0] : item.invoice + return { invoice_number: inv?.invoice_number ?? null, requested_amount: item.requested_amount } + }), + })) + const rotRutById = new Map(rotRutRequests.map((req) => [req.id, req] as const)) + // Per row: the persisted 1:1 hint when its begäran is still open, else the + // exact covering set over the open pool (several begäran in one transfer). + const rotRutByTransaction = new Map() + for (const row of rows) { + const hinted = row.potential_rot_rut_payout_request_id + ? rotRutById.get(row.potential_rot_rut_payout_request_id) + : undefined + if (hinted) rotRutByTransaction.set(row.id, { requests: [hinted] }) + } + for (const [txId, match] of matchTransactionsToRotRutPayoutSets(rows, rotRutRequests)) { + rotRutByTransaction.set(txId, { requests: match.requests }) } const voucherMap: Record = {} @@ -396,7 +410,7 @@ async function fetchPotentialMatches( invoiceMap: buildInvoiceMap(invoiceResults.flatMap((r) => r.data ?? [])), supplierInvoiceMap: buildSupplierInvoiceMap(supplierInvoiceResults.flatMap((r) => r.data ?? [])), voucherMap, - rotRutMap, + rotRutByTransaction, } } @@ -1171,8 +1185,8 @@ 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 }, expensePayouts] = await Promise.all([ - fetchPotentialMatches(supabase, allRows), + const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutByTransaction }, expensePayouts] = await Promise.all([ + fetchPotentialMatches(supabase, companyId, allRows), fetchExpensePayoutMatches(supabase, companyId, allRows), ]) const expensePayoutMap = expensePayouts.byTransaction @@ -1189,9 +1203,7 @@ export default function TransactionsPage() { potential_supplier_invoice: t.potential_supplier_invoice_id ? supplierInvoiceMap[t.potential_supplier_invoice_id] : undefined, - potential_rot_rut_payout: t.potential_rot_rut_payout_request_id - ? rotRutMap[t.potential_rot_rut_payout_request_id] - : undefined, + potential_rot_rut_payout: rotRutByTransaction.get(t.id), potential_voucher: t.potential_journal_entry_id ? voucherMap[t.potential_journal_entry_id] : undefined, @@ -1265,8 +1277,8 @@ 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 }, expensePayouts] = await Promise.all([ - fetchPotentialMatches(supabase, txData), + const [{ invoiceMap, supplierInvoiceMap, voucherMap, rotRutByTransaction }, expensePayouts] = await Promise.all([ + fetchPotentialMatches(supabase, companyId, txData), fetchExpensePayoutMatches(supabase, companyId, txData), ]) const expensePayoutMap = expensePayouts.byTransaction @@ -1285,9 +1297,7 @@ export default function TransactionsPage() { potential_supplier_invoice: t.potential_supplier_invoice_id ? supplierInvoiceMap[t.potential_supplier_invoice_id] : undefined, - potential_rot_rut_payout: t.potential_rot_rut_payout_request_id - ? rotRutMap[t.potential_rot_rut_payout_request_id] - : undefined, + potential_rot_rut_payout: rotRutByTransaction.get(t.id), potential_voucher: t.potential_journal_entry_id ? voucherMap[t.potential_journal_entry_id] : undefined, @@ -2393,15 +2403,18 @@ export default function TransactionsPage() { async function handleConfirmRotRutPayoutMatch() { if (!selectedTransaction?.potential_rot_rut_payout) return - const request = selectedTransaction.potential_rot_rut_payout + const { requests } = selectedTransaction.potential_rot_rut_payout + if (requests.length === 0) return setIsConfirmingMatch(true) try { + // One begäran or a bundle Skatteverket paid together: the route books + // one voucher either way and takes the ids as request_ids. const response = await fetch( `/api/transactions/${selectedTransaction.id}/match-rot-rut-payout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ request_id: request.id }), + body: JSON.stringify({ request_ids: requests.map((request) => request.id) }), }, ) const result = await response.json() @@ -2417,7 +2430,13 @@ export default function TransactionsPage() { toast({ title: t('rot_rut_payout_matched_title'), - description: t('rot_rut_payout_matched_description', { name: request.name }), + description: + requests.length === 1 + ? t('rot_rut_payout_matched_description', { name: requests[0].name }) + : t('rot_rut_payout_matched_set_description', { + count: requests.length, + names: requests.map((request) => request.name).join(', '), + }), }) setRotRutMatchDialogOpen(false) @@ -2694,7 +2713,7 @@ export default function TransactionsPage() { setMatchDialogOpen(true) } - function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayout) { + function handleSelectRotRutPayoutFromPicker(request: PotentialRotRutPayoutRequest) { if (!invoicePickerTransaction) return // Same handoff as the invoice pick: close the picker, hang the request on // the row and open the ROT/RUT confirm dialog so the user sees the @@ -2702,7 +2721,7 @@ export default function TransactionsPage() { const tx = invoicePickerTransaction setInvoicePickerOpen(false) setInvoicePickerTransaction(null) - setSelectedTransaction({ ...tx, potential_rot_rut_payout: request }) + setSelectedTransaction({ ...tx, potential_rot_rut_payout: { requests: [request] } }) setRotRutMatchDialogOpen(true) } diff --git a/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts b/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts index 5be38b07..f623bbec 100644 --- a/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-rot-rut-payout/__tests__/route.test.ts @@ -28,8 +28,10 @@ vi.mock('@/lib/init', () => ({ })) const mockSettle = vi.fn() +const mockSettleSet = vi.fn() vi.mock('@/lib/invoices/rot-rut-settle', () => ({ settleRotRutPayoutRequest: (...args: unknown[]) => mockSettle(...args), + settleRotRutPayoutRequestSet: (...args: unknown[]) => mockSettleSet(...args), })) const mockResolveSettlementAccount = vi.fn() @@ -218,4 +220,80 @@ describe('POST /api/transactions/[id]/match-rot-rut-payout', () => { const conflict = await POST(makeReq(), routeParams) expect(conflict.status).toBe(409) }) + + // Several begäran paid in ONE transfer (#2239): request_ids. + const REQUEST_ID_2 = '33333333-3333-4333-8333-333333333333' + + it('returns 400 when neither or both of request_id and request_ids are given', async () => { + let response = await POST(makeReq({}), routeParams) + expect(response.status).toBe(400) + response = await POST( + makeReq({ request_id: REQUEST_ID, request_ids: [REQUEST_ID, REQUEST_ID_2] }), + routeParams, + ) + expect(response.status).toBe(400) + expect(mockSettle).not.toHaveBeenCalled() + expect(mockSettleSet).not.toHaveBeenCalled() + }) + + it('settles a bundle through the set writer with the row amount, date and cash account', async () => { + enqueue({ data: makeTxRow({ amount: 5250 }) }) + mockSettleSet.mockResolvedValue({ + ok: true, + journalEntryId: 'je-set', + amount: 5250, + requests: [ + { id: REQUEST_ID, name: 'ROT 2026-07', status: 'paid' }, + { id: REQUEST_ID_2, name: 'RUT 2026-07', status: 'paid' }, + ], + }) + + const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID_2] }), routeParams) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + requests: Array<{ id: string }> + category: string + }>(response) + + expect(status).toBe(200) + expect(body).toMatchObject({ success: true, journal_entry_id: 'je-set', category: 'income_other' }) + expect(body.requests.map((r) => r.id)).toEqual([REQUEST_ID, REQUEST_ID_2]) + expect(mockSettleSet).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { + requestIds: [REQUEST_ID, REQUEST_ID_2], + paymentDate: '2026-07-10', + amount: 5250, + bankAccount: '1930', + transactionId: TX_ID, + previousJournalEntryId: null, + }) + expect(mockSettle).not.toHaveBeenCalled() + }) + + it('routes a one-element (or duplicated) request_ids through the single writer', async () => { + enqueue({ data: makeTxRow() }) + const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID] }), routeParams) + expect(response.status).toBe(200) + expect(mockSettle).toHaveBeenCalledWith( + expect.anything(), + 'user-1', + 'company-1', + expect.objectContaining({ requestId: REQUEST_ID }), + ) + expect(mockSettleSet).not.toHaveBeenCalled() + }) + + it('maps the set writer\x27s amount refusal onto the canonical envelope', async () => { + enqueue({ data: makeTxRow({ amount: 5000 }) }) + mockSettleSet.mockResolvedValue({ + ok: false, + kind: 'code', + code: 'ROT_RUT_SETTLE_SET_AMOUNT', + details: { amount: 5000, expected_total: 5250, request_ids: [REQUEST_ID, REQUEST_ID_2] }, + }) + const response = await POST(makeReq({ request_ids: [REQUEST_ID, REQUEST_ID_2] }), routeParams) + const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response) + expect(status).toBe(400) + expect(body.error.code).toBe('ROT_RUT_SETTLE_SET_AMOUNT') + }) }) diff --git a/app/api/transactions/[id]/match-rot-rut-payout/route.ts b/app/api/transactions/[id]/match-rot-rut-payout/route.ts index 2eb59fa3..eb8e72e4 100644 --- a/app/api/transactions/[id]/match-rot-rut-payout/route.ts +++ b/app/api/transactions/[id]/match-rot-rut-payout/route.ts @@ -4,7 +4,10 @@ import { validateBody } from '@/lib/api/validate' import { MatchRotRutPayoutSchema } from '@/lib/api/schemas' import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error' import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' -import { settleRotRutPayoutRequest } from '@/lib/invoices/rot-rut-settle' +import { + settleRotRutPayoutRequest, + settleRotRutPayoutRequestSet, +} from '@/lib/invoices/rot-rut-settle' import { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry' import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked' import { ensureInitialized } from '@/lib/init' @@ -23,6 +26,11 @@ ensureInitialized() * date and bank account come from the bank row and the row is linked to the * voucher in the same call, so the payout can never be booked twice (once by * settle, once by categorising the bank row). + * + * Skatteverket bundles the beslut it pays that day into one transfer, so the + * body may name several begäran (`request_ids`, #2239): then ONE voucher + * carries one 1513 credit per begäran and the row is linked to it, provided + * the expected payouts sum to the row exactly. */ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( 'transaction.match_rot_rut_payout', @@ -35,9 +43,11 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( operation: 'transaction.match_rot_rut_payout', }) if (!validation.success) return validation.response - const { request_id: payoutRequestId } = validation.data + const payoutRequestIds = [ + ...new Set(validation.data.request_ids ?? [validation.data.request_id!]), + ] - const txLog = log.child({ transactionId, payoutRequestId }) + const txLog = log.child({ transactionId, payoutRequestIds }) // transaction_voucher_links rides along: a row bulk-booked into a // samlingsverifikat carries journal_entry_id = NULL and must still refuse. @@ -99,16 +109,27 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( txLog, ) - const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, { - requestId: payoutRequestId, + // Shared by both shapes: amount, date and account come from the bank row; + // the link CAS locks on the pointer read above (null for a free row, or + // the stale pointer of a reversed entry the guard let through). + const settleParams = { paymentDate: transaction.date, amount: transaction.amount, bankAccount, transactionId, - // Null for a free row, or the stale pointer of a reversed entry the - // guard above let through: the link CAS locks on exactly this value. previousJournalEntryId: transaction.journal_entry_id, - }) + } + + const outcome = + payoutRequestIds.length === 1 + ? await settleRotRutPayoutRequest(supabase, user.id, companyId!, { + requestId: payoutRequestIds[0], + ...settleParams, + }) + : await settleRotRutPayoutRequestSet(supabase, user.id, companyId!, { + requestIds: payoutRequestIds, + ...settleParams, + }) if (!outcome.ok) { if (outcome.kind === 'code') { @@ -124,13 +145,13 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>( userId: user.id, journalEntryId: outcome.journalEntryId, amount: outcome.amount, - fullyPaid: outcome.fullyPaid, + fullyPaid: 'fullyPaid' in outcome ? outcome.fullyPaid : true, }) return NextResponse.json({ success: true, journal_entry_id: outcome.journalEntryId, - request: outcome.request, + ...('request' in outcome ? { request: outcome.request } : { requests: outcome.requests }), category: 'income_other', }) }, diff --git a/components/dashboard/AttGoraSection.tsx b/components/dashboard/AttGoraSection.tsx index 3298cb90..a1b2bee1 100644 --- a/components/dashboard/AttGoraSection.tsx +++ b/components/dashboard/AttGoraSection.tsx @@ -173,7 +173,7 @@ export default function AttGoraSection({ match.kind === 'invoice' ? { invoice_id: match.candidate_id } : match.kind === 'rot_rut_payout' - ? { request_id: match.candidate_id } + ? { request_ids: match.request_ids ?? [match.candidate_id] } : match.kind === 'expense_payout' ? { claim_ids: match.claim_ids ?? [] } : { supplier_invoice_id: match.candidate_id } diff --git a/components/transactions/InvoicePicker.tsx b/components/transactions/InvoicePicker.tsx index b46add9b..0e8dffec 100644 --- a/components/transactions/InvoicePicker.tsx +++ b/components/transactions/InvoicePicker.tsx @@ -9,7 +9,7 @@ import { roundOre } from '@/lib/money' import { Search, FileText, Loader2, Landmark } from 'lucide-react' import { useCompany } from '@/contexts/CompanyContext' import type { Invoice, Customer } from '@/types' -import type { PotentialRotRutPayout, TransactionWithInvoice } from './transaction-types' +import type { PotentialRotRutPayoutRequest, TransactionWithInvoice } from './transaction-types' import { DOMESTIC_CURRENCY, normalizeCurrency, @@ -27,7 +27,7 @@ interface InvoicePickerProps { onSelect: (invoice: OpenInvoice) => void /** Pick an open ROT/RUT begäran instead of an invoice (Skatteverkets * utbetalning). The section only renders when the company has one. */ - onSelectRotRutPayout?: (request: PotentialRotRutPayout) => void + onSelectRotRutPayout?: (request: PotentialRotRutPayoutRequest) => void } type RotRutRequestRow = { @@ -49,7 +49,7 @@ export default function InvoicePicker({ transaction, onSelect, onSelectRotRutPay const { company } = useCompany() const supabase = useMemo(() => createClient(), []) const [invoices, setInvoices] = useState([]) - const [rotRutRequests, setRotRutRequests] = useState([]) + const [rotRutRequests, setRotRutRequests] = useState([]) const [isLoading, setIsLoading] = useState(true) const [search, setSearch] = useState('') // Boolean, not the callback: a fresh function identity per parent render diff --git a/components/transactions/RotRutPayoutMatchDialog.tsx b/components/transactions/RotRutPayoutMatchDialog.tsx index c3a7aedf..9fd73dfe 100644 --- a/components/transactions/RotRutPayoutMatchDialog.tsx +++ b/components/transactions/RotRutPayoutMatchDialog.tsx @@ -29,13 +29,16 @@ interface RotRutPayoutMatchDialogProps { } /** - * Confirm dialog for matching an income bank row to an open ROT/RUT begäran: - * Skatteverkets utbetalning clears the 1513 receivable (debit the row's cash - * account, credit 1513) and the row is linked to that voucher. + * Confirm dialog for matching an income bank row to one or several open + * ROT/RUT begäran: Skatteverkets utbetalning clears the 1513 receivable + * (debit the row's cash account, one 1513 credit per begäran) and the row is + * linked to that voucher. * * Kept separate from InvoiceMatchDialog on purpose: no FX, no preview fetch, - * no editable lines. The entry has exactly two legs and the amount is the - * bank row's, so everything the user needs to approve is known up front. + * no editable lines. The legs are known up front, so everything the user + * needs to approve is on screen. A bundle (several begäran paid in one + * transfer) is booked at exactly the decided sums: partial and over + * variants exist only for a single begäran. */ export default function RotRutPayoutMatchDialog({ open, @@ -45,29 +48,41 @@ export default function RotRutPayoutMatchDialog({ onConfirm, }: RotRutPayoutMatchDialogProps) { const t = useTranslations('tx_rot_rut_match') - const request = transaction?.potential_rot_rut_payout ?? null + const requests = transaction?.potential_rot_rut_payout?.requests ?? [] + const isSet = requests.length > 1 + const single = requests.length === 1 ? requests[0] : null - const targetState = getRotRutPayoutMatchTargetState(request) - const targetBlocked = targetState !== 'matchable' + // Every begäran must still be open and unsettled; the first blocked one + // explains the refusal. + const blockedState = + requests.map((request) => getRotRutPayoutMatchTargetState(request)).find((s) => s !== 'matchable') ?? + null + const targetBlocked = requests.length === 0 || blockedState !== null const txAmount = transaction ? roundOre(transaction.amount) : 0 - const expected = request ? expectedRotRutPayoutAmount(request) : 0 - const requestedTotal = request ? roundOre(Number(request.requested_total)) : 0 + const expected = roundOre( + requests.reduce((sum, request) => sum + expectedRotRutPayoutAmount(request), 0), + ) + const requestedTotal = roundOre( + requests.reduce((sum, request) => sum + roundOre(Number(request.requested_total)), 0), + ) const diff = roundOre(Math.abs(txAmount - expected)) const amountsMatch = diff < 0.01 // The settle service refuses a payout below requested_total unless the // beslut (decided_total) is recorded: say so here instead of letting the // button fail. - const isPartial = request ? txAmount < requestedTotal - 0.005 : false - const partialBlocked = isPartial && request?.decided_total == null + const isPartial = single ? txAmount < requestedTotal - 0.005 : false + const partialBlocked = isPartial && single?.decided_total == null // The service refuses more than Skatteverket can owe on this begäran: a // larger row would drive 1513 negative. Block here too, with the reason. - const overBlocked = request ? txAmount > expected + 0.005 : false + const overBlocked = single ? txAmount > expected + 0.005 : false + // A bundle only ever books the exact sum of its begäran. + const setBlocked = isSet && !amountsMatch // Skatteverket pays out in SEK only; the route refuses anything else. const currencyBlocked = (transaction?.currency || 'SEK').toUpperCase() !== 'SEK' const currency = transaction?.currency || 'SEK' - const typeLabel = request?.deduction_type === 'rut' ? 'RUT' : 'ROT' + const typeLabel = (deductionType: 'rot' | 'rut') => (deductionType === 'rut' ? 'RUT' : 'ROT') return ( @@ -79,7 +94,7 @@ export default function RotRutPayoutMatchDialog({ - {transaction && request && ( + {transaction && requests.length > 0 && (

{t('transaction_label')}

@@ -92,32 +107,50 @@ export default function RotRutPayoutMatchDialog({
-
-

{t('request_label')}

-

{t('request_name', { type: typeLabel, name: request.name })}

-
- - {t('requested_total', { amount: formatCurrency(requestedTotal, 'SEK') })} - - {request.decided_total != null && ( - - {t('decided_total', { amount: formatCurrency(Number(request.decided_total), 'SEK') })} - - )} -
- {request.invoices.length > 0 && ( -
-

{t('invoices_title')}

-
    - {request.invoices.map((inv, i) => ( -
  • - {t('invoice_row', { number: inv.invoice_number ?? '' })} - - {formatCurrency(Number(inv.requested_amount), 'SEK')} - -
  • - ))} -
+
+

+ {isSet ? t('requests_label', { count: requests.length }) : t('request_label')} +

+ {requests.map((request) => ( +
+

+ {t('request_name', { type: typeLabel(request.deduction_type), name: request.name })} +

+
+ + {t('requested_total', { + amount: formatCurrency(roundOre(Number(request.requested_total)), 'SEK'), + })} + + {request.decided_total != null && ( + + {t('decided_total', { + amount: formatCurrency(Number(request.decided_total), 'SEK'), + })} + + )} +
+ {request.invoices.length > 0 && ( +
+

{t('invoices_title')}

+
    + {request.invoices.map((inv, i) => ( +
  • + {t('invoice_row', { number: inv.invoice_number ?? '' })} + + {formatCurrency(Number(inv.requested_amount), 'SEK')} + +
  • + ))} +
+
+ )} +
+ ))} + {isSet && ( +
+ {t('requests_total_label')} + {formatCurrency(expected, 'SEK')}
)}
@@ -127,11 +160,11 @@ export default function RotRutPayoutMatchDialog({

- {t(targetState === 'settled' ? 'target_settled_title' : 'target_not_open_title')} + {t(blockedState === 'settled' ? 'target_settled_title' : 'target_not_open_title')}

{t( - targetState === 'settled' + blockedState === 'settled' ? 'target_settled_description' : 'target_not_open_description', )} @@ -141,14 +174,17 @@ export default function RotRutPayoutMatchDialog({ ) : amountsMatch ? (

-

{t('amounts_match')}

+

+ {isSet ? t('amounts_match_set') : t('amounts_match')} +

) : (
-

{t('amounts_differ')}

+

{isSet ? t('amounts_differ_set') : t('amounts_differ')}

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

+ {setBlocked &&

{t('set_exact_required')}

} {overBlocked &&

{t('over_payout_blocked')}

} {partialBlocked &&

{t('partial_requires_beslut')}

} {isPartial && !partialBlocked && ( @@ -169,13 +205,27 @@ export default function RotRutPayoutMatchDialog({ {formatCurrency(txAmount, currency)}
-
- - {t('booking_credit')}{' '} - {t('booking_receivable_line')} - - {formatCurrency(txAmount, currency)} -
+ {isSet ? ( + requests.map((request) => ( +
+ + {t('booking_credit')}{' '} + {t('booking_receivable_line_named', { name: request.name })} + + + {formatCurrency(expectedRotRutPayoutAmount(request), currency)} + +
+ )) + ) : ( +
+ + {t('booking_credit')}{' '} + {t('booking_receivable_line')} + + {formatCurrency(txAmount, currency)} +
+ )}
)} @@ -185,7 +235,9 @@ export default function RotRutPayoutMatchDialog({

{t('on_confirm_title')}

  • • {t('on_confirm_link')}
  • -
  • • {t('on_confirm_request')}
  • +
  • + • {isSet ? t('on_confirm_requests', { count: requests.length }) : t('on_confirm_request')} +
  • • {t('on_confirm_voucher')}
@@ -200,7 +252,13 @@ export default function RotRutPayoutMatchDialog({