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:
Jakob Wennberg
2026-02-23 09:24:21 +01:00
co-authored by Claude Opus 4.6
parent 59d935f2cc
commit ef5a84a5d5
24 changed files with 1058 additions and 185 deletions
@@ -21,10 +21,6 @@ vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() },
}))
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({
createSupplierInvoiceRegistrationEntry: vi.fn().mockResolvedValue({ id: 'je-1' }),
}))
import { createClient } from '@/lib/supabase/server'
import { POST } from '../route'
@@ -160,11 +156,7 @@ describe('Invoice Inbox Confirm Route', () => {
{ data: { id: 'si-1', total: 625 }, error: null },
// 6. Insert items
{ data: null, error: null },
// 7. Get company settings
{ data: { accounting_method: 'accrual' }, error: null },
// 8. Update invoice with journal entry id
{ data: null, error: null },
// 9. Update inbox item as confirmed
// 7. Update inbox item as confirmed
{ data: null, error: null },
])
mockCreateClient.mockResolvedValue(supabase as never)
@@ -225,9 +217,7 @@ describe('Invoice Inbox Confirm Route', () => {
{ data: { id: 'si-2', total: 1250 }, error: null },
// 5. Insert items
{ data: null, error: null },
// 6. Get company settings
{ data: { accounting_method: 'cash' }, error: null },
// 7. Update inbox item as confirmed
// 6. Update inbox item as confirmed
{ data: null, error: null },
])
mockCreateClient.mockResolvedValue(supabase as never)
@@ -2,9 +2,8 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
import type { SupplierInvoice } from '@/types'
ensureInitialized()
@@ -192,36 +191,6 @@ export async function POST(
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
// Accrual method: create registration journal entry
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('user_id', user.id)
.single()
const accountingMethod = settings?.accounting_method || 'accrual'
let registrationJournalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
user.id,
invoice as SupplierInvoice,
items as SupplierInvoiceItem[],
supplier.supplier_type
)
if (journalEntry) {
registrationJournalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
}
} catch (err) {
console.error('[invoice-inbox] Failed to create registration journal entry:', err)
}
}
// Update inbox item as confirmed
await supabase
.from('invoice_inbox_items')
@@ -246,11 +215,12 @@ export async function POST(
// Non-blocking
}
// Journal entry creation is handled asynchronously by the core
// supplier_invoice.confirmed event handler (see lib/bookkeeping/handlers/)
return NextResponse.json({
data: {
...invoice,
items: itemInserts,
registration_journal_entry_id: registrationJournalEntryId,
},
})
} catch (error) {