diff --git a/app/api/extensions/ext/[...path]/__tests__/route.test.ts b/app/api/extensions/ext/[...path]/__tests__/route.test.ts new file mode 100644 index 00000000..8e3f7236 --- /dev/null +++ b/app/api/extensions/ext/[...path]/__tests__/route.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createQueuedMockSupabase, + createMockRequest, + parseJsonResponse, +} from '@/tests/helpers' +import { NextResponse } from 'next/server' + +// Mock dependencies +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +vi.mock('@/lib/extensions/toggle-check', () => ({ + isExtensionEnabled: vi.fn(), +})) + +vi.mock('@/lib/extensions/context-factory', () => ({ + createExtensionContext: vi.fn().mockReturnValue({ + userId: 'user-1', + extensionId: 'test-ext', + }), +})) + +import { createClient } from '@/lib/supabase/server' +import { extensionRegistry } from '@/lib/extensions/registry' +import { isExtensionEnabled } from '@/lib/extensions/toggle-check' +import { GET, POST } from '../route' + +const mockCreateClient = vi.mocked(createClient) +const mockIsEnabled = vi.mocked(isExtensionEnabled) + +function createPathParams(path: string[]) { + return { params: Promise.resolve({ path }) } +} + +describe('Extension Catch-All Route', () => { + beforeEach(() => { + vi.clearAllMocks() + extensionRegistry.clear() + }) + + it('returns 400 for empty path', async () => { + const request = createMockRequest('/api/extensions/ext/') + const response = await GET(request, createPathParams([])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('returns 404 for unknown extension', async () => { + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/ext/nonexistent/foo') + const response = await GET(request, createPathParams(['nonexistent', 'foo'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('returns 401 when not authenticated', async () => { + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: null }, + error: { message: 'Not authenticated' }, + }) + mockCreateClient.mockResolvedValue(supabase as never) + + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(401) + }) + + it('returns 403 when extension is disabled', async () => { + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/data', handler: vi.fn() }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(false) + + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 404 for unmatched method/path', async () => { + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'POST', path: '/data', handler: vi.fn() }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(true) + + // GET doesn't match POST /data + const request = createMockRequest('/api/extensions/ext/test-ext/data') + const response = await GET(request, createPathParams(['test-ext', 'data'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(404) + }) + + it('dispatches to matching handler with context', async () => { + const handler = vi.fn().mockResolvedValue( + NextResponse.json({ banks: [] }) + ) + + extensionRegistry.register({ + id: 'enable-banking', + name: 'Enable Banking', + version: '1.0.0', + apiRoutes: [{ method: 'GET', path: '/banks', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(true) + + const request = createMockRequest('/api/extensions/ext/enable-banking/banks') + const response = await GET(request, createPathParams(['enable-banking', 'banks'])) + const { status, body } = await parseJsonResponse<{ banks: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.banks).toEqual([]) + expect(handler).toHaveBeenCalledWith(request, expect.objectContaining({ + extensionId: 'test-ext', + })) + }) + + it('dispatches POST requests correctly', async () => { + const handler = vi.fn().mockResolvedValue( + NextResponse.json({ ok: true }) + ) + + extensionRegistry.register({ + id: 'test-ext', + name: 'Test', + version: '1.0.0', + apiRoutes: [{ method: 'POST', path: '/connect', handler }], + }) + + const { supabase } = createQueuedMockSupabase() + supabase.auth.getUser.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + error: null, + }) + mockCreateClient.mockResolvedValue(supabase as never) + mockIsEnabled.mockResolvedValue(true) + + const request = createMockRequest('/api/extensions/ext/test-ext/connect', { + method: 'POST', + body: { foo: 'bar' }, + }) + const response = await POST(request, createPathParams(['test-ext', 'connect'])) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(handler).toHaveBeenCalled() + }) +}) diff --git a/app/api/extensions/ext/[...path]/route.ts b/app/api/extensions/ext/[...path]/route.ts new file mode 100644 index 00000000..0646efab --- /dev/null +++ b/app/api/extensions/ext/[...path]/route.ts @@ -0,0 +1,73 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { extensionRegistry } from '@/lib/extensions/registry' +import { createExtensionContext } from '@/lib/extensions/context-factory' +import { isExtensionEnabled } from '@/lib/extensions/toggle-check' + +ensureInitialized() + +/** + * Catch-all route for extension-declared API routes. + * + * URL scheme: /api/extensions/ext/{extensionId}/{...routePath} + * Example: /api/extensions/ext/enable-banking/banks → GET /banks + * + * - Looks up the extension in the registry + * - Checks the extension toggle (disabled → 403) + * - Matches method + path to registered apiRoutes + * - Builds an ExtensionContext and passes it to the handler + */ +async function handleRequest( + request: Request, + { params }: { params: Promise<{ path: string[] }> } +): Promise { + const segments = await params + + if (!segments.path || segments.path.length < 1) { + return NextResponse.json({ error: 'Invalid extension route' }, { status: 400 }) + } + + const [extensionId, ...rest] = segments.path + const routePath = '/' + rest.join('/') + const method = request.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' + + // Look up extension + const extension = extensionRegistry.get(extensionId) + if (!extension || !extension.apiRoutes || extension.apiRoutes.length === 0) { + return NextResponse.json({ error: 'Extension not found' }, { status: 404 }) + } + + // Auth check + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Toggle check — disabled extensions return 403 + const enabled = await isExtensionEnabled(user.id, 'general', extensionId) + if (!enabled) { + return NextResponse.json({ error: 'Extension is disabled' }, { status: 403 }) + } + + // Find matching route + const route = extension.apiRoutes.find( + (r) => r.method === method && r.path === routePath + ) + + if (!route) { + return NextResponse.json({ error: 'Route not found' }, { status: 404 }) + } + + // Build context and dispatch + const ctx = createExtensionContext(supabase, user.id, extensionId) + return route.handler(request, ctx) +} + +export const GET = handleRequest +export const POST = handleRequest +export const PUT = handleRequest +export const DELETE = handleRequest +export const PATCH = handleRequest diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts index a3aa06f4..9f1b634b 100644 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/__tests__/route.test.ts @@ -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) diff --git a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts index 44c90762..62badf33 100644 --- a/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts +++ b/app/api/extensions/invoice-inbox/inbox/[id]/confirm/route.ts @@ -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) { diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts index 1a8a8657..3a8e0e2b 100644 --- a/app/api/invoices/[id]/send/__tests__/route.test.ts +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -29,7 +29,7 @@ vi.mock('@react-pdf/renderer', () => ({ StyleSheet: { create: (s: unknown) => s }, })) -vi.mock('@/lib/invoice/pdf-template', () => ({ +vi.mock('@/lib/invoices/pdf-template', () => ({ InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), })) diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 7a1a1322..26a197c9 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -19,9 +19,11 @@ vi.mock('@/lib/init', () => ({ const mockGetVatRules = vi.fn() const mockCalculateVat = vi.fn() -vi.mock('@/lib/invoice/vat-rules', () => ({ +const mockGetAvailableVatRates = vi.fn() +vi.mock('@/lib/invoices/vat-rules', () => ({ getVatRules: (...args: unknown[]) => mockGetVatRules(...args), calculateVat: (...args: unknown[]) => mockCalculateVat(...args), + getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args), calculateTotal: vi.fn(), })) @@ -164,6 +166,12 @@ describe('POST /api/invoices (create invoice)', () => { reverseChargeText: null, }) mockCalculateVat.mockReturnValue(2500) + mockGetAvailableVatRates.mockReturnValue([ + { rate: 25, label: '25%', treatment: 'standard_25' }, + { rate: 12, label: '12%', treatment: 'reduced_12' }, + { rate: 6, label: '6%', treatment: 'reduced_6' }, + { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, + ]) // Fetch customer enqueue({ data: customer, error: null }) @@ -209,6 +217,12 @@ describe('POST /api/invoices (create invoice)', () => { reverseChargeText: null, }) mockCalculateVat.mockReturnValue(2500) + mockGetAvailableVatRates.mockReturnValue([ + { rate: 25, label: '25%', treatment: 'standard_25' }, + { rate: 12, label: '12%', treatment: 'reduced_12' }, + { rate: 6, label: '6%', treatment: 'reduced_6' }, + { rate: 0, label: '0% (momsfri)', treatment: 'exempt' }, + ]) enqueue({ data: customer, error: null }) enqueue({ data: 'F-2024001' }) diff --git a/extensions/general/ai-categorization/index.ts b/extensions/general/ai-categorization/index.ts index 73dd779b..d4e5b9d9 100644 --- a/extensions/general/ai-categorization/index.ts +++ b/extensions/general/ai-categorization/index.ts @@ -1,5 +1,4 @@ -import { createClient } from '@/lib/supabase/server' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' import type { Transaction, EntityType } from '@/types' import { @@ -26,7 +25,15 @@ const DEFAULT_SETTINGS: AiCategorizationSettings = { providerModel: 'claude-haiku-4-5-20251001', } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, on-demand API) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -38,7 +45,6 @@ export async function getSettings(userId: string): Promise) } } @@ -49,6 +55,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -87,6 +94,7 @@ export async function categorizeTransactions( userId: string, transactionIds: string[] ): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const settings = await getSettings(userId) @@ -125,12 +133,14 @@ export async function categorizeTransactions( // ============================================================ async function handleTransactionSynced( - payload: EventPayload<'transaction.synced'> + payload: EventPayload<'transaction.synced'>, + ctx?: ExtensionContext ): Promise { const { transactions: syncedTransactions, userId } = payload + const log = ctx?.log ?? console // Gate: Is autoSuggestEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoSuggestEnabled) { return } @@ -143,12 +153,10 @@ async function handleTransactionSynced( return } - console.log( - `[ai-categorization] Auto-suggest triggered for ${uncategorized.length} uncategorized transactions` - ) + log.info(`Auto-suggest triggered for ${uncategorized.length} uncategorized transactions`) try { - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const batch: TransactionForCategorization[] = uncategorized.map((t: Transaction) => ({ id: t.id, @@ -173,11 +181,11 @@ async function handleTransactionSynced( await storeSuggestions(userId, qualifiedSuggestions, supabase) } - console.log( - `[ai-categorization] Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})` + log.info( + `Generated ${suggestions.length} suggestions, ${qualifiedSuggestions.length} above threshold (${settings.confidenceThreshold})` ) } catch (error) { - console.error('[ai-categorization] handleTransactionSynced failed:', error) + log.error('handleTransactionSynced failed:', error) } } @@ -251,6 +259,6 @@ export const aiCategorizationExtension: Extension = { path: '/settings/extensions/ai-categorization', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/extensions/general/enable-banking/index.ts b/extensions/general/enable-banking/index.ts index e92cd254..9e4c997c 100644 --- a/extensions/general/enable-banking/index.ts +++ b/extensions/general/enable-banking/index.ts @@ -1,16 +1,9 @@ -import type { Extension } from '@/lib/extensions/types' -import { createClient, createServiceClient } from '@/lib/supabase/server' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import { NextResponse } from 'next/server' -import { eventBus } from '@/lib/events' import { startAuthorization, getASPSPs, - createSession, - getAccountBalance, - isConsentExpiringSoon, - getDaysUntilExpiry, type ASPSP, - type AccountInfo, } from './lib/api-client' import { syncAccountTransactions } from './lib/sync' import type { StoredAccount } from './types' @@ -41,7 +34,8 @@ export const enableBankingExtension: Extension = { { method: 'GET', path: '/banks', - handler: async () => { + handler: async (_request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console try { const aspsps = await getASPSPs('SE') const banks = aspsps.map((aspsp: ASPSP) => ({ @@ -52,7 +46,7 @@ export const enableBankingExtension: Extension = { })) return NextResponse.json({ banks }) } catch (error) { - console.error('Error fetching banks:', error) + log.error('Error fetching banks:', error) return NextResponse.json({ banks: [ { name: 'Nordea', country: 'SE', bic: 'NDEASESS' }, @@ -67,8 +61,9 @@ export const enableBankingExtension: Extension = { { method: 'POST', path: '/connect', - handler: async (request: Request) => { - const supabase = await createClient() + handler: async (request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { @@ -108,7 +103,7 @@ export const enableBankingExtension: Extension = { .single() if (error) { - console.error('Database error:', error) + log.error('Database error:', error) throw new Error('Failed to store connection') } @@ -117,7 +112,7 @@ export const enableBankingExtension: Extension = { authorization_url: url, }) } catch (error) { - console.error('Bank connection error:', error) + log.error('Bank connection error:', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Connection failed' }, { status: 500 } @@ -128,8 +123,9 @@ export const enableBankingExtension: Extension = { { method: 'POST', path: '/sync', - handler: async (request: Request) => { - const supabase = await createClient() + handler: async (request: Request, ctx?: ExtensionContext) => { + const log = ctx?.log ?? console + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) { @@ -161,6 +157,9 @@ export const enableBankingExtension: Extension = { .toISOString() .split('T')[0] + // Use ctx.services.ingestTransactions when available + const ingestFn = ctx?.services.ingestTransactions + let totalImported = 0 let totalDuplicates = 0 @@ -171,7 +170,8 @@ export const enableBankingExtension: Extension = { connection.id, account, fromDate, - toDate + toDate, + ingestFn ) totalImported += result.imported @@ -198,7 +198,8 @@ export const enableBankingExtension: Extension = { .limit(totalImported) if (syncedTransactions && syncedTransactions.length > 0) { - await eventBus.emit({ + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + await emit({ type: 'transaction.synced', payload: { transactions: syncedTransactions as Transaction[], userId: user.id }, }) @@ -211,7 +212,7 @@ export const enableBankingExtension: Extension = { last_synced_at: syncedAt, }) } catch (error) { - console.error('Sync error:', error) + log.error('Sync error:', error) return NextResponse.json( { error: error instanceof Error ? error.message : 'Sync failed' }, { status: 500 } diff --git a/extensions/general/enable-banking/lib/sync.ts b/extensions/general/enable-banking/lib/sync.ts index fcde7031..5e53c5c6 100644 --- a/extensions/general/enable-banking/lib/sync.ts +++ b/extensions/general/enable-banking/lib/sync.ts @@ -1,8 +1,16 @@ -import { SupabaseClient } from '@supabase/supabase-js' +import type { SupabaseClient } from '@supabase/supabase-js' import { getTransactions, getAccountBalance } from './api-client' -import { ingestTransactions, type RawTransaction } from '@/lib/transactions/ingest' +import { ingestTransactions as defaultIngest } from '@/lib/transactions/ingest' +import type { RawTransaction, IngestResult } from '@/types' import type { StoredAccount } from '../types' +/** Ingest function signature — matches lib/transactions/ingest */ +export type IngestFn = ( + supabase: SupabaseClient, + userId: string, + raw: RawTransaction[] +) => Promise + export interface SyncResult { imported: number duplicates: number @@ -14,6 +22,10 @@ export interface SyncResult { * * Fetches transactions from the Enable Banking API, converts to RawTransaction * format, and delegates to the shared ingestion pipeline. + * + * @param ingest - Optional ingest function override (defaults to core ingestTransactions). + * When called from an extension handler with ctx.services.ingestTransactions, + * pass that function to avoid direct @/lib imports. */ export async function syncAccountTransactions( supabase: SupabaseClient, @@ -21,7 +33,8 @@ export async function syncAccountTransactions( connectionId: string, account: StoredAccount, fromDate: string, - toDate: string + toDate: string, + ingest: IngestFn = defaultIngest ): Promise { const bankTransactions = await getTransactions( account.uid, @@ -44,7 +57,7 @@ export async function syncAccountTransactions( import_source: 'enable_banking', })) - const ingestResult = await ingestTransactions(supabase, userId, rawTransactions) + const ingestResult = await ingest(supabase, userId, rawTransactions) // Update account balance try { diff --git a/extensions/general/invoice-inbox/index.ts b/extensions/general/invoice-inbox/index.ts index b049f95c..f5b476d9 100644 --- a/extensions/general/invoice-inbox/index.ts +++ b/extensions/general/invoice-inbox/index.ts @@ -1,8 +1,6 @@ -import { createClient } from '@/lib/supabase/server' -import { eventBus } from '@/lib/events/bus' import { analyzeInvoice } from './lib/invoice-analyzer' import { matchSupplier } from './lib/supplier-matcher' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' import type { InvoiceInboxSettings } from './types' @@ -17,7 +15,15 @@ const DEFAULT_SETTINGS: InvoiceInboxSettings = { inboxEmail: null, } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, API routes) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -40,6 +46,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -73,9 +80,11 @@ const INVOICE_MIME_TYPES = [ * be auto-processed as a supplier invoice. */ async function handleDocumentUploaded( - payload: EventPayload<'document.uploaded'> + payload: EventPayload<'document.uploaded'>, + ctx?: ExtensionContext ): Promise { const { document, userId } = payload + const log = ctx?.log ?? console // Gate: Is it a supported file type? if (!document.mime_type || !INVOICE_MIME_TYPES.includes(document.mime_type)) { @@ -83,13 +92,13 @@ async function handleDocumentUploaded( } // Gate: Is autoProcessEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoProcessEnabled) { return } // Gate: Was this document already processed as an inbox item? - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const { data: existing } = await supabase .from('invoice_inbox_items') .select('id') @@ -101,7 +110,7 @@ async function handleDocumentUploaded( return } - console.log(`[invoice-inbox] Auto-process triggered for document ${document.id}`) + log.info(`Auto-process triggered for document ${document.id}`) try { // Create inbox item @@ -117,7 +126,7 @@ async function handleDocumentUploaded( .single() if (insertError || !inboxItem) { - console.error('[invoice-inbox] Failed to create inbox item:', insertError) + log.error('Failed to create inbox item:', insertError) return } @@ -176,7 +185,8 @@ async function handleDocumentUploaded( .single() if (updatedItem) { - await eventBus.emit({ + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + await emit({ type: 'supplier_invoice.extracted', payload: { inboxItem: updatedItem, @@ -186,9 +196,9 @@ async function handleDocumentUploaded( }) } - console.log(`[invoice-inbox] Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`) + log.info(`Invoice ${inboxItem.id} processed (confidence: ${extraction.confidence})`) } catch (error) { - console.error('[invoice-inbox] handleDocumentUploaded failed:', error) + log.error('handleDocumentUploaded failed:', error) } } @@ -208,6 +218,6 @@ export const invoiceInboxExtension: Extension = { path: '/settings/extensions/invoice-inbox', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/extensions/general/push-notifications/index.ts b/extensions/general/push-notifications/index.ts index 14e53bb4..1dfbba81 100644 --- a/extensions/general/push-notifications/index.ts +++ b/extensions/general/push-notifications/index.ts @@ -9,8 +9,7 @@ * check extension setting -> build payload -> send via unified pipeline */ -import { createClient } from '@/lib/supabase/server' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' import { sendNotificationToUser } from './notification-sender' import { @@ -41,7 +40,15 @@ const DEFAULT_SETTINGS: PushNotificationSettings = { receiptMatchedEnabled: true, } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, cron jobs) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -53,7 +60,6 @@ export async function getSettings(userId: string): Promise) } } @@ -64,6 +70,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -86,14 +93,15 @@ export async function saveSettings( // ============================================================ async function handlePeriodLocked( - payload: EventPayload<'period.locked'> + payload: EventPayload<'period.locked'>, + ctx?: ExtensionContext ): Promise { const { period, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.periodLockedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createPeriodLockedPayload(period.name, period.id) const result = await sendNotificationToUser( @@ -105,19 +113,20 @@ async function handlePeriodLocked( ) if (result.sent) { - console.log(`[push-notifications] Period locked notification sent for ${period.name}`) + (ctx?.log ?? console).info(`Period locked notification sent for ${period.name}`) } } async function handleYearClosed( - payload: EventPayload<'period.year_closed'> + payload: EventPayload<'period.year_closed'>, + ctx?: ExtensionContext ): Promise { const { period, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.periodYearClosedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createYearClosedPayload(period.name, period.id) const result = await sendNotificationToUser( @@ -129,19 +138,20 @@ async function handleYearClosed( ) if (result.sent) { - console.log(`[push-notifications] Year closed notification sent for ${period.name}`) + (ctx?.log ?? console).info(`Year closed notification sent for ${period.name}`) } } async function handleInvoiceSent( - payload: EventPayload<'invoice.sent'> + payload: EventPayload<'invoice.sent'>, + ctx?: ExtensionContext ): Promise { const { invoice, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.invoiceSentEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createInvoiceSentPayload( invoice.invoice_number, invoice.id @@ -156,19 +166,20 @@ async function handleInvoiceSent( ) if (result.sent) { - console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`) + (ctx?.log ?? console).info(`Invoice sent notification for #${invoice.invoice_number}`) } } async function handleReceiptExtracted( - payload: EventPayload<'receipt.extracted'> + payload: EventPayload<'receipt.extracted'>, + ctx?: ExtensionContext ): Promise { const { receipt, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.receiptExtractedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createReceiptExtractedPayload( receipt.merchant_name, receipt.id @@ -183,19 +194,20 @@ async function handleReceiptExtracted( ) if (result.sent) { - console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`) + (ctx?.log ?? console).info(`Receipt extracted notification for ${receipt.id}`) } } async function handleReceiptMatched( - payload: EventPayload<'receipt.matched'> + payload: EventPayload<'receipt.matched'>, + ctx?: ExtensionContext ): Promise { const { receipt, transaction, userId } = payload - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.receiptMatchedEnabled) return - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id) const result = await sendNotificationToUser( @@ -207,7 +219,7 @@ async function handleReceiptMatched( ) if (result.sent) { - console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`) + (ctx?.log ?? console).info(`Receipt matched notification for ${receipt.id}`) } } @@ -231,6 +243,6 @@ export const pushNotificationsExtension: Extension = { path: '/settings/extensions/push-notifications', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/extensions/general/receipt-ocr/index.ts b/extensions/general/receipt-ocr/index.ts index 80234ec9..830a9830 100644 --- a/extensions/general/receipt-ocr/index.ts +++ b/extensions/general/receipt-ocr/index.ts @@ -1,11 +1,9 @@ -import { createClient } from '@/lib/supabase/server' -import { eventBus } from '@/lib/events/bus' import { analyzeReceipt } from './lib/receipt-analyzer' import { processLineItems } from './lib/receipt-categorizer' import { autoMatchReceipts } from './lib/receipt-matcher' -import type { Extension } from '@/lib/extensions/types' +import type { Extension, ExtensionContext } from '@/lib/extensions/types' import type { EventPayload } from '@/lib/events/types' -import type { Receipt, Transaction } from '@/types' +import type { Receipt } from '@/types' // ============================================================ // Settings @@ -25,7 +23,15 @@ const DEFAULT_SETTINGS: ReceiptOcrSettings = { ocrConfidenceThreshold: 0.6, } +/** Get settings via ExtensionContext (preferred in event handlers) */ +async function getSettingsViaCtx(ctx: ExtensionContext): Promise { + const stored = await ctx.settings.get>() + return { ...DEFAULT_SETTINGS, ...(stored || {}) } +} + +/** Get settings for external callers (settings routes, API routes) */ export async function getSettings(userId: string): Promise { + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() const { data } = await supabase @@ -49,6 +55,7 @@ export async function saveSettings( const current = await getSettings(userId) const merged = { ...current, ...partial } + const { createClient } = await import('@/lib/supabase/server') const supabase = await createClient() await supabase @@ -76,9 +83,11 @@ const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] * When an image is uploaded via the document archive, auto-trigger OCR. */ async function handleDocumentUploaded( - payload: EventPayload<'document.uploaded'> + payload: EventPayload<'document.uploaded'>, + ctx?: ExtensionContext ): Promise { const { document, userId } = payload + const log = ctx?.log ?? console // Gate: Is it an image? if (!document.mime_type || !IMAGE_MIME_TYPES.includes(document.mime_type)) { @@ -86,15 +95,15 @@ async function handleDocumentUploaded( } // Gate: Is autoOcrEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoOcrEnabled) { return } - console.log(`[receipt-ocr] Auto-OCR triggered for document ${document.id}`) + log.info(`Auto-OCR triggered for document ${document.id}`) try { - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() // Download image from storage const { data: fileData, error: downloadError } = await supabase.storage @@ -102,7 +111,7 @@ async function handleDocumentUploaded( .download(document.storage_path) if (downloadError || !fileData) { - console.error('[receipt-ocr] Failed to download document:', downloadError) + log.error('Failed to download document:', downloadError) return } @@ -116,8 +125,8 @@ async function handleDocumentUploaded( // Gate: Is confidence high enough? if (extraction.confidence < settings.ocrConfidenceThreshold) { - console.log( - `[receipt-ocr] Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping` + log.info( + `Confidence ${extraction.confidence} below threshold ${settings.ocrConfidenceThreshold}, skipping` ) return } @@ -155,7 +164,7 @@ async function handleDocumentUploaded( .single() if (insertError || !receipt) { - console.error('[receipt-ocr] Failed to create receipt:', insertError) + log.error('Failed to create receipt:', insertError) return } @@ -188,7 +197,8 @@ async function handleDocumentUploaded( .single() // Emit receipt.extracted - await eventBus.emit({ + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + await emit({ type: 'receipt.extracted', payload: { receipt: (completeReceipt || receipt) as unknown as Receipt, @@ -198,9 +208,9 @@ async function handleDocumentUploaded( }, }) - console.log(`[receipt-ocr] Receipt ${receipt.id} created from document ${document.id}`) + log.info(`Receipt ${receipt.id} created from document ${document.id}`) } catch (error) { - console.error('[receipt-ocr] handleDocumentUploaded failed:', error) + log.error('handleDocumentUploaded failed:', error) } } @@ -208,12 +218,14 @@ async function handleDocumentUploaded( * When new transactions arrive from banking sync, auto-match unmatched receipts. */ async function handleTransactionSynced( - payload: EventPayload<'transaction.synced'> + payload: EventPayload<'transaction.synced'>, + ctx?: ExtensionContext ): Promise { const { transactions: syncedTransactions, userId } = payload + const log = ctx?.log ?? console // Gate: Is autoMatchEnabled? - const settings = await getSettings(userId) + const settings = ctx ? await getSettingsViaCtx(ctx) : await getSettings(userId) if (!settings.autoMatchEnabled) { return } @@ -224,12 +236,10 @@ async function handleTransactionSynced( return } - console.log( - `[receipt-ocr] Auto-match triggered for ${expenseTransactions.length} expense transactions` - ) + log.info(`Auto-match triggered for ${expenseTransactions.length} expense transactions`) try { - const supabase = await createClient() + const supabase = ctx?.supabase ?? await (await import('@/lib/supabase/server')).createClient() // Fetch unmatched receipts const { data: unmatchedReceipts, error: fetchError } = await supabase @@ -250,6 +260,8 @@ async function handleTransactionSynced( settings.autoMatchThreshold ) + const emit = ctx?.emit ?? (await import('@/lib/events/bus')).eventBus.emit.bind((await import('@/lib/events/bus')).eventBus) + // Process each match for (const { receipt, match } of matches) { // Update receipt with match @@ -268,7 +280,7 @@ async function handleTransactionSynced( .eq('id', match.transaction.id) // Emit receipt.matched - await eventBus.emit({ + await emit({ type: 'receipt.matched', payload: { receipt, @@ -279,12 +291,12 @@ async function handleTransactionSynced( }, }) - console.log( - `[receipt-ocr] Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})` + log.info( + `Auto-matched receipt ${receipt.id} to transaction ${match.transaction.id} (confidence: ${match.confidence})` ) } } catch (error) { - console.error('[receipt-ocr] handleTransactionSynced failed:', error) + log.error('handleTransactionSynced failed:', error) } } @@ -317,6 +329,6 @@ export const receiptOcrExtension: Extension = { path: '/settings/extensions/receipt-ocr', }, async onInstall(ctx) { - await saveSettings(ctx.userId, DEFAULT_SETTINGS) + await ctx.settings.set('settings', DEFAULT_SETTINGS) }, } diff --git a/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts new file mode 100644 index 00000000..f67195bd --- /dev/null +++ b/lib/bookkeeping/handlers/__tests__/supplier-invoice-handler.test.ts @@ -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() + }) +}) diff --git a/lib/bookkeeping/handlers/supplier-invoice-handler.ts b/lib/bookkeeping/handlers/supplier-invoice-handler.ts new file mode 100644 index 00000000..b619bffb --- /dev/null +++ b/lib/bookkeeping/handlers/supplier-invoice-handler.ts @@ -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 { + 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) +} diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts index 440c8e55..a430149b 100644 --- a/lib/bookkeeping/invoice-entries.ts +++ b/lib/bookkeeping/invoice-entries.ts @@ -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, diff --git a/lib/extensions/__tests__/context-factory.test.ts b/lib/extensions/__tests__/context-factory.test.ts new file mode 100644 index 00000000..c14a7281 --- /dev/null +++ b/lib/extensions/__tests__/context-factory.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createExtensionContext } from '../context-factory' +import { eventBus } from '@/lib/events/bus' +import { createMockSupabase } from '@/tests/helpers' + +vi.mock('@/lib/transactions/ingest', () => ({ + ingestTransactions: vi.fn().mockResolvedValue({ + imported: 0, duplicates: 0, reconciled: 0, + auto_categorized: 0, auto_matched_invoices: 0, errors: 0, + transaction_ids: [], + }), +})) + +beforeEach(() => { + eventBus.clear() + vi.clearAllMocks() +}) + +describe('createExtensionContext', () => { + it('returns context with correct userId and extensionId', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + expect(ctx.userId).toBe('user-1') + expect(ctx.extensionId).toBe('test-ext') + }) + + it('provides supabase client', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + expect(ctx.supabase).toBe(supabase) + }) + + it('emit() delegates to eventBus.emit()', async () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + const handler = vi.fn() + eventBus.on('journal_entry.committed', handler) + + await ctx.emit({ + type: 'journal_entry.committed', + payload: { entry: { id: 'e1' } as never, userId: 'user-1' }, + }) + + expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1' }) + }) + + it('log methods prefix with extensionId', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'my-ext') + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + ctx.log.info('hello', 42) + ctx.log.warn('caution') + ctx.log.error('oops') + + expect(logSpy).toHaveBeenCalledWith('[my-ext]', 'hello', 42) + expect(warnSpy).toHaveBeenCalledWith('[my-ext]', 'caution') + expect(errorSpy).toHaveBeenCalledWith('[my-ext]', 'oops') + + logSpy.mockRestore() + warnSpy.mockRestore() + errorSpy.mockRestore() + }) + + it('settings.get() queries extension_data table', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: { value: { autoOcr: true } }, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'receipt-ocr') + const result = await ctx.settings.get<{ autoOcr: boolean }>('settings') + + expect(result).toEqual({ autoOcr: true }) + expect(supabase.from).toHaveBeenCalledWith('extension_data') + }) + + it('settings.get() without key defaults to "settings"', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: { value: { foo: 'bar' } }, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + const result = await ctx.settings.get<{ foo: string }>() + + expect(result).toEqual({ foo: 'bar' }) + }) + + it('settings.get() returns null when no data', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: null, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + const result = await ctx.settings.get('missing-key') + + expect(result).toBeNull() + }) + + it('settings.set() upserts into extension_data table', async () => { + const { supabase, mockResult } = createMockSupabase() + mockResult({ data: null, error: null }) + + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + await ctx.settings.set('my-key', { value: 123 }) + + expect(supabase.from).toHaveBeenCalledWith('extension_data') + }) + + it('storage.getPublicUrl() returns URL string', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + const url = ctx.storage.getPublicUrl('documents', 'path/to/file.pdf') + expect(typeof url).toBe('string') + }) + + it('services.ingestTransactions is a function', () => { + const { supabase } = createMockSupabase() + const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext') + + expect(typeof ctx.services.ingestTransactions).toBe('function') + }) +}) diff --git a/lib/extensions/__tests__/registry.test.ts b/lib/extensions/__tests__/registry.test.ts index 7c15deb1..5296ec8b 100644 --- a/lib/extensions/__tests__/registry.test.ts +++ b/lib/extensions/__tests__/registry.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { extensionRegistry } from '../registry' +import { extensionRegistry, setContextFactory } from '../registry' import { eventBus } from '@/lib/events/bus' -import type { Extension } from '../types' +import type { Extension, ExtensionContext } from '../types' + +vi.mock('@/lib/supabase/server', () => ({ + createClient: vi.fn().mockResolvedValue({ + from: vi.fn(), + storage: { from: vi.fn() }, + }), +})) beforeEach(() => { extensionRegistry.clear() @@ -40,7 +47,8 @@ describe('ExtensionRegistry', () => { payload: { entry: { id: 'e1' } as never, userId: 'u1' }, }) - expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' }) + expect(handler).toHaveBeenCalled() + expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' }) }) it('register() skips duplicate registration (same id)', () => { @@ -126,4 +134,28 @@ describe('ExtensionRegistry', () => { expect(handler1).not.toHaveBeenCalled() expect(handler2).not.toHaveBeenCalled() }) + + it('handler receives ExtensionContext when context factory is set', async () => { + const handler = vi.fn() + const mockCtx = { extensionId: 'ctx-ext', userId: 'u1' } as ExtensionContext + + setContextFactory((_supabase, _userId, _extId) => mockCtx) + + const ext = makeExtension({ + id: 'ctx-ext', + eventHandlers: [{ eventType: 'journal_entry.committed', handler }], + }) + + extensionRegistry.register(ext) + + await eventBus.emit({ + type: 'journal_entry.committed', + payload: { entry: { id: 'e1' } as never, userId: 'u1' }, + }) + + expect(handler).toHaveBeenCalledWith( + { entry: { id: 'e1' }, userId: 'u1' }, + mockCtx + ) + }) }) diff --git a/lib/extensions/context-factory.ts b/lib/extensions/context-factory.ts new file mode 100644 index 00000000..131f8cab --- /dev/null +++ b/lib/extensions/context-factory.ts @@ -0,0 +1,122 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CoreEvent } from '@/lib/events/types' +import { eventBus } from '@/lib/events/bus' +import { ingestTransactions } from '@/lib/transactions/ingest' +import type { + ExtensionContext, + ExtensionLogger, + ExtensionSettings, + ExtensionStorage, + ExtensionServices, +} from './types' + +/** + * Create a prefixed logger for an extension. + */ +function createLogger(extensionId: string): ExtensionLogger { + const prefix = `[${extensionId}]` + return { + info: (message: string, ...args: unknown[]) => console.log(prefix, message, ...args), + warn: (message: string, ...args: unknown[]) => console.warn(prefix, message, ...args), + error: (message: string, ...args: unknown[]) => console.error(prefix, message, ...args), + } +} + +/** + * Create a settings accessor scoped to a specific extension. + */ +function createSettings( + supabase: SupabaseClient, + userId: string, + extensionId: string +): ExtensionSettings { + return { + async get(key?: string): Promise { + const lookupKey = key ?? 'settings' + const { data } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', userId) + .eq('extension_id', extensionId) + .eq('key', lookupKey) + .single() + + return (data?.value as T) ?? null + }, + + async set(key: string, value: T): Promise { + await supabase + .from('extension_data') + .upsert( + { + user_id: userId, + extension_id: extensionId, + key, + value, + }, + { onConflict: 'user_id,extension_id,key' } + ) + }, + } +} + +/** + * Create a storage accessor wrapping Supabase storage. + */ +function createStorage(supabase: SupabaseClient): ExtensionStorage { + return { + async download(bucket: string, path: string) { + const { data, error } = await supabase.storage + .from(bucket) + .download(path) + return { data, error: error?.message } + }, + + async upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }) { + const { error } = await supabase.storage + .from(bucket) + .upload(path, data, options ? { contentType: options.contentType } : undefined) + if (error) return { path: '', error: error.message } + return { path } + }, + + getPublicUrl(bucket: string, path: string): string { + const { data } = supabase.storage + .from(bucket) + .getPublicUrl(path) + return data.publicUrl + }, + } +} + +/** + * Create core services exposed to extensions. + */ +function createServices(): ExtensionServices { + return { + ingestTransactions, + } +} + +/** + * Build a fully populated ExtensionContext. + * + * The context gives extensions access to Supabase, event emission, settings, + * storage, logging, and core services — without importing from core modules. + */ +export function createExtensionContext( + supabase: SupabaseClient, + userId: string, + extensionId: string +): ExtensionContext { + return { + userId, + extensionId, + supabase, + emit: (event: CoreEvent) => eventBus.emit(event), + settings: createSettings(supabase, userId, extensionId), + storage: createStorage(supabase), + log: createLogger(extensionId), + services: createServices(), + } +} diff --git a/lib/extensions/registry.ts b/lib/extensions/registry.ts index 55d33957..21809846 100644 --- a/lib/extensions/registry.ts +++ b/lib/extensions/registry.ts @@ -1,6 +1,25 @@ import { eventBus } from '@/lib/events/bus' import type { CoreEventType } from '@/lib/events/types' -import type { Extension } from './types' +import type { Extension, ExtensionContext } from './types' +import type { SupabaseClient } from '@supabase/supabase-js' + +/** Factory function type for creating extension contexts */ +export type ContextFactory = ( + supabase: SupabaseClient, + userId: string, + extensionId: string +) => ExtensionContext + +/** Lazy-loaded context factory — set during initialization */ +let contextFactory: ContextFactory | null = null + +/** + * Set the context factory used to build ExtensionContext for event handlers. + * Called once during system initialization. + */ +export function setContextFactory(factory: ContextFactory): void { + contextFactory = factory +} /** * Extension Registry — singleton that manages extension lifecycle. @@ -15,6 +34,10 @@ class ExtensionRegistry { /** * Register an extension: store it and wire its event handlers to the bus. + * + * Each handler is wrapped so it receives `(payload, ctx)`. The context is + * built lazily when the event fires, using the userId from the payload and + * a Supabase client created from the current request cookies. */ register(extension: Extension): void { if (this.extensions.has(extension.id)) { @@ -28,8 +51,24 @@ class ExtensionRegistry { const unsubs: (() => void)[] = [] if (extension.eventHandlers) { for (const { eventType, handler } of extension.eventHandlers) { - // Cast is safe: the handler is stored by eventType key, so it only receives matching payloads - const unsub = eventBus.on(eventType as CoreEventType, handler) + // Wrap handler to inject ExtensionContext as second argument + const wrappedHandler = async (payload: { userId: string; [key: string]: unknown }) => { + let ctx: ExtensionContext | undefined + if (contextFactory && payload.userId) { + try { + // Dynamic import to avoid circular deps at module load time + const { createClient } = await import('@/lib/supabase/server') + const supabase = await createClient() + ctx = contextFactory(supabase, payload.userId, extension.id) + } catch { + // Context creation failed (e.g. no request cookies in cron jobs). + // Handler still gets called — ctx will be undefined. + } + } + return handler(payload, ctx) + } + + const unsub = eventBus.on(eventType as CoreEventType, wrappedHandler) unsubs.push(unsub) } } diff --git a/lib/extensions/types.ts b/lib/extensions/types.ts index bfd917a5..b74cacf8 100644 --- a/lib/extensions/types.ts +++ b/lib/extensions/types.ts @@ -1,5 +1,6 @@ -import type { CoreEventType } from '@/lib/events/types' -import type { EntityType } from '@/types' +import type { SupabaseClient } from '@supabase/supabase-js' +import type { CoreEvent, CoreEventType } from '@/lib/events/types' +import type { EntityType, RawTransaction, IngestResult } from '@/types' // ============================================================ // Extension Marketplace Types @@ -63,7 +64,7 @@ export interface RouteDefinition { export interface ApiRouteDefinition { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' path: string - handler: (request: Request) => Promise + handler: (request: Request, ctx?: ExtensionContext) => Promise } /** Sidebar navigation item added by an extension */ @@ -112,13 +113,44 @@ export interface MappingRuleTypeDefinition { export interface ExtensionEventHandler { eventType: CoreEventType // eslint-disable-next-line @typescript-eslint/no-explicit-any - handler: (payload: any) => Promise | void + handler: (payload: any, ctx?: ExtensionContext) => Promise | void } -/** Context passed to extension lifecycle hooks */ +/** Logger interface for extensions */ +export interface ExtensionLogger { + info(message: string, ...args: unknown[]): void + warn(message: string, ...args: unknown[]): void + error(message: string, ...args: unknown[]): void +} + +/** Settings accessor for extension-scoped key-value data */ +export interface ExtensionSettings { + get(key?: string): Promise + set(key: string, value: T): Promise +} + +/** Storage accessor wrapping Supabase storage */ +export interface ExtensionStorage { + download(bucket: string, path: string): Promise<{ data: Blob | null; error?: string }> + upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }): Promise<{ path: string; error?: string }> + getPublicUrl(bucket: string, path: string): string +} + +/** Core services exposed to extensions */ +export interface ExtensionServices { + ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[]): Promise +} + +/** Context passed to extension lifecycle hooks and event handlers */ export interface ExtensionContext { userId: string extensionId: string + supabase: SupabaseClient + emit(event: CoreEvent): Promise + settings: ExtensionSettings + storage: ExtensionStorage + log: ExtensionLogger + services: ExtensionServices } /** diff --git a/lib/init.ts b/lib/init.ts index 1b40eb85..5047fd48 100644 --- a/lib/init.ts +++ b/lib/init.ts @@ -1,10 +1,21 @@ import { loadExtensions } from '@/lib/extensions/loader' +import { setContextFactory } from '@/lib/extensions/registry' +import { createExtensionContext } from '@/lib/extensions/context-factory' +import { registerSupplierInvoiceHandler } from '@/lib/bookkeeping/handlers/supplier-invoice-handler' + +let initialized = false /** - * Ensure the system is initialized (extensions loaded). + * Ensure the system is initialized (extensions loaded, context factory wired, + * core event handlers registered). * Called from API routes that emit events. * Idempotent — safe to call multiple times. */ export function ensureInitialized(): void { + if (initialized) return + initialized = true + + setContextFactory(createExtensionContext) + registerSupplierInvoiceHandler() loadExtensions() } diff --git a/lib/transactions/ingest.ts b/lib/transactions/ingest.ts index fb1171ed..7000ed7b 100644 --- a/lib/transactions/ingest.ts +++ b/lib/transactions/ingest.ts @@ -4,34 +4,10 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' import { tryReconcileTransaction, fetchUnlinkedGLLines } from '@/lib/reconciliation/bank-reconciliation' import type { UnlinkedGLLine } from '@/lib/reconciliation/bank-reconciliation' -import type { Transaction } from '@/types' +import type { Transaction, RawTransaction, IngestResult } from '@/types' -/** - * Normalized transaction input for the generic ingestion pipeline. - * Both file import and PSD2 sync convert to this format before ingesting. - */ -export interface RawTransaction { - date: string - description: string - amount: number - currency: string - external_id: string // dedup key - mcc_code?: number | null - merchant_name?: string | null - reference?: string | null // OCR number, Bankgiro ref, etc. - bank_connection_id?: string | null - import_source?: string // 'csv_nordea', 'camt053', 'enable_banking', etc. -} - -export interface IngestResult { - imported: number - duplicates: number - reconciled: number - auto_categorized: number - auto_matched_invoices: number - errors: number - transaction_ids: string[] -} +// Re-export types for backward compatibility +export type { RawTransaction, IngestResult } from '@/types' /** * Generic transaction ingestion pipeline. diff --git a/tests/helpers.ts b/tests/helpers.ts index 00a306bd..74e7d14c 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -302,6 +302,8 @@ export function makeInvoice(overrides: Partial = {}): Invoice { notes: null, reverse_charge_text: null, credited_invoice_id: null, + document_type: 'invoice', + converted_from_id: null, paid_at: null, paid_amount: null, created_at: '2024-06-15T14:30:00Z', @@ -436,6 +438,7 @@ export function makeCompanySettings( invoice_prefix: 'F', next_invoice_number: 1, invoice_default_days: 30, + invoice_default_notes: null, onboarding_step: 6, onboarding_complete: true, sector_slug: null, diff --git a/types/index.ts b/types/index.ts index 2918f459..3827c2ca 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1659,3 +1659,32 @@ export const REMINDER_LEVEL_DESCRIPTIONS: Record<1 | 2 | 3, string> = { 2: '30 dagar efter förfallodatum', 3: '45 dagar efter förfallodatum' } + +// ============================================================ +// Transaction Ingestion Types (re-exported for extension use) +// ============================================================ + +/** Normalized transaction input for the generic ingestion pipeline */ +export interface RawTransaction { + date: string + description: string + amount: number + currency: string + external_id: string + mcc_code?: number | null + merchant_name?: string | null + reference?: string | null + bank_connection_id?: string | null + import_source?: string +} + +/** Result of the transaction ingestion pipeline */ +export interface IngestResult { + imported: number + duplicates: number + reconciled: number + auto_categorized: number + auto_matched_invoices: number + errors: number + transaction_ids: string[] +}