fix(payments): refuse to book a bank row that unlinked vouchers already explain (#2300)
* fix(payments): refuse to book a bank row that unlinked vouchers already explain A bank feed can deliver several affarshandelser as one row (a Bankgirot daily aggregate: two customers' invoices, one "BGGIRERING" row with no payer). When each invoice was already marked paid by hand, nothing on the account equals the row, the 1:1 duplicate check passes, and "Dela betalning" books the money a second time against whatever open invoices the user picks (the next period's identical ones, in the reported case). - lib/reconciliation/covering-set.ts: exact ore subset sum over a capped candidate list, smallest set first, closest in date second. - detectExplainingVoucherSet(+ForTransaction): the vouchers whose bank legs on the row's settlement account, in the row's direction, within 7 days, add up exactly to the row; linked through any of the three anchors drops a voucher, a payment row without a bank transaction keeps it. - POST match-batch refuses with BATCH_TX_POSSIBLE_DUPLICATE and returns the set; force=true must echo expected_journal_entry_ids (same binding as the single door). Fails open on a detection error. - GET duplicate-payment-check returns candidate_set next to candidate. - MatchAllocationDialog: pre-flight panel with the vouchers, one click links the row to them through the existing 1:1 or 1:N bank link (no new voucher), "Bokfor anda" acknowledges the set; confirm is disabled until then. Invoices dated after the bank row get a hint badge. - Mark-paid guard: aggregate sweep (row = this invoice + an exact subset of other open invoices, 7 days, kronor) when the name sweeps found nothing; PaymentBookingDialog shows the covered invoice numbers and points to the split under Transaktioner. Follow-ups: #2293 (1:N proposals in the auto-matcher), #2294 (MCP staging guard), #2299 (supplier-side text guard). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * test(invoices): account for the aggregate sweep in the mark-paid route queue The sweep issues one more transactions query whenever the name probes come back empty, so every queued-mock sequence that reaches it gains a slot. The sweep itself now fails open on odd client shapes (a single object for a list query) and on errors: an advisory guard must never block "Markera som betald". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu * fix(payments): fail open on resolved query errors; aggregate sweep without a payer name Review follow-ups on #2300. A PostgREST failure resolves with { data: null, error } instead of throwing, so the set detector read a failed link lookup as "no links" and a failed cash-account lookup as "scan every 19xx account"; both now return null (the booking RPC keeps the last word). The aggregate sweep never needed a customer name (a Bankgirot row names nobody), so a nameless invoice goes straight to it instead of skipping the guard. The already-booked panel is announced as a live region, and the "also covers" string is plural-aware. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NyjeEi1U8vnuPT4QXgayXu --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
9418de585f
commit
287828a850
@@ -165,6 +165,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
// Fetch company settings (now before update due to journal-first ordering)
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
@@ -235,6 +237,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
// Duplicate-payment guard: two ILIKE probes, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
// Company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Deliberately NO status-update enqueued: the route must fail closed BEFORE
|
||||
@@ -266,6 +270,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
@@ -875,6 +881,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: description ILIKE, no candidates
|
||||
enqueue({ data: [], error: null })
|
||||
// Duplicate-payment guard: aggregate sweep (larger unbooked kronor rows), none
|
||||
enqueue({ data: [], error: null })
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
// Update invoice status (CAS guard: returns matched row)
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
@@ -1153,6 +1161,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // duplicate guard: merchant_name
|
||||
enqueue({ data: [], error: null }) // duplicate guard: description
|
||||
enqueue({ data: [], error: null }) // duplicate guard: aggregate sweep
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
enqueue({ data: { id: 'ip-1' }, error: null }) // invoice_payments insert
|
||||
enqueue({ data: [{ id: 'inv-1' }], error: null }) // CAS update matched
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
createMockRouteParams,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
const { mockDetectOne, mockDetectSet } = vi.hoisted(() => ({
|
||||
mockDetectOne: vi.fn(),
|
||||
mockDetectSet: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
|
||||
detectDuplicatePaymentVoucher: mockDetectOne,
|
||||
detectExplainingVoucherSetForTransaction: mockDetectSet,
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
|
||||
const TX_UUID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
describe('GET /api/transactions/[id]/duplicate-payment-check', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: { id: 'user-1', email: 't@t.se' } } })
|
||||
mockDetectOne.mockResolvedValue(null)
|
||||
mockDetectSet.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`),
|
||||
createMockRouteParams({ id: TX_UUID }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 404 when the transaction is not in the company', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`),
|
||||
createMockRouteParams({ id: TX_UUID }),
|
||||
)
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns both nulls for a row that is already linked, without detecting', async () => {
|
||||
enqueue({ data: { id: TX_UUID, date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: 'je-live' }, error: null })
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`),
|
||||
createMockRouteParams({ id: TX_UUID }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ candidate: unknown; candidate_set: unknown }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body).toEqual({ candidate: null, candidate_set: null })
|
||||
expect(mockDetectOne).not.toHaveBeenCalled()
|
||||
expect(mockDetectSet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns the 1:1 candidate and the explaining set side by side', async () => {
|
||||
enqueue({
|
||||
data: { id: TX_UUID, date: '2026-07-31', amount: 88250, currency: 'SEK', amount_sek: null, exchange_rate: null, journal_entry_id: null, cash_account_id: 'ca-1' },
|
||||
error: null,
|
||||
})
|
||||
const set = {
|
||||
vouchers: [{ journal_entry_id: 'je-a', voucher_label: 'A57', entry_date: '2026-07-31', description: null, source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' }],
|
||||
total: 62500,
|
||||
bank_account_number: '1930',
|
||||
same_date: true,
|
||||
}
|
||||
mockDetectSet.mockResolvedValue(set)
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`),
|
||||
createMockRouteParams({ id: TX_UUID }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ candidate: unknown; candidate_set: typeof set }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.candidate).toBeNull()
|
||||
expect(body.candidate_set).toEqual(set)
|
||||
// The row the route already holds is handed over: no second transactions fetch.
|
||||
expect(mockDetectSet).toHaveBeenCalledWith(
|
||||
mockSupabase,
|
||||
'company-1',
|
||||
expect.objectContaining({ id: TX_UUID, amount: 88250, cash_account_id: 'ca-1', journal_entry_id: null }),
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open per detector: a throwing set detector still returns the 1:1 candidate', async () => {
|
||||
enqueue({ data: { id: TX_UUID, date: '2026-07-31', amount: 100, currency: 'SEK', journal_entry_id: null }, error: null })
|
||||
const candidate = { journal_entry_id: 'je-1', voucher_label: 'A1', entry_date: '2026-07-31', description: null, amount: 100, bank_account_number: '1930', reason: 'exact_amount_same_date', amount_verified: true, unverified_reason: null }
|
||||
mockDetectOne.mockResolvedValue(candidate)
|
||||
mockDetectSet.mockRejectedValue(new Error('boom'))
|
||||
|
||||
const response = await GET(
|
||||
createMockRequest(`/api/transactions/${TX_UUID}/duplicate-payment-check`),
|
||||
createMockRouteParams({ id: TX_UUID }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ candidate: unknown; candidate_set: unknown }>(response)
|
||||
expect(status).toBe(200)
|
||||
expect(body.candidate).toEqual(candidate)
|
||||
expect(body.candidate_set).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,24 @@
|
||||
/**
|
||||
* GET /api/transactions/[id]/duplicate-payment-check
|
||||
*
|
||||
* Proactive check used by the InvoiceMatchDialog: returns the candidate
|
||||
* verifikation that already books this bank transaction, or null if no
|
||||
* duplicate is detected. Lets the UI display the warning panel without
|
||||
* Proactive check used by the InvoiceMatchDialog and MatchAllocationDialog:
|
||||
* returns the candidate verifikation that already books this bank
|
||||
* transaction (`candidate`, 1:1, or null), and the set of one or more posted
|
||||
* unlinked vouchers whose bank legs add up exactly to the row
|
||||
* (`candidate_set`, or null). Lets the UI display the warning panel without
|
||||
* needing to first submit a doomed match.
|
||||
*
|
||||
* Same detector as the match-invoice route's pre-flight, so what you see
|
||||
* here matches what the POST would refuse.
|
||||
* Same detectors as the match-invoice and match-batch pre-flights, so what
|
||||
* you see here matches what the POSTs would refuse.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import {
|
||||
detectDuplicatePaymentVoucher,
|
||||
detectExplainingVoucherSetForTransaction,
|
||||
type ExplainingVoucherSet,
|
||||
} from '@/lib/invoices/duplicate-payment-detection'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'transaction.duplicate_payment_check',
|
||||
@@ -35,7 +41,7 @@ export const GET = withRouteContext(
|
||||
// exactly how this guard would go dead on FX rows.
|
||||
const { data: transaction, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id')
|
||||
.select('id, date, amount, currency, amount_sek, exchange_rate, journal_entry_id, cash_account_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
@@ -46,11 +52,15 @@ export const GET = withRouteContext(
|
||||
|
||||
// Already linked → no possible duplicate to surface.
|
||||
if (transaction.journal_entry_id) {
|
||||
return NextResponse.json({ candidate: null })
|
||||
return NextResponse.json({ candidate: null, candidate_set: null })
|
||||
}
|
||||
|
||||
// Both detectors fail open: returning null preserves current UX. The
|
||||
// POSTs still run their own checks, so a missed pre-flight doesn't allow
|
||||
// a duplicate booking.
|
||||
let candidate: Awaited<ReturnType<typeof detectDuplicatePaymentVoucher>> = null
|
||||
try {
|
||||
const candidate = await detectDuplicatePaymentVoucher(supabase, {
|
||||
candidate = await detectDuplicatePaymentVoucher(supabase, {
|
||||
companyId: companyId!,
|
||||
transactionId,
|
||||
transactionDate: transaction.date,
|
||||
@@ -59,13 +69,26 @@ export const GET = withRouteContext(
|
||||
transactionAmountSek: transaction.amount_sek ?? null,
|
||||
transactionExchangeRate: transaction.exchange_rate ?? null,
|
||||
})
|
||||
return NextResponse.json({ candidate })
|
||||
} catch (err) {
|
||||
log.warn('duplicate-payment-voucher detection failed', err as Error)
|
||||
// Fail-open: returning null preserves current UX. The POST still
|
||||
// runs its own check, so a missed pre-flight doesn't allow a
|
||||
// duplicate booking.
|
||||
return NextResponse.json({ candidate: null })
|
||||
}
|
||||
|
||||
let candidateSet: ExplainingVoucherSet | null = null
|
||||
try {
|
||||
candidateSet = await detectExplainingVoucherSetForTransaction(supabase, companyId!, {
|
||||
id: transaction.id,
|
||||
date: transaction.date,
|
||||
amount: transaction.amount,
|
||||
currency: transaction.currency ?? null,
|
||||
amount_sek: transaction.amount_sek ?? null,
|
||||
exchange_rate: transaction.exchange_rate ?? null,
|
||||
cash_account_id: transaction.cash_account_id ?? null,
|
||||
journal_entry_id: null,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('explaining-voucher-set detection failed', err as Error)
|
||||
}
|
||||
|
||||
return NextResponse.json({ candidate, candidate_set: candidateSet })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,6 +28,15 @@ vi.mock('@/lib/invoices/clear-settled-invoice-suggestions', () => ({
|
||||
clearSettledInvoiceSuggestions: mockClearSuggestions,
|
||||
}))
|
||||
|
||||
// The already-explained guard (BATCH_TX_POSSIBLE_DUPLICATE) runs before the
|
||||
// RPC. Mocked so it consumes no slot in the queued Supabase mock; the
|
||||
// detector's own query shape is pinned by
|
||||
// lib/invoices/__tests__/duplicate-payment-detection.test.ts.
|
||||
const { mockDetectExplaining } = vi.hoisted(() => ({ mockDetectExplaining: vi.fn() }))
|
||||
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
|
||||
detectExplainingVoucherSetForTransaction: mockDetectExplaining,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
@@ -51,6 +60,7 @@ describe('POST /api/transactions/[id]/match-batch', () => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockDetectExplaining.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns 400 when allocations is missing', async () => {
|
||||
@@ -257,3 +267,132 @@ describe('POST /api/transactions/[id]/match-batch', () => {
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/transactions/[id]/match-batch: already-explained guard', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
const JE_A = '55555555-5555-4555-8555-555555555555'
|
||||
const JE_B = '66666666-6666-4666-8666-666666666666'
|
||||
const explainingSet = {
|
||||
vouchers: [
|
||||
{ journal_entry_id: JE_A, voucher_label: 'A57', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 063', source_type: 'invoice_paid', amount: 62500, bank_account_number: '1930' },
|
||||
{ journal_entry_id: JE_B, voucher_label: 'A58', entry_date: '2026-07-31', description: 'Inbetalning kundfaktura 064', source_type: 'invoice_paid', amount: 25750, bank_account_number: '1930' },
|
||||
],
|
||||
total: 88250,
|
||||
bank_account_number: '1930',
|
||||
same_date: true,
|
||||
}
|
||||
|
||||
function enqueueHappyRpc() {
|
||||
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
|
||||
enqueue({
|
||||
data: {
|
||||
ok: true,
|
||||
journal_entry_id: 'je-batch-9',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 59,
|
||||
tx_id: TX_UUID,
|
||||
allocations: [
|
||||
{ kind: 'customer_invoice', invoice_id: INV_UUID, payment_id: 'ip-9', status: 'paid', paid_amount: 88250, remaining_amount: 0, amount: 88250 },
|
||||
],
|
||||
total_allocated: 88250,
|
||||
leftover: 0,
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: { id: TX_UUID, amount: 88250, currency: 'SEK' }, error: null })
|
||||
enqueue({ data: { id: INV_UUID, currency: 'SEK', status: 'paid' }, error: null })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
mockDetectExplaining.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('refuses with 409 and the vouchers when unlinked vouchers already sum to the row', async () => {
|
||||
mockDetectExplaining.mockResolvedValue(explainingSet)
|
||||
// document_type pre-check runs before the guard.
|
||||
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: { allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details: { vouchers: Array<{ voucher_label: string }>; total: number; force_rejected: boolean } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE')
|
||||
expect(body.error.details.vouchers.map((v) => v.voucher_label)).toEqual(['A57', 'A58'])
|
||||
expect(body.error.details.total).toBe(88250)
|
||||
expect(body.error.details.force_rejected).toBe(false)
|
||||
expect(mockDetectExplaining).toHaveBeenCalledWith(mockSupabase, 'company-1', TX_UUID)
|
||||
// The RPC was never reached.
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('books anyway when force=true echoes exactly the reviewed voucher ids', async () => {
|
||||
mockDetectExplaining.mockResolvedValue(explainingSet)
|
||||
enqueueHappyRpc()
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }],
|
||||
force: true,
|
||||
// Order must not matter.
|
||||
expected_journal_entry_ids: [JE_B, JE_A],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
expect(response.status).toBe(200)
|
||||
expect(mockSupabase.rpc).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('refuses force=true whose ids do not match the set it re-detects', async () => {
|
||||
mockDetectExplaining.mockResolvedValue(explainingSet)
|
||||
enqueue({ data: [{ id: INV_UUID, document_type: 'invoice' }], error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }],
|
||||
force: true,
|
||||
expected_journal_entry_ids: [JE_A],
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details: { force_rejected: boolean } } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('BATCH_TX_POSSIBLE_DUPLICATE')
|
||||
expect(body.error.details.force_rejected).toBe(true)
|
||||
expect(mockSupabase.rpc).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects force=true without expected_journal_entry_ids at the schema (400)', async () => {
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }],
|
||||
force: true,
|
||||
},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('fails open when the detector throws: the RPC still decides', async () => {
|
||||
mockDetectExplaining.mockRejectedValue(new Error('ledger scan timed out'))
|
||||
enqueueHappyRpc()
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/match-batch`, {
|
||||
method: 'POST',
|
||||
body: { allocations: [{ kind: 'customer_invoice', invoice_id: INV_UUID, amount: 88250 }] },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MatchBatchSchema } from '@/lib/api/schemas'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { clearSettledBatchAllocationSuggestions } from '@/lib/invoices/clear-settled-batch-allocations'
|
||||
import { detectExplainingVoucherSetForTransaction } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice, SupplierInvoice, Transaction } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
@@ -102,6 +103,50 @@ export const POST = withRouteContext(
|
||||
}
|
||||
}
|
||||
|
||||
// Already-explained guard. A bank feed can deliver several affärshändelser
|
||||
// as ONE row (a Bankgirot daily aggregate covering two customers'
|
||||
// invoices), and each may already be booked on its own via "Markera som
|
||||
// betald". The RPC only knows the invoices in the request: it correctly
|
||||
// refuses the PAID ones, and then books the money a second time against
|
||||
// whatever open invoices the user picked (the next period's identical
|
||||
// ones, in the case that prompted this). The vouchers that explain the
|
||||
// row are on the ledger, so refuse here and hand them back; the dialog
|
||||
// links the row to them (1:N, /api/reconciliation/bank/link) instead of
|
||||
// creating a new voucher. Fail-open on a detection error: the guard is
|
||||
// advisory, the RPC remains the atomicity boundary.
|
||||
let explaining: Awaited<ReturnType<typeof detectExplainingVoucherSetForTransaction>> = null
|
||||
try {
|
||||
explaining = await detectExplainingVoucherSetForTransaction(supabase, companyId!, transactionId)
|
||||
} catch (err) {
|
||||
txLog.warn('match-batch: explaining-voucher detection failed', err as Error)
|
||||
}
|
||||
if (explaining) {
|
||||
const detectedIds = explaining.vouchers.map((v) => v.journal_entry_id).sort()
|
||||
const expectedIds = [...(validation.data.expected_journal_entry_ids ?? [])].sort()
|
||||
const acknowledged =
|
||||
validation.data.force === true &&
|
||||
detectedIds.length === expectedIds.length &&
|
||||
detectedIds.every((id, i) => id === expectedIds[i])
|
||||
if (!acknowledged) {
|
||||
return errorResponseFromCode('BATCH_TX_POSSIBLE_DUPLICATE', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
vouchers: explaining.vouchers,
|
||||
total: explaining.total,
|
||||
bank_account_number: explaining.bank_account_number,
|
||||
same_date: explaining.same_date,
|
||||
// force=true with a stale or missing set: the caller must re-read.
|
||||
force_rejected: validation.data.force === true,
|
||||
},
|
||||
})
|
||||
}
|
||||
txLog.warn('match-batch: already-explained guard bypassed', {
|
||||
reason: 'force=true',
|
||||
journalEntryIds: detectedIds,
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc('match_batch_allocate', {
|
||||
p_tx_id: transactionId,
|
||||
p_allocations: validation.data.allocations,
|
||||
|
||||
Reference in New Issue
Block a user