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
@@ -46,6 +46,7 @@ const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' |
|
||||
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
|
||||
sent: { label: 'Skickad', variant: 'default', icon: Send },
|
||||
paid: { label: 'Betald', variant: 'success', icon: CheckCircle },
|
||||
partially_paid: { label: 'Delbetalad', variant: 'warning', icon: Clock },
|
||||
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle },
|
||||
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle },
|
||||
|
||||
@@ -20,6 +20,7 @@ const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' |
|
||||
draft: { label: 'Utkast', variant: 'secondary', icon: FileText, borderColor: 'border-muted-foreground/30' },
|
||||
sent: { label: 'Skickad', variant: 'default', icon: Send, borderColor: 'border-warning/50' },
|
||||
paid: { label: 'Betald', variant: 'success', icon: CheckCircle, borderColor: 'border-success/50' },
|
||||
partially_paid: { label: 'Delbetalad', variant: 'warning', icon: Clock, borderColor: 'border-warning/50' },
|
||||
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock, borderColor: 'border-destructive/50' },
|
||||
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' },
|
||||
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle, borderColor: 'border-muted-foreground/30' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -247,7 +247,8 @@ export async function createInvoicePaymentJournalEntry(
|
||||
invoice: Invoice,
|
||||
paymentDate: string,
|
||||
exchangeRateDifference?: number,
|
||||
customerName?: string
|
||||
customerName?: string,
|
||||
paymentAmount?: number
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
@@ -255,11 +256,22 @@ export async function createInvoicePaymentJournalEntry(
|
||||
return null
|
||||
}
|
||||
|
||||
const desc = buildInvoiceDescription('Inbetalning kundfaktura', invoice.invoice_number, customerName)
|
||||
const bookedSekAmount = resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
const isPartial = paymentAmount != null
|
||||
const desc = buildInvoiceDescription(
|
||||
isPartial ? 'Delbetalning kundfaktura' : 'Inbetalning kundfaktura',
|
||||
invoice.invoice_number,
|
||||
customerName
|
||||
)
|
||||
|
||||
// When paymentAmount is provided, use it for the 1930/1510 line amounts.
|
||||
// Otherwise use the full invoice total (backward compatible).
|
||||
const bookedSekAmount = isPartial
|
||||
? resolveSekAmount(paymentAmount, null, invoice.currency, invoice.exchange_rate)
|
||||
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (exchangeRateDifference && exchangeRateDifference !== 0) {
|
||||
if (!isPartial && exchangeRateDifference && exchangeRateDifference !== 0) {
|
||||
// Foreign currency with exchange rate difference
|
||||
// For receivables: positive diff = gain (received more), negative = loss (received less)
|
||||
const actualSekReceived = bookedSekAmount + exchangeRateDifference
|
||||
|
||||
@@ -61,6 +61,9 @@ export type CoreEvent =
|
||||
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string } }
|
||||
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string } }
|
||||
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string } }
|
||||
// Payment Matching
|
||||
| { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string } }
|
||||
| { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string } }
|
||||
// Supplier Invoice Inbox
|
||||
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } }
|
||||
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } }
|
||||
|
||||
@@ -40,7 +40,8 @@ function amountsMatchExact(transactionAmount: number, invoiceTotal: number): boo
|
||||
function amountsMatchFuzzy(transactionAmount: number, invoiceTotal: number): boolean {
|
||||
if (invoiceTotal === 0) return false
|
||||
const diff = Math.abs(transactionAmount - invoiceTotal)
|
||||
const tolerance = invoiceTotal * FUZZY_TOLERANCE
|
||||
// Cap fuzzy tolerance at 500 SEK to prevent false positives on large invoices
|
||||
const tolerance = Math.min(invoiceTotal * FUZZY_TOLERANCE, 500)
|
||||
return diff <= tolerance
|
||||
}
|
||||
|
||||
@@ -134,7 +135,7 @@ export async function findMatchingInvoices(
|
||||
customer:customers(*)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.order('due_date', { ascending: true })
|
||||
|
||||
if (error || !invoices) {
|
||||
@@ -175,11 +176,19 @@ export async function findMatchingInvoices(
|
||||
|
||||
if (!currencyMatch) continue
|
||||
|
||||
// Use remaining_amount for partially paid invoices, otherwise total
|
||||
const invoiceAmount = invoice.remaining_amount ?? invoice.total
|
||||
|
||||
// Use SEK amount for comparison if currencies differ
|
||||
const compareAmount =
|
||||
invoice.currency === transaction.currency
|
||||
? invoice.total
|
||||
: invoice.total_sek || invoice.total
|
||||
? invoiceAmount
|
||||
: (() => {
|
||||
if (invoice.total_sek && invoice.total) {
|
||||
return Math.round((invoiceAmount / invoice.total) * invoice.total_sek * 100) / 100
|
||||
}
|
||||
return invoiceAmount
|
||||
})()
|
||||
|
||||
const transactionAmount = transaction.amount
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
type MatchAction =
|
||||
| 'matched'
|
||||
| 'unmatched'
|
||||
| 'auto_suggested'
|
||||
| 'suggestion_cleared'
|
||||
| 'storno_conflict_resolved'
|
||||
|
||||
/**
|
||||
* Log a payment match event to the append-only audit trail.
|
||||
* Fire-and-forget — never throws, never blocks the caller.
|
||||
*/
|
||||
export async function logMatchEvent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
transactionId: string,
|
||||
action: MatchAction,
|
||||
opts?: {
|
||||
invoiceId?: string
|
||||
supplierInvoiceId?: string
|
||||
matchConfidence?: number
|
||||
matchMethod?: string
|
||||
previousState?: Record<string, unknown>
|
||||
newState?: Record<string, unknown>
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await supabase.from('payment_match_log').insert({
|
||||
user_id: userId,
|
||||
transaction_id: transactionId,
|
||||
invoice_id: opts?.invoiceId ?? null,
|
||||
supplier_invoice_id: opts?.supplierInvoiceId ?? null,
|
||||
action,
|
||||
match_confidence: opts?.matchConfidence ?? null,
|
||||
match_method: opts?.matchMethod ?? null,
|
||||
previous_state: opts?.previousState ?? null,
|
||||
new_state: opts?.newState ?? null,
|
||||
})
|
||||
} catch {
|
||||
// Non-critical — audit log insert must never break the main flow
|
||||
}
|
||||
}
|
||||
@@ -99,8 +99,9 @@ export function findSupplierInvoiceMatch(
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: Fuzzy amount (±0.01) + supplier name in description → 0.70
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 0.01
|
||||
// Pass 4: Fuzzy amount (±5 SEK) + supplier name in description → 0.70
|
||||
// Tolerance covers öresavrundning and minor fee differences
|
||||
const fuzzyAmountMatch = Math.abs(txAmount - remaining) <= 5.00
|
||||
const supplierName = invoice.supplier?.name
|
||||
if (fuzzyAmountMatch && supplierName) {
|
||||
const txDesc = (transaction.description || '').toLowerCase()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { Transaction, ReconciliationMethod } from '@/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
@@ -416,6 +417,13 @@ export async function unlinkReconciliation(
|
||||
return { success: false, error: 'Failed to unlink transaction' }
|
||||
}
|
||||
|
||||
logMatchEvent(supabase, userId, transactionId, 'unmatched', {
|
||||
previousState: {
|
||||
journal_entry_id: tx.journal_entry_id,
|
||||
reconciliation_method: tx.reconciliation_method,
|
||||
},
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
|
||||
@@ -434,9 +434,10 @@ describe('ingestTransactions', () => {
|
||||
enqueue({ data: insertedNew, error: null })
|
||||
// Invoice match update for income transaction
|
||||
enqueue({ data: null, error: null })
|
||||
// Auto-categorization update
|
||||
// logMatchEvent insert (fire-and-forget)
|
||||
enqueue({ data: null, error: null })
|
||||
// rawDup: skipped (in Set) — no queue entry needed
|
||||
// NOTE: auto-categorization is skipped because invoice match triggers `continue`
|
||||
// Transaction rawErr: insert fails
|
||||
enqueue({ data: null, error: { message: 'Insert failed' } })
|
||||
|
||||
@@ -464,7 +465,7 @@ describe('ingestTransactions', () => {
|
||||
expect(result.duplicates).toBe(1)
|
||||
expect(result.errors).toBe(1)
|
||||
expect(result.auto_matched_invoices).toBe(1)
|
||||
expect(result.auto_categorized).toBe(1)
|
||||
expect(result.auto_categorized).toBe(0) // Skipped: invoice match triggers continue
|
||||
expect(result.transaction_ids).toEqual(['tx-new'])
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching'
|
||||
import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matching'
|
||||
import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import { fetchMultipleRates } from '@/lib/currency/riksbanken'
|
||||
import { logMatchEvent } from '@/lib/invoices/match-log'
|
||||
import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation'
|
||||
import type { Transaction, RawTransaction, IngestResult, SupplierInvoice, Currency, ExchangeRate } from '@/types'
|
||||
|
||||
@@ -137,6 +138,11 @@ export async function ingestTransactions(
|
||||
data?.forEach(r => existingExternalIds.add(r.external_id))
|
||||
}
|
||||
|
||||
// Track already-matched invoice IDs within this ingestion batch
|
||||
// to prevent suggesting the same invoice for multiple transactions
|
||||
const matchedInvoiceIds = new Set<string>()
|
||||
const matchedSupplierInvoiceIds = new Set<string>()
|
||||
|
||||
for (const raw of rawTransactions) {
|
||||
// 1. Check for duplicates via external_id (batch pre-fetched)
|
||||
if (existingExternalIds.has(raw.external_id)) {
|
||||
@@ -229,13 +235,24 @@ export async function ingestTransactions(
|
||||
0.50
|
||||
)
|
||||
|
||||
if (bestMatch) {
|
||||
if (bestMatch && !matchedInvoiceIds.has(bestMatch.invoice.id)) {
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_invoice_id: bestMatch.invoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
invoiceId: bestMatch.invoice.id,
|
||||
matchConfidence: bestMatch.confidence,
|
||||
matchMethod: bestMatch.matchReason,
|
||||
})
|
||||
|
||||
matchedInvoiceIds.add(bestMatch.invoice.id)
|
||||
result.auto_matched_invoices++
|
||||
// Skip mapping engine — transaction has an invoice match.
|
||||
// Auto-categorization would create an orphaned journal entry
|
||||
// that conflicts with the eventual invoice payment entry.
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — continue processing
|
||||
@@ -250,7 +267,7 @@ export async function ingestTransactions(
|
||||
unpaidSupplierInvoices
|
||||
)
|
||||
|
||||
if (match) {
|
||||
if (match && !matchedSupplierInvoiceIds.has(match.supplierInvoice.id)) {
|
||||
if (match.confidence >= 0.85) {
|
||||
// Auto-link at high confidence
|
||||
await supabase
|
||||
@@ -258,13 +275,35 @@ export async function ingestTransactions(
|
||||
.update({ supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
// Log the match THEN drain the pool (captures which invoice was matched)
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
|
||||
// Drain the pool — prevents next transaction from matching same invoice
|
||||
unpaidSupplierInvoices = unpaidSupplierInvoices.filter(
|
||||
inv => inv.id !== match.supplierInvoice.id
|
||||
)
|
||||
matchedSupplierInvoiceIds.add(match.supplierInvoice.id)
|
||||
|
||||
result.auto_matched_invoices++
|
||||
// Skip mapping engine — transaction has a supplier invoice match
|
||||
continue
|
||||
} else {
|
||||
// Store as suggestion at lower confidence (0.70–0.85)
|
||||
// Do NOT drain pool for suggestions — they are tentative
|
||||
await supabase
|
||||
.from('transactions')
|
||||
.update({ potential_supplier_invoice_id: match.supplierInvoice.id })
|
||||
.eq('id', newTransaction.id)
|
||||
|
||||
logMatchEvent(supabase, userId, newTransaction.id, 'auto_suggested', {
|
||||
supplierInvoiceId: match.supplierInvoice.id,
|
||||
matchConfidence: match.confidence,
|
||||
matchMethod: match.matchMethod,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- Migration: payment_match_log
|
||||
-- Append-only audit trail for all payment matching state transitions.
|
||||
-- Required by BFL 7:1 — all match/unmatch events are räkenskapsinformation.
|
||||
-- Do NOT add cleanup/DELETE jobs — 7-year retention is legally required.
|
||||
-- Partition by created_at (yearly) when volume exceeds ~100k rows.
|
||||
|
||||
CREATE TABLE public.payment_match_log (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
|
||||
transaction_id uuid NOT NULL REFERENCES public.transactions ON DELETE CASCADE,
|
||||
invoice_id uuid REFERENCES public.invoices ON DELETE SET NULL,
|
||||
supplier_invoice_id uuid REFERENCES public.supplier_invoices ON DELETE SET NULL,
|
||||
action text NOT NULL CHECK (action IN (
|
||||
'matched',
|
||||
'unmatched',
|
||||
'auto_suggested',
|
||||
'suggestion_cleared',
|
||||
'storno_conflict_resolved'
|
||||
)),
|
||||
match_confidence numeric,
|
||||
match_method text,
|
||||
previous_state jsonb,
|
||||
new_state jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
-- Intentionally NO updated_at: append-only
|
||||
);
|
||||
|
||||
ALTER TABLE public.payment_match_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Users can read their own match log entries
|
||||
CREATE POLICY "payment_match_log_select" ON public.payment_match_log
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can insert their own match log entries
|
||||
CREATE POLICY "payment_match_log_insert" ON public.payment_match_log
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- NO UPDATE or DELETE policies — immutability enforced by triggers below
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_payment_match_log_user_id ON public.payment_match_log (user_id);
|
||||
CREATE INDEX idx_payment_match_log_transaction_id ON public.payment_match_log (transaction_id);
|
||||
CREATE INDEX idx_payment_match_log_invoice_id ON public.payment_match_log (invoice_id);
|
||||
CREATE INDEX idx_payment_match_log_supplier_invoice_id ON public.payment_match_log (supplier_invoice_id);
|
||||
CREATE INDEX idx_payment_match_log_created_at ON public.payment_match_log (created_at);
|
||||
|
||||
-- Immutability triggers: reuse audit_log_immutable() from migration 014
|
||||
CREATE TRIGGER payment_match_log_no_update
|
||||
BEFORE UPDATE ON public.payment_match_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
|
||||
|
||||
CREATE TRIGGER payment_match_log_no_delete
|
||||
BEFORE DELETE ON public.payment_match_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
|
||||
@@ -0,0 +1,89 @@
|
||||
-- Migration: invoice_partial_payments
|
||||
-- Adds remaining_amount to invoices, 'partially_paid' status, and invoice_payments table.
|
||||
-- Mirrors the existing supplier_invoice_payments pattern for customer invoices.
|
||||
|
||||
-- 1. Add remaining_amount column to invoices
|
||||
ALTER TABLE public.invoices
|
||||
ADD COLUMN IF NOT EXISTS remaining_amount numeric;
|
||||
|
||||
-- 2. Drop and re-add status CHECK to include 'partially_paid'
|
||||
ALTER TABLE public.invoices
|
||||
DROP CONSTRAINT IF EXISTS invoices_status_check;
|
||||
|
||||
ALTER TABLE public.invoices
|
||||
ADD CONSTRAINT invoices_status_check
|
||||
CHECK (status IN ('draft', 'sent', 'paid', 'partially_paid', 'overdue', 'cancelled', 'credited'));
|
||||
|
||||
-- 3. Backfill remaining_amount from existing data
|
||||
UPDATE public.invoices
|
||||
SET remaining_amount = GREATEST(0, total - COALESCE(paid_amount, 0));
|
||||
|
||||
-- 4. Make remaining_amount NOT NULL with default
|
||||
ALTER TABLE public.invoices
|
||||
ALTER COLUMN remaining_amount SET NOT NULL,
|
||||
ALTER COLUMN remaining_amount SET DEFAULT 0;
|
||||
|
||||
-- 5. Create invoice_payments table (mirrors supplier_invoice_payments)
|
||||
CREATE TABLE public.invoice_payments (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users ON DELETE CASCADE,
|
||||
invoice_id uuid NOT NULL REFERENCES public.invoices ON DELETE CASCADE,
|
||||
payment_date date NOT NULL,
|
||||
amount numeric NOT NULL,
|
||||
currency text DEFAULT 'SEK',
|
||||
exchange_rate numeric,
|
||||
exchange_rate_difference numeric DEFAULT 0,
|
||||
journal_entry_id uuid REFERENCES public.journal_entries ON DELETE SET NULL,
|
||||
transaction_id uuid REFERENCES public.transactions ON DELETE SET NULL,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.invoice_payments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE TRIGGER update_invoice_payments_updated_at
|
||||
BEFORE UPDATE ON public.invoice_payments
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
CREATE POLICY "invoice_payments_select" ON public.invoice_payments
|
||||
FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "invoice_payments_insert" ON public.invoice_payments
|
||||
FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE INDEX idx_invoice_payments_user_id ON public.invoice_payments (user_id);
|
||||
CREATE INDEX idx_invoice_payments_invoice_id ON public.invoice_payments (invoice_id);
|
||||
CREATE INDEX idx_invoice_payments_transaction_id ON public.invoice_payments (transaction_id);
|
||||
|
||||
-- Prevent same transaction matched to same invoice twice
|
||||
CREATE UNIQUE INDEX idx_invoice_payments_tx_inv_unique
|
||||
ON public.invoice_payments (transaction_id, invoice_id);
|
||||
|
||||
-- 6. Add unique constraint to supplier_invoice_payments if not present
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_supplier_invoice_payments_tx_inv_unique
|
||||
ON public.supplier_invoice_payments (transaction_id, supplier_invoice_id);
|
||||
|
||||
-- 7. Add user_id to supplier_invoice_payments if not present (denormalized for RLS perf)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'supplier_invoice_payments'
|
||||
AND column_name = 'user_id'
|
||||
) THEN
|
||||
ALTER TABLE public.supplier_invoice_payments
|
||||
ADD COLUMN user_id uuid REFERENCES auth.users ON DELETE CASCADE;
|
||||
|
||||
-- Backfill user_id from parent supplier_invoices
|
||||
UPDATE public.supplier_invoice_payments sip
|
||||
SET user_id = si.user_id
|
||||
FROM public.supplier_invoices si
|
||||
WHERE sip.supplier_invoice_id = si.id;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE public.supplier_invoice_payments
|
||||
ALTER COLUMN user_id SET NOT NULL;
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DocumentAttachment,
|
||||
TaxCode,
|
||||
Invoice,
|
||||
InvoicePayment,
|
||||
Customer,
|
||||
Supplier,
|
||||
SupplierInvoice,
|
||||
@@ -309,12 +310,33 @@ export function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
|
||||
converted_from_id: null,
|
||||
paid_at: null,
|
||||
paid_amount: null,
|
||||
remaining_amount: 12500,
|
||||
created_at: '2024-06-15T14:30:00Z',
|
||||
updated_at: '2024-06-15T14:30:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeInvoicePayment(
|
||||
overrides: Partial<InvoicePayment> = {}
|
||||
): InvoicePayment {
|
||||
return {
|
||||
id: nextId(),
|
||||
user_id: 'user-1',
|
||||
invoice_id: 'invoice-1',
|
||||
payment_date: '2024-07-01',
|
||||
amount: 12500,
|
||||
currency: 'SEK',
|
||||
exchange_rate: null,
|
||||
exchange_rate_difference: 0,
|
||||
journal_entry_id: null,
|
||||
transaction_id: null,
|
||||
notes: null,
|
||||
created_at: '2024-07-01T00:00:00Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
export function makeCustomer(overrides: Partial<Customer> = {}): Customer {
|
||||
return {
|
||||
id: nextId(),
|
||||
|
||||
+22
-1
@@ -32,7 +32,7 @@ export type CustomerType =
|
||||
| 'non_eu_business' // Non-EU company
|
||||
|
||||
// Invoice status
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled' | 'credited'
|
||||
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partially_paid' | 'overdue' | 'cancelled' | 'credited'
|
||||
|
||||
// Invoice document type
|
||||
export type InvoiceDocumentType = 'invoice' | 'proforma' | 'delivery_note'
|
||||
@@ -438,6 +438,25 @@ export interface SupplierInvoicePayment {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Invoice Payment (partial payments)
|
||||
export interface InvoicePayment {
|
||||
id: string
|
||||
user_id: string
|
||||
invoice_id: string
|
||||
|
||||
payment_date: string
|
||||
amount: number
|
||||
currency: string
|
||||
exchange_rate: number | null
|
||||
exchange_rate_difference: number
|
||||
|
||||
journal_entry_id: string | null
|
||||
transaction_id: string | null
|
||||
notes: string | null
|
||||
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Invoice
|
||||
export interface Invoice {
|
||||
id: string
|
||||
@@ -498,6 +517,7 @@ export interface Invoice {
|
||||
// Payment tracking
|
||||
paid_at: string | null
|
||||
paid_amount: number | null
|
||||
remaining_amount: number
|
||||
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -505,6 +525,7 @@ export interface Invoice {
|
||||
// Relations (populated when fetched)
|
||||
customer?: Customer
|
||||
items?: InvoiceItem[]
|
||||
payments?: InvoicePayment[]
|
||||
}
|
||||
|
||||
// Invoice Item
|
||||
|
||||
Reference in New Issue
Block a user