Merge remote-tracking branch 'origin/main' into fixing-extensions-bug
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getCategoryAccountMapping, getExpenseAccountForCategory } from '../category-mapping'
|
||||
import {
|
||||
getCategoryAccountMapping,
|
||||
getExpenseAccountForCategory,
|
||||
getDefaultAccountForCategory,
|
||||
getDefaultVatTreatmentForCategory,
|
||||
} from '../category-mapping'
|
||||
|
||||
describe('getCategoryAccountMapping', () => {
|
||||
describe('income_products uses correct account', () => {
|
||||
@@ -43,3 +48,62 @@ describe('getExpenseAccountForCategory', () => {
|
||||
expect(getExpenseAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultAccountForCategory', () => {
|
||||
it('returns expense account for expense categories', () => {
|
||||
expect(getDefaultAccountForCategory('expense_equipment')).toBe('5410')
|
||||
expect(getDefaultAccountForCategory('expense_software')).toBe('5420')
|
||||
expect(getDefaultAccountForCategory('expense_travel')).toBe('5800')
|
||||
expect(getDefaultAccountForCategory('expense_bank_fees')).toBe('6570')
|
||||
})
|
||||
|
||||
it('returns income account for income categories', () => {
|
||||
expect(getDefaultAccountForCategory('income_services')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_products')).toBe('3001')
|
||||
expect(getDefaultAccountForCategory('income_other')).toBe('3900')
|
||||
})
|
||||
|
||||
it('returns private account for enskild firma', () => {
|
||||
expect(getDefaultAccountForCategory('private', 'enskild_firma')).toBe('2013')
|
||||
})
|
||||
|
||||
it('returns private account for aktiebolag', () => {
|
||||
expect(getDefaultAccountForCategory('private', 'aktiebolag')).toBe('2893')
|
||||
})
|
||||
|
||||
it('returns entity-specific education account', () => {
|
||||
expect(getDefaultAccountForCategory('expense_education', 'enskild_firma')).toBe('6991')
|
||||
expect(getDefaultAccountForCategory('expense_education', 'aktiebolag')).toBe('7610')
|
||||
})
|
||||
|
||||
it('returns fallback for uncategorized', () => {
|
||||
expect(getDefaultAccountForCategory('uncategorized')).toBe('6991')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultVatTreatmentForCategory', () => {
|
||||
it('returns standard_25 for regular expense categories', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('expense_equipment')).toBe('standard_25')
|
||||
expect(getDefaultVatTreatmentForCategory('expense_software')).toBe('standard_25')
|
||||
expect(getDefaultVatTreatmentForCategory('expense_travel')).toBe('standard_25')
|
||||
})
|
||||
|
||||
it('returns standard_25 for income categories', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('income_services')).toBe('standard_25')
|
||||
expect(getDefaultVatTreatmentForCategory('income_products')).toBe('standard_25')
|
||||
})
|
||||
|
||||
it('returns null for VAT-exempt categories', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('expense_bank_fees')).toBeNull()
|
||||
expect(getDefaultVatTreatmentForCategory('expense_card_fees')).toBeNull()
|
||||
expect(getDefaultVatTreatmentForCategory('expense_currency_exchange')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for private transactions', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('private')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for uncategorized', () => {
|
||||
expect(getDefaultVatTreatmentForCategory('uncategorized')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -257,3 +257,69 @@ export function getExpenseAccountForCategory(category: TransactionCategory): str
|
||||
}
|
||||
return mapping[category] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default account number for a category.
|
||||
* For expense categories: returns the expense account (debit side).
|
||||
* For income categories: returns the revenue account (credit side).
|
||||
* For private/uncategorized: returns the entity-specific private or fallback account.
|
||||
*/
|
||||
export function getDefaultAccountForCategory(
|
||||
category: TransactionCategory,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
): string {
|
||||
if (category === 'private') {
|
||||
return PRIVATE_ACCOUNTS[entityType] || PRIVATE_ACCOUNTS.enskild_firma
|
||||
}
|
||||
|
||||
const expenseMapping: Record<string, string> = {
|
||||
expense_equipment: '5410',
|
||||
expense_software: '5420',
|
||||
expense_travel: '5800',
|
||||
expense_office: '5010',
|
||||
expense_marketing: '5910',
|
||||
expense_professional_services: '6530',
|
||||
expense_education: entityType === 'aktiebolag' ? '7610' : '6991',
|
||||
expense_bank_fees: '6570',
|
||||
expense_card_fees: '6570',
|
||||
expense_currency_exchange: '7960',
|
||||
expense_other: '6991',
|
||||
}
|
||||
|
||||
if (category.startsWith('expense_')) {
|
||||
return expenseMapping[category] || '6991'
|
||||
}
|
||||
|
||||
const incomeMapping: Record<string, string> = {
|
||||
income_services: '3001',
|
||||
income_products: '3001',
|
||||
income_other: '3900',
|
||||
}
|
||||
|
||||
if (category.startsWith('income_')) {
|
||||
return incomeMapping[category] || '3900'
|
||||
}
|
||||
|
||||
// uncategorized
|
||||
return '6991'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default VAT treatment for a category.
|
||||
* Bank fees, card fees, and currency exchange are VAT-exempt.
|
||||
* All other business categories default to standard 25%.
|
||||
*/
|
||||
export function getDefaultVatTreatmentForCategory(
|
||||
category: TransactionCategory
|
||||
): VatTreatment | null {
|
||||
if (category === 'private' || category === 'uncategorized') {
|
||||
return null
|
||||
}
|
||||
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
|
||||
if (vatExemptCategories.includes(category)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return 'standard_25'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
CAMT054Notification,
|
||||
AuditSecurityEvent,
|
||||
ReconciliationMethod,
|
||||
InvoiceInboxItem,
|
||||
SupplierInvoice,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================
|
||||
@@ -62,6 +64,10 @@ export type CoreEvent =
|
||||
privateTotal: number;
|
||||
userId: string;
|
||||
}}
|
||||
// Supplier Invoice Inbox
|
||||
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string } }
|
||||
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string } }
|
||||
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string } }
|
||||
// Audit
|
||||
| { type: 'audit.security_event'; payload: { event: AuditSecurityEvent; userId: string } }
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(6)
|
||||
})
|
||||
|
||||
it('should have 17 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(17)
|
||||
it('should have 18 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(18)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { pushNotificationsExtension } from '@/extensions/general/push-notificati
|
||||
import { sruExportExtension } from '@/extensions/sru-export'
|
||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import type { Extension } from './types'
|
||||
|
||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||
@@ -26,6 +27,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
sruExportExtension,
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
invoiceInboxExtension,
|
||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ export const SECTORS: Sector[] = [
|
||||
longDescription:
|
||||
'Få push-notiser direkt i webbläsaren när viktiga händelser sker — nya fakturor, förfallna betalningar, slutförda bokföringar med mera.',
|
||||
},
|
||||
{
|
||||
slug: 'invoice-inbox',
|
||||
name: 'Leverantörsfaktura-inbox',
|
||||
sector: 'general',
|
||||
category: 'import',
|
||||
icon: 'Inbox',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Ta emot leverantörsfakturor via e-post eller uppladdning',
|
||||
longDescription:
|
||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||
},
|
||||
{
|
||||
slug: 'enable-banking',
|
||||
name: 'Bankintegration (PSD2)',
|
||||
|
||||
+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.
|
||||
|
||||
Reference in New Issue
Block a user