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:
Mattsson
2026-09-04 14:33:41 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 82859d01db
commit 50b6299699
34 changed files with 2350 additions and 147 deletions
@@ -3,7 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { RotRutSettleSchema } from '@/lib/api/schemas'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries'
import { settleRotRutPayoutRequest } from '@/lib/invoices/rot-rut-settle'
/**
* POST /api/rot-rut/payout-requests/[id]/settle
@@ -18,6 +18,11 @@ import { createRotRutPayoutEntry } from '@/lib/bookkeeping/rot-rut-entries'
* amount defaults to decided_total, falling back to requested_total. If the
* amount equals requested_total the request completes as 'paid'; anything
* lower records 'partially_paid' with decided_total = amount.
*
* Headless variant: no bank transaction is linked. The transactions inbox
* settles the same request WITH the bank row through
* POST /api/transactions/[id]/match-rot-rut-payout (shared service in
* lib/invoices/rot-rut-settle.ts).
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'rot_rut.requests.settle',
@@ -29,134 +34,36 @@ export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
if (!validation.success) return validation.response
const input = validation.data
const { data: payoutRequest, error: fetchError } = await supabase
.from('rot_rut_payout_requests')
.select('*')
.eq('company_id', companyId!)
.eq('id', id)
.maybeSingle()
const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, {
requestId: id,
paymentDate: input.payment_date,
amount: input.amount,
bankAccount: input.bank_account,
})
if (fetchError) {
log.error('failed to fetch rot/rut payout request', fetchError)
return errorResponse(fetchError, log, { requestId })
}
if (!payoutRequest) {
return errorResponseFromCode('ROT_RUT_REQUEST_NOT_FOUND', log, { requestId })
}
const settleable =
!payoutRequest.settlement_journal_entry_id &&
!['cancelled', 'rejected'].includes(payoutRequest.status)
if (!settleable) {
return errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, {
requestId,
details: {
status: payoutRequest.status,
already_settled: !!payoutRequest.settlement_journal_entry_id,
},
})
}
const amount =
input.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 errorResponseFromCode('ROT_RUT_SETTLE_INVALID_STATE', log, {
requestId,
details: {
status: payoutRequest.status,
reason:
'Delutbetalning kräver att Skatteverkets beslut registreras först (decided_total via PATCH).',
},
})
}
// The voucher is the accounting record: engine failure must block.
let journalEntryId: string
try {
const entry = await createRotRutPayoutEntry(supabase, companyId!, user.id, {
requestId: payoutRequest.id,
requestName: payoutRequest.name,
deductionType: payoutRequest.deduction_type,
paymentDate: input.payment_date,
amount,
bankAccount: input.bank_account,
})
journalEntryId = entry.id
} catch (engineError) {
log.error('failed to book rot/rut payout entry', engineError as Error)
return errorResponse(engineError, log, { requestId })
}
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()
}
const { data: updated, error: updateError } = await supabase
.from('rot_rut_payout_requests')
.update(update)
.eq('company_id', companyId!)
.eq('id', id)
.select(
'id, name, deduction_type, status, requested_total, decided_total, decided_at, settlement_journal_entry_id',
)
.single()
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: id,
})
return errorResponse(updateError, log, { requestId })
}
if (fullyPaid) {
const { data: items, error: itemsFetchError } = await supabase
.from('rot_rut_payout_request_items')
.select('id, requested_amount')
.eq('request_id', id)
if (itemsFetchError) {
log.warn('failed to fetch items for decided_amount mirror', {
payoutRequestId: id,
message: itemsFetchError.message,
})
if (!outcome.ok) {
if (outcome.kind === 'code') {
return errorResponseFromCode(outcome.code, log, { requestId, details: outcome.details })
}
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,
})
}
if (outcome.stage === 'fetch') {
log.error('failed to fetch rot/rut payout request', outcome.error as Error)
} else if (outcome.stage === 'book') {
log.error('failed to book rot/rut payout entry', outcome.error as Error)
}
return errorResponse(outcome.error, log, { requestId })
}
log.info('rot/rut payout settled', {
userId: user.id,
payoutRequestId: id,
journalEntryId,
amount,
fullyPaid,
journalEntryId: outcome.journalEntryId,
amount: outcome.amount,
fullyPaid: outcome.fullyPaid,
})
return NextResponse.json({ data: { request: updated, journal_entry_id: journalEntryId } })
return NextResponse.json({
data: { request: outcome.request, journal_entry_id: outcome.journalEntryId },
})
},
{ requireWrite: true },
)
@@ -0,0 +1,221 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import {
createMockRequest,
createMockRouteParams,
parseJsonResponse,
createQueuedMockSupabase,
} from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({
requireAuth: (...args: unknown[]) => requireAuthMock(...args),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
const mockSettle = vi.fn()
vi.mock('@/lib/invoices/rot-rut-settle', () => ({
settleRotRutPayoutRequest: (...args: unknown[]) => mockSettle(...args),
}))
const mockResolveSettlementAccount = vi.fn()
vi.mock('@/lib/bookkeeping/settlement-account', () => ({
resolveSettlementAccount: (...args: unknown[]) => mockResolveSettlementAccount(...args),
}))
const mockHasLiveLink = vi.fn()
vi.mock('@/lib/transactions/link-journal-entry', () => ({
hasLiveJournalEntryLink: (...args: unknown[]) => mockHasLiveLink(...args),
}))
import { POST } from '../route'
const TX_ID = '11111111-1111-4111-8111-111111111111'
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
const mockUser = { id: 'user-1', email: 'test@test.se' }
const routeParams = createMockRouteParams({ id: TX_ID })
function makeReq(body: unknown = { request_id: REQUEST_ID }) {
return createMockRequest(`/api/transactions/${TX_ID}/match-rot-rut-payout`, {
method: 'POST',
body,
})
}
function makeTxRow(overrides: Record<string, unknown> = {}) {
return {
id: TX_ID,
date: '2026-07-10',
amount: 3000,
currency: 'SEK',
journal_entry_id: null,
cash_account_id: 'ca-1',
transaction_voucher_links: [],
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase })
mockResolveSettlementAccount.mockResolvedValue('1930')
mockHasLiveLink.mockResolvedValue(false)
mockSettle.mockResolvedValue({
ok: true,
journalEntryId: 'je-1',
amount: 3000,
fullyPaid: true,
request: { id: REQUEST_ID, name: 'ROT 2026-07', status: 'paid' },
})
})
describe('POST /api/transactions/[id]/match-rot-rut-payout', () => {
it('returns 401 when not authenticated', async () => {
requireAuthMock.mockResolvedValue({
user: null,
supabase: mockSupabase,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
})
const response = await POST(makeReq(), routeParams)
expect(response.status).toBe(401)
expect(mockSettle).not.toHaveBeenCalled()
})
it('returns 400 on an invalid body', async () => {
const response = await POST(makeReq({ request_id: 'not-a-uuid' }), routeParams)
expect(response.status).toBe(400)
expect(mockSettle).not.toHaveBeenCalled()
})
it('returns 404 when the transaction is not in the company', async () => {
enqueue({ data: null, error: { message: 'not found' } })
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(404)
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
})
it('refuses an expense row', async () => {
enqueue({ data: makeTxRow({ amount: -3000 }) })
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_MATCH_NOT_INCOME')
expect(mockSettle).not.toHaveBeenCalled()
})
it('refuses a non-SEK row', async () => {
enqueue({ data: makeTxRow({ currency: 'EUR' }) })
const response = await POST(makeReq(), routeParams)
const { body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(body.error.code).toBe('ROT_RUT_MATCH_CURRENCY')
})
it('refuses a row that is already booked (live pointer or bank_line junction)', async () => {
enqueue({ data: makeTxRow({ journal_entry_id: 'je-old' }) })
mockHasLiveLink.mockResolvedValue(true)
let response = await POST(makeReq(), routeParams)
let parsed = await parseJsonResponse<{ error: { code: string } }>(response)
expect(parsed.status).toBe(400)
expect(parsed.body.error.code).toBe('ROT_RUT_MATCH_TX_ALREADY_LINKED')
reset()
enqueue({
data: makeTxRow({
transaction_voucher_links: [{ journal_entry_id: 'je-bulk', role: 'bank_line' }],
}),
})
response = await POST(makeReq(), routeParams)
parsed = await parseJsonResponse<{ error: { code: string } }>(response)
expect(parsed.body.error.code).toBe('ROT_RUT_MATCH_TX_ALREADY_LINKED')
expect(mockSettle).not.toHaveBeenCalled()
})
it('settles the request with the transaction amount, date and cash account, linking the row', async () => {
enqueue({ data: makeTxRow() })
mockResolveSettlementAccount.mockResolvedValue('1920')
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{
success: boolean
journal_entry_id: string
request: { status: string }
category: string
}>(response)
expect(status).toBe(200)
expect(body).toMatchObject({
success: true,
journal_entry_id: 'je-1',
request: { status: 'paid' },
category: 'income_other',
})
expect(mockResolveSettlementAccount).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'ca-1',
expect.anything(),
)
expect(mockSettle).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', {
requestId: REQUEST_ID,
paymentDate: '2026-07-10',
amount: 3000,
bankAccount: '1920',
transactionId: TX_ID,
previousJournalEntryId: null,
})
})
it('forwards a stale (non-live) pointer so the link CAS locks on it', async () => {
enqueue({ data: makeTxRow({ journal_entry_id: 'je-reversed' }) })
mockHasLiveLink.mockResolvedValue(false)
const response = await POST(makeReq(), routeParams)
expect(response.status).toBe(200)
expect(mockSettle).toHaveBeenCalledWith(
expect.anything(),
'user-1',
'company-1',
expect.objectContaining({ previousJournalEntryId: 'je-reversed' }),
)
})
it('maps service error codes onto the canonical envelope', async () => {
enqueue({ data: makeTxRow() })
mockSettle.mockResolvedValue({
ok: false,
kind: 'code',
code: 'ROT_RUT_SETTLE_INVALID_STATE',
details: { status: 'submitted', reason: 'beslut saknas' },
})
const response = await POST(makeReq(), routeParams)
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('ROT_RUT_SETTLE_INVALID_STATE')
reset()
enqueue({ data: makeTxRow() })
mockSettle.mockResolvedValue({
ok: false,
kind: 'code',
code: 'ROT_RUT_MATCH_TX_LINK_FAILED',
details: { journal_entry_id: 'je-1', request_id: REQUEST_ID },
})
const conflict = await POST(makeReq(), routeParams)
expect(conflict.status).toBe(409)
})
})
@@ -0,0 +1,138 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
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 { hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
import { hasBankLineJunctionRow } from '@/lib/transactions/is-booked'
import { ensureInitialized } from '@/lib/init'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-rot-rut-payout
*
* Match an income bank row to the ROT/RUT begäran whose payout it is:
*
* Debit 19xx (the transaction's cash account) [tx.amount]
* Credit 1513 Skattereduktion rot/rut [tx.amount]
*
* Same settle as POST /api/rot-rut/payout-requests/[id]/settle, but amount,
* 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).
*/
export const POST = withRouteContext<{ params: Promise<{ id: string }> }>(
'transaction.match_rot_rut_payout',
async (request, ctx, { params }) => {
const { id: transactionId } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, MatchRotRutPayoutSchema, {
log,
operation: 'transaction.match_rot_rut_payout',
})
if (!validation.success) return validation.response
const { request_id: payoutRequestId } = validation.data
const txLog = log.child({ transactionId, payoutRequestId })
// transaction_voucher_links rides along: a row bulk-booked into a
// samlingsverifikat carries journal_entry_id = NULL and must still refuse.
const { data: transactionRow, error: fetchTxError } = await supabase
.from('transactions')
.select(
'id, date, amount, currency, journal_entry_id, cash_account_id, transaction_voucher_links(journal_entry_id, role)',
)
.eq('id', transactionId)
.eq('company_id', companyId!)
.single()
if (fetchTxError || !transactionRow) {
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId })
}
const { transaction_voucher_links: junctionLinks, ...transaction } = transactionRow as {
id: string
date: string
amount: number
currency: string | null
journal_entry_id: string | null
cash_account_id: string | null
transaction_voucher_links?: Array<{ journal_entry_id: string; role?: string | null }> | null
}
if (!(transaction.amount > 0)) {
return errorResponseFromCode('ROT_RUT_MATCH_NOT_INCOME', txLog, {
requestId,
details: { amount: transaction.amount },
})
}
if ((transaction.currency || 'SEK').toUpperCase() !== 'SEK') {
return errorResponseFromCode('ROT_RUT_MATCH_CURRENCY', txLog, {
requestId,
details: { currency: transaction.currency },
})
}
// Only a LIVE (posted) pointer or a bank_line junction row blocks: a
// pointer left behind by a storno reads as "utan koppling" in the UI and
// must stay matchable (same predicate as link-journal-entry, issue #988).
if (
hasBankLineJunctionRow(junctionLinks) ||
(await hasLiveJournalEntryLink(supabase, companyId!, transaction.journal_entry_id))
) {
return errorResponseFromCode('ROT_RUT_MATCH_TX_ALREADY_LINKED', txLog, {
requestId,
details: { existingJournalEntryId: transaction.journal_entry_id },
})
}
// Debit the cash account THIS transaction belongs to, never a company-wide
// default (mirrors match-supplier-invoice).
const bankAccount = await resolveSettlementAccount(
supabase,
companyId!,
transaction.cash_account_id,
txLog,
)
const outcome = await settleRotRutPayoutRequest(supabase, user.id, companyId!, {
requestId: payoutRequestId,
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,
})
if (!outcome.ok) {
if (outcome.kind === 'code') {
return errorResponseFromCode(outcome.code, txLog, { requestId, details: outcome.details })
}
if (outcome.stage === 'book') {
txLog.error('failed to book rot/rut payout entry', outcome.error as Error)
}
return errorResponse(outcome.error, txLog, { requestId })
}
txLog.info('rot/rut payout matched from bank transaction', {
userId: user.id,
journalEntryId: outcome.journalEntryId,
amount: outcome.amount,
fullyPaid: outcome.fullyPaid,
})
return NextResponse.json({
success: true,
journal_entry_id: outcome.journalEntryId,
request: outcome.request,
category: 'income_other',
})
},
{ requireWrite: true },
)
@@ -14,6 +14,13 @@ vi.mock('@/lib/invoices/invoice-matching', () => ({
getBestInvoiceMatch: (...args: unknown[]) => mockGetBestInvoiceMatch(...args),
}))
// Mocked so the open-begäran pool consumes no slot in the queued Supabase mock.
const mockLoadOpenRotRutPayoutRequests = vi.fn()
vi.mock('@/lib/invoices/rot-rut-payout-candidates', () => ({
loadOpenRotRutPayoutRequests: (...args: unknown[]) => mockLoadOpenRotRutPayoutRequests(...args),
}))
mockLoadOpenRotRutPayoutRequests.mockResolvedValue([])
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
@@ -1,11 +1,16 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
import { findRotRutPayoutMatch } from '@/lib/invoices/rot-rut-payout-matching'
import { loadOpenRotRutPayoutRequests } from '@/lib/invoices/rot-rut-payout-candidates'
import type { Transaction } from '@/types'
/**
* POST /api/transactions/batch-match-invoices
* Run invoice matching for all uncategorized income transactions without potential_invoice_id
* Run invoice matching for all uncategorized income transactions without potential_invoice_id.
* Rows that match no invoice are then tried against open ROT/RUT payout
* requests (Skatteverkets utbetalning), so bank rows imported before that
* hint existed still get a suggestion on the next run.
*/
export const POST = withRouteContext(
'transaction.batch_match_invoices',
@@ -26,6 +31,9 @@ export const POST = withRouteContext(
return NextResponse.json({ error: 'Failed to fetch transactions' }, { status: 500 })
}
// Open begäran, loaded once: the matcher is pure and the table is tiny.
let openRotRutRequests = await loadOpenRotRutPayoutRequests(supabase, companyId!)
let matched = 0
const matchedInvoiceIds = new Set<string>()
@@ -46,6 +54,25 @@ export const POST = withRouteContext(
matchedInvoiceIds.add(bestMatch.invoice.id)
matched++
continue
}
if (
openRotRutRequests.length > 0 &&
!(tx as Transaction).potential_rot_rut_payout_request_id
) {
const payoutMatch = findRotRutPayoutMatch(tx as Transaction, openRotRutRequests)
if (payoutMatch) {
const { error: hintError } = await supabase
.from('transactions')
.update({ potential_rot_rut_payout_request_id: payoutMatch.request.id })
.eq('id', tx.id)
// A hint that never persisted must not drain the pool or count.
if (hintError) throw hintError
// One payout per begäran: drain so a same-amount sibling can't claim it.
openRotRutRequests = openRotRutRequests.filter((r) => r.id !== payoutMatch.request.id)
matched++
}
}
} catch {
// Continue with other transactions