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:
co-authored by
Claude Opus 4.6
parent
fdcc94102a
commit
7c3518a080
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user