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
@@ -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)