Merge remote-tracking branch 'origin/main' into fixing-extensions-bug

This commit is contained in:
Emil
2026-02-23 09:46:32 +01:00
58 changed files with 5693 additions and 860 deletions
@@ -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()
})
})
+66
View File
@@ -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)
}
+6
View File
@@ -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')
})
})
+35 -3
View File
@@ -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
)
})
})
+2 -2
View File
@@ -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', () => {
+122
View File
@@ -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(),
}
}
+2
View File
@@ -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
]
+42 -3
View File
@@ -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)
}
}
+12
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+3 -27
View File
@@ -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.