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
+38
View File
@@ -33,6 +33,7 @@ import {
import { useCanWrite } from '@/lib/hooks/use-can-write'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import {
Dialog,
DialogContent,
@@ -63,6 +64,10 @@ interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
sent_at?: string
// Optional reference to the issuance verifikation. Populated by the
// backend when the invoice flow auto-books an entry on send; absent on
// older invoices and on companies where issuance is not auto-booked.
journal_entry_id?: string | null
}
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
@@ -690,6 +695,39 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
)}
{invoice.journal_entry_id && (
<>
<Separator />
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-sm">Bokföring</span>
<div className="flex flex-col items-end gap-1.5">
<Link
href={`/bookkeeping/${invoice.journal_entry_id}`}
className="text-sm hover:underline tabular-nums"
>
Visa verifikation
</Link>
{canWrite && (
<CorrectionAffordance
journalEntryId={invoice.journal_entry_id}
onCorrected={fetchInvoice}
>
{({ open, isLoading }) => (
<button
type="button"
onClick={open}
disabled={isLoading}
className="text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
>
{isLoading ? 'Hämtar…' : 'Något fel? Skapa ändringsverifikation'}
</button>
)}
</CorrectionAffordance>
)}
</div>
</div>
</>
)}
</CardContent>
</Card>
+84 -2
View File
@@ -699,7 +699,7 @@ export default function TransactionsPage() {
}
}
async function handleConfirmInvoiceMatch() {
async function handleConfirmInvoiceMatch(opts?: { force?: boolean; expected_journal_entry_id?: string }) {
if (!selectedTransaction) return
const isSupplier = !!selectedTransaction.potential_supplier_invoice
const isCustomer = !!selectedTransaction.potential_invoice
@@ -711,9 +711,19 @@ export default function TransactionsPage() {
const url = isSupplier
? `/api/transactions/${selectedTransaction.id}/match-supplier-invoice`
: `/api/transactions/${selectedTransaction.id}/match-invoice`
const body = isSupplier
const body: Record<string, unknown> = isSupplier
? { supplier_invoice_id: selectedTransaction.potential_supplier_invoice!.id }
: { invoice_id: selectedTransaction.potential_invoice!.id }
if (!isSupplier && opts?.force) {
body.force = true
// Bind the override to the candidate the user saw in the dialog.
// The server re-detects the candidate and rejects the bypass if
// the id doesn't match, so an empty value here surfaces as a
// clean validation error instead of silently widening the guard.
if (opts.expected_journal_entry_id) {
body.expected_journal_entry_id = opts.expected_journal_entry_id
}
}
const response = await fetch(url, {
method: 'POST',
@@ -777,6 +787,77 @@ export default function TransactionsPage() {
}
}
async function handleLinkToExistingVoucher(journalEntryId: string) {
if (!selectedTransaction) return
const invoiceId = selectedTransaction.potential_invoice?.id ?? null
setIsConfirmingMatch(true)
try {
const response = await fetch(
`/api/transactions/${selectedTransaction.id}/link-journal-entry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
journal_entry_id: journalEntryId,
...(invoiceId ? { invoice_id: invoiceId } : {}),
}),
},
)
const result = await response.json()
if (!response.ok) {
toast({
title: 'Kunde inte koppla till befintlig verifikation',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsConfirmingMatch(false)
return
}
const voucherLabel = (result as { voucher_label?: string }).voucher_label ?? ''
toast({
title: 'Bankhändelsen kopplad',
description: voucherLabel
? `Kopplad till verifikation ${voucherLabel}. Ingen ny bokföring skapad.`
: 'Ingen ny bokföring skapad.',
})
setMatchDialogOpen(false)
// Animate out + update local state, same pattern as handleConfirmInvoiceMatch.
setExitingIds((prev) => new Set(prev).add(selectedTransaction.id))
setTimeout(() => {
setTransactions((prev) =>
prev.map((t) =>
t.id === selectedTransaction.id
? {
...t,
invoice_id: invoiceId,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
journal_entry_id: journalEntryId,
}
: t,
),
)
setExitingIds((prev) => {
const next = new Set(prev)
next.delete(selectedTransaction.id)
return next
})
setSelectedTransaction(null)
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({
title: 'Koppling misslyckades',
description: 'Verifikationen kunde inte kopplas. Försök igen.',
variant: 'destructive',
})
setIsConfirmingMatch(false)
}
}
async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise<boolean> {
try {
const response = await fetch(`/api/transactions/${transactionId}/match-invoice`, {
@@ -1386,6 +1467,7 @@ export default function TransactionsPage() {
transaction={selectedTransaction}
isConfirming={isConfirmingMatch}
onConfirm={handleConfirmInvoiceMatch}
onLinkToExisting={handleLinkToExistingVoucher}
/>
<TransactionBookingDialog
@@ -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)
@@ -0,0 +1,95 @@
'use client'
/**
* Lazy entry point for CorrectionEntryDialog when the user is not on the
* /bookkeeping/[id] page (e.g. invoice detail, transaction row). Renders a
* trigger (button or link slot) that, on click, fetches the journal entry
* with its lines and opens the existing CorrectionEntryDialog.
*
* Used by:
* - /invoices/[id] when invoice.journal_entry_id is set
* - /transactions row menu when transaction.journal_entry_id is set
*
* Surfacing the storno+rättelse flow at the point where users notice the
* mistake matters — the dialog itself was already correct (it pre-fills
* lines and emits the storno+correction pair per BFL), but it was hidden
* behind a deep-link the customer never reached.
*/
import { useState } from 'react'
import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { JournalEntry } from '@/types'
interface Props {
journalEntryId: string
onCorrected?: () => void
/**
* Render prop: receives the click handler and current loading state.
* Letting the caller render its own trigger keeps the affordance visually
* native to its host page (link on invoice detail, menu item in dropdown).
*/
children: (args: { open: () => void; isLoading: boolean }) => React.ReactNode
}
export default function CorrectionAffordance({ journalEntryId, onCorrected, children }: Props) {
const { toast } = useToast()
const [entry, setEntry] = useState<JournalEntry | null>(null)
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
async function handleOpen() {
if (isLoading) return
setIsLoading(true)
try {
const res = await fetch(`/api/bookkeeping/journal-entries/${journalEntryId}`)
const json = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte hämta verifikationen',
description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }),
variant: 'destructive',
})
return
}
const fetched = json.data as JournalEntry
if (fetched.status !== 'posted') {
toast({
title: 'Verifikationen kan inte ändras',
description:
'Endast bokförda verifikationer kan rättas. Utkast hanteras direkt under bokföringen.',
variant: 'destructive',
})
return
}
setEntry(fetched)
setOpen(true)
} catch (err) {
toast({
title: 'Kunde inte hämta verifikationen',
description: getErrorMessage(err, { context: 'journal_entry' }),
variant: 'destructive',
})
} finally {
setIsLoading(false)
}
}
return (
<>
{children({ open: handleOpen, isLoading })}
{entry && (
<CorrectionEntryDialog
entry={entry}
open={open}
onOpenChange={setOpen}
onCorrected={() => {
setOpen(false)
setEntry(null)
onCorrected?.()
}}
/>
)}
</>
)
}
+108 -2
View File
@@ -1,17 +1,29 @@
'use client'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CheckCircle2, AlertTriangle } from 'lucide-react'
import type { TransactionWithInvoice } from './transaction-types'
interface DuplicateCandidate {
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
amount: number
bank_account_number: string
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
}
interface InvoiceMatchDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
transaction: TransactionWithInvoice | null
isConfirming: boolean
onConfirm: () => void
onConfirm: (opts?: { force?: boolean; expected_journal_entry_id?: string }) => void
onLinkToExisting?: (journalEntryId: string) => void
}
export default function InvoiceMatchDialog({
@@ -20,9 +32,43 @@ export default function InvoiceMatchDialog({
transaction,
isConfirming,
onConfirm,
onLinkToExisting,
}: InvoiceMatchDialogProps) {
const isSupplierInvoice = !!transaction?.potential_supplier_invoice
const isCustomerInvoice = !!transaction?.potential_invoice
const transactionId = transaction?.id ?? null
// Customer-side only: pre-flight check for a manual verifikation that
// already books this receipt. Supplier-side duplicate-payment surfacing
// is handled by the mark-paid guard on the supplier-invoice side; here
// we only need the customer flow for the reported issue.
const [candidate, setCandidate] = useState<DuplicateCandidate | null>(null)
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false)
useEffect(() => {
if (!open || !transactionId || !isCustomerInvoice || !onLinkToExisting) {
setCandidate(null)
return
}
let cancelled = false
async function check() {
setIsCheckingDuplicate(true)
try {
const res = await fetch(`/api/transactions/${transactionId}/duplicate-payment-check`)
if (!res.ok) return
const data = (await res.json()) as { candidate: DuplicateCandidate | null }
if (!cancelled) setCandidate(data.candidate ?? null)
} catch {
// Fail-open: hide the warning panel; the server still enforces the guard.
} finally {
if (!cancelled) setIsCheckingDuplicate(false)
}
}
check()
return () => {
cancelled = true
}
}, [open, transactionId, isCustomerInvoice, onLinkToExisting])
// The invoice candidate the dialog is about, normalized to a single shape.
// Supplier invoices show the negative-amount paid-out match; customer
@@ -43,6 +89,66 @@ export default function InvoiceMatchDialog({
{transaction && (isCustomerInvoice || isSupplierInvoice) && (
<div className="space-y-4">
{/* Duplicate-payment warning — customer-side only, only when a candidate exists */}
{candidate && isCustomerInvoice && (
<div className="rounded-lg border border-warning/40 bg-warning/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5 text-warning-foreground" />
<div className="text-sm space-y-1">
<p className="font-medium text-warning-foreground">Möjlig dubblettbokning</p>
<p className="text-muted-foreground">
Det finns redan en bokförd verifikation <span className="font-mono">{candidate.voucher_label}</span> på samma belopp ({formatCurrency(candidate.amount, transaction.currency)}) {candidate.reason === 'exact_amount_same_date' ? 'på samma datum' : `inom ±7 dagar (${formatDate(candidate.entry_date)})`}.
Har du redan bokfört denna betalning manuellt?
</p>
{candidate.description && (
// Truncate to a short head before render. The
// description is free-text and may carry a customer
// name or note that's not strictly required to
// identify the verifikation (voucher_label + amount +
// date already do that). Cap length to keep the
// dialog tight and limit incidental PII surfacing
// in the rendered DOM. GDPR Art.5(1)(c).
<p className="text-xs text-muted-foreground truncate">
{candidate.description.length > 80
? `${candidate.description.slice(0, 80).trimEnd()}…`
: candidate.description}
</p>
)}
</div>
</div>
{onLinkToExisting && (
<div className="flex flex-col sm:flex-row gap-2">
<Button
variant="default"
size="sm"
onClick={() => onLinkToExisting(candidate.journal_entry_id)}
disabled={isConfirming}
className="sm:flex-1"
>
Koppla till {candidate.voucher_label}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
onConfirm({
force: true,
// Echo the candidate the user reviewed back to
// the server so the bypass is bound to this
// specific duplicate. See match-invoice route.
expected_journal_entry_id: candidate.journal_entry_id,
})
}
disabled={isConfirming}
className="sm:flex-1"
>
Skapa ny verifikation ändå
</Button>
</div>
)}
</div>
)}
{/* Transaction details */}
<div className="rounded-lg border p-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Transaktion</p>
@@ -156,7 +262,7 @@ export default function InvoiceMatchDialog({
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isConfirming}>
Avbryt
</Button>
<Button onClick={onConfirm} disabled={isConfirming}>
<Button onClick={() => onConfirm()} disabled={isConfirming || isCheckingDuplicate}>
{isConfirming ? 'Bekräftar...' : 'Bekräfta matchning'}
</Button>
</DialogFooter>
+31 -2
View File
@@ -26,6 +26,11 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
useEffect(() => {
if (!company) return
// Capture the company id once so the async closure below never
// dereferences a `company` that has flipped to null between renders.
// The earlier non-null assertions allowed a stale render to query
// against an undefined company_id; pinning the value avoids that.
const companyId = company.id
let cancelled = false
async function load() {
setIsLoading(true)
@@ -39,14 +44,38 @@ export default function InvoicePicker({ transaction, onSelect, isProcessing }: I
const { data } = await supabase
.from('invoices')
.select('*, customer:customers(*)')
.eq('company_id', company!.id)
.eq('company_id', companyId)
.eq('document_type', 'invoice')
.in('status', ['sent', 'overdue', 'partially_paid'])
.gt('remaining_amount', 0)
.order('invoice_date', { ascending: false })
.limit(200)
if (cancelled) return
setInvoices((data as OpenInvoice[]) || [])
const all = (data as OpenInvoice[]) || []
// Status-leak guard: if an invoice still says 'sent'/'overdue' but
// already has a payment voucher attached (manual or system), hide it.
// Partially-paid invoices intentionally pass through — they may take
// more payments. Mirrors the server-side filter in findMatchingInvoices.
const fullIds = all
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
.map((inv) => inv.id)
let visible = all
if (fullIds.length > 0) {
const { data: paid } = await supabase
.from('invoice_payments')
.select('invoice_id')
.eq('company_id', companyId)
.in('invoice_id', fullIds)
.not('journal_entry_id', 'is', null)
if (cancelled) return
const paidSet = new Set<string>(
((paid as { invoice_id: string }[] | null) ?? []).map((r) => r.invoice_id),
)
visible = all.filter((inv) => !paidSet.has(inv.id))
}
setInvoices(visible)
setIsLoading(false)
}
load()
@@ -21,6 +21,8 @@ import {
Trash2,
} from 'lucide-react'
import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
import type {
SkattekontoTransactionWithSuggestion,
@@ -216,6 +218,11 @@ function BankHistoryRow({
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
onDelete?: (id: string) => void
}) {
// Viewers must not see write affordances. CorrectionAffordance opens a
// dialog that stages a storno + correction journal entry; the API path
// already 403s for viewers but rendering the trigger creates a confusing
// dead end. Mirrors the canWrite gate on the invoice detail page.
const { canWrite } = useCanWrite()
return (
<Card data-tx-id={transaction.id} className="hover:border-primary/50 transition-colors">
<CardContent className="py-4">
@@ -272,6 +279,26 @@ function BankHistoryRow({
<Check className="h-3 w-3 mr-1" />
Bokförd
</Badge>
<Link
href={`/bookkeeping/${transaction.journal_entry_id}`}
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
>
Visa verifikation
</Link>
{canWrite && (
<CorrectionAffordance journalEntryId={transaction.journal_entry_id}>
{({ open, isLoading }) => (
<button
type="button"
onClick={open}
disabled={isLoading}
className="text-xs text-muted-foreground hover:text-foreground hover:underline disabled:opacity-50"
>
{isLoading ? 'Hämtar…' : 'Skapa ändringsverifikation'}
</button>
)}
</CorrectionAffordance>
)}
</>
) : (
<>
+29 -2
View File
@@ -360,8 +360,35 @@ export const BookInboxItemDirectlySchema = z.object({
transaction_id: uuid.optional(),
})
export const MatchInvoiceSchema = z.object({
invoice_id: uuid,
export const MatchInvoiceSchema = z
.object({
invoice_id: uuid,
// Bypass the soft-duplicate guard (MATCH_INVOICE_POSSIBLE_DUPLICATE).
// Set after the user reviews the candidate verifikation and confirms it
// is not this payment. v1 callers must use a fresh Idempotency-Key on
// the retry — the original is body-hash bound.
force: z.boolean().optional(),
// Required whenever force=true. Echoes the journal_entry_id of the
// candidate the user reviewed in the duplicate-payment-check pre-flight.
// The server re-detects the candidate and refuses force=true unless the
// re-detected id matches this value. That binds the override to a
// specific, user-seen duplicate so an automation can't sweep through
// force=true to bypass the guard without ever consulting the candidate.
expected_journal_entry_id: uuid.optional(),
})
.refine((v) => !v.force || !!v.expected_journal_entry_id, {
message: 'expected_journal_entry_id is required when force=true',
path: ['expected_journal_entry_id'],
})
export const LinkTransactionJournalEntrySchema = z.object({
journal_entry_id: uuid,
// Optional invoice to settle alongside the link. When provided, the
// server inserts an invoice_payments row pointing at the existing JE
// and flips the invoice status with the same optimistic-lock pattern
// as the match-invoice route. Omit to only link the bank transaction
// (e.g. when the JE doesn't relate to a customer invoice).
invoice_id: uuid.optional(),
})
export const CreateTransactionFromDocumentSchema = z.object({
+7
View File
@@ -45,6 +45,13 @@ export interface RouteContext {
* resolved, so handlers can treat this as guaranteed non-null. Routes that
* need to opt out of the guarantee (e.g. onboarding) shouldn't use
* withRouteContext.
*
* Membership invariant: `getActiveCompanyId` only returns a company the
* authenticated user is a current member of (it validates
* `company_members` and excludes archived companies). The handler may
* therefore treat `companyId` as "a company the caller is authorized to
* read", and routes that mutate state additionally enforce a non-viewer
* role via `requireWrite: true`. ASVS V8.2.1 / SOC 2 CC6.3.
*/
companyId: string
}
+55
View File
@@ -356,6 +356,60 @@ const MATCH_INVOICE: Record<string, StructuredErrorEntry> = {
message_sv: 'Matchningen registrerades men verifikationen kunde inte skapas.',
message_en: 'Match recorded but the journal entry could not be created.',
},
MATCH_INVOICE_ALREADY_HAS_PAYMENT_VOUCHER: {
httpStatus: 409,
message_sv:
'Fakturan har redan en betalningsverifikation. Koppla istället bankhändelsen till befintlig verifikation, eller rätta tidigare bokföring först.',
message_en:
'Invoice already has a payment journal entry. Link the bank transaction to the existing voucher instead, or correct the prior bookkeeping first.',
},
MATCH_INVOICE_POSSIBLE_DUPLICATE: {
httpStatus: 409,
message_sv:
'Det finns redan en bokförd verifikation på samma belopp och datum. Har du redan bokfört denna betalning? Koppla bankhändelsen till befintlig verifikation, eller skapa ny verifikation ändå om de inte hör ihop.',
message_en:
'A posted journal entry already books the same amount on a nearby date. The user may have already booked this payment manually — link to the existing voucher or pass force=true to create a new one anyway.',
},
MATCH_INVOICE_FORCE_CANDIDATE_MISMATCH: {
httpStatus: 409,
message_sv:
'Verifikationen som dubblettkontrollen visade matchar inte längre. Stäng dialogen och försök igen så att rätt verifikation visas.',
message_en:
'The candidate journal entry echoed in expected_journal_entry_id does not match the one detected at request time. Re-run the duplicate-payment pre-flight to obtain the current candidate, then retry.',
},
}
const LINK_TX_JE: Record<string, StructuredErrorEntry> = {
LINK_TX_JE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Verifikationen kunde inte hittas.',
message_en: 'Journal entry not found.',
},
LINK_TX_JE_NOT_POSTED: {
httpStatus: 400,
message_sv: 'Endast bokförda verifikationer kan kopplas till en banktransaktion.',
message_en: 'Only posted journal entries can be linked to a transaction.',
},
LINK_TX_TX_ALREADY_LINKED: {
httpStatus: 400,
message_sv: 'Transaktionen är redan kopplad till en verifikation.',
message_en: 'Transaction is already linked to a journal entry.',
},
LINK_TX_INVOICE_NOT_FOUND: {
httpStatus: 404,
message_sv: 'Fakturan kunde inte hittas.',
message_en: 'Invoice not found.',
},
LINK_TX_INVOICE_NOT_OPEN: {
httpStatus: 400,
message_sv: 'Fakturan är inte i ett obetalt läge och kan inte kopplas.',
message_en: 'Invoice is not in an unpaid state.',
},
LINK_TX_INVOICE_RACE: {
httpStatus: 409,
message_sv: 'Fakturan ändrades samtidigt. Försök igen.',
message_en: 'Invoice status changed concurrently. Retry the request.',
},
}
const MATCH_SI: Record<string, StructuredErrorEntry> = {
@@ -1523,6 +1577,7 @@ const REGISTRY: Record<string, StructuredErrorEntry> = {
...BOOKKEEPING,
...TRANSACTIONS,
...MATCH_INVOICE,
...LINK_TX_JE,
...MATCH_SI,
...INVOICE,
...SUPPLIER_INVOICE,
@@ -0,0 +1,348 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { detectDuplicatePaymentVoucher } from '../duplicate-payment-detection'
import { createQueuedMockSupabase } from '@/tests/helpers'
const { supabase, enqueue, reset } = createQueuedMockSupabase()
describe('detectDuplicatePaymentVoucher', () => {
beforeEach(() => {
reset()
})
function makeLineRow(opts: {
je_id: string
account: string
debit: number
date: string
voucher_label?: string
source_type?: string | null
description?: string | null
}) {
const [series, ...numParts] = (opts.voucher_label ?? 'A1').split('')
const num = parseInt(numParts.join(''), 10) || 1
return {
account_number: opts.account,
debit_amount: opts.debit,
journal_entry: {
id: opts.je_id,
entry_date: opts.date,
description: opts.description ?? `Voucher ${opts.je_id}`,
voucher_series: series,
voucher_number: num,
status: 'posted',
source_type: opts.source_type ?? 'manual',
company_id: 'company-1',
},
}
}
it('returns null when transaction amount is 0', async () => {
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 0,
})
expect(result).toBeNull()
})
it('returns null when transaction date is invalid', async () => {
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: 'not-a-date',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('returns null when no lines are found', async () => {
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('returns the candidate when an unlinked manual JE matches exactly on the same date', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-1',
account: '1930',
debit: 1000,
date: '2026-05-15',
voucher_label: 'A12',
}),
],
error: null,
})
// invoice_payments link check (no links)
enqueue({ data: [], error: null })
// transactions link check (no links)
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).not.toBeNull()
expect(result!.journal_entry_id).toBe('je-1')
expect(result!.bank_account_number).toBe('1930')
expect(result!.reason).toBe('exact_amount_same_date')
expect(result!.amount).toBe(1000)
})
it('returns within_window reason when JE date is close but not equal', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-2',
account: '1930',
debit: 500,
date: '2026-05-12',
voucher_label: 'A5',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 500,
})
expect(result).not.toBeNull()
expect(result!.reason).toBe('exact_amount_within_window')
})
it('excludes JEs that are already linked via invoice_payments', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-3',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
],
error: null,
})
// invoice_payments has a row linking this JE
enqueue({ data: [{ journal_entry_id: 'je-3' }], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('excludes JEs already linked from another transaction', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-4',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
],
error: null,
})
enqueue({ data: [], error: null })
// another transaction already links this JE
enqueue({ data: [{ id: 'tx-other', journal_entry_id: 'je-4' }], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('excludes storno entries', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-storno',
account: '1930',
debit: 1000,
date: '2026-05-15',
source_type: 'storno',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('excludes correction entries', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-corr',
account: '1930',
debit: 1000,
date: '2026-05-15',
source_type: 'correction',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('picks the same-date candidate over a within-window candidate', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-far',
account: '1930',
debit: 1000,
date: '2026-05-12',
voucher_label: 'A1',
}),
makeLineRow({
je_id: 'je-same',
account: '1930',
debit: 1000,
date: '2026-05-15',
voucher_label: 'A2',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).not.toBeNull()
expect(result!.journal_entry_id).toBe('je-same')
expect(result!.reason).toBe('exact_amount_same_date')
})
it('matches absolute value for negative transaction amounts (expense)', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-x',
account: '1930',
debit: 250,
date: '2026-05-15',
}),
],
error: null,
})
enqueue({ data: [], error: null })
enqueue({ data: [], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: -250,
})
// Note: while the match-invoice route only handles income, the
// detector itself is amount-direction agnostic — it just finds JEs
// that book the same magnitude on the bank side. Callers gate by
// direction.
expect(result).not.toBeNull()
expect(result!.amount).toBe(250)
})
it('skips lines whose amount differs by more than 0.01', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-off',
account: '1930',
debit: 1001,
date: '2026-05-15',
}),
],
error: null,
})
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-1',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).toBeNull()
})
it('ignores the caller transaction even if it carries a journal_entry_id link', async () => {
enqueue({
data: [
makeLineRow({
je_id: 'je-caller',
account: '1930',
debit: 1000,
date: '2026-05-15',
}),
],
error: null,
})
enqueue({ data: [], error: null })
// The caller transaction itself links the JE (defensive — shouldn't happen
// in normal flow because we call this before the link, but a retry could).
enqueue({ data: [{ id: 'tx-caller', journal_entry_id: 'je-caller' }], error: null })
const result = await detectDuplicatePaymentVoucher(supabase as never, {
companyId: 'company-1',
transactionId: 'tx-caller',
transactionDate: '2026-05-15',
transactionAmount: 1000,
})
expect(result).not.toBeNull()
expect(result!.journal_entry_id).toBe('je-caller')
})
})
@@ -8,7 +8,13 @@ import {
getBestInvoiceMatch,
} from '../invoice-matching'
import type { Transaction, Invoice, Customer } from '@/types'
import { makeTransaction, makeInvoice, makeCustomer, createMockSupabase } from '@/tests/helpers'
import {
makeTransaction,
makeInvoice,
makeCustomer,
createMockSupabase,
createQueuedMockSupabase,
} from '@/tests/helpers'
// ============================================================
// amountsMatchExact
@@ -353,3 +359,82 @@ describe('getBestInvoiceMatch', () => {
expect(result).not.toBeNull()
})
})
// ============================================================
// findMatchingInvoices — paid-voucher status-leak guard
// ============================================================
//
// Defensive filter added because manual verifikationer (booked outside the
// match-invoice flow) leave the invoice in 'sent' status even though a
// payment voucher exists via invoice_payments. Matching such an invoice
// would double-book the bank receipt. Tests verify that:
// - sent/overdue invoices with an invoice_payments.journal_entry_id are
// excluded from the candidate list
// - partially_paid invoices remain candidates regardless (they may take
// more payments legitimately)
// - invoices without payment rows still pass through unchanged
describe('findMatchingInvoices — status-leak guard', () => {
it('excludes a sent invoice that already has a payment voucher', async () => {
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
const inv = {
...makeInvoice({
id: 'inv-leaked',
total: 1000,
status: 'sent',
remaining_amount: 1000,
currency: 'SEK',
}),
customer: makeCustomer({ name: 'Acme AB' }),
}
enqueue({ data: [inv], error: null })
enqueue({ data: [{ invoice_id: 'inv-leaked' }], error: null })
const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null })
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
expect(result).toEqual([])
})
it('keeps a partially_paid invoice as a candidate even with a prior payment voucher', async () => {
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
const inv = {
...makeInvoice({
id: 'inv-partial',
total: 1000,
status: 'partially_paid',
remaining_amount: 400,
currency: 'SEK',
}),
customer: makeCustomer({ name: 'Acme AB' }),
}
enqueue({ data: [inv], error: null })
// The status-leak guard only queries when there are sent/overdue rows;
// partially_paid invoices skip the second query, so no enqueue needed.
const tx = makeTransaction({ amount: 400, description: 'Acme partial', reference: null })
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].invoice.id).toBe('inv-partial')
})
it('passes sent invoices through when no payment rows exist for them', async () => {
const { supabase: queuedSupabase, enqueue } = createQueuedMockSupabase()
const inv = {
...makeInvoice({
id: 'inv-clean',
total: 1000,
status: 'sent',
remaining_amount: 1000,
currency: 'SEK',
}),
customer: makeCustomer({ name: 'Acme AB' }),
}
enqueue({ data: [inv], error: null })
enqueue({ data: [], error: null })
const tx = makeTransaction({ amount: 1000, description: 'Acme payment', reference: null })
const result = await findMatchingInvoices(queuedSupabase as never, 'company-1', tx)
expect(result).toHaveLength(1)
expect(result[0].invoice.id).toBe('inv-clean')
})
})
@@ -0,0 +1,111 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const chainCalls: Array<{ method: string; args: unknown[] }> = []
vi.mock('@supabase/ssr', () => {
const buildChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: [], error: null, count: null })
}
return (...args: unknown[]) => {
chainCalls.push({ method: String(prop), args })
return buildChain()
}
},
},
)
return {
createServerClient: vi.fn(() => ({
from: vi.fn(() => buildChain()),
rpc: vi.fn(() => buildChain()),
})),
}
})
vi.mock('@/lib/email/service', () => ({
getEmailService: () => ({
sendEmail: vi.fn().mockResolvedValue({ success: true }),
}),
}))
import {
processOverdueReminders,
determineReminderLevel,
calculateDaysOverdue,
} from '../reminder-processor'
describe('determineReminderLevel', () => {
it('returns null below the level-1 threshold', () => {
expect(determineReminderLevel(10, [])).toBeNull()
})
it('returns 1 at 15 days overdue', () => {
expect(determineReminderLevel(15, [])).toBe(1)
})
it('returns 2 at 30 days when level 1 already sent', () => {
expect(determineReminderLevel(30, [1])).toBe(2)
})
it('returns 3 at 45 days when 1 and 2 already sent', () => {
expect(determineReminderLevel(45, [1, 2])).toBe(3)
})
it('returns null when all levels have been sent', () => {
expect(determineReminderLevel(60, [1, 2, 3])).toBeNull()
})
})
describe('calculateDaysOverdue', () => {
it('returns a positive number for a past due date', () => {
const tenDaysAgo = new Date()
tenDaysAgo.setDate(tenDaysAgo.getDate() - 10)
const days = calculateDaysOverdue(tenDaysAgo.toISOString().split('T')[0])
expect(days).toBeGreaterThanOrEqual(9)
expect(days).toBeLessThanOrEqual(10)
})
})
describe('processOverdueReminders — credit-note filter', () => {
beforeEach(() => {
chainCalls.length = 0
})
it('excludes credit notes via .is("credited_invoice_id", null)', async () => {
await processOverdueReminders()
const isCall = chainCalls.find(
(c) => c.method === 'is' && c.args[0] === 'credited_invoice_id',
)
expect(
isCall,
'overdue-invoice query must filter out credit notes — credit notes have a negative total and must never trigger a payment reminder (e.g. KR-F2026002)',
).toBeDefined()
expect(isCall?.args[1]).toBeNull()
})
it('combines the credit-note filter with status=sent and due_date cutoff', async () => {
await processOverdueReminders()
const eqStatus = chainCalls.find(
(c) => c.method === 'eq' && c.args[0] === 'status',
)
const isCreditedNull = chainCalls.find(
(c) => c.method === 'is' && c.args[0] === 'credited_invoice_id',
)
const lteDueDate = chainCalls.find(
(c) => c.method === 'lte' && c.args[0] === 'due_date',
)
expect(eqStatus?.args[1]).toBe('sent')
expect(isCreditedNull?.args[1]).toBeNull()
expect(lteDueDate).toBeDefined()
})
})
+188
View File
@@ -0,0 +1,188 @@
/**
* Detect a "soft duplicate" payment voucher for a bank transaction.
*
* Scenario: the user manually booked the receipt as a verifikation
* (Dr 19xx / Cr 1510 or Cr 30xx) *outside* the match-invoice flow. The
* invoice's status stays 'sent', no `invoice_payments` row exists, and the
* matcher would happily propose a second payment voucher — double-booking
* the bank receipt.
*
* Heuristic: a posted journal entry within a tight date window whose lines
* debit a bank/cash account (BAS 19xx) for the same amount, and which is
* not already linked to any transaction or invoice payment, is almost
* certainly the manual booking. We surface it as a candidate; the API
* refuses the match unless the caller passes `force: true`.
*
* Mirrors `findDuplicatePaymentCandidatesForInvoice` (which scans for the
* reverse direction — unlinked transactions that look like a manually-marked
* invoice payment).
*/
import type { SupabaseClient } from '@supabase/supabase-js'
/** ± days around the transaction date considered "the same payment". */
const DATE_WINDOW_DAYS = 7
/** BAS "kassa och bank" range. 1910-1919 = kassa, 1920-1949 = bank/giro. */
const BANK_ACCOUNT_LOW = 1910
const BANK_ACCOUNT_HIGH = 1949
export interface DuplicateVoucherCandidate {
journal_entry_id: string
voucher_label: string
entry_date: string
description: string | null
amount: number
bank_account_number: string
reason: 'exact_amount_same_date' | 'exact_amount_within_window'
}
interface DetectArgs {
companyId: string
transactionId: string
transactionDate: string
transactionAmount: number
}
/**
* Find the single most likely manual verifikation that already books this
* bank transaction. Returns null when no candidate is found.
*
* Filters applied:
* - posted status (drafts cannot be a duplicate by definition)
* - entry date within ±DATE_WINDOW_DAYS of transaction.date
* - has a line that debits a BAS 19xx (kassa/bank) account for the same
* rounded amount (within 0.01 SEK)
* - not already linked from `transactions.journal_entry_id` (for any row)
* - not already referenced by `invoice_payments.journal_entry_id`
* - not the storno/correction entry for any prior original (source_type
* excluded — those are valid second-line vouchers, not duplicates)
*/
export async function detectDuplicatePaymentVoucher(
supabase: SupabaseClient,
args: DetectArgs,
): Promise<DuplicateVoucherCandidate | null> {
const { companyId, transactionId, transactionDate, transactionAmount } = args
const targetAmount = Math.round(Math.abs(transactionAmount) * 100) / 100
if (targetAmount === 0) return null
const dateMs = new Date(transactionDate).getTime()
if (Number.isNaN(dateMs)) return null
const lowDate = new Date(dateMs - DATE_WINDOW_DAYS * 24 * 3600 * 1000)
.toISOString()
.split('T')[0]
const highDate = new Date(dateMs + DATE_WINDOW_DAYS * 24 * 3600 * 1000)
.toISOString()
.split('T')[0]
// Query journal_entry_lines for bank-account debits within the window.
// The join filters by company_id at the parent — RLS handles isolation,
// but we filter explicitly as defense-in-depth.
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select(
`account_number,
debit_amount,
journal_entry:journal_entries!inner(
id,
entry_date,
description,
voucher_series,
voucher_number,
status,
source_type,
company_id
)`,
)
.eq('journal_entry.company_id', companyId)
.eq('journal_entry.status', 'posted')
.gte('journal_entry.entry_date', lowDate)
.lte('journal_entry.entry_date', highDate)
.gte('account_number', String(BANK_ACCOUNT_LOW))
.lte('account_number', String(BANK_ACCOUNT_HIGH))
.gt('debit_amount', 0)
.limit(50)
if (error || !lines || lines.length === 0) return null
// Narrow to lines whose debit matches the transaction amount within 0.01 SEK.
type LineRow = {
account_number: string
debit_amount: number | string
journal_entry: {
id: string
entry_date: string
description: string | null
voucher_series: string | null
voucher_number: number | null
status: string
source_type: string | null
}
}
const candidates = (lines as unknown as LineRow[])
.filter((l) => {
const debit = Math.round(Number(l.debit_amount) * 100) / 100
return Math.abs(debit - targetAmount) < 0.01
})
// System-generated payment vouchers (invoice_paid etc.) ARE valid
// duplicates to surface — those are exactly the case where the user
// already booked through a different flow. Only exclude reversals
// and corrections, which are bookkeeping noise rather than payment
// candidates the user would want to link to.
.filter((l) => l.journal_entry.source_type !== 'storno' && l.journal_entry.source_type !== 'correction')
if (candidates.length === 0) return null
// Exclude entries already linked from invoice_payments or any transaction.
const entryIds = candidates.map((l) => l.journal_entry.id)
const [{ data: paymentLinks }, { data: txLinks }] = await Promise.all([
supabase
.from('invoice_payments')
.select('journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
supabase
.from('transactions')
.select('id, journal_entry_id')
.eq('company_id', companyId)
.in('journal_entry_id', entryIds),
])
const linkedIds = new Set<string>()
for (const row of (paymentLinks ?? []) as { journal_entry_id: string | null }[]) {
if (row.journal_entry_id) linkedIds.add(row.journal_entry_id)
}
for (const row of (txLinks ?? []) as { id: string; journal_entry_id: string | null }[]) {
// A transaction can link to its own JE via the current match flow — but
// we're called *before* that link is created, so the caller's own
// transactionId shouldn't appear. Guard anyway in case of a retry.
if (row.journal_entry_id && row.id !== transactionId) {
linkedIds.add(row.journal_entry_id)
}
}
const unlinked = candidates.filter((l) => !linkedIds.has(l.journal_entry.id))
if (unlinked.length === 0) return null
// Pick the best candidate: same-date beats within-window; otherwise pick
// the closest by date difference.
const targetDateMs = new Date(transactionDate).getTime()
unlinked.sort((a, b) => {
const aDiff = Math.abs(new Date(a.journal_entry.entry_date).getTime() - targetDateMs)
const bDiff = Math.abs(new Date(b.journal_entry.entry_date).getTime() - targetDateMs)
return aDiff - bDiff
})
const best = unlinked[0]
const sameDate = best.journal_entry.entry_date === transactionDate
return {
journal_entry_id: best.journal_entry.id,
voucher_label: `${best.journal_entry.voucher_series ?? 'A'}${best.journal_entry.voucher_number ?? ''}`,
entry_date: best.journal_entry.entry_date,
description: best.journal_entry.description,
amount: Math.round(Number(best.debit_amount) * 100) / 100,
bank_account_number: best.account_number,
reason: sameDate ? 'exact_amount_same_date' : 'exact_amount_within_window',
}
}
+26 -2
View File
@@ -143,6 +143,30 @@ export async function findMatchingInvoices(
return []
}
// Defensive filter: exclude invoices that already have a payment voucher
// attached but whose status leaked (still 'sent'/'overdue'). Partially-paid
// invoices can legitimately take more payments, so they pass through.
// Without this, a status leak would double-book the receipt.
const fullCandidateIds = invoices
.filter((inv) => inv.status === 'sent' || inv.status === 'overdue')
.map((inv) => inv.id as string)
const paidIds = new Set<string>()
if (fullCandidateIds.length > 0) {
const { data: paymentRows } = await supabase
.from('invoice_payments')
.select('invoice_id')
.eq('company_id', companyId)
.in('invoice_id', fullCandidateIds)
.not('journal_entry_id', 'is', null)
for (const row of paymentRows ?? []) {
paidIds.add((row as { invoice_id: string }).invoice_id)
}
}
const filteredInvoices = invoices.filter((inv) => !paidIds.has(inv.id as string))
if (filteredInvoices.length === 0) {
return []
}
const matches: InvoiceMatch[] = []
// OCR/Bankgiro reference matching — highest confidence
@@ -150,7 +174,7 @@ export async function findMatchingInvoices(
const txReference = (transaction as Transaction & { reference?: string | null }).reference
if (txReference) {
const normalizedRef = txReference.replace(/\s+/g, '')
for (const invoice of invoices) {
for (const invoice of filteredInvoices) {
// Match against invoice_number (used as OCR reference in Swedish payments)
const invoiceRef = invoice.invoice_number?.replace(/\s+/g, '')
if (invoiceRef && normalizedRef === invoiceRef) {
@@ -168,7 +192,7 @@ export async function findMatchingInvoices(
}
}
for (const invoice of invoices) {
for (const invoice of filteredInvoices) {
// Currency filter - must match or be SEK equivalent
const currencyMatch =
invoice.currency === transaction.currency ||
+1
View File
@@ -6,6 +6,7 @@ type MatchAction =
| 'auto_suggested'
| 'suggestion_cleared'
| 'storno_conflict_resolved'
| 'linked_to_existing_voucher'
/**
* Log a payment match event to the append-only audit trail.
+1
View File
@@ -143,6 +143,7 @@ export async function processOverdueReminders(): Promise<ProcessRemindersResult>
customer:customers(*)
`)
.eq('status', 'sent')
.is('credited_invoice_id', null)
.lte('due_date', cutoffDate.toISOString().split('T')[0])
.order('due_date', { ascending: true })