feat: enhance supplier invoices, entity-aware categorization, and new tests

Expand supplier invoice module with overdue cron job, credit note journal
entries, and event emissions on approve/mark-paid/create flows. Add entity
type (EF/AB) awareness to transaction categorization UI and category
mapping logic. Add comprehensive tests for supplier-invoice-entries,
transaction-entries, and expanded API route coverage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-03 17:08:26 +01:00
co-authored by Claude Opus 4.6
parent f6a9889f0a
commit a9bf24ce0b
23 changed files with 2293 additions and 113 deletions
@@ -12,6 +12,12 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
import { eventBus } from '@/lib/events'
import { POST } from '../route'
describe('POST /api/supplier-invoices/[id]/approve', () => {
@@ -20,6 +26,7 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -46,7 +53,7 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
})
it('returns 400 when invoice is not in registered status', async () => {
enqueue({ data: { status: 'approved' }, error: null })
enqueue({ data: makeSupplierInvoice({ status: 'approved' }), error: null })
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
@@ -60,8 +67,8 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' })
const approvedInvoice = { ...invoice, status: 'approved' }
// First call: fetch invoice status
enqueue({ data: { status: 'registered' }, error: null })
// First call: fetch full invoice
enqueue({ data: invoice, error: null })
// Second call: update + select
enqueue({ data: approvedInvoice, error: null })
@@ -72,4 +79,26 @@ describe('POST /api/supplier-invoices/[id]/approve', () => {
expect(status).toBe(200)
expect(body.data).toEqual(approvedInvoice)
})
it('emits supplier_invoice.approved event', async () => {
const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' })
const approvedInvoice = { ...invoice, status: 'approved' }
enqueue({ data: invoice, error: null })
enqueue({ data: approvedInvoice, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.approved',
payload: expect.objectContaining({ userId: 'user-1' }),
})
)
})
})
@@ -1,5 +1,10 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import type { SupplierInvoice } from '@/types'
ensureInitialized()
export async function POST(
_request: Request,
@@ -16,7 +21,7 @@ export async function POST(
const { data: invoice } = await supabase
.from('supplier_invoices')
.select('status')
.select('*')
.eq('id', id)
.eq('user_id', user.id)
.single()
@@ -44,5 +49,14 @@ export async function POST(
return NextResponse.json({ error: error.message }, { status: 500 })
}
try {
await eventBus.emit({
type: 'supplier_invoice.approved',
payload: { supplierInvoice: data as SupplierInvoice, userId: user.id },
})
} catch {
// Non-blocking
}
return NextResponse.json({ data })
}
@@ -1,8 +1,12 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { SupplierInvoice, SupplierInvoiceItem, AccountingMethod } from '@/types'
ensureInitialized()
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -133,6 +137,19 @@ export async function POST(
})
.eq('id', id)
try {
await eventBus.emit({
type: 'supplier_invoice.credited',
payload: {
supplierInvoice: original as SupplierInvoice,
creditNote: creditNote as SupplierInvoice,
userId: user.id,
},
})
} catch {
// Non-blocking
}
return NextResponse.json({
data: creditNote,
journal_entry_id: journalEntryId,
@@ -13,6 +13,10 @@ vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
const mockCreateSupplierInvoicePaymentEntry = vi.fn()
const mockCreateSupplierInvoiceCashEntry = vi.fn()
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
@@ -22,6 +26,8 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoiceCashEntry(...args),
}))
import { eventBus } from '@/lib/events'
import { POST } from '../route'
describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
@@ -30,6 +36,7 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -252,4 +259,43 @@ describe('POST /api/supplier-invoices/[id]/mark-paid', () => {
expect(body.success).toBe(true)
expect(body.journal_entry_id).toBeNull()
})
it('emits supplier_invoice.paid event', async () => {
const supplier = makeSupplier()
const invoice = makeSupplierInvoice({
id: 'si-1',
status: 'approved',
total: 10000,
remaining_amount: 10000,
paid_amount: 0,
supplier,
items: [],
})
enqueue({ data: invoice, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', {
method: 'POST',
body: {},
})
const response = await POST(request, createMockRouteParams({ id: 'si-1' }))
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.paid',
payload: expect.objectContaining({
userId: 'user-1',
paymentAmount: 10000,
}),
})
)
})
})
@@ -1,5 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
@@ -8,6 +10,8 @@ import { validateBody } from '@/lib/api/validate'
import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
ensureInitialized()
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -125,6 +129,15 @@ export async function POST(
console.error('Failed to record payment:', paymentError)
}
try {
await eventBus.emit({
type: 'supplier_invoice.paid',
payload: { supplierInvoice: invoice as SupplierInvoice, paymentAmount, userId: user.id },
})
} catch {
// Non-blocking
}
return NextResponse.json({
success: true,
status: newStatus,
@@ -27,6 +27,8 @@ vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
mockCreateSupplierInvoiceRegistrationEntry(...args),
}))
import { eventBus } from '@/lib/events'
import { GET, POST } from '../route'
describe('GET /api/supplier-invoices', () => {
@@ -107,6 +109,7 @@ describe('POST /api/supplier-invoices', () => {
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
@@ -192,6 +195,45 @@ describe('POST /api/supplier-invoices', () => {
expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
})
it('emits supplier_invoice.registered event', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
enqueue({ data: supplier, error: null })
enqueue({ data: 5 })
enqueue({ data: createdInvoice, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { accounting_method: 'accrual' }, error: null })
mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' })
enqueue({ data: null, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/supplier-invoices', {
method: 'POST',
body: {
supplier_id: VALID_UUID,
supplier_invoice_number: 'LF-001',
invoice_date: '2024-06-01',
due_date: '2024-07-01',
items: [
{ description: 'Material', quantity: 10, unit_price: 800, account_number: '4010', vat_rate: 0.25 },
],
},
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'supplier_invoice.registered',
payload: expect.objectContaining({ userId: 'user-1' }),
})
)
})
it('skips registration entry for cash method', async () => {
const supplier = makeSupplier({ id: VALID_UUID })
const createdInvoice = makeSupplierInvoice({ id: 'si-1' })
+10
View File
@@ -1,5 +1,6 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { eventBus } from '@/lib/events'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
@@ -185,6 +186,15 @@ export async function POST(request: Request) {
}
}
try {
await eventBus.emit({
type: 'supplier_invoice.registered',
payload: { supplierInvoice: invoice as SupplierInvoice, userId: user.id },
})
} catch {
// Non-blocking — event emission failure should not affect the response
}
return NextResponse.json({
data: {
...invoice,