Bug/customer cron job (#516)
* fix(reminder-processor): filter out credited invoices in overdue reminders * feat: implement linking of transactions to journal entries - Added POST endpoint for linking a bank transaction to an existing journal entry without creating new bookkeeping. - Implemented validation for required fields and error handling for various scenarios (e.g., missing journal_entry_id, transaction already linked, journal entry not found). - Created tests for the new endpoint to cover various cases including successful linking, error responses, and invoice handling. - Introduced duplicate payment detection logic to prevent double-booking of bank receipts. - Added a new component for correction affordance in the UI to facilitate user corrections on journal entries. * feat(invoice-matching): enhance force matching with expected journal entry validation
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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'
|
||||
|
||||
export const GET = withRouteContext(
|
||||
'transaction.duplicate_payment_check',
|
||||
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: transactionId } = await params
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
// Membership is enforced by withRouteContext (see its docstring) — the
|
||||
// resolved companyId is always a company the caller is a member of, so
|
||||
// intra-company multi-user visibility of transaction metadata here is
|
||||
// the intended tenancy model. The selected column set is intentionally
|
||||
// narrow (id, date, amount, journal_entry_id) so this endpoint cannot
|
||||
// leak description / counterparty fields that aren't required to
|
||||
// surface a duplicate-payment candidate. GDPR Art.5(1)(c)/(f).
|
||||
const { data: transaction, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, journal_entry_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (error || !transaction) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', log, { requestId })
|
||||
}
|
||||
|
||||
// Already linked → no possible duplicate to surface.
|
||||
if (transaction.journal_entry_id) {
|
||||
return NextResponse.json({ candidate: null })
|
||||
}
|
||||
|
||||
try {
|
||||
const candidate = await detectDuplicatePaymentVoucher(supabase, {
|
||||
companyId: companyId!,
|
||||
transactionId,
|
||||
transactionDate: transaction.date,
|
||||
transactionAmount: transaction.amount,
|
||||
})
|
||||
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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createMockRouteParams,
|
||||
createQueuedMockSupabase,
|
||||
makeTransaction,
|
||||
makeInvoice,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
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 }),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
|
||||
const TX_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const JE_UUID = '550e8400-e29b-41d4-a716-446655440001'
|
||||
const INV_UUID = '550e8400-e29b-41d4-a716-446655440002'
|
||||
|
||||
describe('POST /api/transactions/[id]/link-journal-entry', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
it('returns 400 when journal_entry_id is missing', async () => {
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: {},
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when transaction not found', async () => {
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('TX_CATEGORIZE_TX_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when transaction is already linked', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: 'je-prior' }),
|
||||
error: null,
|
||||
})
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('LINK_TX_TX_ALREADY_LINKED')
|
||||
})
|
||||
|
||||
it('returns 404 when journal entry not found', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('LINK_TX_JE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when journal entry is not posted', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'draft',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('LINK_TX_JE_NOT_POSTED')
|
||||
})
|
||||
|
||||
it('happy path: links tx without invoice, no new bookkeeping created', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 12,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// logMatchEvent insert
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
journal_entry_id: string
|
||||
voucher_label: string
|
||||
invoice_id: string | null
|
||||
invoice_status: string | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe(JE_UUID)
|
||||
expect(body.voucher_label).toBe('A12')
|
||||
expect(body.invoice_id).toBeNull()
|
||||
expect(body.invoice_status).toBeNull()
|
||||
})
|
||||
|
||||
it('happy path with invoice: links tx, flips invoice to paid, inserts invoice_payments', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: INV_UUID,
|
||||
status: 'sent',
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
currency: 'SEK',
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
// Update transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// Update invoice (optimistic lock returns updated row)
|
||||
enqueue({ data: [{ id: INV_UUID }], error: null })
|
||||
// Insert invoice_payments
|
||||
enqueue({ data: null, error: null })
|
||||
// logMatchEvent
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
success: boolean
|
||||
invoice_status: string | null
|
||||
paid_amount: number | null
|
||||
remaining_amount: number | null
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.invoice_status).toBe('paid')
|
||||
expect(body.paid_amount).toBe(1000)
|
||||
expect(body.remaining_amount).toBe(0)
|
||||
})
|
||||
|
||||
it('returns 404 when invoice_id supplied but invoice not found', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({ data: null, error: { message: 'not found' } })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(404)
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('returns 400 when supplied invoice is not in an open state', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({ id: INV_UUID, status: 'paid' }),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_NOT_OPEN')
|
||||
})
|
||||
|
||||
it('returns 409 LINK_TX_INVOICE_RACE when optimistic lock loses and rolls back the tx link', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({ id: INV_UUID, status: 'sent', total: 1000, remaining_amount: 1000 }),
|
||||
error: null,
|
||||
})
|
||||
// Update transaction succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
// Optimistic invoice update returns 0 rows
|
||||
enqueue({ data: [], error: null })
|
||||
// Compensating rollback: restore prior tx state
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('LINK_TX_INVOICE_RACE')
|
||||
})
|
||||
|
||||
it('rolls back both the tx link and the invoice update when invoice_payments insert fails', async () => {
|
||||
enqueue({
|
||||
data: makeTransaction({ id: TX_UUID, journal_entry_id: null, amount: 1000, date: '2026-05-15' }),
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: {
|
||||
id: JE_UUID,
|
||||
status: 'posted',
|
||||
voucher_series: 'A',
|
||||
voucher_number: 1,
|
||||
entry_date: '2026-05-15',
|
||||
},
|
||||
error: null,
|
||||
})
|
||||
enqueue({
|
||||
data: makeInvoice({
|
||||
id: INV_UUID,
|
||||
status: 'sent',
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
paid_amount: 0,
|
||||
}),
|
||||
error: null,
|
||||
})
|
||||
// Update transaction succeeds
|
||||
enqueue({ data: null, error: null })
|
||||
// Optimistic invoice update succeeds
|
||||
enqueue({ data: [{ id: INV_UUID }], error: null })
|
||||
// invoice_payments insert fails with non-23505 error
|
||||
enqueue({ data: null, error: { code: '99999', message: 'unexpected' } })
|
||||
// Compensating invoice revert
|
||||
enqueue({ data: null, error: null })
|
||||
// Compensating tx rollback
|
||||
enqueue({ data: null, error: null })
|
||||
|
||||
const request = createMockRequest(`/api/transactions/${TX_UUID}/link-journal-entry`, {
|
||||
method: 'POST',
|
||||
body: { journal_entry_id: JE_UUID, invoice_id: INV_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: TX_UUID }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(500)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_RECORD_PAYMENT_FAILED')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* POST /api/transactions/[id]/link-journal-entry
|
||||
*
|
||||
* Link a bank transaction to an already-posted journal entry without
|
||||
* creating new bookkeeping. Used by the duplicate-payment UI when the user
|
||||
* confirms the suggested candidate already books this receipt — typically
|
||||
* a manual verifikation made outside the match-invoice flow.
|
||||
*
|
||||
* Body:
|
||||
* - journal_entry_id (required): the existing posted JE to link to.
|
||||
* - invoice_id (optional): when supplied, also inserts an
|
||||
* invoice_payments row pointing at the existing JE and flips the
|
||||
* invoice status to 'paid' / 'partially_paid'. Same optimistic-lock
|
||||
* pattern as match-invoice. Omit when linking against a JE that
|
||||
* doesn't relate to a customer invoice (uncommon but supported).
|
||||
*
|
||||
* Effects:
|
||||
* - transactions.journal_entry_id = je_id
|
||||
* - transactions.is_business = true
|
||||
* - transactions.potential_invoice_id = null
|
||||
* - transactions.potential_supplier_invoice_id = null
|
||||
* - if invoice_id provided:
|
||||
* - invoice_payments row inserted (transaction_id, amount, journal_entry_id)
|
||||
* - invoice.status / paid_amount / remaining_amount updated
|
||||
*
|
||||
* NEVER creates a new journal entry; the underlying double-entry already
|
||||
* exists. The match log records 'linked_to_existing_voucher' for audit.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { LinkTransactionJournalEntrySchema } from '@/lib/api/schemas'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { Invoice, Transaction } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const POST = withRouteContext(
|
||||
'transaction.link_journal_entry',
|
||||
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const { id: transactionId } = await params
|
||||
const { user, supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = await validateBody(request, LinkTransactionJournalEntrySchema, {
|
||||
log,
|
||||
operation: 'transaction.link_journal_entry',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { journal_entry_id, invoice_id } = validation.data
|
||||
|
||||
const txLog = log.child({ transactionId, journalEntryId: journal_entry_id, invoiceId: invoice_id })
|
||||
|
||||
// Data minimization (GDPR Art.5(1)(c)): pull only the columns the route
|
||||
// actually uses for validation, the optimistic-lock invoice update, the
|
||||
// invoice_payments insert, and the compensating-rollback path. Avoid
|
||||
// `select('*')` so freshly-added columns (PII or otherwise) never leak
|
||||
// into the request scope or downstream logs by accident.
|
||||
const { data: transaction, 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',
|
||||
)
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchTxError || !transaction) {
|
||||
return errorResponseFromCode('TX_CATEGORIZE_TX_NOT_FOUND', txLog, { requestId })
|
||||
}
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
return errorResponseFromCode('LINK_TX_TX_ALREADY_LINKED', txLog, {
|
||||
requestId,
|
||||
details: { existingJournalEntryId: transaction.journal_entry_id },
|
||||
})
|
||||
}
|
||||
|
||||
const { data: journalEntry, error: fetchJeError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id, status, voucher_series, voucher_number, entry_date')
|
||||
.eq('id', journal_entry_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchJeError || !journalEntry) {
|
||||
return errorResponseFromCode('LINK_TX_JE_NOT_FOUND', txLog, { requestId })
|
||||
}
|
||||
|
||||
if (journalEntry.status !== 'posted') {
|
||||
return errorResponseFromCode('LINK_TX_JE_NOT_POSTED', txLog, {
|
||||
requestId,
|
||||
details: { currentStatus: journalEntry.status },
|
||||
})
|
||||
}
|
||||
|
||||
// If invoice_id supplied, validate + prepare invoice update.
|
||||
let invoice: (Invoice & { customer?: { name?: string } | null }) | null = null
|
||||
let newPaidAmount = 0
|
||||
let newRemaining = 0
|
||||
let isFullyPaid = false
|
||||
let newStatus: 'paid' | 'partially_paid' = 'paid'
|
||||
|
||||
if (invoice_id) {
|
||||
const { data: invoiceRow, error: fetchInvError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(name)')
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (fetchInvError || !invoiceRow) {
|
||||
return errorResponseFromCode('LINK_TX_INVOICE_NOT_FOUND', txLog, { requestId })
|
||||
}
|
||||
|
||||
if (
|
||||
invoiceRow.status !== 'sent' &&
|
||||
invoiceRow.status !== 'overdue' &&
|
||||
invoiceRow.status !== 'partially_paid'
|
||||
) {
|
||||
return errorResponseFromCode('LINK_TX_INVOICE_NOT_OPEN', txLog, {
|
||||
requestId,
|
||||
details: { currentStatus: invoiceRow.status },
|
||||
})
|
||||
}
|
||||
|
||||
invoice = invoiceRow as Invoice & { customer?: { name?: string } | null }
|
||||
|
||||
const paidAmount = transaction.amount
|
||||
newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
|
||||
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
|
||||
newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
|
||||
isFullyPaid = newRemaining <= 0
|
||||
newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
}
|
||||
|
||||
// Capture pre-link values so the compensating-rollback path below can
|
||||
// restore the row if the optimistic invoice update loses its race or
|
||||
// the invoice_payments insert fails. Without this snapshot a partial
|
||||
// state would persist: tx linked, invoice unchanged, no payment row.
|
||||
const priorTxState = {
|
||||
journal_entry_id: transaction.journal_entry_id, // validated null above
|
||||
invoice_id: transaction.invoice_id,
|
||||
potential_invoice_id: transaction.potential_invoice_id,
|
||||
potential_supplier_invoice_id: transaction.potential_supplier_invoice_id,
|
||||
is_business: transaction.is_business,
|
||||
}
|
||||
|
||||
// Link the transaction first. If a subsequent step fails the compensating
|
||||
// path below restores priorTxState. Doing the tx update before the invoice
|
||||
// update preserves the "transaction disappears from inbox" UX even if the
|
||||
// invoice update races.
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
journal_entry_id,
|
||||
invoice_id: invoice_id ?? null,
|
||||
potential_invoice_id: null,
|
||||
potential_supplier_invoice_id: null,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
|
||||
if (updateTxError) {
|
||||
txLog.error('failed to link transaction to journal entry', updateTxError)
|
||||
return errorResponse(updateTxError, txLog, { requestId })
|
||||
}
|
||||
|
||||
async function rollbackTxLink(reason: string) {
|
||||
const { error: rollbackErr } = await supabase
|
||||
.from('transactions')
|
||||
.update(priorTxState)
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
if (rollbackErr) {
|
||||
// Best-effort: the original error is more useful to surface; a
|
||||
// failed rollback gets warn-logged so a reconciliation job can pick
|
||||
// up the partial state offline. PI1.3 risk is documented here so
|
||||
// the audit trail is honest about the remaining gap.
|
||||
txLog.warn('failed to roll back transaction link after subsequent step failed', {
|
||||
rollbackError: rollbackErr.message,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (invoice && invoice_id) {
|
||||
// Optimistic lock: only flip status if invoice is still matchable.
|
||||
const { data: updatedRows, error: updateInvError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
})
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.select('id')
|
||||
|
||||
if (updateInvError) {
|
||||
await rollbackTxLink('invoice update errored')
|
||||
txLog.error('failed to update invoice status', updateInvError)
|
||||
return errorResponse(updateInvError, txLog, { requestId })
|
||||
}
|
||||
|
||||
if (!updatedRows || updatedRows.length === 0) {
|
||||
await rollbackTxLink('invoice optimistic lock returned 0 rows')
|
||||
return errorResponseFromCode('LINK_TX_INVOICE_RACE', txLog, { requestId })
|
||||
}
|
||||
|
||||
const { error: paymentInsertError } = await supabase
|
||||
.from('invoice_payments')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
invoice_id,
|
||||
payment_date: transaction.date,
|
||||
amount: transaction.amount,
|
||||
currency: invoice.currency,
|
||||
exchange_rate: invoice.exchange_rate,
|
||||
journal_entry_id,
|
||||
transaction_id: transactionId,
|
||||
notes: 'Kopplad till befintlig verifikation (ingen ny bokföring skapad)',
|
||||
})
|
||||
|
||||
if (paymentInsertError && paymentInsertError.code !== '23505') {
|
||||
// Compensate: revert the invoice update and the tx link before
|
||||
// surfacing the error so the ledger doesn't carry an invoice that
|
||||
// says "paid" with no corresponding payment row.
|
||||
const { error: invRevertErr } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: invoice.status,
|
||||
paid_at: invoice.paid_at ?? null,
|
||||
paid_amount: invoice.paid_amount ?? 0,
|
||||
remaining_amount: invoice.remaining_amount ?? invoice.total,
|
||||
})
|
||||
.eq('id', invoice_id)
|
||||
.eq('company_id', companyId)
|
||||
if (invRevertErr) {
|
||||
txLog.warn('failed to revert invoice status after payment insert failed', {
|
||||
rollbackError: invRevertErr.message,
|
||||
})
|
||||
}
|
||||
await rollbackTxLink('invoice_payments insert failed')
|
||||
txLog.error('failed to record invoice payment', paymentInsertError)
|
||||
return errorResponseFromCode('MATCH_INVOICE_RECORD_PAYMENT_FAILED', txLog, { requestId })
|
||||
}
|
||||
}
|
||||
|
||||
logMatchEvent(supabase, user.id, transactionId, 'linked_to_existing_voucher', {
|
||||
invoiceId: invoice_id,
|
||||
newState: {
|
||||
journal_entry_id,
|
||||
invoice_id: invoice_id ?? null,
|
||||
invoice_status: invoice ? newStatus : null,
|
||||
},
|
||||
})
|
||||
|
||||
if (invoice && invoice_id) {
|
||||
try {
|
||||
eventBus.emit({
|
||||
type: 'invoice.match_confirmed',
|
||||
payload: {
|
||||
invoice: invoice as Invoice,
|
||||
transaction: transaction as Transaction,
|
||||
userId: user.id,
|
||||
companyId,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
txLog.warn('invoice.match_confirmed event emission failed', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
journal_entry_id,
|
||||
voucher_label: `${journalEntry.voucher_series ?? 'A'}${journalEntry.voucher_number ?? ''}`,
|
||||
invoice_id: invoice_id ?? null,
|
||||
invoice_status: invoice ? newStatus : null,
|
||||
paid_amount: invoice ? newPaidAmount : null,
|
||||
remaining_amount: invoice ? newRemaining : null,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -32,6 +32,11 @@ vi.mock('@/lib/invoices/match-log', () => ({
|
||||
logMatchEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockDetectDuplicate = vi.fn()
|
||||
vi.mock('@/lib/invoices/duplicate-payment-detection', () => ({
|
||||
detectDuplicatePaymentVoucher: (...args: unknown[]) => mockDetectDuplicate(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/events/bus', () => ({
|
||||
eventBus: { emit: vi.fn() },
|
||||
}))
|
||||
@@ -53,6 +58,9 @@ import { POST } from '../route'
|
||||
|
||||
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
|
||||
const CANDIDATE_UUID = '550e8400-e29b-41d4-a716-446655440003'
|
||||
const STALE_UUID = '550e8400-e29b-41d4-a716-446655440004'
|
||||
const OTHER_CANDIDATE_UUID = '550e8400-e29b-41d4-a716-446655440005'
|
||||
|
||||
describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
@@ -61,6 +69,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
// Default to no soft-duplicate detected — happy-path tests don't care.
|
||||
mockDetectDuplicate.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
@@ -205,6 +215,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check: no prior payment voucher for this invoice
|
||||
enqueue({ data: [], error: null })
|
||||
// Fetch company settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
@@ -271,6 +283,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
enqueue({ data: tx, error: null })
|
||||
// Fetch invoice
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check: no prior payment voucher for this invoice
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
mockReverseEntry.mockResolvedValue({ id: 'je-storno' })
|
||||
// Clear journal_entry_id on transaction
|
||||
@@ -315,6 +329,8 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check: no prior payment voucher
|
||||
enqueue({ data: [], error: null })
|
||||
|
||||
mockReverseEntry.mockRejectedValue(new Error('Period locked'))
|
||||
|
||||
@@ -345,6 +361,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial' })
|
||||
@@ -388,6 +405,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' })
|
||||
@@ -426,6 +444,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
@@ -454,6 +473,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
|
||||
|
||||
@@ -479,6 +499,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked'))
|
||||
@@ -508,4 +529,216 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
|
||||
expect(body.journal_entry_id).toBeNull()
|
||||
expect(body.journal_entry_error).toBe('Period locked')
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Duplicate-payment guards (Phase A4)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('returns 409 MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER when a payment row already links a JE for a sent invoice', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null })
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 12500,
|
||||
remaining_amount: 12500,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check returns a row pointing at the existing JE
|
||||
enqueue({ data: [{ journal_entry_id: 'je-existing' }], error: null })
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string; details?: { existing_journal_entry_id?: string } } }>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER')
|
||||
expect(body.error.details?.existing_journal_entry_id).toBe('je-existing')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does NOT run hard-duplicate guard for partially_paid invoices (legitimate additional payment)', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 2500, invoice_id: null, date: '2024-06-15' })
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'partially_paid',
|
||||
total: 12500,
|
||||
remaining_amount: 2500,
|
||||
paid_amount: 10000,
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
// Hard-duplicate check is skipped for partially_paid; jump straight to settings
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial-extra' })
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
enqueue({ data: null, error: null }) // update tx
|
||||
enqueue({ data: null, error: null }) // logMatchEvent
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; invoice_status: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.invoice_status).toBe('paid')
|
||||
})
|
||||
|
||||
it('returns 409 MATCH_INVOICE_POSSIBLE_DUPLICATE when the soft-duplicate detector finds a manual voucher', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 1000, remaining_amount: 1000 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check: clean
|
||||
|
||||
mockDetectDuplicate.mockResolvedValueOnce({
|
||||
journal_entry_id: 'je-manual',
|
||||
voucher_label: 'A12',
|
||||
entry_date: '2026-05-15',
|
||||
description: 'Inbetalning faktura',
|
||||
amount: 1000,
|
||||
bank_account_number: '1930',
|
||||
reason: 'exact_amount_same_date',
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { candidate?: { journal_entry_id: string; voucher_label: string } } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_POSSIBLE_DUPLICATE')
|
||||
expect(body.error.details?.candidate?.journal_entry_id).toBe('je-manual')
|
||||
expect(body.error.details?.candidate?.voucher_label).toBe('A12')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force=true bypasses the soft-duplicate guard when the candidate echo matches', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' })
|
||||
const invoice = makeInvoice({
|
||||
id: VALID_UUID,
|
||||
status: 'sent',
|
||||
total: 1000,
|
||||
remaining_amount: 1000,
|
||||
invoice_number: 'F-2024099',
|
||||
})
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check: clean
|
||||
|
||||
// force=true re-detects the candidate to verify the echoed id matches.
|
||||
mockDetectDuplicate.mockResolvedValueOnce({
|
||||
journal_entry_id: CANDIDATE_UUID,
|
||||
voucher_label: 'A12',
|
||||
entry_date: '2026-05-15',
|
||||
description: 'Inbetalning faktura',
|
||||
amount: 1000,
|
||||
bank_account_number: '1930',
|
||||
reason: 'exact_amount_same_date',
|
||||
})
|
||||
|
||||
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
|
||||
|
||||
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-forced' })
|
||||
enqueue({ data: [{ id: VALID_UUID }], error: null }) // update invoice
|
||||
enqueue({ data: null, error: null }) // insert invoice_payments
|
||||
enqueue({ data: null, error: null }) // update tx
|
||||
enqueue({ data: null, error: null }) // logMatchEvent
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID, force: true, expected_journal_entry_id: CANDIDATE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ success: boolean; journal_entry_id: string }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.journal_entry_id).toBe('je-forced')
|
||||
expect(mockDetectDuplicate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns 400 when force=true is sent without expected_journal_entry_id', async () => {
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID, force: true },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
// Refusal happens at the schema layer (refine) before any DB work.
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 409 MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when the echoed candidate no longer matches', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 1000, remaining_amount: 1000 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check: clean
|
||||
|
||||
// Re-detection returns a different candidate than the caller echoed.
|
||||
mockDetectDuplicate.mockResolvedValueOnce({
|
||||
journal_entry_id: OTHER_CANDIDATE_UUID,
|
||||
voucher_label: 'A99',
|
||||
entry_date: '2026-05-15',
|
||||
description: 'Annan verifikation',
|
||||
amount: 1000,
|
||||
bank_account_number: '1930',
|
||||
reason: 'exact_amount_same_date',
|
||||
})
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID, force: true, expected_journal_entry_id: STALE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
error: { code: string; details?: { expected_journal_entry_id?: string; detected_journal_entry_id?: string } }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH')
|
||||
expect(body.error.details?.expected_journal_entry_id).toBe(STALE_UUID)
|
||||
expect(body.error.details?.detected_journal_entry_id).toBe(OTHER_CANDIDATE_UUID)
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH when no current duplicate exists for the force call', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-1', amount: 1000, invoice_id: null, date: '2026-05-15' })
|
||||
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 1000, remaining_amount: 1000 })
|
||||
|
||||
enqueue({ data: tx, error: null })
|
||||
enqueue({ data: invoice, error: null })
|
||||
enqueue({ data: [], error: null }) // hard-duplicate check: clean
|
||||
|
||||
// Detection returns null — the duplicate the caller saw has resolved.
|
||||
mockDetectDuplicate.mockResolvedValueOnce(null)
|
||||
|
||||
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
|
||||
method: 'POST',
|
||||
body: { invoice_id: VALID_UUID, force: true, expected_journal_entry_id: STALE_UUID },
|
||||
})
|
||||
const response = await POST(request, createMockRouteParams({ id: 'tx-1' }))
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH')
|
||||
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structure
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { MatchInvoiceSchema } from '@/lib/api/schemas'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import type { EntityType, Invoice, Transaction } from '@/types'
|
||||
@@ -40,7 +41,7 @@ export const POST = withRouteContext(
|
||||
operation: 'transaction.match_invoice',
|
||||
})
|
||||
if (!validation.success) return validation.response
|
||||
const { invoice_id } = validation.data
|
||||
const { invoice_id, force, expected_journal_entry_id } = validation.data
|
||||
|
||||
const txLog = log.child({ transactionId, invoiceId: invoice_id })
|
||||
|
||||
@@ -100,6 +101,96 @@ export const POST = withRouteContext(
|
||||
})
|
||||
}
|
||||
|
||||
// Hard-duplicate guard: if the invoice is 'sent'/'overdue' but already
|
||||
// has a payment voucher attached (status leak), refuse — booking again
|
||||
// would double-credit 1510 / double-debit 1930. Partially-paid invoices
|
||||
// pass through; additional payments are legitimate.
|
||||
if (invoice.status === 'sent' || invoice.status === 'overdue') {
|
||||
const { data: existingPayments } = await supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', companyId)
|
||||
.eq('invoice_id', invoice_id)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.limit(1)
|
||||
if (existingPayments && existingPayments.length > 0) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
existing_journal_entry_id: (existingPayments[0] as { journal_entry_id: string }).journal_entry_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-duplicate guard: scan for a manual verifikation that already
|
||||
// books this bank receipt outside the invoice flow. The customer's
|
||||
// exact case: they posted Dr 1930 / Cr 3100 by hand; the matcher
|
||||
// would otherwise create a second voucher and double-book. Bypassed
|
||||
// with force=true after the user reviews the candidate in the UI.
|
||||
//
|
||||
// force=true is bound to a specific candidate via expected_journal_entry_id
|
||||
// (validated by the schema). We re-detect the candidate server-side and
|
||||
// refuse the bypass if it no longer matches: a stale or fabricated
|
||||
// expected id cannot wave the guard away. The pre-flight runs even when
|
||||
// a candidate is detected so the audit log records the verifikation the
|
||||
// user opted to dismiss.
|
||||
let dismissedCandidateId: string | null = null
|
||||
try {
|
||||
const candidate = await detectDuplicatePaymentVoucher(supabase, {
|
||||
companyId: companyId!,
|
||||
transactionId,
|
||||
transactionDate: transaction.date,
|
||||
transactionAmount: transaction.amount,
|
||||
})
|
||||
if (!force) {
|
||||
if (candidate) {
|
||||
return errorResponseFromCode('MATCH_INVOICE_POSSIBLE_DUPLICATE', txLog, {
|
||||
requestId,
|
||||
details: { candidate },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (!candidate || candidate.journal_entry_id !== expected_journal_entry_id) {
|
||||
// Either no current duplicate (force is moot — caller should retry
|
||||
// without force) or the candidate the caller claims to have seen
|
||||
// doesn't match what we detect now. Reject so an automation can't
|
||||
// smuggle force=true past the guard with a guessed id.
|
||||
return errorResponseFromCode('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH', txLog, {
|
||||
requestId,
|
||||
details: {
|
||||
expected_journal_entry_id,
|
||||
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
dismissedCandidateId = candidate.journal_entry_id
|
||||
}
|
||||
} catch (err) {
|
||||
// Detection failure must not block the non-force match — log and
|
||||
// continue. force=true requires a successful detection, so re-throw
|
||||
// its branch as a clean 500 via the wrapper.
|
||||
if (force) {
|
||||
txLog.error('duplicate-payment-voucher detection failed under force=true', err as Error)
|
||||
return errorResponse(err, txLog, { requestId })
|
||||
}
|
||||
txLog.warn('duplicate-payment-voucher detection failed (continuing)', err as Error)
|
||||
}
|
||||
|
||||
if (force && dismissedCandidateId) {
|
||||
txLog.warn('soft-duplicate guard bypassed', {
|
||||
reason: 'force=true',
|
||||
requestId,
|
||||
transactionId,
|
||||
invoiceId: invoice_id,
|
||||
userId: user.id,
|
||||
// The verifikation the user reviewed and dismissed. Recorded so the
|
||||
// override can be traced back to the specific duplicate that was
|
||||
// surfaced in the pre-flight UI.
|
||||
dismissedJournalEntryId: dismissedCandidateId,
|
||||
})
|
||||
}
|
||||
|
||||
// Storno conflicting auto-categorization JE before any other state change.
|
||||
// If storno fails, return immediately — nothing else has been modified.
|
||||
if (transaction.journal_entry_id) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import { reverseEntry } from '@/lib/bookkeeping/engine'
|
||||
import { AccountsNotInChartError, isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import { detectDuplicatePaymentVoucher } from '@/lib/invoices/duplicate-payment-detection'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { EntityType, Invoice, Transaction } from '@/types'
|
||||
|
||||
@@ -122,7 +123,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
},
|
||||
})
|
||||
}
|
||||
const { invoice_id } = parsed.data
|
||||
const { invoice_id, force, expected_journal_entry_id } = parsed.data
|
||||
const txLog = ctx.log.child({ transactionId: txId, invoiceId: invoice_id })
|
||||
|
||||
const { data: transaction, error: fetchTxErr } = await ctx.supabase
|
||||
@@ -184,6 +185,86 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
})
|
||||
}
|
||||
|
||||
// Hard-duplicate guard: status leak — the invoice still says
|
||||
// 'sent'/'overdue' but already has a payment voucher attached. Mirror
|
||||
// of the internal route's defensive check.
|
||||
if (invoice.status === 'sent' || invoice.status === 'overdue') {
|
||||
const { data: existingPayments } = await ctx.supabase
|
||||
.from('invoice_payments')
|
||||
.select('journal_entry_id')
|
||||
.eq('company_id', ctx.companyId!)
|
||||
.eq('invoice_id', invoice_id)
|
||||
.not('journal_entry_id', 'is', null)
|
||||
.limit(1)
|
||||
if (existingPayments && existingPayments.length > 0) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
existing_journal_entry_id:
|
||||
(existingPayments[0] as { journal_entry_id: string }).journal_entry_id,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Soft-duplicate guard: a manual verifikation already books this
|
||||
// bank receipt. Bypassed only when the caller echoes the candidate's
|
||||
// journal_entry_id back in expected_journal_entry_id (validated by
|
||||
// the schema). The Idempotency-Key body hash already prevents replay
|
||||
// with a different body, and re-detecting the candidate here means an
|
||||
// automation can't fabricate or stale-roll an id past the guard.
|
||||
let dismissedCandidateId: string | null = null
|
||||
try {
|
||||
const candidate = await detectDuplicatePaymentVoucher(ctx.supabase, {
|
||||
companyId: ctx.companyId!,
|
||||
transactionId: txId,
|
||||
transactionDate: transaction.date,
|
||||
transactionAmount: transaction.amount,
|
||||
})
|
||||
if (!force) {
|
||||
if (candidate) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_POSSIBLE_DUPLICATE', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: { candidate },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (!candidate || candidate.journal_entry_id !== expected_journal_entry_id) {
|
||||
return v1ErrorResponseFromCode('MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH', txLog, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
expected_journal_entry_id,
|
||||
detected_journal_entry_id: candidate?.journal_entry_id ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
dismissedCandidateId = candidate.journal_entry_id
|
||||
}
|
||||
} catch (err) {
|
||||
if (force) {
|
||||
txLog.error('duplicate-payment-voucher detection failed under force=true', err as Error)
|
||||
return v1ErrorResponse(err, txLog, { requestId: ctx.requestId })
|
||||
}
|
||||
txLog.warn('duplicate-payment-voucher detection failed (continuing)', err as Error)
|
||||
}
|
||||
|
||||
if (force && dismissedCandidateId) {
|
||||
txLog.warn('soft-duplicate guard bypassed', {
|
||||
reason: 'force=true',
|
||||
requestId: ctx.requestId,
|
||||
transactionId: txId,
|
||||
invoiceId: invoice_id,
|
||||
// Attribute the override to the calling user AND the API key. The
|
||||
// user identifier alone is not enough for v1 — a single user can
|
||||
// hold multiple keys (CI bot, integration, personal), and revocation
|
||||
// / abuse triage needs to know which key was used.
|
||||
userId: ctx.userId,
|
||||
apiKeyId: ctx.apiKeyId,
|
||||
// The verifikation the caller acknowledged and dismissed.
|
||||
dismissedJournalEntryId: dismissedCandidateId,
|
||||
})
|
||||
}
|
||||
|
||||
if (transaction.journal_entry_id) {
|
||||
try {
|
||||
await reverseEntry(ctx.supabase, ctx.companyId!, ctx.userId, transaction.journal_entry_id)
|
||||
|
||||
Reference in New Issue
Block a user