feat(rot-rut): match Skatteverket's payout against the begäran from the bank row (#2271)
* feat(rot-rut): match Skatteverket's payout against the begäran from the bank row A ROT/RUT invoice is stored with remaining_amount net of the deduction, so once the customer pays it flips to paid and drops out of the matchable set. Skatteverket's payout for the 1513 share then lands as an income row with no candidate: the only clearing path was a headless settle endpoint that never linked the bank row. The candidate is the payout request (one lump sum per begäran, possibly covering several invoices), modelled exactly like the supplier-invoice hint: - migration 20260904020000: transactions.potential_rot_rut_payout_request_id - pure matcher (exact amount vs decided_total ?? requested_total, boosted when Skatteverket is named, ambiguous when two requests share the amount) - hint written at bank ingest and by batch-match-invoices; cleared by the link and reconciliation paths and by clearSettledInvoiceSuggestions - shared settle service (lib/invoices/rot-rut-settle.ts) used by the existing settle route and the new POST /api/transactions/[id]/match-rot-rut-payout, which books debit 19xx / credit 1513 and links the row in one call - transactions inbox pill, own confirm dialog listing the covered invoices, manual fallback section in the invoice picker, worklist and Att göra rows Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(rot-rut): cap the payout at the begäran, CAS on the request and on stale pointers Skeptic findings on 6aa7b2e5c: - a bank row larger than the begäran was booked in full, driving 1513 into a credit balance and rewriting decided_total to the bank amount: refuse amount > decided_total ?? requested_total in the service and block the dialog's confirm with the reason - two concurrent settles could both attach and credit 1513 twice: the request update now locks on settlement_journal_entry_id IS NULL and the loser returns ROT_RUT_SETTLE_RACE (409) with its orphan voucher id - a row with a stale (reversed) journal_entry_id passed the route guard but always lost the null-only link CAS: the route forwards the pointer it read and the service locks on that value, as link-journal-entry does - the pinned underlag on the bank row now propagates onto the voucher Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil <emilmattsson14@gmail.com> * fix(rot-rut): review round: SEK gate, voucher-less paid matchable, hint-write errors, one live voucher per begäran CodeRabbit findings on a93dc46b8, one batch: - picker and dialog only offer a begäran to SEK rows (the route refuses other currencies, so the manual flow no longer dead-ends) - a voucher-less `paid` request (beslut recorded via PATCH, money not yet booked) is matchable; settled means a settlement voucher exists - ingest and batch-match check the hint update's error before draining the pool or counting the match - the invoice.match_confirmed payload clears the payout hint like the row - migration 20260904021000: partial unique index on journal_entries (company_id, source_id) for live rot_rut_payout entries, so two racing settles cannot both book a voucher; pg test included Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HmEpYNMHycUPzSwBECEzZ5 Signed-off-by: Emil <emilmattsson14@gmail.com> --------- Signed-off-by: Emil <emilmattsson14@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
82859d01db
commit
50b6299699
@@ -2129,6 +2129,15 @@ export const CreateTransactionFromDocumentSchema = z.object({
|
||||
description: z.string().min(1).max(500),
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-rot-rut-payout: settle a ROT/RUT begäran
|
||||
* with the bank row that carried Skatteverkets utbetalning. Amount, date and
|
||||
* bank account all come from the transaction, so the body is just the target.
|
||||
*/
|
||||
export const MatchRotRutPayoutSchema = z.object({
|
||||
request_id: uuid,
|
||||
})
|
||||
|
||||
export const MatchSupplierInvoiceSchema = z.object({
|
||||
supplier_invoice_id: uuid,
|
||||
// Same purpose as MatchInvoiceSchema.lines: user-edited rows override
|
||||
|
||||
@@ -959,6 +959,42 @@ const INVOICE: Record<string, StructuredErrorEntry> = {
|
||||
message_sv: 'Utbetalningen kan bara bokföras för en inskickad begäran som inte redan är bokförd.',
|
||||
message_en: 'The payout can only be booked for a submitted request that is not already settled.',
|
||||
},
|
||||
ROT_RUT_SETTLE_AMOUNT_EXCEEDS: {
|
||||
httpStatus: 400,
|
||||
message_sv:
|
||||
'Beloppet kan inte bokföras mot begäran: det är större än begärt eller beslutat belopp. Bokför transaktionen på annat sätt.',
|
||||
message_en:
|
||||
'The amount cannot be booked against the request: it exceeds the requested or decided amount. Book the transaction another way.',
|
||||
},
|
||||
ROT_RUT_SETTLE_RACE: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Begäran hann redan bokföras som utbetald av en annan åtgärd. Verifikationen som skapades kan inte kopplas: kontrollera bokföringen på konto 1513.',
|
||||
message_en:
|
||||
'The request was already settled by another action. The voucher that was created could not be attached: check the bookkeeping on account 1513.',
|
||||
},
|
||||
ROT_RUT_MATCH_NOT_INCOME: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Endast inbetalningar kan matchas mot en ROT/RUT-utbetalning från Skatteverket.',
|
||||
message_en: 'Only income transactions can be matched to a ROT/RUT payout from Skatteverket.',
|
||||
},
|
||||
ROT_RUT_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.',
|
||||
},
|
||||
ROT_RUT_MATCH_CURRENCY: {
|
||||
httpStatus: 400,
|
||||
message_sv: 'Transaktionen kan inte matchas: Skatteverket betalar ut i SEK och transaktionen har en annan valuta.',
|
||||
message_en: 'Skatteverket pays out in SEK; the transaction is in another currency.',
|
||||
},
|
||||
ROT_RUT_MATCH_TX_LINK_FAILED: {
|
||||
httpStatus: 409,
|
||||
message_sv:
|
||||
'Utbetalningen bokfördes men transaktionen kunde inte kopplas till verifikationen. Koppla den via "Matcha mot befintlig verifikation".',
|
||||
message_en:
|
||||
'The payout was booked but the transaction could not be linked to the voucher. Link it via "Match against existing voucher".',
|
||||
},
|
||||
ROT_RUT_FILE_CREATE_FAILED: {
|
||||
httpStatus: 500,
|
||||
message_sv: 'Filen kunde inte skapas.',
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { makeTransaction } from '@/tests/helpers'
|
||||
import {
|
||||
expectedRotRutPayoutAmount,
|
||||
findRotRutPayoutMatch,
|
||||
getRotRutPayoutMatchTargetState,
|
||||
isMatchableRotRutPayoutRequest,
|
||||
mentionsSkatteverket,
|
||||
type RotRutPayoutRequestCandidate,
|
||||
} from '../rot-rut-payout-matching'
|
||||
|
||||
function makeRequest(
|
||||
overrides: Partial<RotRutPayoutRequestCandidate> = {},
|
||||
): RotRutPayoutRequestCandidate {
|
||||
return {
|
||||
id: 'rr-1',
|
||||
name: 'ROT 2026-07',
|
||||
deduction_type: 'rot',
|
||||
status: 'submitted',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
settlement_journal_entry_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('expectedRotRutPayoutAmount', () => {
|
||||
it('prefers the recorded beslut over the requested total', () => {
|
||||
expect(expectedRotRutPayoutAmount(makeRequest())).toBe(3000)
|
||||
expect(expectedRotRutPayoutAmount(makeRequest({ decided_total: 2500 }))).toBe(2500)
|
||||
})
|
||||
|
||||
it('coerces PostgREST NUMERIC strings', () => {
|
||||
expect(expectedRotRutPayoutAmount(makeRequest({ requested_total: '3000.00' }))).toBe(3000)
|
||||
expect(expectedRotRutPayoutAmount(makeRequest({ decided_total: '2499.995' }))).toBe(2500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRotRutPayoutMatchTargetState', () => {
|
||||
it('is matchable for open, unsettled requests, including a voucher-less paid beslut', () => {
|
||||
for (const status of ['generated', 'submitted', 'partially_paid', 'paid']) {
|
||||
expect(getRotRutPayoutMatchTargetState(makeRequest({ status }))).toBe('matchable')
|
||||
}
|
||||
})
|
||||
|
||||
it('is settled only once a settlement voucher exists', () => {
|
||||
expect(
|
||||
getRotRutPayoutMatchTargetState(makeRequest({ settlement_journal_entry_id: 'je-1' })),
|
||||
).toBe('settled')
|
||||
expect(
|
||||
getRotRutPayoutMatchTargetState(
|
||||
makeRequest({ status: 'paid', settlement_journal_entry_id: 'je-1' }),
|
||||
),
|
||||
).toBe('settled')
|
||||
})
|
||||
|
||||
it('suggests a voucher-less paid request for its payout (beslut recorded via PATCH)', () => {
|
||||
const tx = makeTransaction({ amount: 2500, description: 'Skatteverket' })
|
||||
const request = makeRequest({ status: 'paid', decided_total: 2500 })
|
||||
expect(findRotRutPayoutMatch(tx, [request])?.request.id).toBe('rr-1')
|
||||
})
|
||||
|
||||
it('is not_open for cancelled, rejected or missing requests', () => {
|
||||
expect(getRotRutPayoutMatchTargetState(makeRequest({ status: 'cancelled' }))).toBe('not_open')
|
||||
expect(getRotRutPayoutMatchTargetState(makeRequest({ status: 'rejected' }))).toBe('not_open')
|
||||
expect(getRotRutPayoutMatchTargetState(null)).toBe('not_open')
|
||||
expect(isMatchableRotRutPayoutRequest(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mentionsSkatteverket', () => {
|
||||
it('matches the agency name or the SKV abbreviation in description or merchant', () => {
|
||||
expect(mentionsSkatteverket({ amount: 1, description: 'Utbetalning SKATTEVERKET' })).toBe(true)
|
||||
expect(mentionsSkatteverket({ amount: 1, description: 'Ins. SKV rot' })).toBe(true)
|
||||
expect(mentionsSkatteverket({ amount: 1, merchant_name: 'Skatteverket' })).toBe(true)
|
||||
expect(mentionsSkatteverket({ amount: 1, description: 'Kund AB faktura 12' })).toBe(false)
|
||||
// "skvadron" must not count as SKV: the abbreviation needs word boundaries.
|
||||
expect(mentionsSkatteverket({ amount: 1, description: 'Skvadron AB' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findRotRutPayoutMatch', () => {
|
||||
it('returns null with no open requests', () => {
|
||||
expect(findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [])).toBeNull()
|
||||
})
|
||||
|
||||
it('matches an exact-amount income row at 0.85', () => {
|
||||
const tx = makeTransaction({ amount: 3000, description: 'Insättning' })
|
||||
const match = findRotRutPayoutMatch(tx, [makeRequest()])
|
||||
expect(match).toEqual({ request: makeRequest(), confidence: 0.85, matchMethod: 'amount' })
|
||||
})
|
||||
|
||||
it('boosts to 0.95 when Skatteverket is named', () => {
|
||||
const tx = makeTransaction({ amount: 3000, description: 'Skatteverket utbetalning' })
|
||||
const match = findRotRutPayoutMatch(tx, [makeRequest()])
|
||||
expect(match?.confidence).toBe(0.95)
|
||||
expect(match?.matchMethod).toBe('amount_skatteverket')
|
||||
})
|
||||
|
||||
it('compares against the beslut amount when one is recorded', () => {
|
||||
const tx = makeTransaction({ amount: 2500, description: 'Skatteverket' })
|
||||
const request = makeRequest({ decided_total: 2500 })
|
||||
expect(findRotRutPayoutMatch(tx, [request])?.request.id).toBe('rr-1')
|
||||
expect(findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [request])).toBeNull()
|
||||
})
|
||||
|
||||
it('never fuzzy-matches: an öre off is not this request', () => {
|
||||
const tx = makeTransaction({ amount: 3000.01, description: 'Skatteverket' })
|
||||
expect(findRotRutPayoutMatch(tx, [makeRequest()])).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores expenses, non-SEK rows and unmatchable requests', () => {
|
||||
expect(findRotRutPayoutMatch(makeTransaction({ amount: -3000 }), [makeRequest()])).toBeNull()
|
||||
expect(
|
||||
findRotRutPayoutMatch(makeTransaction({ amount: 3000, currency: 'EUR' as never }), [makeRequest()]),
|
||||
).toBeNull()
|
||||
expect(
|
||||
findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [
|
||||
makeRequest({ settlement_journal_entry_id: 'je-1' }),
|
||||
]),
|
||||
).toBeNull()
|
||||
expect(
|
||||
findRotRutPayoutMatch(makeTransaction({ amount: 3000 }), [makeRequest({ status: 'cancelled' })]),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a NULL currency as SEK (legacy bank rows)', () => {
|
||||
const tx = makeTransaction({ amount: 3000, currency: null as never })
|
||||
expect(findRotRutPayoutMatch(tx, [makeRequest()])?.request.id).toBe('rr-1')
|
||||
})
|
||||
|
||||
it('refuses to guess between two open requests with the same amount', () => {
|
||||
const tx = makeTransaction({ amount: 3000, description: 'Skatteverket' })
|
||||
const pool = [makeRequest({ id: 'rr-1' }), makeRequest({ id: 'rr-2', name: 'RUT 2026-07', deduction_type: 'rut' })]
|
||||
expect(findRotRutPayoutMatch(tx, pool)).toBeNull()
|
||||
// ...but picks the unique hit when the other differs in amount.
|
||||
const distinct = [makeRequest({ id: 'rr-1' }), makeRequest({ id: 'rr-2', requested_total: 4500 })]
|
||||
expect(findRotRutPayoutMatch(tx, distinct)?.request.id).toBe('rr-1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const mockCreatePayoutEntry = vi.fn()
|
||||
vi.mock('@/lib/bookkeeping/rot-rut-entries', () => ({
|
||||
createRotRutPayoutEntry: (...args: unknown[]) => mockCreatePayoutEntry(...args),
|
||||
}))
|
||||
|
||||
const mockLogMatchEvent = vi.fn()
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: (...args: unknown[]) => mockLogMatchEvent(...args),
|
||||
}))
|
||||
|
||||
// Mocked so the sibling-hint sweep consumes no slot in the queued mock; its
|
||||
// query shape is pinned by clear-settled-invoice-suggestions.test.ts.
|
||||
const mockClearSuggestions = vi.fn()
|
||||
vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
|
||||
clearSettledInvoiceSuggestions: (...args: unknown[]) => mockClearSuggestions(...args),
|
||||
}))
|
||||
|
||||
const mockPropagateUnderlag = vi.fn()
|
||||
vi.mock('@/lib/transactions/inbox-underlag', () => ({
|
||||
propagateUnderlagForBookedTransaction: (...args: unknown[]) => mockPropagateUnderlag(...args),
|
||||
}))
|
||||
|
||||
import { settleRotRutPayoutRequest } from '../rot-rut-settle'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
|
||||
const supabase = mockSupabase as unknown as SupabaseClient
|
||||
|
||||
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
|
||||
const TX_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function makeRequestRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: REQUEST_ID,
|
||||
company_id: 'company-1',
|
||||
name: 'ROT 2026-07',
|
||||
deduction_type: 'rot',
|
||||
status: 'submitted',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
decided_at: null,
|
||||
settlement_journal_entry_id: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-1' })
|
||||
})
|
||||
|
||||
describe('settleRotRutPayoutRequest', () => {
|
||||
it('returns ROT_RUT_REQUEST_NOT_FOUND for an unknown request', async () => {
|
||||
enqueue({ data: null })
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
})
|
||||
expect(outcome).toEqual({ ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' })
|
||||
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses an already settled or cancelled request before booking anything', async () => {
|
||||
enqueue({ data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-0' }) })
|
||||
const settled = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
})
|
||||
expect(settled.ok).toBe(false)
|
||||
expect(settled).toMatchObject({ code: 'ROT_RUT_SETTLE_INVALID_STATE' })
|
||||
|
||||
enqueue({ data: makeRequestRow({ status: 'cancelled' }) })
|
||||
const cancelled = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
})
|
||||
expect(cancelled).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_INVALID_STATE' })
|
||||
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a partial payout when no beslut is recorded', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 2500,
|
||||
})
|
||||
expect(outcome).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_INVALID_STATE' })
|
||||
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses a payout above the requested (or decided) amount before booking', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
const over = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 12500,
|
||||
})
|
||||
expect(over).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS',
|
||||
details: { amount: 12500, expected_amount: 3000, status: 'submitted' },
|
||||
})
|
||||
|
||||
// With a beslut recorded, the beslut is the ceiling.
|
||||
reset()
|
||||
enqueue({ data: makeRequestRow({ decided_total: 2500 }) })
|
||||
const overDecided = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 3000,
|
||||
})
|
||||
expect(overDecided).toMatchObject({ ok: false, code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS' })
|
||||
expect(mockCreatePayoutEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports ROT_RUT_SETTLE_RACE when another settle attached first, keeping the voucher', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
enqueue({ data: null }) // request CAS on settlement_journal_entry_id IS NULL matched 0 rows
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
transactionId: TX_ID,
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_RACE',
|
||||
details: { journal_entry_id: 'je-1', request_id: REQUEST_ID },
|
||||
})
|
||||
expect(findCall('rot_rut_payout_requests', 'is')).toEqual(['settlement_journal_entry_id', null])
|
||||
// The loser never touches the bank row.
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('locks the link on a stale pointer when the route passes one (issue #988 rows)', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
enqueue({
|
||||
data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }),
|
||||
})
|
||||
enqueue({ data: [{ id: TX_ID }] })
|
||||
enqueue({ data: [] })
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 3000,
|
||||
transactionId: TX_ID,
|
||||
previousJournalEntryId: 'je-reversed',
|
||||
})
|
||||
|
||||
expect(outcome.ok).toBe(true)
|
||||
const eqCalls = findCalls('transactions', 'eq')
|
||||
expect(eqCalls).toContainEqual(['journal_entry_id', 'je-reversed'])
|
||||
expect(findCall('transactions', 'is')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('books the voucher, completes the request and mirrors decided_amount (headless)', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
enqueue({
|
||||
data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }),
|
||||
})
|
||||
enqueue({ data: [{ id: 'item-1', requested_amount: 3000 }] })
|
||||
enqueue({ data: null })
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
bankAccount: '1920',
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-1', amount: 3000, fullyPaid: true })
|
||||
expect(mockCreatePayoutEntry).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
requestId: REQUEST_ID,
|
||||
amount: 3000,
|
||||
paymentDate: '2026-07-10',
|
||||
bankAccount: '1920',
|
||||
}),
|
||||
)
|
||||
const requestUpdate = findCall('rot_rut_payout_requests', 'update')?.[0] as Record<string, unknown>
|
||||
expect(requestUpdate).toMatchObject({
|
||||
settlement_journal_entry_id: 'je-1',
|
||||
status: 'paid',
|
||||
decided_total: 3000,
|
||||
})
|
||||
// No transaction was passed: nothing is written to transactions.
|
||||
expect(findCalls('transactions', 'update')).toEqual([])
|
||||
expect(mockLogMatchEvent).not.toHaveBeenCalled()
|
||||
expect(mockClearSuggestions).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'rot_rut_payout_request',
|
||||
REQUEST_ID,
|
||||
{ exceptTransactionId: null },
|
||||
)
|
||||
})
|
||||
|
||||
it('links the bank transaction to the settlement voucher and clears its hints', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
enqueue({
|
||||
data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }),
|
||||
})
|
||||
enqueue({ data: [{ id: TX_ID }] }) // transactions CAS update
|
||||
enqueue({ data: [] }) // items
|
||||
// payment_match_log insert is mocked (logMatchEvent)
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 3000,
|
||||
bankAccount: '1930',
|
||||
transactionId: TX_ID,
|
||||
})
|
||||
|
||||
expect(outcome.ok).toBe(true)
|
||||
const txUpdate = findCall('transactions', 'update')?.[0] as Record<string, unknown>
|
||||
expect(txUpdate).toEqual({
|
||||
journal_entry_id: 'je-1',
|
||||
is_business: true,
|
||||
category: 'income_other',
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
reconciliation_method: null,
|
||||
})
|
||||
// Optimistic lock: only a free row absorbs the link.
|
||||
expect(findCall('transactions', 'is')).toEqual(['journal_entry_id', null])
|
||||
expect(mockLogMatchEvent).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'user-1',
|
||||
TX_ID,
|
||||
'matched',
|
||||
expect.objectContaining({
|
||||
matchMethod: 'rot_rut_payout_manual_confirm',
|
||||
newState: expect.objectContaining({ journal_entry_id: 'je-1', rot_rut_payout_request_id: REQUEST_ID }),
|
||||
}),
|
||||
)
|
||||
expect(mockClearSuggestions).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'company-1',
|
||||
'rot_rut_payout_request',
|
||||
REQUEST_ID,
|
||||
{ exceptTransactionId: TX_ID },
|
||||
)
|
||||
})
|
||||
|
||||
it('reports ROT_RUT_MATCH_TX_LINK_FAILED when the optimistic lock loses, keeping the voucher', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
enqueue({
|
||||
data: makeRequestRow({ status: 'paid', settlement_journal_entry_id: 'je-1', decided_total: 3000 }),
|
||||
})
|
||||
enqueue({ data: [] }) // CAS matched 0 rows: someone booked the row meanwhile
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 3000,
|
||||
transactionId: TX_ID,
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
|
||||
details: { journal_entry_id: 'je-1', request_id: REQUEST_ID },
|
||||
})
|
||||
expect(mockLogMatchEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records a partial payout as partially_paid once a beslut exists', async () => {
|
||||
enqueue({ data: makeRequestRow({ decided_total: 2500, decided_at: '2026-07-01T00:00:00Z' }) })
|
||||
enqueue({
|
||||
data: makeRequestRow({
|
||||
status: 'partially_paid',
|
||||
settlement_journal_entry_id: 'je-2',
|
||||
decided_total: 2500,
|
||||
}),
|
||||
})
|
||||
mockCreatePayoutEntry.mockResolvedValue({ id: 'je-2' })
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
amount: 2500,
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ ok: true, journalEntryId: 'je-2', amount: 2500, fullyPaid: false })
|
||||
const requestUpdate = findCall('rot_rut_payout_requests', 'update')?.[0] as Record<string, unknown>
|
||||
expect(requestUpdate).toEqual({
|
||||
settlement_journal_entry_id: 'je-2',
|
||||
status: 'partially_paid',
|
||||
decided_total: 2500,
|
||||
})
|
||||
// No item mirror on a partial payout.
|
||||
expect(findCalls('rot_rut_payout_request_items', 'update')).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces an engine failure as a raw error without touching the request', async () => {
|
||||
enqueue({ data: makeRequestRow() })
|
||||
mockCreatePayoutEntry.mockRejectedValue(new Error('No open fiscal period'))
|
||||
|
||||
const outcome = await settleRotRutPayoutRequest(supabase, 'user-1', 'company-1', {
|
||||
requestId: REQUEST_ID,
|
||||
paymentDate: '2026-07-10',
|
||||
})
|
||||
|
||||
expect(outcome).toMatchObject({ ok: false, kind: 'error', stage: 'book' })
|
||||
expect(findCalls('rot_rut_payout_requests', 'update')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,13 @@ import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('invoices/clear-settled-invoice-suggestions')
|
||||
|
||||
export type SettledInvoiceKind = 'invoice' | 'supplier_invoice'
|
||||
export type SettledInvoiceKind = 'invoice' | 'supplier_invoice' | 'rot_rut_payout_request'
|
||||
|
||||
const HINT_COLUMN_BY_KIND: Record<SettledInvoiceKind, string> = {
|
||||
invoice: 'potential_invoice_id',
|
||||
supplier_invoice: 'potential_supplier_invoice_id',
|
||||
rot_rut_payout_request: 'potential_rot_rut_payout_request_id',
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the match SUGGESTIONS that point at an invoice which has just been
|
||||
@@ -36,7 +42,7 @@ export async function clearSettledInvoiceSuggestions(
|
||||
invoiceId: string,
|
||||
options?: { exceptTransactionId?: string | null },
|
||||
): Promise<void> {
|
||||
const column = kind === 'invoice' ? 'potential_invoice_id' : 'potential_supplier_invoice_id'
|
||||
const column = HINT_COLUMN_BY_KIND[kind]
|
||||
let query = supabase
|
||||
.from('transactions')
|
||||
.update({ [column]: null })
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import {
|
||||
OPEN_ROT_RUT_PAYOUT_STATUSES,
|
||||
type RotRutPayoutRequestCandidate,
|
||||
} from './rot-rut-payout-matching'
|
||||
|
||||
/**
|
||||
* Open, unsettled ROT/RUT begäran for a company: the candidate pool for
|
||||
* matching Skatteverkets utbetalning against a bank row.
|
||||
*
|
||||
* Own module so import-time callers (bank ingest, the batch re-suggest route)
|
||||
* can be unit-tested with the pool mocked, the same way getBestInvoiceMatch
|
||||
* is. Non-fatal: any error yields an empty pool and matching is skipped.
|
||||
*/
|
||||
export async function loadOpenRotRutPayoutRequests(
|
||||
supabase: SupabaseClient,
|
||||
companyId: string,
|
||||
): Promise<RotRutPayoutRequestCandidate[]> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('id, name, deduction_type, status, requested_total, decided_total, settlement_journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES])
|
||||
.is('settlement_journal_entry_id', null)
|
||||
if (error) return []
|
||||
return (data ?? []) as RotRutPayoutRequestCandidate[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* ROT/RUT payout matching: suggest the open begäran om utbetalning that an
|
||||
* income bank row from Skatteverket settles.
|
||||
*
|
||||
* Why the payout REQUEST and not the invoice: under fakturamodellen the
|
||||
* customer pays their share (settles the invoice, remaining_amount is stored
|
||||
* net of the deduction) and Skatteverket's share sits on BAS 1513 until the
|
||||
* agency pays out one lump sum per begäran, which may cover several invoices.
|
||||
* The invoice is therefore already `paid` when the SKV money lands and can
|
||||
* never be a match candidate; the request is the thing that still has an
|
||||
* outstanding balance.
|
||||
*
|
||||
* Confidence ladder (mirrors supplier-invoice-matching.ts):
|
||||
* exact amount + Skatteverket named in description/merchant -> 0.95
|
||||
* exact amount only -> 0.85
|
||||
* Two open requests with the same amount are ambiguous: no suggestion. There
|
||||
* is no fuzzy amount pass on purpose: Skatteverket pays exactly the decided
|
||||
* sum, so a near-miss is not this request.
|
||||
*
|
||||
* No server-only imports on purpose (only lib/money): client components
|
||||
* import the target-state helper too.
|
||||
*/
|
||||
|
||||
import { roundOre } from '@/lib/money'
|
||||
|
||||
/**
|
||||
* Statuses that can still absorb a payout. `paid` is included on purpose: the
|
||||
* PATCH lifecycle records Skatteverkets beslut as `paid` BEFORE the money is
|
||||
* booked, so a voucher-less `paid` request is exactly the one waiting for its
|
||||
* bank row. Settled means "has a settlement voucher", never a status alone.
|
||||
*/
|
||||
export const OPEN_ROT_RUT_PAYOUT_STATUSES = [
|
||||
'generated',
|
||||
'submitted',
|
||||
'paid',
|
||||
'partially_paid',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* The columns the matcher and the match dialog need from
|
||||
* rot_rut_payout_requests. Numeric columns arrive as strings from PostgREST
|
||||
* (NUMERIC), so callers must coerce with Number() where they read them.
|
||||
*/
|
||||
export interface RotRutPayoutRequestCandidate {
|
||||
id: string
|
||||
name: string
|
||||
deduction_type: 'rot' | 'rut'
|
||||
status: string
|
||||
requested_total: number | string
|
||||
decided_total: number | string | null
|
||||
settlement_journal_entry_id: string | null
|
||||
}
|
||||
|
||||
export interface RotRutPayoutMatch<T extends RotRutPayoutRequestCandidate = RotRutPayoutRequestCandidate> {
|
||||
request: T
|
||||
confidence: number
|
||||
matchMethod: 'amount_skatteverket' | 'amount'
|
||||
}
|
||||
|
||||
export type RotRutPayoutMatchTargetState = 'matchable' | 'settled' | 'not_open'
|
||||
|
||||
const SKATTEVERKET_PATTERN = /skatteverket|\bskv\b/i
|
||||
|
||||
/** The amount Skatteverket is expected to pay: the beslut when recorded, else the request. */
|
||||
export function expectedRotRutPayoutAmount(request: RotRutPayoutRequestCandidate): number {
|
||||
const raw = request.decided_total ?? request.requested_total
|
||||
return roundOre(Number(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this request still absorb a payout? Mirrors the settle service's guard:
|
||||
* no settlement voucher yet and not cancelled/rejected. A voucher-less `paid`
|
||||
* (beslut recorded via PATCH, money not yet booked) is matchable.
|
||||
*/
|
||||
export function getRotRutPayoutMatchTargetState(
|
||||
request: RotRutPayoutRequestCandidate | null | undefined,
|
||||
): RotRutPayoutMatchTargetState {
|
||||
if (!request) return 'not_open'
|
||||
if (request.settlement_journal_entry_id) return 'settled'
|
||||
if (!(OPEN_ROT_RUT_PAYOUT_STATUSES as readonly string[]).includes(request.status)) return 'not_open'
|
||||
return 'matchable'
|
||||
}
|
||||
|
||||
export function isMatchableRotRutPayoutRequest(
|
||||
request: RotRutPayoutRequestCandidate | null | undefined,
|
||||
): boolean {
|
||||
return getRotRutPayoutMatchTargetState(request) === 'matchable'
|
||||
}
|
||||
|
||||
interface MatchableTransaction {
|
||||
amount: number
|
||||
currency?: string | null
|
||||
description?: string | null
|
||||
merchant_name?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the bank row name Skatteverket? Used both to boost confidence and, in
|
||||
* the dialog, to explain why the suggestion fired.
|
||||
*/
|
||||
export function mentionsSkatteverket(transaction: MatchableTransaction): boolean {
|
||||
return (
|
||||
SKATTEVERKET_PATTERN.test(transaction.description ?? '') ||
|
||||
SKATTEVERKET_PATTERN.test(transaction.merchant_name ?? '')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the open payout request an income transaction settles, or null.
|
||||
* Pure: the caller loads the open requests once per import batch.
|
||||
*/
|
||||
export function findRotRutPayoutMatch<T extends RotRutPayoutRequestCandidate>(
|
||||
transaction: MatchableTransaction,
|
||||
openRequests: T[],
|
||||
): RotRutPayoutMatch<T> | null {
|
||||
if (openRequests.length === 0) return null
|
||||
// Skatteverket pays in kronor only. A NULL currency on a legacy bank row
|
||||
// means SEK (transactions.currency DEFAULT 'SEK').
|
||||
if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') return null
|
||||
if (!(transaction.amount > 0)) return null
|
||||
|
||||
const txAmount = roundOre(transaction.amount)
|
||||
const hits = openRequests.filter(
|
||||
(request) =>
|
||||
isMatchableRotRutPayoutRequest(request) &&
|
||||
Math.abs(expectedRotRutPayoutAmount(request) - txAmount) < 0.005,
|
||||
)
|
||||
// Ambiguous: the amount alone cannot pick between two begäran. Never guess.
|
||||
if (hits.length !== 1) return null
|
||||
|
||||
const named = mentionsSkatteverket(transaction)
|
||||
return {
|
||||
request: hits[0],
|
||||
confidence: named ? 0.95 : 0.85,
|
||||
matchMethod: named ? 'amount_skatteverket' : 'amount',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Settle a ROT/RUT begäran: book Skatteverkets utbetalning and, optionally,
|
||||
* link the bank transaction that carried it.
|
||||
*
|
||||
* Debit 19xx bank account (default 1930) [amount]
|
||||
* Credit 1513 Skattereduktion rot/rut [amount]
|
||||
*
|
||||
* Shared between two callers:
|
||||
* - REST: app/api/rot-rut/payout-requests/[id]/settle/route.ts
|
||||
* (headless settle: amount/date/bank account supplied by the caller)
|
||||
* - REST: app/api/transactions/[id]/match-rot-rut-payout/route.ts
|
||||
* (bank-row match: amount/date/bank account come from the transaction,
|
||||
* and the row is linked to the settlement voucher in the same call)
|
||||
*
|
||||
* The journal entry IS the accounting record: engine failure blocks the whole
|
||||
* operation. Everything after the voucher is best-effort-with-loud-logging,
|
||||
* never an unbook (the voucher is immutable per BFL).
|
||||
*/
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries'
|
||||
import { clearSettledInvoiceSuggestions } from '@/lib/invoices/clear-settled-invoice-suggestions'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { propagateUnderlagForBookedTransaction } from '@/lib/transactions/inbox-underlag'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
|
||||
const log = createLogger('invoices/rot-rut-settle')
|
||||
|
||||
export interface SettleRotRutPayoutParams {
|
||||
requestId: string
|
||||
paymentDate: string
|
||||
/** Defaults to decided_total ?? requested_total. */
|
||||
amount?: number
|
||||
/** BAS 19xx account the payout landed on. Defaults to 1930 in the engine. */
|
||||
bankAccount?: string
|
||||
/**
|
||||
* Bank transaction that carried the payout. When set, the row is linked to
|
||||
* the settlement voucher (journal_entry_id) and its match hints are cleared.
|
||||
* The caller must have verified the row is unbooked and belongs to the
|
||||
* company; this function re-checks with an optimistic lock on
|
||||
* journal_entry_id IS NULL.
|
||||
*/
|
||||
transactionId?: string
|
||||
/**
|
||||
* The transaction's journal_entry_id as the caller read it: null for a free
|
||||
* row, or the STALE id of a reversed/cancelled entry the route judged not
|
||||
* live (issue #988). The link CAS locks on exactly that value, so a stale
|
||||
* pointer can be overwritten while a concurrent live link still turns the
|
||||
* write into a no-op (same contract as link-journal-entry.ts).
|
||||
*/
|
||||
previousJournalEntryId?: string | null
|
||||
}
|
||||
|
||||
export interface SettledRotRutPayoutRequest {
|
||||
id: string
|
||||
name: string
|
||||
deduction_type: 'rot' | 'rut'
|
||||
status: string
|
||||
requested_total: number | string
|
||||
decided_total: number | string | null
|
||||
decided_at: string | null
|
||||
settlement_journal_entry_id: string | null
|
||||
}
|
||||
|
||||
export type SettleRotRutPayoutErrorCode =
|
||||
| 'ROT_RUT_REQUEST_NOT_FOUND'
|
||||
| 'ROT_RUT_SETTLE_INVALID_STATE'
|
||||
| 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS'
|
||||
| 'ROT_RUT_SETTLE_RACE'
|
||||
| 'ROT_RUT_MATCH_TX_LINK_FAILED'
|
||||
|
||||
export type SettleRotRutPayoutOutcome =
|
||||
| {
|
||||
ok: true
|
||||
request: SettledRotRutPayoutRequest
|
||||
journalEntryId: string
|
||||
amount: number
|
||||
fullyPaid: boolean
|
||||
}
|
||||
| { ok: false; kind: 'code'; code: SettleRotRutPayoutErrorCode; details?: Record<string, unknown> }
|
||||
/** A raw Supabase/engine error the route maps through errorResponse(). */
|
||||
| { ok: false; kind: 'error'; error: unknown; stage: 'fetch' | 'book' | 'update' }
|
||||
|
||||
export async function settleRotRutPayoutRequest(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: SettleRotRutPayoutParams,
|
||||
): Promise<SettleRotRutPayoutOutcome> {
|
||||
const { data: payoutRequest, error: fetchError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', params.requestId)
|
||||
.maybeSingle()
|
||||
|
||||
if (fetchError) {
|
||||
return { ok: false, kind: 'error', error: fetchError, stage: 'fetch' }
|
||||
}
|
||||
if (!payoutRequest) {
|
||||
return { ok: false, kind: 'code', code: 'ROT_RUT_REQUEST_NOT_FOUND' }
|
||||
}
|
||||
|
||||
const settleable =
|
||||
!payoutRequest.settlement_journal_entry_id &&
|
||||
!['cancelled', 'rejected'].includes(payoutRequest.status)
|
||||
if (!settleable) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_INVALID_STATE',
|
||||
details: {
|
||||
status: payoutRequest.status,
|
||||
already_settled: !!payoutRequest.settlement_journal_entry_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const amount =
|
||||
params.amount ?? Number(payoutRequest.decided_total ?? payoutRequest.requested_total)
|
||||
|
||||
// A partial settlement must follow a recorded beslut: without this guard a
|
||||
// settle with amount < requested_total on an undecided request would flip
|
||||
// it to partially_paid while bypassing the PATCH lifecycle rule that
|
||||
// partially_paid requires decided_total: the beslut would never be
|
||||
// recorded and later PATCH calls would be blocked by ALLOWED_TRANSITIONS.
|
||||
if (amount < Number(payoutRequest.requested_total) && payoutRequest.decided_total == null) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_INVALID_STATE',
|
||||
details: {
|
||||
status: payoutRequest.status,
|
||||
reason:
|
||||
'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Never book more than Skatteverket can owe on this begäran: a larger bank
|
||||
// row (a moms/skattekonto refund, two begäran in one transfer) would drive
|
||||
// 1513 into a credit balance and rewrite decided_total to the bank amount.
|
||||
// The user books such a row another way; this path stays exact.
|
||||
const expectedAmount = roundOre(Number(payoutRequest.decided_total ?? payoutRequest.requested_total))
|
||||
if (amount > expectedAmount + 0.005) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_AMOUNT_EXCEEDS',
|
||||
details: { amount, expected_amount: expectedAmount, status: payoutRequest.status },
|
||||
}
|
||||
}
|
||||
|
||||
// The voucher is the accounting record: engine failure must block.
|
||||
let journalEntryId: string
|
||||
try {
|
||||
const entry = await createRotRutPayoutEntry(supabase, companyId, userId, {
|
||||
requestId: payoutRequest.id,
|
||||
requestName: payoutRequest.name,
|
||||
deductionType: payoutRequest.deduction_type,
|
||||
paymentDate: params.paymentDate,
|
||||
amount,
|
||||
bankAccount: params.bankAccount,
|
||||
})
|
||||
journalEntryId = entry.id
|
||||
} catch (engineError) {
|
||||
return { ok: false, kind: 'error', error: engineError, stage: 'book' }
|
||||
}
|
||||
|
||||
const fullyPaid = amount >= Number(payoutRequest.requested_total)
|
||||
const update: Record<string, unknown> = {
|
||||
settlement_journal_entry_id: journalEntryId,
|
||||
status: fullyPaid ? 'paid' : 'partially_paid',
|
||||
decided_total: payoutRequest.decided_total ?? amount,
|
||||
}
|
||||
if (!payoutRequest.decided_at) {
|
||||
update.decided_at = new Date().toISOString()
|
||||
}
|
||||
|
||||
// CAS on settlement_journal_entry_id IS NULL: two concurrent settles (two
|
||||
// same-amount bank rows, or a headless call racing a match) must not both
|
||||
// attach and credit 1513 twice. The loser's voucher already exists
|
||||
// (immutable per BFL): say so loudly rather than overwrite the winner.
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.update(update)
|
||||
.eq('company_id', companyId)
|
||||
.eq('id', params.requestId)
|
||||
.is('settlement_journal_entry_id', null)
|
||||
.select(
|
||||
'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id',
|
||||
)
|
||||
.maybeSingle()
|
||||
|
||||
if (updateError) {
|
||||
// The voucher exists (immutable per BFL) but the request row didn't
|
||||
// absorb the link: surface loudly, do NOT try to unbook.
|
||||
log.error('rot/rut payout entry booked but request update failed', updateError, {
|
||||
journalEntryId,
|
||||
payoutRequestId: params.requestId,
|
||||
})
|
||||
return { ok: false, kind: 'error', error: updateError, stage: 'update' }
|
||||
}
|
||||
if (!updated) {
|
||||
log.error('rot/rut payout entry booked but request was settled concurrently', undefined, {
|
||||
journalEntryId,
|
||||
payoutRequestId: params.requestId,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_SETTLE_RACE',
|
||||
details: { journal_entry_id: journalEntryId, request_id: params.requestId },
|
||||
}
|
||||
}
|
||||
|
||||
if (params.transactionId) {
|
||||
// Optimistic lock on the pointer the route read: null for a free row, or
|
||||
// the stale id of a reversed entry (issue #988) that the route judged not
|
||||
// live. A concurrent booking between that read and this write changes
|
||||
// the pointer, so the write matches 0 rows instead of silently
|
||||
// overwriting it (same CAS contract as link-journal-entry.ts).
|
||||
const previousJournalEntryId = params.previousJournalEntryId ?? null
|
||||
const txUpdate = supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
category: 'income_other',
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
// The match supersedes any prior reconciliation link (mirrors
|
||||
// match-invoice): a literal null keeps the phantom-column scanner
|
||||
// able to verify the column set.
|
||||
reconciliation_method: null,
|
||||
})
|
||||
.eq('id', params.transactionId)
|
||||
.eq('company_id', companyId)
|
||||
const { data: linkedRows, error: linkError } = await (previousJournalEntryId === null
|
||||
? txUpdate.is('journal_entry_id', null)
|
||||
: txUpdate.eq('journal_entry_id', previousJournalEntryId)
|
||||
).select('id')
|
||||
|
||||
if (linkError || !linkedRows || linkedRows.length === 0) {
|
||||
// Voucher booked and request settled, but the bank row is not linked:
|
||||
// the user can still attach it via "Matcha mot befintlig verifikation".
|
||||
// Say exactly that instead of pretending the match went through.
|
||||
log.error('rot/rut payout settled but transaction link failed', linkError ?? undefined, {
|
||||
journalEntryId,
|
||||
payoutRequestId: params.requestId,
|
||||
transactionId: params.transactionId,
|
||||
reason: linkError?.message ?? 'optimistic lock returned 0 rows',
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'code',
|
||||
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
|
||||
details: { journal_entry_id: journalEntryId, request_id: params.requestId },
|
||||
}
|
||||
}
|
||||
|
||||
// An utbetalningsbesked pinned on the bank row becomes the voucher's
|
||||
// underlag (BFL 5 kap 6 §), as every other booking path does.
|
||||
await propagateUnderlagForBookedTransaction(
|
||||
supabase,
|
||||
companyId,
|
||||
params.transactionId,
|
||||
journalEntryId,
|
||||
)
|
||||
|
||||
await logMatchEvent(supabase, userId, params.transactionId, 'matched', {
|
||||
matchConfidence: 1.0,
|
||||
matchMethod: 'rot_rut_payout_manual_confirm',
|
||||
newState: {
|
||||
journal_entry_id: journalEntryId,
|
||||
rot_rut_payout_request_id: params.requestId,
|
||||
request_status: update.status,
|
||||
amount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (fullyPaid) {
|
||||
const { data: items, error: itemsFetchError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.select('id, requested_amount')
|
||||
.eq('request_id', params.requestId)
|
||||
if (itemsFetchError) {
|
||||
log.warn('failed to fetch items for decided_amount mirror', {
|
||||
payoutRequestId: params.requestId,
|
||||
message: itemsFetchError.message,
|
||||
})
|
||||
}
|
||||
for (const item of items ?? []) {
|
||||
const { error: mirrorError } = await supabase
|
||||
.from('rot_rut_payout_request_items')
|
||||
.update({ decided_amount: item.requested_amount })
|
||||
.eq('id', item.id)
|
||||
if (mirrorError) {
|
||||
log.warn('failed to mirror decided_amount onto item', {
|
||||
itemId: item.id,
|
||||
message: mirrorError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The request is settled: every OTHER bank row still hinting at it is a dead
|
||||
// suggestion. This row's own hint was cleared by the link update above.
|
||||
await clearSettledInvoiceSuggestions(
|
||||
supabase,
|
||||
companyId,
|
||||
'rot_rut_payout_request',
|
||||
params.requestId,
|
||||
{ exceptTransactionId: params.transactionId ?? null },
|
||||
)
|
||||
|
||||
log.info('rot/rut payout settled', {
|
||||
userId,
|
||||
payoutRequestId: params.requestId,
|
||||
journalEntryId,
|
||||
amount,
|
||||
fullyPaid,
|
||||
transactionId: params.transactionId ?? null,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
request: updated as SettledRotRutPayoutRequest,
|
||||
journalEntryId,
|
||||
amount,
|
||||
fullyPaid,
|
||||
}
|
||||
}
|
||||
@@ -1852,13 +1852,18 @@ export async function autoReconcileTransactionForLinkedVoucher(
|
||||
// Tag the transaction with the (supplier) invoice for traceability + parity
|
||||
// with the transactions-side match. is_business is already set by manualLink,
|
||||
// so the row has already dropped out of the inbox regardless of this update.
|
||||
const tag: Record<string, unknown> = { potential_invoice_id: null }
|
||||
const tag: Record<string, unknown> = {
|
||||
potential_invoice_id: null,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
}
|
||||
if (options.invoiceId) tag.invoice_id = options.invoiceId
|
||||
if (options.supplierInvoiceId) {
|
||||
tag.supplier_invoice_id = options.supplierInvoiceId
|
||||
tag.potential_supplier_invoice_id = null
|
||||
}
|
||||
if (Object.keys(tag).length > 1) {
|
||||
// Two hint clears are always present; only an actual invoice tag warrants
|
||||
// the extra write (the row already left the inbox via is_business).
|
||||
if (Object.keys(tag).length > 2) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update(tag)
|
||||
|
||||
@@ -34,6 +34,16 @@ vi.mock('@/lib/currency/riksbanken', () => ({
|
||||
fetchExchangeRate: (...args: unknown[]) => mockFetchExchangeRate(...args),
|
||||
}))
|
||||
|
||||
// Mocked so the open-begäran pool consumes no slot in the queued Supabase
|
||||
// mock: every existing test enqueues results in the pre-fetch order.
|
||||
const mockLoadOpenRotRutPayoutRequests = vi.fn()
|
||||
vi.mock('@/lib/invoices/rot-rut-payout-candidates', () => ({
|
||||
loadOpenRotRutPayoutRequests: (...args: unknown[]) => mockLoadOpenRotRutPayoutRequests(...args),
|
||||
}))
|
||||
// Default: no open begäran. vi.clearAllMocks keeps implementations, so this
|
||||
// survives beforeEach; tests that need a pool override it.
|
||||
mockLoadOpenRotRutPayoutRequests.mockResolvedValue([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue-based Supabase mock
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1757,6 +1767,51 @@ describe('ingestTransactions', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4a. Skatteverkets ROT/RUT payout: an income row equal to an open begäran
|
||||
// gets potential_rot_rut_payout_request_id (a suggestion, never a hard
|
||||
// link) and skips auto-categorisation, exactly like an invoice hint.
|
||||
// -----------------------------------------------------------------------
|
||||
it('suggests the open ROT/RUT payout request for a matching Skatteverket income row', async () => {
|
||||
const { supabase, enqueue, updates } = createQueueMockSupabase()
|
||||
const raw = makeRaw({ amount: 3000, description: 'Skatteverket utbetalning' })
|
||||
const inserted = makeTransaction({
|
||||
id: 'tx-skv',
|
||||
amount: 3000,
|
||||
currency: 'SEK',
|
||||
description: 'Skatteverket utbetalning',
|
||||
external_id: raw.external_id,
|
||||
})
|
||||
mockLoadOpenRotRutPayoutRequests.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'rr-1',
|
||||
name: 'ROT 2026-07',
|
||||
deduction_type: 'rot',
|
||||
status: 'submitted',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
settlement_journal_entry_id: null,
|
||||
},
|
||||
])
|
||||
mockGetBestInvoiceMatch.mockResolvedValue(null)
|
||||
|
||||
enqueue({ data: [], error: null }) // booked map
|
||||
enqueue({ data: [], error: null }) // unbooked bank-synced map
|
||||
enqueue({ data: [], error: null }) // supplier invoices pool
|
||||
enqueue({ data: [], error: null }) // external_id dedup
|
||||
enqueue({ data: inserted, error: null }) // insert
|
||||
enqueue({ data: null, error: null }) // suggestion update
|
||||
|
||||
const result = await ingestTransactions(supabase as never, COMPANY_ID, USER_ID, [raw])
|
||||
|
||||
expect(result.imported).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
const txUpdates = (updates['transactions'] ?? []) as Record<string, unknown>[]
|
||||
expect(txUpdates).toEqual([{ potential_rot_rut_payout_request_id: 'rr-1' }])
|
||||
// The hint short-circuits the mapping engine: no auto-booked voucher.
|
||||
expect(mockEvaluateMappingRules).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4b. Supplier-invoice match at sync is ALWAYS a suggestion, never a hard
|
||||
// link. Regression: a high-confidence hit used to set
|
||||
|
||||
@@ -4,6 +4,11 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
|
||||
import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import {
|
||||
findRotRutPayoutMatch,
|
||||
type RotRutPayoutRequestCandidate,
|
||||
} from '@/lib/invoices/rot-rut-payout-matching'
|
||||
import { loadOpenRotRutPayoutRequests } from '@/lib/invoices/rot-rut-payout-candidates'
|
||||
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { fetchAllRows } from '@/lib/supabase/fetch-all'
|
||||
@@ -358,6 +363,7 @@ export async function ingestTransactions(
|
||||
// When rawInsertOnly is set (viewer imports), skip pre-fetching supplier
|
||||
// invoices and exchange rates: they are not used.
|
||||
let unpaidSupplierInvoices: SupplierInvoice[] = []
|
||||
let openRotRutPayoutRequests: RotRutPayoutRequestCandidate[] = []
|
||||
// Keyed by `${currency}|${date}` so each non-SEK transaction gets the
|
||||
// rate that was valid on its own transaction date, not the import date.
|
||||
const exchangeRatesByDate = new Map<string, ExchangeRate>()
|
||||
@@ -377,6 +383,11 @@ export async function ingestTransactions(
|
||||
} catch {
|
||||
// Non-critical: supplier invoice matching will be skipped
|
||||
}
|
||||
// Pre-fetch open ROT/RUT payout requests for income matching (non-critical).
|
||||
// Skatteverket's payout is the one income row a paid ROT/RUT invoice can no
|
||||
// longer explain (remaining_amount is net of the deduction), so the begäran
|
||||
// is the candidate. Small table: a company has a handful of open requests.
|
||||
openRotRutPayoutRequests = await loadOpenRotRutPayoutRequests(supabase, companyId)
|
||||
}
|
||||
|
||||
// Pre-fetch exchange rates for each unique (currency, date) pair in the
|
||||
@@ -581,6 +592,7 @@ export async function ingestTransactions(
|
||||
// to prevent suggesting the same invoice for multiple transactions
|
||||
const matchedInvoiceIds = new Set<string>()
|
||||
const matchedSupplierInvoiceIds = new Set<string>()
|
||||
const matchedRotRutRequestIds = new Set<string>()
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// Normalize the source title once. Guarantees a non-empty, Swedish-first
|
||||
@@ -1079,6 +1091,44 @@ export async function ingestTransactions(
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. For income transactions with no invoice hint, try ROT/RUT payout
|
||||
// matching: Skatteverket's lump sum for an open begäran. Always a
|
||||
// suggestion (potential_rot_rut_payout_request_id), never a hard link:
|
||||
// the match route books the 19xx/1513 voucher when it confirms.
|
||||
if (newTransaction.amount > 0 && openRotRutPayoutRequests.length > 0) {
|
||||
try {
|
||||
const match = findRotRutPayoutMatch(newTransaction as Transaction, openRotRutPayoutRequests)
|
||||
if (match && !matchedRotRutRequestIds.has(match.request.id)) {
|
||||
// supabase-js resolves a failed update with { error }: a hint that
|
||||
// never persisted must not drain the pool or count as a match.
|
||||
const { error: hintError } = await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_rot_rut_payout_request_id: match.request.id })
|
||||
.eq('id', newTransaction.id)
|
||||
if (hintError) throw hintError
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
newState: { rot_rut_payout_request_id: match.request.id },
|
||||
})
|
||||
|
||||
// One payout per begäran: drain the pool so a second row of the same
|
||||
// amount can't claim it, and skip the mapping engine (an
|
||||
// auto-categorised 19xx/3xxx voucher would collide with the 1513
|
||||
// clearing the match books).
|
||||
matchedRotRutRequestIds.add(match.request.id)
|
||||
openRotRutPayoutRequests = openRotRutPayoutRequests.filter(
|
||||
(req) => req.id !== match.request.id,
|
||||
)
|
||||
result.auto_matched_invoices++
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: continue processing
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Evaluate mapping rules for auto-categorization
|
||||
// Production-disabled: auto-booking only runs in local dev (and tests).
|
||||
// Users must explicitly book each transaction on the deployed app.
|
||||
|
||||
@@ -140,7 +140,7 @@ export async function linkTransactionToJournalEntry(
|
||||
const { data: transactionRow, error: fetchTxError } = await supabase
|
||||
.from('transactions')
|
||||
.select(
|
||||
'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id, transaction_voucher_links(journal_entry_id, role)'
|
||||
'id, date, amount, currency, exchange_rate, journal_entry_id, invoice_id, is_business, potential_invoice_id, potential_supplier_invoice_id, potential_rot_rut_payout_request_id, transaction_voucher_links(journal_entry_id, role)'
|
||||
)
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
@@ -287,6 +287,7 @@ export async function linkTransactionToJournalEntry(
|
||||
invoice_id: transaction.invoice_id,
|
||||
potential_invoice_id: transaction.potential_invoice_id,
|
||||
potential_supplier_invoice_id: transaction.potential_supplier_invoice_id,
|
||||
potential_rot_rut_payout_request_id: transaction.potential_rot_rut_payout_request_id ?? null,
|
||||
is_business: transaction.is_business,
|
||||
}
|
||||
|
||||
@@ -302,6 +303,7 @@ export async function linkTransactionToJournalEntry(
|
||||
invoice_id: invoiceId ?? null,
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
@@ -463,6 +465,7 @@ export async function linkTransactionToJournalEntry(
|
||||
...transaction,
|
||||
journal_entry_id: journalEntryId,
|
||||
invoice_id: invoiceId,
|
||||
potential_rot_rut_payout_request_id: null,
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
is_business: true,
|
||||
|
||||
@@ -212,6 +212,45 @@ describe('countSuggestedMatches', () => {
|
||||
})
|
||||
|
||||
describe('listSuggestedMatches', () => {
|
||||
it('maps a ROT/RUT payout hint to a confirmable row pointing at the begäran', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'tx-skv',
|
||||
date: '2026-07-10',
|
||||
description: 'Skatteverket',
|
||||
amount: 3000,
|
||||
currency: 'SEK',
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
potential_rot_rut_payout_request_id: 'rr-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
// Only the payout lookup runs: the invoice / supplier id lists are empty.
|
||||
enqueue({
|
||||
data: [{ id: 'rr-1', name: 'ROT 2026-07', requested_total: '3000.00', decided_total: null }],
|
||||
})
|
||||
|
||||
const matches = await listSuggestedMatches(supabase, COMPANY)
|
||||
expect(matches).toEqual([
|
||||
{
|
||||
transaction_id: 'tx-skv',
|
||||
transaction_date: '2026-07-10',
|
||||
transaction_description: 'Skatteverket',
|
||||
transaction_amount: 3000,
|
||||
transaction_currency: 'SEK',
|
||||
kind: 'rot_rut_payout',
|
||||
candidate_id: 'rr-1',
|
||||
candidate_number: 'ROT 2026-07',
|
||||
counterparty_name: 'Skatteverket',
|
||||
candidate_total: 3000,
|
||||
},
|
||||
])
|
||||
const lookup = findCall('rot_rut_payout_requests', 'is')
|
||||
expect(lookup).toEqual(['settlement_journal_entry_id', null])
|
||||
})
|
||||
|
||||
it('maps invoice and supplier-invoice hints to confirmable rows', async () => {
|
||||
enqueue({
|
||||
data: [
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* down the dashboard layout or the home page.
|
||||
*/
|
||||
|
||||
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 {
|
||||
@@ -146,7 +147,7 @@ export async function countInboxDocuments(
|
||||
|
||||
/** Shared predicate for transactions carrying a match hint. */
|
||||
const SUGGESTED_MATCH_OR =
|
||||
'potential_invoice_id.not.is.null,potential_supplier_invoice_id.not.is.null'
|
||||
'potential_invoice_id.not.is.null,potential_supplier_invoice_id.not.is.null,potential_rot_rut_payout_request_id.not.is.null'
|
||||
|
||||
/**
|
||||
* Cap on the hint scan behind countSuggestedMatches; clamps like
|
||||
@@ -285,6 +286,14 @@ interface SuggestedMatchTxRow {
|
||||
currency: string | null
|
||||
potential_invoice_id: string | null
|
||||
potential_supplier_invoice_id: string | null
|
||||
potential_rot_rut_payout_request_id?: string | null
|
||||
}
|
||||
|
||||
type PayoutCandidateRow = {
|
||||
id: string
|
||||
name: string
|
||||
requested_total: number | string
|
||||
decided_total: number | string | null
|
||||
}
|
||||
|
||||
type CandidateRow = {
|
||||
@@ -345,7 +354,7 @@ export async function listSuggestedMatches(
|
||||
const { data: txRows, error } = await supabase
|
||||
.from('transactions')
|
||||
.select(
|
||||
'id, date, description, amount, currency, potential_invoice_id, potential_supplier_invoice_id',
|
||||
'id, date, description, amount, currency, potential_invoice_id, potential_supplier_invoice_id, potential_rot_rut_payout_request_id',
|
||||
)
|
||||
.eq('company_id', companyId)
|
||||
.is('is_business', null)
|
||||
@@ -368,7 +377,13 @@ export async function listSuggestedMatches(
|
||||
...new Set(txs.map((t) => t.potential_supplier_invoice_id).filter((x): x is string => !!x)),
|
||||
]
|
||||
|
||||
const [invoiceRes, supplierRes] = await Promise.all([
|
||||
const payoutRequestIds = [
|
||||
...new Set(
|
||||
txs.map((t) => t.potential_rot_rut_payout_request_id).filter((x): x is string => !!x),
|
||||
),
|
||||
]
|
||||
|
||||
const [invoiceRes, supplierRes, payoutRes] = await Promise.all([
|
||||
fetchCandidatesChunked(invoiceIds, (chunk) =>
|
||||
supabase
|
||||
.from('invoices')
|
||||
@@ -387,12 +402,23 @@ export async function listSuggestedMatches(
|
||||
.in('status', [...MATCHABLE_SUPPLIER_INVOICE_STATUSES])
|
||||
.gt('remaining_amount', 0),
|
||||
),
|
||||
// Open, unsettled begäran only: a settled request must not render a
|
||||
// one-click confirm that the route can only answer with INVALID_STATE.
|
||||
fetchCandidatesChunked(payoutRequestIds, (chunk) =>
|
||||
supabase
|
||||
.from('rot_rut_payout_requests')
|
||||
.select('id, name, requested_total, decided_total')
|
||||
.eq('company_id', companyId)
|
||||
.in('id', chunk)
|
||||
.in('status', [...OPEN_ROT_RUT_PAYOUT_STATUSES])
|
||||
.is('settlement_journal_entry_id', null),
|
||||
),
|
||||
])
|
||||
|
||||
// A failed candidate lookup must not pass for "nothing is matchable": that
|
||||
// would render an empty list and, through countSuggestedMatches, a silent
|
||||
// zero badge. Log it (with companyId) and bail, same as the tx query above.
|
||||
const candidateError = invoiceRes.error ?? supplierRes.error
|
||||
const candidateError = invoiceRes.error ?? supplierRes.error ?? payoutRes.error
|
||||
if (candidateError) {
|
||||
log.error('worklist listSuggestedMatches candidate lookup failed', {
|
||||
companyId,
|
||||
@@ -404,6 +430,10 @@ export async function listSuggestedMatches(
|
||||
const invoiceById = new Map<string, CandidateRow>(invoiceRes.rows.map((r) => [r.id, r]))
|
||||
const supplierById = new Map<string, CandidateRow>(supplierRes.rows.map((r) => [r.id, r]))
|
||||
|
||||
const payoutById = new Map(
|
||||
(payoutRes.rows as unknown as PayoutCandidateRow[]).map((r) => [r.id, r] as const),
|
||||
)
|
||||
|
||||
const matches: SuggestedMatch[] = []
|
||||
for (const tx of txs) {
|
||||
const base = {
|
||||
@@ -441,6 +471,20 @@ export async function listSuggestedMatches(
|
||||
counterparty_name: supplierInvoice.supplier?.name ?? null,
|
||||
candidate_total: supplierInvoice.total ?? null,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const payoutRequest = tx.potential_rot_rut_payout_request_id
|
||||
? payoutById.get(tx.potential_rot_rut_payout_request_id)
|
||||
: undefined
|
||||
if (payoutRequest) {
|
||||
matches.push({
|
||||
...base,
|
||||
kind: 'rot_rut_payout',
|
||||
candidate_id: payoutRequest.id,
|
||||
candidate_number: payoutRequest.name,
|
||||
counterparty_name: 'Skatteverket',
|
||||
candidate_total: Number(payoutRequest.decided_total ?? payoutRequest.requested_total),
|
||||
})
|
||||
}
|
||||
// Hint pointing at a deleted, foreign or already-settled candidate → drop
|
||||
// the row rather than render an unconfirmable suggestion.
|
||||
|
||||
@@ -124,8 +124,12 @@ export interface SuggestedMatch {
|
||||
transaction_description: string
|
||||
transaction_amount: number
|
||||
transaction_currency: string
|
||||
/** Which match endpoint confirms it: match-invoice vs match-supplier-invoice. */
|
||||
kind: 'invoice' | 'supplier_invoice'
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
kind: 'invoice' | 'supplier_invoice' | 'rot_rut_payout'
|
||||
candidate_id: string
|
||||
candidate_number: string | null
|
||||
counterparty_name: string | null
|
||||
|
||||
Reference in New Issue
Block a user