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:
Mattsson
2026-05-18 13:17:15 +02:00
committed by GitHub
parent a652dcae1a
commit d27c3dd3dc
22 changed files with 2364 additions and 13 deletions
@@ -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)