feat: make extension system packageable via enriched ExtensionContext
Enrich ExtensionContext with supabase, emit(), settings, storage, log, and services so extensions can receive everything through dependency injection instead of importing core modules directly. - Add context factory and inject context into event handlers via registry - Move supplier invoice journal entry creation to core event handler - Add services.ingestTransactions to ExtensionContext for enable-banking - Create catch-all API route for extension-declared apiRoutes - Migrate 5 extensions to accept context with dynamic import fallbacks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
59d935f2cc
commit
ef5a84a5d5
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import {
|
||||
createQueuedMockSupabase,
|
||||
makeSupplierInvoice,
|
||||
} from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
|
||||
createSupplierInvoiceRegistrationEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import { registerSupplierInvoiceHandler } from '../supplier-invoice-handler'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockCreateEntry = vi.mocked(createSupplierInvoiceRegistrationEntry)
|
||||
|
||||
describe('Supplier Invoice Core Handler', () => {
|
||||
let unsubscribe: () => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
unsubscribe = registerSupplierInvoiceHandler()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('creates registration journal entry for accrual method', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
// 1. company_settings
|
||||
{ data: { accounting_method: 'accrual' }, error: null },
|
||||
// 2. supplier_invoice_items
|
||||
{ data: [{ id: 'item-1', account_number: '6200', line_total: 1000, sort_order: 0 }], error: null },
|
||||
// 3. supplier (type)
|
||||
{ data: { supplier_type: 'swedish_business' }, error: null },
|
||||
// 4. Update invoice with journal entry id
|
||||
{ data: null, error: null },
|
||||
])
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockCreateEntry.mockResolvedValue({ id: 'je-1' } as never)
|
||||
|
||||
const invoice = makeSupplierInvoice({ id: 'si-1', supplier_id: 'sup-1' })
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.confirmed',
|
||||
payload: {
|
||||
inboxItem: { id: 'inbox-1' } as never,
|
||||
supplierInvoice: invoice,
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockCreateEntry).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
invoice,
|
||||
expect.any(Array),
|
||||
'swedish_business'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips journal entry for cash method', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
// company_settings with cash method
|
||||
{ data: { accounting_method: 'cash' }, error: null },
|
||||
])
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
|
||||
const invoice = makeSupplierInvoice({ id: 'si-2' })
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.confirmed',
|
||||
payload: {
|
||||
inboxItem: { id: 'inbox-2' } as never,
|
||||
supplierInvoice: invoice,
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockCreateEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles journal entry creation failure gracefully', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
enqueueMany([
|
||||
{ data: { accounting_method: 'accrual' }, error: null },
|
||||
{ data: [{ id: 'item-1', account_number: '6200', line_total: 500, sort_order: 0 }], error: null },
|
||||
{ data: { supplier_type: 'swedish_business' }, error: null },
|
||||
])
|
||||
mockCreateClient.mockResolvedValue(supabase as never)
|
||||
mockCreateEntry.mockRejectedValue(new Error('No fiscal period'))
|
||||
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const invoice = makeSupplierInvoice({ id: 'si-3' })
|
||||
|
||||
// Should not throw
|
||||
await eventBus.emit({
|
||||
type: 'supplier_invoice.confirmed',
|
||||
payload: {
|
||||
inboxItem: { id: 'inbox-3' } as never,
|
||||
supplierInvoice: invoice,
|
||||
userId: 'user-1',
|
||||
},
|
||||
})
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[supplier-invoice-handler] Failed to create registration journal entry:',
|
||||
expect.any(Error)
|
||||
)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { SupplierInvoiceItem } from '@/types'
|
||||
|
||||
/**
|
||||
* Core event handler: creates a registration journal entry when a supplier
|
||||
* invoice is confirmed (accrual method only).
|
||||
*
|
||||
* This decouples journal entry creation from the invoice-inbox extension,
|
||||
* making it a core concern triggered by the `supplier_invoice.confirmed` event.
|
||||
*/
|
||||
async function handleSupplierInvoiceConfirmed(
|
||||
payload: EventPayload<'supplier_invoice.confirmed'>
|
||||
): Promise<void> {
|
||||
const { supplierInvoice, userId } = payload
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
// Check accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
if (accountingMethod !== 'accrual') return
|
||||
|
||||
// Fetch invoice items
|
||||
const { data: items, error: itemsError } = await supabase
|
||||
.from('supplier_invoice_items')
|
||||
.select('*')
|
||||
.eq('supplier_invoice_id', supplierInvoice.id)
|
||||
.order('sort_order')
|
||||
|
||||
if (itemsError || !items || items.length === 0) {
|
||||
console.error('[supplier-invoice-handler] Failed to fetch invoice items:', itemsError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch supplier type
|
||||
const { data: supplier } = await supabase
|
||||
.from('suppliers')
|
||||
.select('supplier_type')
|
||||
.eq('id', supplierInvoice.supplier_id)
|
||||
.single()
|
||||
|
||||
const supplierType = supplier?.supplier_type || 'swedish_business'
|
||||
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
userId,
|
||||
supplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
supplierType
|
||||
)
|
||||
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', supplierInvoice.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[supplier-invoice-handler] Failed to create registration journal entry:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the core supplier invoice handler on the event bus.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
export function registerSupplierInvoiceHandler(): () => void {
|
||||
return eventBus.on('supplier_invoice.confirmed', handleSupplierInvoiceConfirmed)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoice/vat-rules'
|
||||
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
|
||||
Reference in New Issue
Block a user