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,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()
|
||||
})
|
||||
})
|
||||
@@ -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<Response> {
|
||||
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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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<AiCategorizationSettings> {
|
||||
const stored = await ctx.settings.get<Partial<AiCategorizationSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, on-demand API) */
|
||||
export async function getSettings(userId: string): Promise<AiCategorizationSettings> {
|
||||
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<AiCategorizationSetti
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<AiCategorizationSettings>) }
|
||||
}
|
||||
|
||||
@@ -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<CategorizationSuggestion[]> {
|
||||
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<void> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<IngestResult>
|
||||
|
||||
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<SyncResult> {
|
||||
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 {
|
||||
|
||||
@@ -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<InvoiceInboxSettings> {
|
||||
const stored = await ctx.settings.get<Partial<InvoiceInboxSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, API routes) */
|
||||
export async function getSettings(userId: string): Promise<InvoiceInboxSettings> {
|
||||
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<void> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<PushNotificationSettings> {
|
||||
const stored = await ctx.settings.get<Partial<PushNotificationSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, cron jobs) */
|
||||
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
|
||||
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<PushNotificationSetti
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<PushNotificationSettings>) }
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<ReceiptOcrSettings> {
|
||||
const stored = await ctx.settings.get<Partial<ReceiptOcrSettings>>()
|
||||
return { ...DEFAULT_SETTINGS, ...(stored || {}) }
|
||||
}
|
||||
|
||||
/** Get settings for external callers (settings routes, API routes) */
|
||||
export async function getSettings(userId: string): Promise<ReceiptOcrSettings> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<T>(key?: string): Promise<T | null> {
|
||||
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<T>(key: string, value: T): Promise<void> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-5
@@ -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<Response>
|
||||
handler: (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
}
|
||||
|
||||
/** 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> | void
|
||||
handler: (payload: any, ctx?: ExtensionContext) => Promise<void> | 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<T>(key?: string): Promise<T | null>
|
||||
set<T>(key: string, value: T): Promise<void>
|
||||
}
|
||||
|
||||
/** 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<IngestResult>
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks and event handlers */
|
||||
export interface ExtensionContext {
|
||||
userId: string
|
||||
extensionId: string
|
||||
supabase: SupabaseClient
|
||||
emit(event: CoreEvent): Promise<void>
|
||||
settings: ExtensionSettings
|
||||
storage: ExtensionStorage
|
||||
log: ExtensionLogger
|
||||
services: ExtensionServices
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+12
-1
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -302,6 +302,8 @@ export function makeInvoice(overrides: Partial<Invoice> = {}): 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,
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user