feat: payment matching audit trail and partial payments (#89)

* feat: payment matching audit trail, partial payments, and match improvements

- Add payment_match_log table (append-only audit trail per BFL 7:1) with
  immutability triggers and proper RLS
- Add invoice_payments table for partial payment tracking, mirroring
  supplier_invoice_payments pattern
- Add remaining_amount column + partially_paid status to invoices
- Add match-log.ts service for recording match/unmatch state transitions
- Improve match-invoice route: support partial payments, record audit log,
  emit payment.matched events, handle storno conflicts
- Improve match-supplier-invoice route: audit logging, partial payment support
- Update invoice-entries.ts for partial payment journal entries
- Update transaction ingest to detect and auto-suggest invoice matches
- Update bank-reconciliation to handle partial payment state
- Add invoice_payments, payment_match_log types and helpers
- Extend tests for match-invoice route and transaction ingest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback — payment matching correctness bugs

- P1: Return 500 on non-23505 payment insert failures instead of
  silently continuing with corrupted invoice state (both routes)
- P1: Fix foreign-currency partial payment matching — use proportional
  remaining SEK amount instead of full total_sek
- P2: Check error when clearing journal_entry_id after storno
- P2: Add updated_at column + trigger to invoice_payments table
- P2: Make supplier_invoice_payments.user_id NOT NULL after backfill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-23 13:00:37 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent fdcc94102a
commit 7c3518a080
18 changed files with 786 additions and 54 deletions
@@ -23,6 +23,23 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({
getOutputVatAccount: vi.fn().mockReturnValue('2611'),
}))
const mockReverseEntry = vi.fn()
vi.mock('@/lib/bookkeeping/engine', () => ({
reverseEntry: (...args: unknown[]) => mockReverseEntry(...args),
}))
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(),
}))
import { POST } from '../route'
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
@@ -140,13 +157,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.error).toBe('Invoice is not in an unpaid state')
})
it('matches transaction to invoice with accrual method', async () => {
it('matches transaction to invoice with accrual method (full payment)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
const customer = makeCustomer()
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
subtotal: 10000,
vat_amount: 2500,
invoice_number: 'F-2024001',
@@ -162,10 +180,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
// Update invoice to paid
// Update invoice (optimistic lock returns updated row)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent insert (fire-and-forget)
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
@@ -176,6 +198,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
success: boolean
invoice_status: string
paid_amount: number
remaining_amount: number
journal_entry_id: string
}>(response)
@@ -183,22 +206,243 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
expect(body.success).toBe(true)
expect(body.invoice_status).toBe('paid')
expect(body.paid_amount).toBe(12500)
expect(body.remaining_amount).toBe(0)
expect(body.journal_entry_id).toBe('je-1')
// Verify accrual payment entry was called
// Verify accrual payment entry was called with paymentAmount
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({ id: VALID_UUID }),
'2024-06-15',
undefined,
expect.anything()
expect.anything(),
12500
)
})
it('stornos conflicting journal entry before matching', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: 12500,
invoice_id: null,
journal_entry_id: 'je-conflict',
date: '2024-06-15',
})
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
})
// Fetch transaction
enqueue({ data: tx, error: null })
// Fetch invoice
enqueue({ data: invoice, error: null })
mockReverseEntry.mockResolvedValue({ id: 'je-storno' })
// Clear journal_entry_id on transaction
enqueue({ data: null, error: null })
// logMatchEvent for storno
enqueue({ data: null, error: null })
// Fetch company settings
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-payment' })
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent for match
enqueue({ data: null, 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<{ success: boolean; journal_entry_id: string }>(response)
expect(status).toBe(200)
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBe('je-payment')
expect(mockReverseEntry).toHaveBeenCalledWith(expect.anything(), 'user-1', 'je-conflict')
})
it('returns 500 when storno fails — no partial state change', async () => {
const tx = makeTransaction({
id: 'tx-1',
amount: 12500,
invoice_id: null,
journal_entry_id: 'je-conflict',
})
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', remaining_amount: 12500 })
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
mockReverseEntry.mockRejectedValue(new Error('Period locked'))
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: string }>(response)
expect(status).toBe(500)
expect(body.error).toBe('Failed to reverse conflicting journal entry')
// Invoice should NOT have been updated — no further DB calls after storno failure
expect(mockCreateInvoicePaymentJournalEntry).not.toHaveBeenCalled()
})
it('supports partial payment (partially_paid status)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-partial' })
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
enqueue({ data: null, 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<{
success: boolean
invoice_status: string
paid_amount: number
remaining_amount: number
}>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('partially_paid')
expect(body.paid_amount).toBe(5000)
expect(body.remaining_amount).toBe(7500)
})
it('cash method partial payment uses clearing entry with note', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 5000, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({
id: VALID_UUID,
status: 'sent',
total: 12500,
remaining_amount: 12500,
paid_amount: 0,
})
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-clearing' })
// Update invoice
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
enqueue({ data: null, 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<{ invoice_status: string }>(response)
expect(status).toBe(200)
expect(body.invoice_status).toBe('partially_paid')
// Cash partial uses accrual-style clearing entry, NOT createInvoiceCashEntry
expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalled()
expect(mockCreateInvoiceCashEntry).not.toHaveBeenCalled()
})
it('returns 409 when invoice is fully paid (optimistic lock)', 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 })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
// Optimistic lock returns 0 rows (another request fully paid it)
enqueue({ data: [], 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: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already been fully paid')
})
it('returns 409 on duplicate invoice_payment (unique constraint)', 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 })
enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null })
mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' })
// Optimistic lock succeeds
enqueue({ data: [{ id: VALID_UUID }], error: null })
// invoice_payments insert fails with unique constraint violation
enqueue({ data: null, error: { code: '23505', message: 'duplicate' } })
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: string }>(response)
expect(status).toBe(409)
expect(body.error).toContain('already matched')
})
it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' })
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500 })
const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500, remaining_amount: 12500 })
enqueue({ data: tx, error: null })
enqueue({ data: invoice, error: null })
@@ -206,10 +450,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => {
mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked'))
// Update invoice
// Update invoice (optimistic lock)
enqueue({ data: [{ id: VALID_UUID }], error: null })
// Insert invoice_payments
enqueue({ data: null, error: null })
// Update transaction
enqueue({ data: null, error: null })
// logMatchEvent
enqueue({ data: null, error: null })
const request = createMockRequest('/api/transactions/tx-1/match-invoice', {
method: 'POST',
+149 -20
View File
@@ -4,18 +4,25 @@ import {
createInvoicePaymentJournalEntry,
createInvoiceCashEntry,
} from '@/lib/bookkeeping/invoice-entries'
import { reverseEntry } from '@/lib/bookkeeping/engine'
import { validateBody } from '@/lib/api/validate'
import { MatchInvoiceSchema } from '@/lib/api/schemas'
import type { EntityType, Invoice } from '@/types'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { EntityType, Invoice, Transaction } from '@/types'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-invoice
*
* Confirms an invoice match for a transaction:
* 1. Links transaction to invoice (sets invoice_id)
* 2. Updates invoice status to 'paid'
* 3. Sets paid_at and paid_amount on invoice
* 4. Creates journal entry for payment receipt
* Confirms an invoice match for a transaction. Supports partial payments:
* 1. If transaction has an auto-categorization journal entry, storno it first
* 2. Links transaction to invoice (sets invoice_id)
* 3. Updates invoice status to 'paid' or 'partially_paid'
* 4. Records payment in invoice_payments table
* 5. Creates journal entry for payment receipt
* - Debit 1930 Företagskonto (Bank)
* - Credit 1510 Kundfordringar (Accounts Receivable)
*/
@@ -77,17 +84,54 @@ export async function POST(
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
// Verify invoice is unpaid
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
// Verify invoice is in a matchable state (sent, overdue, or partially_paid)
if (invoice.status !== 'sent' && invoice.status !== 'overdue' && invoice.status !== 'partially_paid') {
return NextResponse.json(
{ error: 'Invoice is not in an unpaid state' },
{ status: 400 }
)
}
// --- Commit 1: Storno conflicting auto-categorization journal entry ---
// Order: storno MUST complete before any other state changes.
// If storno fails, return 500 immediately — nothing else has been modified.
if (transaction.journal_entry_id) {
try {
await reverseEntry(supabase, user.id, transaction.journal_entry_id)
// Clear the journal_entry_id on the transaction
const { error: clearJeError } = await supabase
.from('transactions')
.update({ journal_entry_id: null })
.eq('id', transactionId)
if (clearJeError) {
console.error('Failed to clear journal_entry_id after storno:', clearJeError)
}
logMatchEvent(supabase, user.id, transactionId, 'storno_conflict_resolved', {
invoiceId: invoice_id,
previousState: { journal_entry_id: transaction.journal_entry_id },
newState: { journal_entry_id: null },
})
} catch (err) {
console.error('Failed to storno conflicting journal entry:', err)
return NextResponse.json(
{ error: 'Failed to reverse conflicting journal entry' },
{ status: 500 }
)
}
}
const now = new Date().toISOString()
const paidAmount = transaction.amount
// Calculate partial payment amounts
const newPaidAmount = Math.round(((invoice.paid_amount || 0) + paidAmount) * 100) / 100
const currentRemaining = invoice.remaining_amount ?? (invoice.total - (invoice.paid_amount || 0))
const newRemaining = Math.max(0, Math.round((currentRemaining - paidAmount) * 100) / 100)
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
// Fetch accounting method
const { data: settings } = await supabase
.from('company_settings')
@@ -103,8 +147,8 @@ export async function POST(
let journalEntryError: string | null = null
try {
if (accountingMethod === 'cash') {
// Kontantmetoden: combined revenue entry with per-line VAT rates
if (accountingMethod === 'cash' && isFullyPaid) {
// Kontantmetoden, full payment: combined revenue entry with per-line VAT rates
const journalEntry = await createInvoiceCashEntry(
supabase,
user.id,
@@ -114,6 +158,24 @@ export async function POST(
invoice.customer?.name
)
journalEntryId = journalEntry?.id ?? null
} else if (accountingMethod === 'cash' && !isFullyPaid) {
// Kontantmetoden, partial payment: use accrual-style clearing entry.
// Under kontantmetoden, invoice creation produces no journal entry,
// so 1510 has no prior balance. The debit 1930 / credit 1510 creates
// a credit on 1510 with no offsetting debit — this is intentional.
// 1510 is used as a temporary clearing account under cash method.
// The full revenue + VAT recognition (with 1510 reversal) happens at
// final payment when createInvoiceCashEntry is called.
const journalEntry = await createInvoicePaymentJournalEntry(
supabase,
user.id,
invoice as Invoice,
transaction.date,
undefined,
invoice.customer?.name,
paidAmount
)
journalEntryId = journalEntry?.id ?? null
} else {
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
const journalEntry = await createInvoicePaymentJournalEntry(
@@ -122,7 +184,8 @@ export async function POST(
invoice as Invoice,
transaction.date,
undefined,
invoice.customer?.name
invoice.customer?.name,
paidAmount
)
journalEntryId = journalEntry?.id ?? null
}
@@ -132,15 +195,21 @@ export async function POST(
// Continue - we still want to update the invoice and transaction
}
// Update invoice to paid status
const { error: updateInvError } = await supabase
// --- Commit 4: Optimistic lock on invoice status ---
// Only update if invoice is still in a matchable state.
// Prevents TOCTOU race where another request fully pays the invoice
// between our fetch and this update.
const { data: updatedRows, error: updateInvError } = await supabase
.from('invoices')
.update({
status: 'paid',
paid_at: now,
paid_amount: paidAmount,
status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
})
.eq('id', invoice_id)
.in('status', ['sent', 'overdue', 'partially_paid'])
.select('id')
if (updateInvError) {
console.error('Failed to update invoice:', updateInvError)
@@ -150,6 +219,44 @@ export async function POST(
)
}
if (!updatedRows || updatedRows.length === 0) {
return NextResponse.json(
{ error: 'Invoice has already been fully paid or is no longer matchable' },
{ status: 409 }
)
}
// Record payment in invoice_payments table
const paymentNotes = (accountingMethod === 'cash' && !isFullyPaid)
? 'Kontantmetoden: intäkt bokförs vid slutbetalning'
: null
const { error: paymentInsertError } = await supabase
.from('invoice_payments')
.insert({
user_id: user.id,
invoice_id,
payment_date: transaction.date,
amount: paidAmount,
currency: invoice.currency,
exchange_rate: invoice.exchange_rate,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
notes: paymentNotes,
})
if (paymentInsertError) {
// Catch unique constraint violation (same transaction matched to same invoice twice)
if (paymentInsertError.code === '23505') {
return NextResponse.json(
{ error: 'This transaction is already matched to this invoice' },
{ status: 409 }
)
}
console.error('Failed to record invoice payment:', paymentInsertError)
return NextResponse.json({ error: 'Failed to record invoice payment' }, { status: 500 })
}
// Update transaction to link to invoice and clear potential match
const { error: updateTxError } = await supabase
.from('transactions')
@@ -158,7 +265,7 @@ export async function POST(
potential_invoice_id: null,
journal_entry_id: journalEntryId,
is_business: true,
category: 'income_services', // Default to services income
category: 'income_services',
})
.eq('id', transactionId)
@@ -170,11 +277,33 @@ export async function POST(
)
}
// Log the match event and emit event
logMatchEvent(supabase, user.id, transactionId, 'matched', {
invoiceId: invoice_id,
matchConfidence: 1.0,
matchMethod: 'manual_confirm',
newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
})
try {
eventBus.emit({
type: 'invoice.match_confirmed',
payload: {
invoice: invoice as Invoice,
transaction: transaction as Transaction,
userId: user.id,
},
})
} catch {
// Event emission is non-critical
}
return NextResponse.json({
success: true,
invoice_status: 'paid',
paid_at: now,
paid_amount: paidAmount,
invoice_status: newStatus,
paid_at: isFullyPaid ? now : null,
paid_amount: newPaidAmount,
remaining_amount: newRemaining,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
})
@@ -6,7 +6,12 @@ import {
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { validateBody } from '@/lib/api/validate'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { eventBus } from '@/lib/events/bus'
import { ensureInitialized } from '@/lib/init'
import type { SupplierInvoice, SupplierInvoiceItem, Transaction } from '@/types'
ensureInitialized()
/**
* POST /api/transactions/[id]/match-supplier-invoice
@@ -116,13 +121,13 @@ export async function POST(
console.error('Failed to create payment journal entry:', err)
}
// Update supplier invoice
// Optimistic lock: only update if invoice is still in a matchable state
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
const isFullyPaid = newRemaining <= 0
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
const { error: updateInvError } = await supabase
const { data: updatedRows, error: updateInvError } = await supabase
.from('supplier_invoices')
.update({
status: newStatus,
@@ -133,20 +138,43 @@ export async function POST(
transaction_id: transactionId,
})
.eq('id', supplier_invoice_id)
.in('status', ['registered', 'approved', 'partially_paid'])
.select('id')
if (updateInvError) {
return NextResponse.json({ error: 'Failed to update supplier invoice' }, { status: 500 })
}
// Record payment
await supabase.from('supplier_invoice_payments').insert({
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
if (!updatedRows || updatedRows.length === 0) {
return NextResponse.json(
{ error: 'Supplier invoice has already been fully paid or is no longer matchable' },
{ status: 409 }
)
}
// Record payment — catch unique constraint violation
const { error: paymentInsertError } = await supabase
.from('supplier_invoice_payments')
.insert({
user_id: user.id,
supplier_invoice_id,
payment_date: transaction.date,
amount: paymentAmount,
currency: invoice.currency,
journal_entry_id: journalEntryId,
transaction_id: transactionId,
})
if (paymentInsertError) {
if (paymentInsertError.code === '23505') {
return NextResponse.json(
{ error: 'This transaction is already matched to this supplier invoice' },
{ status: 409 }
)
}
console.error('Failed to record supplier invoice payment:', paymentInsertError)
return NextResponse.json({ error: 'Failed to record invoice payment' }, { status: 500 })
}
// Update transaction
const { error: updateTxError } = await supabase
@@ -162,6 +190,27 @@ export async function POST(
return NextResponse.json({ error: 'Failed to link transaction' }, { status: 500 })
}
// Log the match event and emit event
logMatchEvent(supabase, user.id, transactionId, 'matched', {
supplierInvoiceId: supplier_invoice_id,
matchConfidence: 1.0,
matchMethod: 'manual_confirm',
newState: { status: newStatus, paid_amount: newPaidAmount, remaining_amount: newRemaining },
})
try {
eventBus.emit({
type: 'supplier_invoice.match_confirmed',
payload: {
supplierInvoice: invoice as SupplierInvoice,
transaction: transaction as Transaction,
userId: user.id,
},
})
} catch {
// Event emission is non-critical
}
return NextResponse.json({
success: true,
invoice_status: newStatus,
@@ -33,6 +33,7 @@ export async function POST() {
}
let matched = 0
const matchedInvoiceIds = new Set<string>()
for (const tx of transactions) {
try {
@@ -43,12 +44,13 @@ export async function POST() {
0.50
)
if (bestMatch) {
if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) {
await supabase
.from('transactions')
.update({ potential_invoice_id: bestMatch.invoice.id })
.eq('id', tx.id)
matchedInvoiceIds.add(bestMatch.invoice.id)
matched++
}
} catch {