* 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>
117 lines
4.8 KiB
TypeScript
117 lines
4.8 KiB
TypeScript
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()
|
|
})
|
|
})
|