refactor: consolidate extension system to general-only with manifest-driven architecture

- Remove all sector-specific extensions (construction, ecommerce, export,
  hotel, restaurant, tech) — only general-purpose extensions remain
- Move NE-bilaga and SRU export from extensions to core reports (lib/reports/)
- Move moms-box-mapping from extensions/export/shared to lib/vat/
- Replace per-extension API routes with catch-all dispatcher
  (app/api/extensions/ext/[...path]/route.ts)
- Add manifest.json for each extension with metadata, env vars, and deps
- Add api-routes.ts pattern for extension-defined API endpoints
- Add code generation scripts (generate-extension-registry, create-extension)
- Add extensions.config.json for opt-in extension loading
- Add extensions.schema.json for config validation
- Add email service interface with noop default (lib/email/service.ts)
- Add CI workflow (core-build.yml) to verify core builds with zero extensions
- Add migration 045: expand account_type CHECK for untaxed_reserves
- Update CLAUDE.md with comprehensive extension system documentation
- Update all report engines and bookkeeping services for new imports
- Clean up extensions.schema.json to only list existing extensions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-26 14:32:56 +01:00
co-authored by Claude Opus 4.6
parent 39e407644d
commit 03b569d708
289 changed files with 7325 additions and 27598 deletions
@@ -6,7 +6,7 @@ import type { Invoice, InvoiceItem, CreateJournalEntryInput } from '@/types'
vi.mock('../engine', () => ({
findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
createJournalEntry: vi.fn().mockImplementation(
async (_userId: string, input: CreateJournalEntryInput) => ({
async (_supabase: unknown, _userId: string, input: CreateJournalEntryInput) => ({
id: 'entry-1',
...input,
lines: input.lines,
@@ -168,10 +168,10 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
],
})
await createInvoiceJournalEntry('user-1', invoice)
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
const input = mockedCreateEntry.mock.calls[0][2]
// Should have 3 lines: 1510 debit, 3001 credit, 2611 credit
expect(input.lines).toHaveLength(3)
@@ -207,10 +207,10 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
invoice.vat_amount = 198
invoice.total = 1198
await createInvoiceJournalEntry('user-1', invoice)
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
const input = mockedCreateEntry.mock.calls[0][2]
// Should have 5 lines: 1510, 3001(25%), 2611(25%), 3002(12%), 2621(12%)
expect(input.lines).toHaveLength(5)
@@ -248,10 +248,10 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
],
})
await createInvoiceJournalEntry('user-1', invoice)
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
const input = mockedCreateEntry.mock.calls[0][2]
// Should have 2 lines: 1510 debit, 3308 credit (no VAT)
expect(input.lines).toHaveLength(2)
@@ -285,9 +285,9 @@ describe('createInvoiceJournalEntry — per-line VAT', () => {
invoice.vat_amount = 378
invoice.total = 2378
await createInvoiceJournalEntry('user-1', invoice)
await createInvoiceJournalEntry(null as never, 'user-1', invoice)
const input = mockedCreateEntry.mock.calls[0][1]
const input = mockedCreateEntry.mock.calls[0][2]
const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
@@ -315,10 +315,10 @@ describe('createCreditNoteJournalEntry — per-line VAT', () => {
],
})
await createCreditNoteJournalEntry('user-1', creditNote)
await createCreditNoteJournalEntry(null as never, 'user-1', creditNote)
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
const input = mockedCreateEntry.mock.calls[0][2]
// Revenue and VAT lines should be debits (reversed)
const debit3001 = input.lines.find((l) => l.account_number === '3001')
@@ -363,10 +363,10 @@ describe('createInvoiceCashEntry — per-line VAT', () => {
],
})
await createInvoiceCashEntry('user-1', invoice, '2024-07-01')
await createInvoiceCashEntry(null as never, 'user-1', invoice, '2024-07-01')
expect(mockedCreateEntry).toHaveBeenCalledOnce()
const input = mockedCreateEntry.mock.calls[0][1]
const input = mockedCreateEntry.mock.calls[0][2]
// Debit 1930 (bank account) instead of 1510
const debit1930 = input.lines.find((l) => l.account_number === '1930')
@@ -3,9 +3,6 @@ import { createMockSupabase, makeTransaction } from '@/tests/helpers'
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue(mockSupabase),
}))
// Mock booking-templates (needed by evaluateMappingRules)
vi.mock('../booking-templates', () => ({
@@ -24,7 +21,7 @@ describe('mapping-engine', () => {
mockResult({ data: null, error: null })
await saveUserMappingRule('user-1', 'ICA Maxi', '5410', '1930', false)
await saveUserMappingRule(mockSupabase as never, 'user-1', 'ICA Maxi', '5410', '1930', false)
// Verify insert was called via supabase.from().insert()
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
@@ -36,6 +33,7 @@ describe('mapping-engine', () => {
mockResult({ data: null, error: null })
await saveUserMappingRule(
mockSupabase as never,
'user-1',
'Restaurant XYZ',
'6071',
@@ -56,7 +54,7 @@ describe('mapping-engine', () => {
// Should not throw
await expect(
saveUserMappingRule('user-1', 'ICA Maxi', '5410', '1930', false)
saveUserMappingRule(mockSupabase as never, 'user-1', 'ICA Maxi', '5410', '1930', false)
).resolves.toBeUndefined()
})
@@ -66,7 +64,7 @@ describe('mapping-engine', () => {
mockResult({ data: null, error: null })
// Merchant name with regex special chars
await saveUserMappingRule('user-1', 'Test (Pty) Ltd.', '5410', '1930', false)
await saveUserMappingRule(mockSupabase as never, 'user-1', 'Test (Pty) Ltd.', '5410', '1930', false)
expect(mockSupabase.from).toHaveBeenCalledWith('mapping_rules')
})
@@ -79,7 +77,7 @@ describe('mapping-engine', () => {
const tx = makeTransaction({ amount: -100, merchant_name: 'Unknown' })
mockResult({ data: [], error: null })
const result = await evaluateMappingRules('user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('6991')
expect(result.credit_account).toBe('1930')
@@ -131,7 +129,7 @@ describe('mapping-engine', () => {
error: null,
})
const result = await evaluateMappingRules('user-1', tx)
const result = await evaluateMappingRules(mockSupabase as never, 'user-1', tx)
expect(result.debit_account).toBe('5410')
expect(result.credit_account).toBe('1930')
@@ -1,233 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { makeTransaction, createMockSupabase } from '@/tests/helpers'
import { BOOKING_TEMPLATES } from '../booking-templates'
import { makeTransaction } from '@/tests/helpers'
import { extensionRegistry } from '@/lib/extensions/registry'
// Mock server-only (no-op in tests)
vi.mock('server-only', () => ({}))
// Mock OpenAI Embeddings
vi.mock('@langchain/openai', () => {
class MockOpenAIEmbeddings {
embedQuery = vi.fn().mockResolvedValue(new Array(1536).fill(0.1))
embedDocuments = vi.fn().mockImplementation((texts: string[]) =>
Promise.resolve(texts.map(() => new Array(1536).fill(0.1)))
)
}
return { OpenAIEmbeddings: MockOpenAIEmbeddings }
})
// Mock Supabase
const { supabase: mockSupabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn().mockResolvedValue(mockSupabase),
// Mock the extension registry
vi.mock('@/lib/extensions/registry', () => ({
extensionRegistry: {
get: vi.fn(),
},
}))
describe('template-embeddings', () => {
describe('template-embeddings facade', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildEmbeddingText', () => {
it('includes all relevant fields for a template', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
describe('findSimilarTemplates', () => {
it('delegates to ai-categorization extension service when available', async () => {
const mockResults = [{ template: { id: 'test' }, confidence: 0.9 }]
const mockFindSimilar = vi.fn().mockResolvedValue(mockResults)
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
vi.mocked(extensionRegistry.get).mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: { findSimilarTemplates: mockFindSimilar },
})
// Should include Swedish and English name
expect(text).toContain('Lokalhyra')
expect(text).toContain('Office rent')
const { findSimilarTemplates } = await import('../template-embeddings')
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
const results = await findSimilarTemplates(tx, 'enskild_firma')
// Should include description
expect(text).toContain(template.description_sv)
// Should include keywords
expect(text).toContain('hyra')
expect(text).toContain('lokal')
// Should include group
expect(text).toContain('premises')
// Should include direction
expect(text).toContain('utgift')
// Should include accounts
expect(text).toContain('5010')
expect(text).toContain('1930')
expect(mockFindSimilar).toHaveBeenCalledWith(tx, 'enskild_firma', undefined, undefined)
expect(results).toEqual(mockResults)
})
it('includes VAT treatment when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
it('falls back to keyword matching when extension not loaded', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
const { findSimilarTemplates } = await import('../template-embeddings')
const tx = makeTransaction({ description: 'SPOTIFY', amount: -109 })
const results = await findSimilarTemplates(tx)
expect(text).toContain('standard_25')
expect(text).toContain('25%')
})
it('includes special rules when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_rent')!
const text = buildEmbeddingText(template)
expect(text).toContain(template.special_rules_sv!)
})
it('includes MCC codes when present', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.id === 'premises_electricity')!
const text = buildEmbeddingText(template)
expect(text).toContain('4900')
})
it('includes deductibility note for non-full deductibility', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
const template = BOOKING_TEMPLATES.find((t) => t.deductibility === 'non_deductible')!
const text = buildEmbeddingText(template)
expect(text).toContain('non_deductible')
})
it('generates text for all 45 templates without error', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
for (const template of BOOKING_TEMPLATES) {
const text = buildEmbeddingText(template)
expect(text.length).toBeGreaterThan(10)
}
// Should return keyword-based matches (may be empty for generic text)
expect(Array.isArray(results)).toBe(true)
})
})
describe('buildTransactionQueryText', () => {
it('combines description, merchant, and direction', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
describe('seedAllTemplateEmbeddings', () => {
it('delegates to extension service when available', async () => {
const mockSeed = vi.fn().mockResolvedValue({ seeded: 48, errors: [] })
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
mcc_code: 5815,
vi.mocked(extensionRegistry.get).mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: { seedAllTemplateEmbeddings: mockSeed },
})
const text = buildTransactionQueryText(tx)
const { seedAllTemplateEmbeddings } = await import('../template-embeddings')
const result = await seedAllTemplateEmbeddings()
expect(text).toContain('SPOTIFY PREMIUM')
expect(text).toContain('Spotify')
expect(text).toContain('MCC 5815')
expect(text).toContain('utgift')
expect(mockSeed).toHaveBeenCalled()
expect(result).toEqual({ seeded: 48, errors: [] })
})
it('marks positive amounts as income', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
it('returns error when extension not loaded', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
const tx = makeTransaction({
description: 'Inbetalning',
amount: 5000,
})
const { seedAllTemplateEmbeddings } = await import('../template-embeddings')
const result = await seedAllTemplateEmbeddings()
const text = buildTransactionQueryText(tx)
expect(text).toContain('intäkt')
})
it('handles null merchant_name and mcc_code', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'Some payment',
merchant_name: null,
mcc_code: null,
amount: -100,
})
const text = buildTransactionQueryText(tx)
expect(text).toContain('Some payment')
expect(text).toContain('utgift')
expect(text).not.toContain('MCC')
})
it('prepends user description when provided', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SWE REST 4521 STHLM',
merchant_name: 'Unknown',
amount: -450,
})
const text = buildTransactionQueryText(tx, 'business lunch with client')
// User description should appear first
expect(text.indexOf('business lunch with client')).toBe(0)
// Transaction data should still be present
expect(text).toContain('SWE REST 4521 STHLM')
expect(text).toContain('Unknown')
expect(text).toContain('utgift')
})
it('behaves identically when userDescription is undefined', async () => {
const { buildTransactionQueryText } = await import('../template-embeddings')
const tx = makeTransaction({
description: 'SPOTIFY PREMIUM',
merchant_name: 'Spotify',
amount: -109,
})
const withoutDesc = buildTransactionQueryText(tx)
const withUndefined = buildTransactionQueryText(tx, undefined)
expect(withoutDesc).toBe(withUndefined)
expect(result.seeded).toBe(0)
expect(result.errors).toHaveLength(1)
})
})
describe('getSchemaVersion', () => {
it('returns a consistent hash string', async () => {
it('delegates to extension service when available', async () => {
const mockVersion = vi.fn().mockResolvedValue('abc123def456')
vi.mocked(extensionRegistry.get).mockReturnValue({
id: 'ai-categorization',
name: 'AI',
version: '1.0.0',
services: { getSchemaVersion: mockVersion },
})
const { getSchemaVersion } = await import('../template-embeddings')
const result = await getSchemaVersion()
const v1 = getSchemaVersion()
const v2 = getSchemaVersion()
expect(v1).toBe(v2)
expect(v1).toHaveLength(12)
expect(v1).toMatch(/^[a-f0-9]+$/)
})
})
describe('findSimilarTemplates', () => {
it('returns empty array on RPC error (graceful fallback)', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
// Mock staleness check
mockResult({ data: { schema_version: 'test' }, error: null })
const tx = makeTransaction({
description: 'SPOTIFY',
amount: -109,
})
// The mock will return error for the RPC call
mockResult({ data: null, error: { message: 'RPC failed' } })
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
expect(result).toBe('abc123def456')
})
it('returns empty array when no embeddings exist', async () => {
const { findSimilarTemplates } = await import('../template-embeddings')
it('returns "none" when extension not loaded', async () => {
vi.mocked(extensionRegistry.get).mockReturnValue(undefined)
mockResult({ data: [], error: null })
const { getSchemaVersion } = await import('../template-embeddings')
const result = await getSchemaVersion()
const tx = makeTransaction({
description: 'Random purchase',
amount: -50,
})
const results = await findSimilarTemplates(tx)
expect(results).toEqual([])
expect(result).toBe('none')
})
})
})
+11 -10
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import type {
CreateJournalEntryInput,
@@ -34,11 +34,11 @@ export function validateBalance(lines: CreateJournalEntryLineInput[]): {
* Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
*/
export async function getNextVoucherNumber(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
series: string = 'A'
): Promise<number> {
const supabase = await createClient()
const { data, error } = await supabase.rpc('next_voucher_number', {
p_user_id: userId,
@@ -57,7 +57,7 @@ export async function getNextVoucherNumber(
* Resolve account IDs from account numbers for a user
*/
async function resolveAccountIds(
supabase: Awaited<ReturnType<typeof createClient>>,
supabase: SupabaseClient,
userId: string,
lines: CreateJournalEntryLineInput[]
): Promise<Map<string, string>> {
@@ -85,10 +85,10 @@ async function resolveAccountIds(
* Find the fiscal period for a given date
*/
export async function findFiscalPeriod(
supabase: SupabaseClient,
userId: string,
date: string
): Promise<string | null> {
const supabase = await createClient()
// Overlapping periods are prevented by a DB exclusion constraint
// (migration 042). limit(1) is kept as a defensive measure.
@@ -140,6 +140,7 @@ function buildLineInserts(
* The entry stays in 'draft' status until commitEntry() is called.
*/
export async function createDraftEntry(
supabase: SupabaseClient,
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
@@ -151,8 +152,6 @@ export async function createDraftEntry(
)
}
const supabase = await createClient()
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
@@ -211,10 +210,10 @@ export async function createDraftEntry(
* Triggers balance validation and sets committed_at via DB triggers
*/
export async function commitEntry(
supabase: SupabaseClient,
userId: string,
entryId: string
): Promise<JournalEntry> {
const supabase = await createClient()
// Fetch the draft entry
const { data: entry, error: fetchError } = await supabase
@@ -231,6 +230,7 @@ export async function commitEntry(
// Assign voucher number
const voucherNumber = await getNextVoucherNumber(
supabase,
userId,
entry.fiscal_period_id,
entry.voucher_series || 'A'
@@ -273,6 +273,7 @@ export async function commitEntry(
* Validates balance, resolves account IDs, assigns voucher number, inserts atomically.
*/
export async function createJournalEntry(
supabase: SupabaseClient,
userId: string,
input: CreateJournalEntryInput
): Promise<JournalEntry> {
@@ -284,13 +285,12 @@ export async function createJournalEntry(
)
}
const supabase = await createClient()
// Resolve account IDs
const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
// Get next voucher number
const voucherNumber = await getNextVoucherNumber(
supabase,
userId,
input.fiscal_period_id,
input.voucher_series || 'A'
@@ -367,10 +367,10 @@ export async function createJournalEntry(
* Sets reversed_by_id/reverses_id links for compliance tracking
*/
export async function reverseEntry(
supabase: SupabaseClient,
userId: string,
entryId: string
): Promise<JournalEntry> {
const supabase = await createClient()
// Fetch original entry with lines
const { data: original, error } = await supabase
@@ -408,6 +408,7 @@ export async function reverseEntry(
// Get voucher number for the reversal
const voucherNumber = await getNextVoucherNumber(
supabase,
userId,
original.fiscal_period_id,
original.voucher_series || 'A'
@@ -60,6 +60,7 @@ describe('Supplier Invoice Core Handler', () => {
})
expect(mockCreateEntry).toHaveBeenCalledWith(
expect.anything(),
'user-1',
invoice,
expect.any(Array),
@@ -54,6 +54,7 @@ async function handleSupplierInvoiceConfirmed(
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
supabase,
userId,
supplierInvoice,
items as SupplierInvoiceItem[],
+13 -8
View File
@@ -2,6 +2,7 @@ import { createJournalEntry, findFiscalPeriod } from './engine'
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -112,11 +113,12 @@ function generatePerRateLines(
* Credit 3305 Försäljning tjänst Export [subtotal]
*/
export async function createInvoiceJournalEntry(
supabase: SupabaseClient,
userId: string,
invoice: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
@@ -167,7 +169,7 @@ export async function createInvoiceJournalEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
@@ -177,11 +179,12 @@ export async function createInvoiceJournalEntry(
* Credit 1510 Kundfordringar [total]
*/
export async function createInvoicePaymentJournalEntry(
supabase: SupabaseClient,
userId: string,
invoice: Invoice,
paymentDate: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -211,7 +214,7 @@ export async function createInvoicePaymentJournalEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
@@ -223,11 +226,12 @@ export async function createInvoicePaymentJournalEntry(
* Credit 1510 Kundfordringar [total]
*/
export async function createCreditNoteJournalEntry(
supabase: SupabaseClient,
userId: string,
creditNote: Invoice,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
@@ -289,7 +293,7 @@ export async function createCreditNoteJournalEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
@@ -301,12 +305,13 @@ export async function createCreditNoteJournalEntry(
* Credit 26xx Utgående moms [vat per rate] (if applicable)
*/
export async function createInvoiceCashEntry(
supabase: SupabaseClient,
userId: string,
invoice: Invoice,
paymentDate: string,
entityType: EntityType = 'enskild_firma'
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -355,7 +360,7 @@ export async function createInvoiceCashEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
+3 -5
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import {
generateInputVatLine,
generateReverseChargeLines,
@@ -29,12 +29,11 @@ const CAPITALIZATION_THRESHOLD = 29400
* 5. Risk fallback (priority 100) → 2013 (private)
*/
export async function evaluateMappingRules(
supabase: SupabaseClient,
userId: string,
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult> {
const supabase = await createClient()
// Fetch all active rules (user-specific + system defaults), ordered by priority
const { data: rules, error } = await supabase
.from('mapping_rules')
@@ -239,6 +238,7 @@ function getDefaultResult(transaction: Transaction): MappingResult {
* (latest description wins).
*/
export async function saveUserMappingRule(
supabase: SupabaseClient,
userId: string,
merchantName: string,
debitAccount: string,
@@ -247,8 +247,6 @@ export async function saveUserMappingRule(
userDescription?: string,
templateId?: string
): Promise<void> {
const supabase = await createClient()
// Escape special regex characters in merchant name
const escapedMerchant = merchantName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+13 -8
View File
@@ -1,6 +1,7 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { generateReverseChargeLines } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -26,12 +27,13 @@ const log = createLogger('supplier-invoice-entries')
* Credit 2440 Leverantörsskulder [total]
*/
export async function createSupplierInvoiceRegistrationEntry(
supabase: SupabaseClient,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, invoice.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
return null
@@ -92,7 +94,7 @@ export async function createSupplierInvoiceRegistrationEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
@@ -107,13 +109,14 @@ export async function createSupplierInvoiceRegistrationEntry(
* Credit/Debit 3960/7960 [difference]
*/
export async function createSupplierInvoicePaymentEntry(
supabase: SupabaseClient,
userId: string,
invoice: SupplierInvoice,
paymentAmount: number,
paymentDate: string,
exchangeRateDifference?: number
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -187,7 +190,7 @@ export async function createSupplierInvoicePaymentEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
@@ -199,13 +202,14 @@ export async function createSupplierInvoicePaymentEntry(
* Credit 1930 Företagskonto [total incl VAT]
*/
export async function createSupplierInvoiceCashEntry(
supabase: SupabaseClient,
userId: string,
invoice: SupplierInvoice,
items: SupplierInvoiceItem[],
paymentDate: string,
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, paymentDate)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for payment date:', paymentDate)
return null
@@ -263,7 +267,7 @@ export async function createSupplierInvoiceCashEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
@@ -274,12 +278,13 @@ export async function createSupplierInvoiceCashEntry(
* Credit 2641 Ingående moms [total VAT]
*/
export async function createSupplierCreditNoteEntry(
supabase: SupabaseClient,
userId: string,
creditNote: SupplierInvoice,
items: SupplierInvoiceItem[],
supplierType: string
): Promise<JournalEntry | null> {
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, creditNote.invoice_date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
return null
@@ -350,7 +355,7 @@ export async function createSupplierCreditNoteEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
+37 -239
View File
@@ -1,265 +1,63 @@
/**
* Template Embeddings Module
* Template Embeddings — Core Facade
*
* SERVER-ONLY: Uses OpenAI embeddings and Supabase service client.
* Delegates to the ai-categorization extension's service when available.
* Returns empty/no-op results when the extension is not loaded,
* allowing core to compile without @langchain/openai.
*
* Provides semantic search over booking templates using pgvector.
* Templates are pre-embedded and stored in the database. Transaction
* text is embedded at query time and compared via cosine similarity.
* Pure helper functions (buildEmbeddingText, buildTransactionQueryText,
* getSchemaVersion) are re-exported from the extension for tests that
* depend on them.
*/
import 'server-only'
import { OpenAIEmbeddings } from '@langchain/openai'
import { extensionRegistry } from '@/lib/extensions/registry'
import {
BOOKING_TEMPLATES,
getTemplateById,
type BookingTemplate,
findMatchingTemplates,
type TemplateMatch,
} from './booking-templates'
import type { Transaction, EntityType } from '@/types'
import { createHash } from 'crypto'
// ============================================================
// Constants
// ============================================================
export const EMBEDDING_MODEL = 'text-embedding-3-small'
const EMBEDDING_LOGIC_VERSION = '1'
const MATCH_COUNT = 5
const MATCH_THRESHOLD = 0.5
/**
* Schema version is a hash of the model + embedding logic version.
* Bump EMBEDDING_LOGIC_VERSION when buildEmbeddingText changes.
* Find similar templates via embedding search.
* Falls back to keyword matching when the AI extension is not loaded.
*/
export function getSchemaVersion(): string {
return createHash('sha256')
.update(`${EMBEDDING_MODEL}:${EMBEDDING_LOGIC_VERSION}`)
.digest('hex')
.slice(0, 12)
}
// ============================================================
// Embedding Text Builders
// ============================================================
/**
* Build a rich text representation of a template for embedding.
* Includes all semantically relevant fields.
*/
export function buildEmbeddingText(template: BookingTemplate): string {
const parts: string[] = []
parts.push(`${template.name_sv} (${template.name_en})`)
parts.push(template.description_sv)
if (template.keywords.length > 0) {
parts.push(`Nyckelord: ${template.keywords.join(', ')}`)
}
parts.push(`Grupp: ${template.group}`)
parts.push(`Typ: ${template.direction === 'expense' ? 'utgift' : template.direction === 'income' ? 'intäkt' : 'överföring'}`)
parts.push(`Konton: ${template.debit_account} (debet) / ${template.credit_account} (kredit)`)
if (template.vat_treatment) {
parts.push(`Moms: ${template.vat_treatment} (${template.vat_rate * 100}%)`)
}
if (template.special_rules_sv) {
parts.push(`Regler: ${template.special_rules_sv}`)
}
if (template.mcc_codes.length > 0) {
parts.push(`MCC-koder: ${template.mcc_codes.join(', ')}`)
}
if (template.deductibility !== 'full') {
parts.push(`Avdragsrätt: ${template.deductibility}`)
}
return parts.join('. ')
}
/**
* Build query text from a transaction for embedding search.
* When userDescription is provided, it is prepended so it dominates
* the semantic search (user intent > raw bank text).
*/
export function buildTransactionQueryText(
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount?: number,
userDescription?: string
): string {
const parts: string[] = []
if (userDescription) {
parts.push(userDescription)
): Promise<TemplateMatch[]> {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.findSimilarTemplates) {
return aiExt.services.findSimilarTemplates(transaction, entityType, matchCount, userDescription)
}
if (transaction.description) {
parts.push(transaction.description)
}
if (transaction.merchant_name) {
parts.push(transaction.merchant_name)
}
if (transaction.mcc_code) {
parts.push(`MCC ${transaction.mcc_code}`)
}
parts.push(transaction.amount < 0 ? 'utgift' : 'intäkt')
return parts.join(' — ')
// Graceful fallback: keyword-based matching (no AI deps)
return findMatchingTemplates(transaction, entityType)
}
// ============================================================
// Embeddings Client
// ============================================================
let embeddingsInstance: OpenAIEmbeddings | null = null
function getEmbeddingsClient(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
// ============================================================
// Seed All Template Embeddings
// ============================================================
/**
* Seed all template embeddings in the database.
* No-op when the AI extension is not loaded.
*/
export async function seedAllTemplateEmbeddings(): Promise<{
seeded: number
errors: string[]
}> {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
const schemaVersion = getSchemaVersion()
const errors: string[] = []
// Build texts for all templates
const texts = BOOKING_TEMPLATES.map((t) => buildEmbeddingText(t))
// Batch embed all texts
let vectors: number[][]
try {
vectors = await embeddings.embedDocuments(texts)
} catch (error) {
return { seeded: 0, errors: [`Embedding generation failed: ${error}`] }
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.seedAllTemplateEmbeddings) {
return aiExt.services.seedAllTemplateEmbeddings()
}
// Upsert each template embedding
let seeded = 0
for (let i = 0; i < BOOKING_TEMPLATES.length; i++) {
const template = BOOKING_TEMPLATES[i]
const { error } = await supabase
.from('booking_template_embeddings')
.upsert(
{
template_id: template.id,
embedding: JSON.stringify(vectors[i]),
embedding_text: texts[i],
model: EMBEDDING_MODEL,
schema_version: schemaVersion,
},
{ onConflict: 'template_id' }
)
if (error) {
errors.push(`Failed to upsert ${template.id}: ${error.message}`)
} else {
seeded++
}
}
return { seeded, errors }
return { seeded: 0, errors: ['ai-categorization extension not loaded'] }
}
// ============================================================
// Find Similar Templates (Semantic Search)
// ============================================================
let stalenessWarned = false
export async function findSimilarTemplates(
transaction: Transaction,
entityType?: EntityType,
matchCount: number = MATCH_COUNT,
userDescription?: string
): Promise<TemplateMatch[]> {
try {
const { createServiceClient } = await import('@/lib/supabase/server')
const supabase = await createServiceClient()
const embeddings = getEmbeddingsClient()
// Check schema version staleness on first call
if (!stalenessWarned) {
const { data: sample } = await supabase
.from('booking_template_embeddings')
.select('schema_version')
.limit(1)
.single()
if (sample && sample.schema_version !== getSchemaVersion()) {
console.warn(
`[template-embeddings] Schema version mismatch: DB has "${sample.schema_version}", current is "${getSchemaVersion()}". Re-seed embeddings.`
)
}
stalenessWarned = true
}
// Embed the transaction query text
const queryText = buildTransactionQueryText(transaction, userDescription)
const queryVector = await embeddings.embedQuery(queryText)
// Request extra results to account for post-filtering
const requestCount = matchCount + 10
const { data, error } = await supabase.rpc('match_booking_templates', {
query_embedding: JSON.stringify(queryVector),
match_count: requestCount,
match_threshold: MATCH_THRESHOLD,
})
if (error || !data) {
console.error('[template-embeddings] RPC error:', error)
return []
}
// Map RPC results to TemplateMatch[], filtering by entity type and direction
const isExpense = transaction.amount < 0
const isIncome = transaction.amount > 0
const results: TemplateMatch[] = []
for (const row of data as { template_id: string; similarity: number }[]) {
const template = getTemplateById(row.template_id)
if (!template) continue
// Filter by entity applicability
if (entityType && template.entity_applicability !== 'all' && template.entity_applicability !== entityType) {
continue
}
// Filter by direction
if (template.direction === 'expense' && !isExpense) continue
if (template.direction === 'income' && !isIncome) continue
results.push({
template,
confidence: Math.round(row.similarity * 100) / 100,
})
if (results.length >= matchCount) break
}
return results
} catch (error) {
console.error('[template-embeddings] findSimilarTemplates failed:', error)
return []
/**
* Get the current schema version hash.
* Returns 'none' when the AI extension is not loaded.
*/
export async function getSchemaVersion(): Promise<string> {
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.getSchemaVersion) {
return aiExt.services.getSchemaVersion()
}
return 'none'
}
+4 -2
View File
@@ -1,6 +1,7 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { generateInputVatLine, generateReverseChargeLines, extractNetAmount, extractVatAmount } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
@@ -38,6 +39,7 @@ const log = createLogger('transaction-entries')
* Credit 3xxx Revenue account [total]
*/
export async function createTransactionJournalEntry(
supabase: SupabaseClient,
userId: string,
transaction: Transaction,
mappingResult: MappingResult
@@ -48,7 +50,7 @@ export async function createTransactionJournalEntry(
)
}
const fiscalPeriodId = await findFiscalPeriod(userId, transaction.date)
const fiscalPeriodId = await findFiscalPeriod(supabase, userId, transaction.date)
if (!fiscalPeriodId) {
log.warn('No open fiscal period found for transaction date:', transaction.date)
return null
@@ -183,7 +185,7 @@ export async function createTransactionJournalEntry(
lines,
}
return createJournalEntry(userId, input)
return createJournalEntry(supabase, userId, input)
}
/**
+4 -4
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { AuditLogEntry, AuditAction } from '@/types'
/**
@@ -22,10 +22,10 @@ export interface AuditLogFilters {
* Get paginated audit log entries for a user
*/
export async function getAuditLog(
supabase: SupabaseClient,
userId: string,
filters: AuditLogFilters = {}
): Promise<{ data: AuditLogEntry[]; count: number }> {
const supabase = await createClient()
const page = filters.page ?? 1
const pageSize = filters.pageSize ?? 50
const offset = (page - 1) * pageSize
@@ -69,11 +69,11 @@ export async function getAuditLog(
* Get full history of a single record (all mutations)
*/
export async function getEntityHistory(
supabase: SupabaseClient,
userId: string,
tableName: string,
recordId: string
): Promise<AuditLogEntry[]> {
const supabase = await createClient()
const { data, error } = await supabase
.from('audit_log')
@@ -95,10 +95,10 @@ export async function getEntityHistory(
* original → storno (reversal) → corrected entry
*/
export async function getCorrectionChain(
supabase: SupabaseClient,
userId: string,
journalEntryId: string
): Promise<AuditLogEntry[]> {
const supabase = await createClient()
// First, find the entry and its linked entries
const { data: entry, error: entryError } = await supabase
@@ -29,10 +29,6 @@ function makeClient() {
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { lockPeriod, closePeriod, createNextPeriod } from '../period-service'
beforeEach(() => {
@@ -55,7 +51,8 @@ describe('lockPeriod', () => {
const handler = vi.fn()
eventBus.on('period.locked', handler)
const result = await lockPeriod('user-1', 'fp-1')
const supabase = makeClient()
const result = await lockPeriod(supabase as never, 'user-1', 'fp-1')
expect(result.locked_at).toBeTruthy()
expect(handler).toHaveBeenCalledOnce()
@@ -70,7 +67,8 @@ describe('lockPeriod', () => {
results = [{ data: period, error: null }]
await expect(lockPeriod('user-1', 'fp-1')).rejects.toThrow('already locked')
const supabase = makeClient()
await expect(lockPeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow('already locked')
})
})
@@ -89,7 +87,8 @@ describe('closePeriod', () => {
{ data: closedPeriod, error: null },
]
const result = await closePeriod('user-1', 'fp-1')
const supabase = makeClient()
const result = await closePeriod(supabase as never, 'user-1', 'fp-1')
expect(result.is_closed).toBe(true)
})
@@ -103,7 +102,8 @@ describe('closePeriod', () => {
results = [{ data: period, error: null }]
await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow('must be locked')
const supabase = makeClient()
await expect(closePeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow('must be locked')
})
it('rejects if no closing_entry_id', async () => {
@@ -116,7 +116,8 @@ describe('closePeriod', () => {
results = [{ data: period, error: null }]
await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow(
const supabase = makeClient()
await expect(closePeriod(supabase as never, 'user-1', 'fp-1')).rejects.toThrow(
'Year-end closing must be executed'
)
})
@@ -144,7 +145,8 @@ describe('createNextPeriod', () => {
{ data: nextPeriod, error: null }, // insert
]
const result = await createNextPeriod('user-1', 'fp-2024')
const supabase = makeClient()
const result = await createNextPeriod(supabase as never, 'user-1', 'fp-2024')
expect(result.period_start).toBe('2025-01-01')
expect(result.period_end).toBe('2025-12-31')
expect(result.previous_period_id).toBe('fp-2024')
@@ -171,7 +173,8 @@ describe('createNextPeriod', () => {
{ data: nextPeriod, error: null },
]
const result = await createNextPeriod('user-1', 'fp-2024')
const supabase = makeClient()
const result = await createNextPeriod(supabase as never, 'user-1', 'fp-2024')
expect(result.period_start).toBe('2024-07-01')
expect(result.period_end).toBe('2025-06-30')
})
@@ -26,10 +26,6 @@ function makeClient() {
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
vi.mock('@/lib/bookkeeping/engine', () => ({
validateBalance: vi.fn().mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 }),
getNextVoucherNumber: vi.fn(async () => ++resultIdx), // just increment
@@ -100,14 +96,16 @@ describe('correctEntry', () => {
it('creates reversal with swapped debit/credit lines', async () => {
setupResults()
const result = await correctEntry('user-1', 'orig-1', correctedLines)
const supabase = makeClient()
const result = await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
expect(result.reversal).toBeDefined()
expect(result.reversal.reverses_id).toBe('orig-1')
})
it('links original ↔ reversal ↔ corrected via IDs', async () => {
setupResults()
const result = await correctEntry('user-1', 'orig-1', correctedLines)
const supabase = makeClient()
const result = await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
expect(result.reversal.id).toBe('reversal-1')
expect(result.corrected.id).toBe('corrected-1')
expect(result.corrected.correction_of_id).toBe('orig-1')
@@ -120,8 +118,9 @@ describe('correctEntry', () => {
totalCredit: 1000,
})
const supabase = makeClient()
await expect(
correctEntry('user-1', 'orig-1', [
correctEntry(supabase as never, 'user-1', 'orig-1', [
{ account_number: '5420', debit_amount: 1200, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
])
@@ -134,7 +133,8 @@ describe('correctEntry', () => {
const handler = vi.fn()
eventBus.on('journal_entry.corrected', handler)
await correctEntry('user-1', 'orig-1', correctedLines)
const supabase = makeClient()
await correctEntry(supabase as never, 'user-1', 'orig-1', correctedLines)
expect(handler).toHaveBeenCalledOnce()
expect(handler).toHaveBeenCalledWith(
@@ -27,10 +27,6 @@ function makeClient() {
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
@@ -80,7 +76,8 @@ describe('validateYearEndReadiness', () => {
totalCredit: 0,
} as never)
const result = await validateYearEndReadiness('user-1', 'fp-1')
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
})
@@ -101,7 +98,8 @@ describe('validateYearEndReadiness', () => {
totalCredit: 9500,
} as never)
const result = await validateYearEndReadiness('user-1', 'fp-1')
const supabase = makeClient()
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
expect(result.ready).toBe(false)
expect(result.trialBalanceBalanced).toBe(false)
expect(result.errors.some((e: string) => e.includes('Trial balance'))).toBe(true)
@@ -110,17 +108,15 @@ describe('validateYearEndReadiness', () => {
it('warns on voucher gaps', async () => {
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
// Override makeClient to return gaps from rpc
const { createClient } = await import('@/lib/supabase/server')
// Create a client with custom rpc that returns gap data
const builder = makeBuilder()
const client = {
const supabase = {
from: vi.fn().mockImplementation(() => builder),
rpc: vi.fn().mockResolvedValue({
data: [{ gap_start: 5, gap_end: 7 }],
error: null,
}),
}
vi.mocked(createClient).mockResolvedValue(client as never)
resultIdx = 0
results = [
@@ -136,7 +132,7 @@ describe('validateYearEndReadiness', () => {
totalCredit: 10000,
} as never)
const result = await validateYearEndReadiness('user-1', 'fp-1')
const result = await validateYearEndReadiness(supabase as never, 'user-1', 'fp-1')
expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
expect(result.voucherGaps).toHaveLength(1)
})
@@ -164,7 +160,8 @@ describe('previewYearEndClosing', () => {
totalCredit: 500000,
} as never)
const preview = await previewYearEndClosing('user-1', 'fp-1')
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'user-1', 'fp-1')
expect(preview.netResult).toBe(150000)
expect(preview.closingAccount).toBe('2099')
@@ -189,7 +186,8 @@ describe('previewYearEndClosing', () => {
totalCredit: 100000,
} as never)
const preview = await previewYearEndClosing('user-1', 'fp-1')
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'user-1', 'fp-1')
expect(preview.closingAccount).toBe('2010')
expect(preview.closingAccountName).toBe('Eget kapital')
+5 -5
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { FiscalPeriod, PeriodStatus } from '@/types'
@@ -8,10 +8,10 @@ import type { FiscalPeriod, PeriodStatus } from '@/types'
* Requires: period exists, belongs to user, not already locked/closed.
*/
export async function lockPeriod(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
const supabase = await createClient()
// Fetch period
const { data: period, error: fetchError } = await supabase
@@ -60,10 +60,10 @@ export async function lockPeriod(
* Requires: period is locked AND closing_entry_id is set (year-end must run first).
*/
export async function closePeriod(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<FiscalPeriod> {
const supabase = await createClient()
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
@@ -112,10 +112,10 @@ export async function closePeriod(
* Sets previous_period_id for chain validation.
*/
export async function createNextPeriod(
supabase: SupabaseClient,
userId: string,
currentPeriodId: string
): Promise<FiscalPeriod> {
const supabase = await createClient()
const { data: current, error: fetchError } = await supabase
.from('fiscal_periods')
@@ -198,10 +198,10 @@ export async function createNextPeriod(
* Get status summary for a fiscal period.
*/
export async function getPeriodStatus(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<PeriodStatus> {
const supabase = await createClient()
const { data: period, error: fetchError } = await supabase
.from('fiscal_periods')
+4 -3
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import type {
CreateJournalEntryLineInput,
@@ -23,6 +23,7 @@ import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
* Returns: { reversal, corrected } - the two new entries created
*/
export async function correctEntry(
supabase: SupabaseClient,
userId: string,
originalEntryId: string,
correctedLines: CreateJournalEntryLineInput[]
@@ -35,8 +36,6 @@ export async function correctEntry(
)
}
const supabase = await createClient()
// Fetch original entry with lines
const { data: original, error: fetchError } = await supabase
.from('journal_entries')
@@ -57,6 +56,7 @@ export async function correctEntry(
// ===== Step 1: Create storno (reversal) entry =====
const reversalVoucherNumber = await getNextVoucherNumber(
supabase,
userId,
original.fiscal_period_id,
original.voucher_series || 'A'
@@ -146,6 +146,7 @@ export async function correctEntry(
try {
const correctedVoucherNumber = await getNextVoucherNumber(
supabase,
userId,
original.fiscal_period_id,
original.voucher_series || 'A'
+17 -17
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
@@ -19,10 +19,10 @@ import type {
* Returns blocking errors and informational warnings.
*/
export async function validateYearEndReadiness(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<YearEndValidation> {
const supabase = await createClient()
const errors: string[] = []
const warnings: string[] = []
@@ -84,7 +84,7 @@ export async function validateYearEndReadiness(
}
// Check: trial balance is balanced
const trialBalance = await generateTrialBalance(userId, fiscalPeriodId)
const trialBalance = await generateTrialBalance(supabase, userId, fiscalPeriodId)
const trialBalanceBalanced = trialBalance.isBalanced
if (!trialBalanceBalanced) {
@@ -120,10 +120,10 @@ export async function validateYearEndReadiness(
* Shows the net result, closing account, and the journal entry lines that would be created.
*/
export async function previewYearEndClosing(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<YearEndPreview> {
const supabase = await createClient()
// Get entity type to determine closing account
const { data: settings } = await supabase
@@ -140,11 +140,11 @@ export async function previewYearEndClosing(
: 'Årets resultat'
// Get income statement for net result
const incomeStatement = await generateIncomeStatement(userId, fiscalPeriodId)
const incomeStatement = await generateIncomeStatement(supabase, userId, fiscalPeriodId)
const netResult = incomeStatement.net_result
// Get trial balance for individual account balances in class 3-8
const { rows } = await generateTrialBalance(userId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
const resultAccounts = rows.filter(
(r) => r.account_class >= 3 && r.account_class <= 8
)
@@ -233,17 +233,16 @@ export async function previewYearEndClosing(
* 7. Generate opening balances in next period
*/
export async function executeYearEndClosing(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<YearEndResult> {
// 1. Validate readiness
const validation = await validateYearEndReadiness(userId, fiscalPeriodId)
const validation = await validateYearEndReadiness(supabase, userId, fiscalPeriodId)
if (!validation.ready) {
throw new Error(`Year-end closing not ready: ${validation.errors.join('; ')}`)
}
const supabase = await createClient()
// Fetch the period for dates
const { data: period } = await supabase
.from('fiscal_periods')
@@ -257,14 +256,14 @@ export async function executeYearEndClosing(
}
// 2. Get closing preview
const preview = await previewYearEndClosing(userId, fiscalPeriodId)
const preview = await previewYearEndClosing(supabase, userId, fiscalPeriodId)
if (preview.closingLines.length === 0) {
throw new Error('No result accounts to close — period has no activity')
}
// 3. Create closing entry via the journal engine
const closingEntry = await createJournalEntry(userId, {
const closingEntry = await createJournalEntry(supabase, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: period.period_end,
description: `Årsbokslut ${period.name}`,
@@ -285,16 +284,17 @@ export async function executeYearEndClosing(
}
// 5. Lock the period
await lockPeriod(userId, fiscalPeriodId)
await lockPeriod(supabase, userId, fiscalPeriodId)
// 6. Close the period
await closePeriod(userId, fiscalPeriodId)
await closePeriod(supabase, userId, fiscalPeriodId)
// 7. Create next period
const nextPeriod = await createNextPeriod(userId, fiscalPeriodId)
const nextPeriod = await createNextPeriod(supabase, userId, fiscalPeriodId)
// 8. Generate opening balances in next period
const openingBalanceEntry = await generateOpeningBalances(
supabase,
userId,
fiscalPeriodId,
nextPeriod.id
@@ -330,11 +330,11 @@ export async function executeYearEndClosing(
* The entry must be balanced (total debit openings = total credit openings).
*/
export async function generateOpeningBalances(
supabase: SupabaseClient,
userId: string,
closedPeriodId: string,
nextPeriodId: string
): Promise<JournalEntry> {
const supabase = await createClient()
// Get next period for the entry date
const { data: nextPeriod } = await supabase
@@ -349,7 +349,7 @@ export async function generateOpeningBalances(
}
// Get trial balance of closed period (includes the closing entry)
const { rows } = await generateTrialBalance(userId, closedPeriodId)
const { rows } = await generateTrialBalance(supabase, userId, closedPeriodId)
// Filter to balance sheet accounts (class 1-2) with non-zero closing balance
const balanceSheetAccounts = rows.filter(
@@ -397,7 +397,7 @@ export async function generateOpeningBalances(
}
// Create opening balance entry in next period
const openingEntry = await createJournalEntry(userId, {
const openingEntry = await createJournalEntry(supabase, userId, {
fiscal_period_id: nextPeriodId,
entry_date: nextPeriod.period_start,
description: `Ingående balans ${nextPeriod.name}`,
@@ -43,13 +43,12 @@ function makeClient(storageOverrides: Record<string, unknown> = {}) {
}
}
// Keep createServiceClient mock since ensureDocumentsBucket still uses it
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
createServiceClient: vi.fn(async () => makeClient()),
}))
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
import { createClient } from '@/lib/supabase/server'
beforeEach(() => {
vi.clearAllMocks()
@@ -57,8 +56,6 @@ beforeEach(() => {
_resetBucketVerified()
resultIdx = 0
results = []
// Reset the mock to use default makeClient
vi.mocked(createClient).mockImplementation(async () => makeClient() as never)
})
describe('uploadDocument', () => {
@@ -76,8 +73,9 @@ describe('uploadDocument', () => {
const handler = vi.fn()
eventBus.on('document.uploaded', handler)
const supabase = makeClient()
const buffer = new TextEncoder().encode('test content').buffer
const result = await uploadDocument('user-1', {
const result = await uploadDocument(supabase as never, 'user-1', {
name: 'test.pdf',
buffer: buffer as ArrayBuffer,
type: 'application/pdf',
@@ -115,8 +113,9 @@ describe('createNewVersion', () => {
{ data: newVersion, error: null }, // insert new version
]
const supabase = makeClient()
const buffer = new TextEncoder().encode('new content').buffer
const result = await createNewVersion('user-1', 'doc-1', {
const result = await createNewVersion(supabase as never, 'user-1', 'doc-1', {
name: 'test-v2.pdf',
buffer: buffer as ArrayBuffer,
type: 'application/pdf',
@@ -140,17 +139,15 @@ describe('verifyIntegrity', () => {
{ data: { storage_path: 'docs/test.pdf', sha256_hash: expectedHash }, error: null },
]
// Override createClient to provide matching download content
vi.mocked(createClient).mockImplementation(async () =>
makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob([content]),
error: null,
}),
}) as never
)
// Create a client with matching download content
const supabase = makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob([content]),
error: null,
}),
})
const result = await verifyIntegrity('user-1', 'doc-1')
const result = await verifyIntegrity(supabase as never, 'user-1', 'doc-1')
expect(result.valid).toBe(true)
expect(result.storedHash).toBe(expectedHash)
expect(result.computedHash).toBe(expectedHash)
@@ -161,16 +158,14 @@ describe('verifyIntegrity', () => {
{ data: { storage_path: 'docs/test.pdf', sha256_hash: 'stored-hash-abc' }, error: null },
]
vi.mocked(createClient).mockImplementation(async () =>
makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob(['different content']),
error: null,
}),
}) as never
)
const supabase = makeClient({
download: vi.fn().mockResolvedValue({
data: new Blob(['different content']),
error: null,
}),
})
const result = await verifyIntegrity('user-1', 'doc-1')
const result = await verifyIntegrity(supabase as never, 'user-1', 'doc-1')
expect(result.valid).toBe(false)
expect(result.storedHash).toBe('stored-hash-abc')
expect(result.computedHash).not.toBe('stored-hash-abc')
+6 -5
View File
@@ -1,4 +1,5 @@
import { createClient, createServiceClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events'
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
@@ -50,6 +51,7 @@ export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
* Upload a document and create a record with SHA-256 integrity hash
*/
export async function uploadDocument(
supabase: SupabaseClient,
userId: string,
file: { name: string; buffer: ArrayBuffer; type?: string },
metadata: {
@@ -59,7 +61,6 @@ export async function uploadDocument(
} = {}
): Promise<DocumentAttachment> {
await ensureDocumentsBucket()
const supabase = await createClient()
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
@@ -126,12 +127,12 @@ export async function uploadDocument(
* - Single transaction (insert new + mark old superseded)
*/
export async function createNewVersion(
supabase: SupabaseClient,
userId: string,
originalId: string,
file: { name: string; buffer: ArrayBuffer; type?: string }
): Promise<DocumentAttachment> {
await ensureDocumentsBucket()
const supabase = await createClient()
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
@@ -186,12 +187,12 @@ export async function createNewVersion(
* Link an existing document to a journal entry
*/
export async function linkToJournalEntry(
supabase: SupabaseClient,
userId: string,
documentId: string,
journalEntryId: string,
journalEntryLineId?: string
): Promise<DocumentAttachment> {
const supabase = await createClient()
const { data, error } = await supabase
.from('document_attachments')
@@ -215,10 +216,10 @@ export async function linkToJournalEntry(
* Verify document integrity by re-hashing and comparing
*/
export async function verifyIntegrity(
supabase: SupabaseClient,
userId: string,
documentId: string
): Promise<{ valid: boolean; storedHash: string; computedHash: string }> {
const supabase = await createClient()
// Fetch document record
const { data: doc, error: docError } = await supabase
@@ -26,10 +26,6 @@ function makeClient() {
}
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { getTaxCodeByCode, calculateMomsFromTaxCodes } from '../tax-code-service'
beforeEach(() => {
@@ -50,7 +46,8 @@ describe('getTaxCodeByCode', () => {
results = [{ data: userCode, error: null }]
const result = await getTaxCodeByCode('user-1', 'MP1')
const supabase = makeClient()
const result = await getTaxCodeByCode(supabase as never, 'user-1', 'MP1')
expect(result).not.toBeNull()
expect(result!.user_id).toBe('user-1')
expect(result!.description).toBe('Custom 25% moms')
@@ -59,7 +56,8 @@ describe('getTaxCodeByCode', () => {
it('returns null when code does not exist', async () => {
results = [{ data: null, error: { code: 'PGRST116' } }]
const result = await getTaxCodeByCode('user-1', 'NONEXISTENT')
const supabase = makeClient()
const result = await getTaxCodeByCode(supabase as never, 'user-1', 'NONEXISTENT')
expect(result).toBeNull()
})
})
@@ -96,7 +94,8 @@ describe('calculateMomsFromTaxCodes', () => {
{ data: [mp1, ip1], error: null },
]
const result = await calculateMomsFromTaxCodes('user-1', '2024-01-01', '2024-12-31')
const supabase = makeClient()
const result = await calculateMomsFromTaxCodes(supabase as never, 'user-1', '2024-01-01', '2024-12-31')
expect(result.length).toBeGreaterThan(0)
// Results should be sorted by box
+6 -8
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { TaxCode } from '@/types'
/**
@@ -12,8 +12,7 @@ import type { TaxCode } from '@/types'
/**
* Get all active tax codes for a user (including system codes)
*/
export async function getTaxCodes(userId: string): Promise<TaxCode[]> {
const supabase = await createClient()
export async function getTaxCodes(supabase: SupabaseClient, userId: string): Promise<TaxCode[]> {
const { data, error } = await supabase
.from('tax_codes')
@@ -32,10 +31,10 @@ export async function getTaxCodes(userId: string): Promise<TaxCode[]> {
* Get a single tax code by code string
*/
export async function getTaxCodeByCode(
supabase: SupabaseClient,
userId: string,
code: string
): Promise<TaxCode | null> {
const supabase = await createClient()
// Prefer user-specific code over system code
const { data, error } = await supabase
@@ -72,11 +71,11 @@ export interface MomsBoxResult {
* category-based VAT calculation.
*/
export async function calculateMomsFromTaxCodes(
supabase: SupabaseClient,
userId: string,
periodStart: string,
periodEnd: string
): Promise<MomsBoxResult[]> {
const supabase = await createClient()
// Fetch journal entry lines with tax_code in the period
const { data: lines, error: linesError } = await supabase
@@ -104,7 +103,7 @@ export async function calculateMomsFromTaxCodes(
}
// Fetch all tax codes for lookup
const taxCodes = await getTaxCodes(userId)
const taxCodes = await getTaxCodes(supabase, userId)
const taxCodeMap = new Map<string, TaxCode>()
for (const tc of taxCodes) {
// User codes take precedence over system codes
@@ -154,8 +153,7 @@ export async function calculateMomsFromTaxCodes(
/**
* Seed tax codes for a user by calling the database function
*/
export async function seedTaxCodes(userId: string): Promise<void> {
const supabase = await createClient()
export async function seedTaxCodes(supabase: SupabaseClient, userId: string): Promise<void> {
const { error } = await supabase.rpc('seed_tax_codes_for_user', {
p_user_id: userId,
+10 -113
View File
@@ -1,120 +1,17 @@
import { Resend } from 'resend'
import { createLogger } from '@/lib/logger'
const log = createLogger('email')
// Default sender configuration
// Using a fixed From address with dynamic Reply-To
// From: "Företagsnamn via ERP Base" <your-from@example.com>
// Reply-To: user's company email from company_settings
// Set RESEND_FROM_EMAIL in .env.local to your verified Resend domain
const DEFAULT_FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'noreply@localhost'
// Lazy initialization to avoid errors at build time
let resendClient: Resend | null = null
function getResendClient(): Resend {
if (!resendClient) {
if (!process.env.RESEND_API_KEY) {
throw new Error('RESEND_API_KEY is not configured')
}
resendClient = new Resend(process.env.RESEND_API_KEY)
}
return resendClient
}
export interface SendEmailOptions {
to: string | string[]
subject: string
html: string
text?: string
replyTo?: string
fromName?: string
attachments?: Array<{
filename: string
content: Buffer | string
contentType?: string
}>
}
export interface SendEmailResult {
success: boolean
messageId?: string
error?: string
}
/**
* Send an email via Resend
*
* @param options Email options including to, subject, html content
* @returns Result with success status and optional error
* @deprecated Import from '@/lib/email/service' instead.
* This file exists only for backward compatibility.
*/
import { getEmailService } from './service'
import type { SendEmailOptions, SendEmailResult } from './service'
export type { SendEmailOptions, SendEmailResult }
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const {
to,
subject,
html,
text,
replyTo,
fromName,
attachments
} = options
// Check configuration before attempting to send
if (!isResendConfigured()) {
return {
success: false,
error: 'Email service is not configured'
}
}
// Construct from address with optional name
const from = fromName
? `${fromName} via ERP Base <${DEFAULT_FROM_EMAIL}>`
: `ERP Base <${DEFAULT_FROM_EMAIL}>`
try {
const resend = getResendClient()
const response = await resend.emails.send({
from,
to: Array.isArray(to) ? to : [to],
subject,
html,
text,
replyTo,
attachments: attachments?.map(att => ({
filename: att.filename,
content: typeof att.content === 'string'
? Buffer.from(att.content, 'base64')
: att.content,
content_type: att.contentType
}))
})
if (response.error) {
log.error('Resend error:', response.error)
return {
success: false,
error: response.error.message
}
}
return {
success: true,
messageId: response.data?.id
}
} catch (error) {
log.error('Failed to send email:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
}
}
return getEmailService().sendEmail(options)
}
/**
* Check if Resend is properly configured
*/
export function isResendConfigured(): boolean {
return !!process.env.RESEND_API_KEY && !!process.env.RESEND_FROM_EMAIL && process.env.RESEND_FROM_EMAIL !== 'noreply@localhost'
return getEmailService().isConfigured()
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Email Service Interface
*
* Core defines the contract. The email extension registers a real
* implementation (Resend). Without the extension, a no-op service
* is used — email-dependent features degrade gracefully.
*/
export interface SendEmailOptions {
to: string | string[]
subject: string
html: string
text?: string
replyTo?: string
fromName?: string
attachments?: Array<{
filename: string
content: Buffer | string
contentType?: string
}>
}
export interface SendEmailResult {
success: boolean
messageId?: string
error?: string
}
export interface EmailService {
sendEmail(options: SendEmailOptions): Promise<SendEmailResult>
isConfigured(): boolean
}
class NoopEmailService implements EmailService {
async sendEmail(): Promise<SendEmailResult> {
return { success: false, error: 'Email service not configured' }
}
isConfigured(): boolean {
return false
}
}
let emailService: EmailService = new NoopEmailService()
export function getEmailService(): EmailService {
return emailService
}
export function registerEmailService(svc: EmailService): void {
emailService = svc
}
-10
View File
@@ -7,9 +7,6 @@ import type {
DocumentAttachment,
Receipt,
CreditNote,
CAMT053Statement,
CAMT054Notification,
AuditSecurityEvent,
ReconciliationMethod,
InvoiceInboxItem,
SupplierInvoice,
@@ -29,21 +26,16 @@ export type CoreEvent =
// Invoicing
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string } }
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string } }
| { type: 'invoice.paid'; payload: { invoice: Invoice; transaction: Transaction; kursdifferens?: number; userId: string } }
| { type: 'invoice.overdue'; payload: { invoice: Invoice; days: number; userId: string } }
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string } }
// Banking
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string } }
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string } }
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string } }
| { type: 'bank.statement_received'; payload: { statement: CAMT053Statement; userId: string } }
| { type: 'bank.payment_notification'; payload: { notification: CAMT054Notification; userId: string } }
// Periods
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string } }
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string } }
// Customers
| { type: 'customer.created'; payload: { customer: Customer; userId: string } }
| { type: 'customer.pseudonymized'; payload: { customerId: string; userId: string } }
// Receipts
| { type: 'receipt.extracted'; payload: {
receipt: Receipt;
@@ -68,8 +60,6 @@ export type CoreEvent =
| { 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 } }
// ============================================================
// Helper Types
+51 -14
View File
@@ -1,3 +1,40 @@
import { resolve, join, dirname } from 'path'
import { readdirSync, readFileSync } from 'fs'
/**
* Build EXTENSION_DEFINITIONS from manifest.json files so the test
* is independent of extensions.config.json.
*/
function buildDefinitionsFromManifests(): Record<string, unknown[]> {
const extensionsDir = resolve(__dirname, '../../../extensions')
const result: Record<string, unknown[]> = {}
function walk(dir: string) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name)
if (entry.isDirectory()) {
walk(fullPath)
} else if (entry.name === 'manifest.json') {
const manifest = JSON.parse(readFileSync(fullPath, 'utf-8'))
const sector: string = manifest.sector
if (!result[sector]) result[sector] = []
result[sector].push({
slug: manifest.id,
sector: manifest.sector,
...manifest.definition,
})
}
}
}
walk(extensionsDir)
return result
}
vi.mock('@/lib/extensions/_generated/sector-definitions', () => ({
EXTENSION_DEFINITIONS: buildDefinitionsFromManifests(),
}))
import {
SECTORS,
getSector,
@@ -7,12 +44,12 @@ import {
} from '../sectors'
describe('sectors registry', () => {
it('should have 7 sectors', () => {
expect(SECTORS.length).toBe(7)
it('should have 1 sector', () => {
expect(SECTORS.length).toBe(1)
})
it('should have 24 total extensions', () => {
expect(getAllExtensions().length).toBe(24)
it('should have 9 total extensions', () => {
expect(getAllExtensions().length).toBe(9)
})
it('should have unique slugs within each sector', () => {
@@ -30,10 +67,10 @@ describe('sectors registry', () => {
})
it('getSector returns correct sector', () => {
const sector = getSector('restaurant')
const sector = getSector('general')
expect(sector).toBeDefined()
expect(sector!.slug).toBe('restaurant')
expect(sector!.name).toBe('Restaurang & Café')
expect(sector!.slug).toBe('general')
expect(sector!.name).toBe('Generella verktyg')
})
it('getSector returns undefined for unknown slug', () => {
@@ -42,21 +79,21 @@ describe('sectors registry', () => {
})
it('getExtensionDefinition returns correct extension', () => {
const ext = getExtensionDefinition('restaurant', 'food-cost')
const ext = getExtensionDefinition('general', 'ai-chat')
expect(ext).toBeDefined()
expect(ext!.slug).toBe('food-cost')
expect(ext!.name).toBe('Food Cost %')
expect(ext!.sector).toBe('restaurant')
expect(ext!.slug).toBe('ai-chat')
expect(ext!.name).toBe('AI-assistent')
expect(ext!.sector).toBe('general')
})
it('getExtensionDefinition returns undefined for unknown extension', () => {
const ext = getExtensionDefinition('restaurant', 'nonexistent')
const ext = getExtensionDefinition('general', 'nonexistent')
expect(ext).toBeUndefined()
})
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('restaurant')
expect(extensions.length).toBe(4)
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(9)
})
it('all extensions have required fields', () => {
@@ -0,0 +1,4 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
])
@@ -0,0 +1,5 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { Extension } from '../types'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
]
@@ -0,0 +1,5 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import type { ExtensionDefinition } from '../types'
export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
}
@@ -0,0 +1,7 @@
// AUTO-GENERATED — do not edit. Run `npm run setup:extensions` to regenerate.
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import type { WorkspaceComponentProps } from '../workspace-registry'
export const WORKSPACES: Record<string, ComponentType<WorkspaceComponentProps>> = {
}
+5 -38
View File
@@ -1,47 +1,14 @@
import { extensionRegistry } from './registry'
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
import { pushNotificationsExtension } from '@/extensions/general/push-notifications'
import { aiChatExtension } from '@/extensions/general/ai-chat'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { calendarExtension } from '@/extensions/general/calendar'
import { userDescriptionMatchExtension } from '@/extensions/general/user-description-match'
import { euSalesListExtension } from '@/extensions/export/eu-sales-list'
import { vatMonitorExtension } from '@/extensions/export/vat-monitor'
import { intrastatExtension } from '@/extensions/export/intrastat'
import { currencyReceivablesExtension } from '@/extensions/export/currency-receivables'
import type { Extension } from './types'
import { enableBankingExtension } from '@/extensions/general/enable-banking'
/**
* Explicit list of first-party extensions.
*
* Next.js bundling requires static imports — no dynamic filesystem scanning.
* Add extensions here as they are built.
*/
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
aiChatExtension,
invoiceInboxExtension,
calendarExtension,
userDescriptionMatchExtension,
enableBankingExtension,
// ── Export sector ──────────────────────────────────────────
euSalesListExtension,
vatMonitorExtension,
intrastatExtension,
currencyReceivablesExtension,
]
import { FIRST_PARTY_EXTENSIONS } from './_generated/extension-list'
let loaded = false
/**
* Load and register all first-party extensions.
* Load and register all configured extensions.
* Idempotent — safe to call multiple times.
*
* Extensions are controlled by extensions.config.json.
* Run `npm run setup:extensions` to regenerate after config changes.
*/
export function loadExtensions(): void {
if (loaded) return
+12 -374
View File
@@ -1,393 +1,31 @@
import type { Sector, SectorSlug, ExtensionDefinition } from './types'
import { EXTENSION_DEFINITIONS } from './_generated/sector-definitions'
// ============================================================
// Sector & Extension Registry
// ============================================================
//
// Pure data file. No React, no database calls.
// All sector and extension metadata lives here.
// Sector shells are structural and always present.
// Extension definitions per sector come from the generated file
// (controlled by extensions.config.json).
// ============================================================
export const SECTORS: Sector[] = [
// ── General ──────────────────────────────────────────────
/** Sector shells — structural metadata, always available */
const SECTOR_SHELLS: Omit<Sector, 'extensions'>[] = [
{
slug: 'general',
name: 'Generella verktyg',
icon: 'Layers',
description: 'Verktyg som passar alla verksamheter',
extensions: [
{
slug: 'receipt-ocr',
name: 'Kvittoscanning',
sector: 'general',
category: 'import',
icon: 'Camera',
dataPattern: 'manual',
hasOwnData: true,
description: 'Skanna kvitton och extrahera data automatiskt',
longDescription:
'Ladda upp kvittofoton och låt systemet automatiskt extrahera leverantör, belopp, moms och datum. Sparar tid och minskar manuell inmatning.',
},
{
slug: 'ai-categorization',
name: 'AI-kategorisering',
sector: 'general',
category: 'operations',
icon: 'Sparkles',
dataPattern: 'core',
readsCoreTables: ['transactions'],
description: 'AI-drivna kategoriförslag för transaktioner',
longDescription:
'Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval.',
},
{
slug: 'ai-chat',
name: 'AI-assistent',
sector: 'general',
category: 'operations',
icon: 'MessageSquare',
dataPattern: 'manual',
hasOwnData: true,
description: 'AI-assistent för skatte- och bokföringsfrågor',
longDescription:
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
quickAction: {
label: 'AI-assistent',
description: 'Fråga om bokföring',
icon: 'MessageSquare',
event: 'open-ai-chat',
},
},
{
slug: 'push-notifications',
name: 'Push-notiser',
sector: 'general',
category: 'operations',
icon: 'Bell',
dataPattern: 'core',
readsCoreTables: ['journal_entries', 'invoices', 'receipts'],
description: 'Händelsenotiser för bokföringsaktiviteter',
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: 'Dokumentinkorg',
sector: 'general',
category: 'import',
icon: 'Inbox',
dataPattern: 'manual',
hasOwnData: true,
description: 'Ta emot alla dokument via e-post — fakturor, kvitton och myndighetspost',
longDescription:
'Skicka alla affärsdokument till en dedikerad e-postadress. AI klassificerar automatiskt dokumenttyp (faktura, kvitto, myndighetspost), extraherar data och matchar mot transaktioner. En inkorg för alla dokument.',
},
{
slug: 'calendar',
name: 'Kalender',
sector: 'general',
category: 'operations',
icon: 'Calendar',
dataPattern: 'core',
readsCoreTables: ['invoices', 'deadlines', 'customers'],
description: 'Fullstandig kalendervy med manads-, vecko- och dagsvisning',
longDescription:
'Se alla fakturadatum och deadlines i en interaktiv kalender med manads-, vecko- och dagsvy.',
},
{
slug: 'user-description-match',
name: 'Beskrivningsmatchning',
sector: 'general',
category: 'operations',
icon: 'TextSearch',
dataPattern: 'core',
readsCoreTables: ['transactions', 'mapping_rules'],
description: 'Matcha transaktioner med egna beskrivningar',
longDescription:
'Beskriv vad en transaktion gäller med egna ord och få smarta bokföringsförslag. Systemet lär sig av dina beskrivningar och applicerar automatiskt på framtida transaktioner från samma leverantör.',
},
{
slug: 'enable-banking',
name: 'Bankintegration (PSD2)',
sector: 'general',
category: 'import',
icon: 'Landmark',
dataPattern: 'manual',
hasOwnData: true,
description: 'Automatisk banktransaktionssynk via PSD2',
longDescription:
'Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.',
subscriptionNotice:
'Denna integration kräver ett aktivt Enable Banking-abonnemang. Utan abonnemang kommer bankintegration inte att fungera.',
},
],
},
// ── Restaurant ───────────────────────────────────────────
{
slug: 'restaurant',
name: 'Restaurang & Café',
icon: 'UtensilsCrossed',
description: 'Branschverktyg för restauranger och caféverksamhet',
extensions: [
{
slug: 'food-cost',
name: 'Food Cost %',
sector: 'restaurant',
category: 'reports',
icon: 'ChefHat',
dataPattern: 'core',
readsCoreTables: ['journal_entry_lines'],
description: 'Beräkna råvarukostnad i procent av omsättning',
longDescription:
'Beräkna och följ upp din råvarukostnadsprocent (food cost) automatiskt utifrån bokföringen. Jämför inköpskonton (4000-serien) mot livsmedelsomsättning (3000-serien) och se trender över tid.',
},
{
slug: 'earnings-per-liter',
name: 'Intäkt per liter alkohol',
sector: 'restaurant',
category: 'reports',
icon: 'Wine',
dataPattern: 'both',
readsCoreTables: ['journal_entry_lines'],
hasOwnData: true,
description: 'Beräkna intäkt per såld liter alkohol',
longDescription:
'Kombinerar alkoholintäkter från bokföringen med manuellt inmatade literuppgifter för att räkna ut intäkt per liter. Följ trender och optimera ditt sortiment.',
},
{
slug: 'pos-import',
name: 'Kassa Z-rapport Import',
sector: 'restaurant',
category: 'import',
icon: 'FileSpreadsheet',
dataPattern: 'manual',
hasOwnData: true,
description: 'Importera Z-rapporter från kassasystem',
longDescription:
'Importera dagliga Z-rapporter från ditt kassasystem (CSV/Excel). Se daglig försäljningsstatistik, betalsättsfördelning och trender.',
},
{
slug: 'tip-tracking',
name: 'Dricksuppföljning',
sector: 'restaurant',
category: 'operations',
icon: 'HandCoins',
dataPattern: 'both',
readsCoreTables: ['journal_entry_lines'],
hasOwnData: true,
description: 'Spåra dricks per skift och anställd',
longDescription:
'Registrera dricks per skift och anställd. Se totaler, snitt per anställd och dricks som andel av omsättningen.',
},
],
},
// ── Construction ─────────────────────────────────────────
{
slug: 'construction',
name: 'Bygg & Anläggning',
icon: 'HardHat',
description: 'Branschverktyg för byggföretag och hantverkare',
extensions: [
{
slug: 'rot-calculator',
name: 'ROT-kalkylator',
sector: 'construction',
category: 'accounting',
icon: 'Calculator',
dataPattern: 'both',
readsCoreTables: ['invoices'],
hasOwnData: true,
description: 'Beräkna ROT-avdrag per kund och jobb',
longDescription:
'Beräkna ROT-avdrag (30% av arbetskostnad, max 50 000 kr/år per kund). Håll koll på utnyttjad kvot per kund och undvik att överskrida maxbeloppet.',
},
{
slug: 'project-cost',
name: 'Projektkostnad',
sector: 'construction',
category: 'reports',
icon: 'FolderKanban',
dataPattern: 'both',
readsCoreTables: ['journal_entry_lines', 'invoices'],
hasOwnData: true,
description: 'Följ upp kostnader och intäkter per byggprojekt',
longDescription:
'Samla alla kostnader och intäkter för varje byggprojekt. Se marginaler, jämför budget mot utfall och identifiera olönsamma projekt.',
},
],
},
// ── Hotel ────────────────────────────────────────────────
{
slug: 'hotel',
name: 'Hotell & Logi',
icon: 'Hotel',
description: 'Branschverktyg för hotell och logi',
extensions: [
{
slug: 'revpar',
name: 'RevPAR',
sector: 'hotel',
category: 'reports',
icon: 'TrendingUp',
dataPattern: 'both',
readsCoreTables: ['journal_entry_lines'],
hasOwnData: true,
description: 'Revenue Per Available Room',
longDescription:
'Beräkna Revenue Per Available Room (RevPAR) — det viktigaste nyckeltalet inom hotellbranschen. Kombinerar beläggningsgrad och snittpris per rum.',
},
{
slug: 'occupancy',
name: 'Beläggningsgrad',
sector: 'hotel',
category: 'reports',
icon: 'BedDouble',
dataPattern: 'manual',
hasOwnData: true,
description: 'Spåra beläggning och rumsstatus',
longDescription:
'Registrera daglig beläggning, tillgängliga rum och belagda rum. Se beläggningsgrad över tid och identifiera säsongsmönster.',
},
],
},
// ── Tech ─────────────────────────────────────────────────
{
slug: 'tech',
name: 'IT & Konsulting',
icon: 'Monitor',
description: 'Branschverktyg för IT-konsulter och teknikföretag',
extensions: [
{
slug: 'billable-hours',
name: 'Debiterbar tid',
sector: 'tech',
category: 'reports',
icon: 'Clock',
dataPattern: 'both',
readsCoreTables: ['invoices'],
hasOwnData: true,
description: 'Följ debiteringsgrad och effektiv timtaxa',
longDescription:
'Registrera arbetade timmar per projekt och beräkna debiteringsgrad (debiterbar/total tid). Se effektiv timtaxa och optimera din tidsanvändning.',
},
{
slug: 'project-billing',
name: 'Projektfakturering',
sector: 'tech',
category: 'reports',
icon: 'ReceiptText',
dataPattern: 'both',
readsCoreTables: ['invoices', 'journal_entry_lines'],
hasOwnData: true,
description: 'Fakturerade belopp per projekt och kund',
longDescription:
'Följ fakturerade belopp per projekt och kund. Jämför mot budget, se olönsamma projekt och identifiera dina mest lönsamma kunder.',
},
],
},
// ── E-commerce ───────────────────────────────────────────
{
slug: 'ecommerce',
name: 'E-handel',
icon: 'ShoppingCart',
description: 'Branschverktyg för nätbutiker och e-handel',
extensions: [
{
slug: 'shopify-import',
name: 'Shopify-import',
sector: 'ecommerce',
category: 'import',
icon: 'Store',
dataPattern: 'manual',
hasOwnData: true,
description: 'Importera ordrar från Shopify',
longDescription:
'Importera orderdata från Shopify-export (CSV). Se intäkter per produkt, ordertrender och genomsnittligt ordervärde.',
},
{
slug: 'multichannel-revenue',
name: 'Flerkanalsintäkter',
sector: 'ecommerce',
category: 'reports',
icon: 'BarChart3',
dataPattern: 'both',
readsCoreTables: ['journal_entry_lines'],
hasOwnData: true,
description: 'Intäktsanalys per försäljningskanal',
longDescription:
'Analysera intäkter fördelat på försäljningskanaler — webshop, marknadsplatser, fysisk butik. Identifiera dina mest lönsamma kanaler.',
},
],
},
// ── Export ──────────────────────────────────────────────
{
slug: 'export',
name: 'Export & Utrikeshandel',
icon: 'Ship',
description: 'Branschverktyg för export och utrikeshandel',
extensions: [
{
slug: 'eu-sales-list',
name: 'Periodisk sammanställning',
sector: 'export',
category: 'accounting',
icon: 'FileText',
dataPattern: 'core',
readsCoreTables: ['invoices', 'customers'],
hasOwnData: false,
description: 'Generera periodisk sammanställning (EC Sales List) för Skatteverket',
longDescription:
'Sammanställer automatiskt alla momsfria EU-försäljningar grupperat per kund och momsregistreringsnummer. Genererar nedladdningsbar fil (CSV/XML) för uppladdning till Skatteverket. Validerar kundernas VAT-nummer via VIES och flaggar saknade uppgifter. Korsvaliderar mot momsdeklarationens ruta 35 och 39.',
},
{
slug: 'vat-monitor',
name: 'Exportmoms-monitor',
sector: 'export',
category: 'reports',
icon: 'Shield',
dataPattern: 'core',
readsCoreTables: ['journal_entry_lines', 'journal_entries', 'invoices'],
hasOwnData: false,
description: 'Övervaka momsbehandling för export och EU-handel',
longDescription:
'Visar intäkter uppdelat på inhemsk försäljning, EU B2B (reverse charge) och export utanför EU. Mappar automatiskt till rätt rutor i momsdeklarationen (ruta 05, 35, 36, 39, 40). Flaggar potentiella fel som saknat momsregistreringsnummer på EU-kunder eller felaktig momsbehandling.',
},
{
slug: 'intrastat',
name: 'Intrastat-generator',
sector: 'export',
category: 'accounting',
icon: 'BarChart3',
dataPattern: 'both',
readsCoreTables: ['invoices', 'customers'],
hasOwnData: true,
description: 'Generera Intrastat-deklarationer för rapportering till SCB',
longDescription:
'Tagga produkter med CN-koder (Combined Nomenclature), vikt och ursprungsland. Genererar kompletta Intrastat-deklarationer i CSV-format för uppladdning till SCB:s IDEP.web. Övervakar tröskelvärdet på 12 MSEK för utförsel och varnar när rapporteringsskyldighet uppstår.',
},
{
slug: 'currency-receivables',
name: 'Valutafordringar',
sector: 'export',
category: 'reports',
icon: 'TrendingUp',
dataPattern: 'core',
readsCoreTables: ['invoices', 'journal_entry_lines', 'transactions'],
hasOwnData: false,
description: 'Övervaka valutaexponering och orealiserade kursvinster/-förluster',
longDescription:
'Visar öppna kundfordringar per valuta med aktuellt SEK-värde baserat på Riksbankens dagskurser. Beräknar orealiserade valutakursvinster och -förluster. Visar realiserade kursdifferenser per period (konto 3960/7960). Ger en samlad bild av företagets valutarisk.',
},
],
},
]
/** Full sectors with extensions merged from generated definitions */
export const SECTORS: Sector[] = SECTOR_SHELLS.map(shell => ({
...shell,
extensions: EXTENSION_DEFINITIONS[shell.slug] ?? [],
}))
// ============================================================
// Helper functions
// ============================================================
+6 -1
View File
@@ -10,7 +10,7 @@ import type { EntityType, RawTransaction, IngestResult } from '@/types'
export type ExtensionCategory = 'accounting' | 'reports' | 'import' | 'operations'
/** Sector slugs for extension organization */
export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce' | 'export'
export type SectorSlug = 'general'
/** How an extension gets its data */
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
@@ -176,6 +176,7 @@ export interface Extension {
id: string
name: string
version: string
sector?: SectorSlug
// Surfaces
routes?: RouteDefinition[]
@@ -188,6 +189,10 @@ export interface Extension {
taxCodes?: TaxCodeDefinition[]
dimensionTypes?: DimensionDefinition[]
/** Named services this extension provides to core via registry lookup */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
services?: Record<string, (...args: any[]) => Promise<any>>
// Lifecycle hooks
onInstall?(ctx: ExtensionContext): Promise<void>
onUninstall?(ctx: ExtensionContext): Promise<void>
+2 -38
View File
@@ -1,49 +1,13 @@
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
import { WORKSPACES } from './_generated/workspace-map'
export interface WorkspaceComponentProps {
userId: string
}
type WorkspaceKey = `${string}/${string}`
const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> = {
// General
'general/receipt-ocr': dynamic(() => import('@/components/extensions/general/ReceiptOcrWorkspace')),
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/DocumentInboxWorkspace')),
'general/calendar': dynamic(() => import('@/components/extensions/general/CalendarWorkspace')),
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
'general/user-description-match': dynamic(() => import('@/components/extensions/general/UserDescriptionMatchWorkspace')),
// Restaurant
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
'restaurant/earnings-per-liter': dynamic(() => import('@/components/extensions/restaurant/EarningsPerLiterWorkspace')),
'restaurant/pos-import': dynamic(() => import('@/components/extensions/restaurant/PosImportWorkspace')),
'restaurant/tip-tracking': dynamic(() => import('@/components/extensions/restaurant/TipTrackingWorkspace')),
// Construction
'construction/rot-calculator': dynamic(() => import('@/components/extensions/construction/RotCalculatorWorkspace')),
'construction/project-cost': dynamic(() => import('@/components/extensions/construction/ProjectCostWorkspace')),
// Hotel
'hotel/revpar': dynamic(() => import('@/components/extensions/hotel/RevparWorkspace')),
'hotel/occupancy': dynamic(() => import('@/components/extensions/hotel/OccupancyWorkspace')),
// Tech
'tech/billable-hours': dynamic(() => import('@/components/extensions/tech/BillableHoursWorkspace')),
'tech/project-billing': dynamic(() => import('@/components/extensions/tech/ProjectBillingWorkspace')),
// E-commerce
'ecommerce/shopify-import': dynamic(() => import('@/components/extensions/ecommerce/ShopifyImportWorkspace')),
'ecommerce/multichannel-revenue': dynamic(() => import('@/components/extensions/ecommerce/MultichannelRevenueWorkspace')),
// Export
'export/eu-sales-list': dynamic(() => import('@/components/extensions/export/EuSalesListWorkspace')),
'export/vat-monitor': dynamic(() => import('@/components/extensions/export/VatMonitorWorkspace')),
'export/intrastat': dynamic(() => import('@/components/extensions/export/IntrastatWorkspace')),
'export/currency-receivables': dynamic(() => import('@/components/extensions/export/CurrencyReceivablesWorkspace')),
}
export function getWorkspaceComponent(
sector: string,
slug: string
): ComponentType<WorkspaceComponentProps> | null {
return WORKSPACES[`${sector}/${slug}` as WorkspaceKey] ?? null
return WORKSPACES[`${sector}/${slug}`] ?? null
}
+14 -15
View File
@@ -6,7 +6,7 @@
* All operations are wrapped to ensure atomic behavior.
*/
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type {
ParsedSIEFile,
@@ -76,10 +76,10 @@ export function generateImportPreview(
* Check if a file has already been imported
*/
export async function checkDuplicateImport(
supabase: SupabaseClient,
userId: string,
fileContent: string
): Promise<SIEImport | null> {
const supabase = await createClient()
const fileHash = await calculateFileHash(fileContent)
const { data } = await supabase
@@ -96,7 +96,7 @@ export async function checkDuplicateImport(
* Create a fiscal period if one doesn't exist for the date range
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
supabase: SupabaseClient,
userId: string,
startDate: Date,
endDate: Date
@@ -160,6 +160,7 @@ async function ensureFiscalPeriod(
* Create opening balance journal entry from IB amounts
*/
async function createOpeningBalanceEntry(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
@@ -230,7 +231,7 @@ async function createOpeningBalanceEntry(
const fiscalYearStart = parsed.stats.fiscalYearStart
const entryDate = fiscalYearStart ? formatDate(fiscalYearStart) : formatDate(new Date())
const entry = await createJournalEntry(userId, {
const entry = await createJournalEntry(supabase, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: entryDate,
description: 'Ingående balanser från SIE-import',
@@ -246,13 +247,13 @@ async function createOpeningBalanceEntry(
* Create journal entries from vouchers using batch insert for performance
*/
async function importVouchers(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string,
parsed: ParsedSIEFile,
accountMap: Map<string, string>,
voucherSeries: string
): Promise<{ created: number; ids: string[]; errors: string[] }> {
const supabase = await createClient()
const results = {
created: 0,
ids: [] as string[],
@@ -439,7 +440,7 @@ async function importVouchers(
* Record the import in the database
*/
async function recordImport(
supabase: Awaited<ReturnType<typeof createClient>>,
supabase: SupabaseClient,
userId: string,
parsed: ParsedSIEFile,
fileContent: string,
@@ -485,11 +486,10 @@ async function recordImport(
* Save account mappings to the database for future use
*/
export async function saveMappings(
supabase: SupabaseClient,
userId: string,
mappings: AccountMapping[]
): Promise<void> {
const supabase = await createClient()
// Filter to only mapped accounts
const mappingsToSave = mappings
.filter((m) => m.targetAccount)
@@ -519,9 +519,7 @@ export async function saveMappings(
/**
* Load existing account mappings for a user
*/
export async function loadMappings(userId: string): Promise<Map<string, AccountMapping>> {
const supabase = await createClient()
export async function loadMappings(supabase: SupabaseClient, userId: string): Promise<Map<string, AccountMapping>> {
const { data } = await supabase
.from('sie_account_mappings')
.select('*')
@@ -548,6 +546,7 @@ export async function loadMappings(userId: string): Promise<Map<string, AccountM
* Execute the full SIE import
*/
export async function executeSIEImport(
supabase: SupabaseClient,
userId: string,
parsed: ParsedSIEFile,
mappings: AccountMapping[],
@@ -572,8 +571,6 @@ export async function executeSIEImport(
}
try {
const supabase = await createClient()
// Validate all accounts are mapped
const unmapped = mappings.filter((m) => !m.targetAccount)
if (unmapped.length > 0) {
@@ -584,7 +581,7 @@ export async function executeSIEImport(
}
// Check for duplicate import
const duplicate = await checkDuplicateImport(userId, options.fileContent)
const duplicate = await checkDuplicateImport(supabase, userId, options.fileContent)
if (duplicate) {
result.errors.push(
`This file has already been imported on ${new Date(duplicate.imported_at!).toLocaleDateString('sv-SE')}`
@@ -632,6 +629,7 @@ export async function executeSIEImport(
// Import opening balances
if (options.importOpeningBalances && parsed.openingBalances.length > 0 && result.fiscalPeriodId) {
result.openingBalanceEntryId = await createOpeningBalanceEntry(
supabase,
userId,
result.fiscalPeriodId,
parsed,
@@ -647,6 +645,7 @@ export async function executeSIEImport(
// Import transactions (SIE4 only)
if (options.importTransactions && parsed.vouchers.length > 0 && result.fiscalPeriodId) {
const voucherResults = await importVouchers(
supabase,
userId,
result.fiscalPeriodId,
parsed,
@@ -660,7 +659,7 @@ export async function executeSIEImport(
}
// Save account mappings for future use
await saveMappings(userId, mappings)
await saveMappings(supabase, userId, mappings)
// Record the import
result.importId = await recordImport(
+4 -4
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Invoice, Transaction, Customer } from '@/types'
export interface InvoiceMatch {
@@ -117,6 +117,7 @@ function calculateMatchScore(
* Returns matches sorted by confidence, filtered to >= 50% confidence
*/
export async function findMatchingInvoices(
supabase: SupabaseClient,
userId: string,
transaction: Transaction
): Promise<InvoiceMatch[]> {
@@ -125,8 +126,6 @@ export async function findMatchingInvoices(
return []
}
const supabase = await createClient()
// Query unpaid invoices (sent or overdue) with customer info
const { data: invoices, error } = await supabase
.from('invoices')
@@ -222,11 +221,12 @@ export async function findMatchingInvoices(
* Returns the highest confidence match if it meets the threshold
*/
export async function getBestInvoiceMatch(
supabase: SupabaseClient,
userId: string,
transaction: Transaction,
minConfidence: number = 0.80
): Promise<InvoiceMatch | null> {
const matches = await findMatchingInvoices(userId, transaction)
const matches = await findMatchingInvoices(supabase, userId, transaction)
if (matches.length > 0 && matches[0].confidence >= minConfidence) {
return matches[0]
+2 -2
View File
@@ -1,5 +1,5 @@
import { createServerClient } from '@supabase/ssr'
import { sendEmail } from '@/lib/email/resend'
import { getEmailService } from '@/lib/email/service'
import {
generateReminderEmailHtml,
generateReminderEmailText,
@@ -110,7 +110,7 @@ export async function sendReminder(
actionUrl
}
const result = await sendEmail({
const result = await getEmailService().sendEmail({
to: customer.email,
subject: generateReminderEmailSubject(emailData),
html: generateReminderEmailHtml(emailData),
+11 -12
View File
@@ -20,19 +20,18 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateARLedger } from '../ar-ledger'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateARLedger', () => {
@@ -41,7 +40,7 @@ describe('generateARLedger', () => {
{ data: [], error: null },
]
const report = await generateARLedger('user-1')
const report = await generateARLedger(supabase, 'user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.unpaid_count).toBe(0)
@@ -52,7 +51,7 @@ describe('generateARLedger', () => {
{ data: null, error: { message: 'DB error' } },
]
const report = await generateARLedger('user-1')
const report = await generateARLedger(supabase, 'user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
@@ -107,7 +106,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger('user-1', asOfDate)
const report = await generateARLedger(supabase, 'user-1', asOfDate)
expect(report.unpaid_count).toBe(3)
expect(report.entries).toHaveLength(2)
@@ -150,7 +149,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger('user-1', '2024-06-15')
const report = await generateARLedger(supabase, 'user-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(2500)
expect(report.total_outstanding).toBe(2500)
@@ -189,7 +188,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger('user-1', '2024-05-15')
const report = await generateARLedger(supabase, 'user-1', '2024-05-15')
// Sorted by due_date: F001 (June 1) before F002 (July 1)
expect(report.entries[0].invoices[0].invoice_number).toBe('F001')
@@ -217,7 +216,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger('user-1', '2024-06-15')
const report = await generateARLedger(supabase, 'user-1', '2024-06-15')
expect(report.entries[0].invoices[0].outstanding).toBe(66.77)
expect(report.total_outstanding).toBe(66.77)
})
@@ -243,7 +242,7 @@ describe('generateARLedger', () => {
},
]
const report = await generateARLedger('user-1', '2024-06-15')
const report = await generateARLedger(supabase, 'user-1', '2024-06-15')
expect(report.entries[0].customer_name).toBe('Okänd kund')
})
})
+10 -11
View File
@@ -20,19 +20,18 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateARReconciliation } from '../ar-reconciliation'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateARReconciliation', () => {
@@ -56,7 +55,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation('user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
// AR: (5000-2000) + (3000-0) = 6000
expect(result.ar_ledger_total).toBe(6000)
@@ -85,7 +84,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation('user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
expect(result.ar_ledger_total).toBe(5000)
expect(result.account_1510_balance).toBe(6000)
@@ -99,7 +98,7 @@ describe('generateARReconciliation', () => {
{ data: [], error: null },
]
const result = await generateARReconciliation('user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(0)
@@ -118,7 +117,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation('user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
expect(result.ar_ledger_total).toBe(0)
expect(result.account_1510_balance).toBe(3000)
@@ -139,7 +138,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation('user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
// Balance = debits - credits = 10000 - 4000 - 3000 = 3000
expect(result.account_1510_balance).toBe(3000)
@@ -161,7 +160,7 @@ describe('generateARReconciliation', () => {
},
]
const result = await generateARReconciliation('user-1', 'period-1')
const result = await generateARReconciliation(supabase, 'user-1', 'period-1')
expect(result.ar_ledger_total).toBe(66.77)
expect(result.account_1510_balance).toBe(66.77)
+10 -8
View File
@@ -10,6 +10,8 @@ import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const supabase = {} as any
beforeEach(() => {
vi.clearAllMocks()
})
@@ -38,7 +40,7 @@ describe('generateBalanceSheet', () => {
isBalanced: true,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
expect(report.asset_sections).toEqual([])
expect(report.equity_liability_sections).toEqual([])
@@ -57,7 +59,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
expect(report.asset_sections).toHaveLength(2)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
@@ -78,7 +80,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
expect(report.equity_liability_sections).toHaveLength(2)
expect(report.equity_liability_sections[0].title).toBe('Eget kapital')
@@ -99,7 +101,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
const bankSection = report.asset_sections.find(s => s.title === 'Kassa och bank')!
expect(bankSection.rows).toHaveLength(1)
@@ -118,7 +120,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kassa och bank')
@@ -135,7 +137,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1)
expect(report.asset_sections[0].title).toBe('Kundfordringar')
@@ -156,7 +158,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
expect(report.asset_sections).toHaveLength(1) // Only 1930
// Class 3-8 accounts are not included as balance sheet rows, but their
@@ -181,7 +183,7 @@ describe('generateBalanceSheet', () => {
isBalanced: false,
})
const report = await generateBalanceSheet('user-1', 'period-1')
const report = await generateBalanceSheet(supabase, 'user-1', 'period-1')
// All three are in group '19' (Kassa och bank)
const section = report.asset_sections.find(s => s.title === 'Kassa och bank')!
+11 -12
View File
@@ -21,19 +21,18 @@ function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateGeneralLedger } from '../general-ledger'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateGeneralLedger', () => {
@@ -43,7 +42,7 @@ describe('generateGeneralLedger', () => {
{ data: null, error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '', end: '' })
})
@@ -56,7 +55,7 @@ describe('generateGeneralLedger', () => {
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
expect(report.accounts).toEqual([])
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
})
@@ -98,7 +97,7 @@ describe('generateGeneralLedger', () => {
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
expect(report.accounts).toHaveLength(4)
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930', '2611', '3001'])
@@ -157,7 +156,7 @@ describe('generateGeneralLedger', () => {
},
]
const report = await generateGeneralLedger('user-1', 'period-2')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-2')
const acc1930 = report.accounts.find((a) => a.account_number === '1930')!
expect(acc1930.opening_balance).toBe(10000)
@@ -191,7 +190,7 @@ describe('generateGeneralLedger', () => {
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1', '1500', '1999')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1', '1500', '1999')
// Only accounts in 1500–1999 range
expect(report.accounts.map((a) => a.account_number)).toEqual(['1510', '1930'])
@@ -225,7 +224,7 @@ describe('generateGeneralLedger', () => {
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
const acc = report.accounts[0]
// e3 (Jan 5) first, then e1 (Jan 10, #1), then e2 (Jan 10, #2)
@@ -253,7 +252,7 @@ describe('generateGeneralLedger', () => {
{ data: [], error: null },
]
const report = await generateGeneralLedger('user-1', 'period-1')
const report = await generateGeneralLedger(supabase, 'user-1', 'period-1')
const acc = report.accounts[0]
expect(acc.total_debit).toBe(33.33)
expect(acc.closing_balance).toBe(33.33)
+10 -8
View File
@@ -10,6 +10,8 @@ import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
const supabase = {} as any
beforeEach(() => {
vi.clearAllMocks()
})
@@ -38,7 +40,7 @@ describe('generateIncomeStatement', () => {
isBalanced: true,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
expect(report.revenue_sections).toEqual([])
expect(report.expense_sections).toEqual([])
@@ -57,7 +59,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
expect(report.revenue_sections).toHaveLength(1)
expect(report.revenue_sections[0].title).toBe('Huvudintäkter')
@@ -78,7 +80,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
expect(report.expense_sections).toHaveLength(2)
expect(report.expense_sections[0].title).toBe('Lokalkostnader')
@@ -98,7 +100,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
expect(report.financial_sections).toHaveLength(2)
// Financial uses credit - debit
@@ -121,7 +123,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
expect(report.total_revenue).toBe(20000)
expect(report.total_expenses).toBe(8000)
@@ -141,7 +143,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
// Only the significant revenue row should appear
const revenueSection = report.revenue_sections.find(s => s.title === 'Huvudintäkter')!
@@ -161,7 +163,7 @@ describe('generateIncomeStatement', () => {
isBalanced: false,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
// Only the non-empty section should remain
expect(report.revenue_sections).toHaveLength(1)
@@ -180,7 +182,7 @@ describe('generateIncomeStatement', () => {
isBalanced: true,
})
const report = await generateIncomeStatement('user-1', 'period-1')
const report = await generateIncomeStatement(supabase, 'user-1', 'period-1')
// Only class 3 should appear
expect(report.revenue_sections).toHaveLength(1)
+10 -11
View File
@@ -20,19 +20,18 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateJournalRegister } from '../journal-register'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateJournalRegister', () => {
@@ -41,7 +40,7 @@ describe('generateJournalRegister', () => {
{ data: null, error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '', end: '' })
@@ -53,7 +52,7 @@ describe('generateJournalRegister', () => {
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
expect(report.entries).toEqual([])
expect(report.total_entries).toBe(0)
expect(report.period).toEqual({ start: '2024-01-01', end: '2024-12-31' })
@@ -94,7 +93,7 @@ describe('generateJournalRegister', () => {
},
]
const report = await generateJournalRegister('user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
expect(report.total_entries).toBe(2)
expect(report.entries[0].voucher_number).toBe(1)
@@ -137,7 +136,7 @@ describe('generateJournalRegister', () => {
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
expect(report.entries[0].status).toBe('reversed')
expect(report.entries[1].status).toBe('posted')
@@ -168,7 +167,7 @@ describe('generateJournalRegister', () => {
},
]
const report = await generateJournalRegister('user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
const line1930 = report.entries[0].lines.find((l) => l.account_number === '1930')!
expect(line1930.account_name).toBe('Företagskonto')
@@ -197,7 +196,7 @@ describe('generateJournalRegister', () => {
{ data: [], error: null },
]
const report = await generateJournalRegister('user-1', 'period-1')
const report = await generateJournalRegister(supabase, 'user-1', 'period-1')
expect(report.entries[0].voucher_series).toBe('A')
})
})
@@ -1,11 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase } from '@/tests/helpers'
// Mock Supabase server client
const { supabase, mockResult } = createMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(() => supabase),
}))
import { generateMonthlyBreakdown } from '../monthly-breakdown'
@@ -17,7 +13,7 @@ describe('generateMonthlyBreakdown', () => {
it('returns empty months when no fiscal period found', async () => {
mockResult({ data: null, error: { message: 'not found' } })
const result = await generateMonthlyBreakdown('user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
expect(result.months).toEqual([])
})
@@ -69,7 +65,7 @@ describe('generateMonthlyBreakdown', () => {
}
})
const result = await generateMonthlyBreakdown('user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
expect(result.months.length).toBe(12)
expect(result.months[0].label).toBe('Jan')
expect(result.months[0].income).toBe(0)
@@ -136,7 +132,7 @@ describe('generateMonthlyBreakdown', () => {
}
})
const result = await generateMonthlyBreakdown('user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
// January
const jan = result.months.find((m) => m.label === 'Jan')!
@@ -215,7 +211,7 @@ describe('generateMonthlyBreakdown', () => {
}
})
const result = await generateMonthlyBreakdown('user-1', 'period-1')
const result = await generateMonthlyBreakdown(supabase, 'user-1', 'period-1')
const jan = result.months.find((m) => m.label === 'Jan')!
// Class 1 and 2 are ignored
// Class 8 debit (8400 interest expense) → expense
+16 -17
View File
@@ -20,19 +20,18 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateSIEExport } from '../sie-export'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
const baseOptions = {
@@ -49,7 +48,7 @@ describe('generateSIEExport', () => {
{ data: null, error: null },
]
await expect(generateSIEExport('user-1', baseOptions))
await expect(generateSIEExport(supabase, 'user-1', baseOptions))
.rejects.toThrow('Fiscal period not found')
})
@@ -67,7 +66,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
const lines = output.split('\r\n')
expect(lines[0]).toBe('#FLAGGA 0')
@@ -89,7 +88,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', {
const output = await generateSIEExport(supabase, 'user-1', {
...baseOptions,
org_number: null,
})
@@ -116,7 +115,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).toContain('#KONTO 1930 "Företagskonto"')
expect(output).toContain('#SRU 1930 7301')
@@ -155,7 +154,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240315 "Sale invoice"')
expect(output).toContain('{')
@@ -186,7 +185,7 @@ describe('generateSIEExport', () => {
},
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).toContain('#DIM 1 "Kostnadsställe"')
expect(output).toContain('#DIM 6 "Projekt"')
@@ -219,7 +218,7 @@ describe('generateSIEExport', () => {
{ data: [{ code: 'P001', name: 'Projekt Alpha', is_active: true }], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).toContain('\t#TRANS 5010 {1 "CC1" 6 "P001"} 8000.00 20240315')
expect(output).toContain('\t#TRANS 1930 {} -8000.00 20240315')
@@ -251,7 +250,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
// Account 1510 (class 1) → #UB, balance = 1250 - 0 = 1250
expect(output).toContain('#UB 0 1510 1250.00')
@@ -286,7 +285,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).toContain('#VER "A" 1 20240115 "Invoice for \\"consulting\\""')
})
@@ -300,7 +299,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
// Every line should end with \r\n
expect(output).toContain('\r\n')
@@ -322,7 +321,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).not.toContain('#VER')
expect(output).not.toContain('#TRANS')
@@ -337,7 +336,7 @@ describe('generateSIEExport', () => {
{ data: [], error: null },
]
const output = await generateSIEExport('user-1', baseOptions)
const output = await generateSIEExport(supabase, 'user-1', baseOptions)
expect(output).not.toContain('#DIM')
expect(output).not.toContain('#OBJEKT')
+11 -12
View File
@@ -20,19 +20,18 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateSupplierLedger } from '../supplier-ledger'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateSupplierLedger', () => {
@@ -41,7 +40,7 @@ describe('generateSupplierLedger', () => {
{ data: [], error: null },
]
const report = await generateSupplierLedger('user-1')
const report = await generateSupplierLedger(supabase, 'user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
expect(report.total_current).toBe(0)
@@ -54,7 +53,7 @@ describe('generateSupplierLedger', () => {
{ data: null, error: { message: 'DB error' } },
]
const report = await generateSupplierLedger('user-1')
const report = await generateSupplierLedger(supabase, 'user-1')
expect(report.entries).toEqual([])
expect(report.total_outstanding).toBe(0)
})
@@ -106,7 +105,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger('user-1', asOfDate)
const report = await generateSupplierLedger(supabase, 'user-1', asOfDate)
expect(report.entries).toHaveLength(1)
const entry = report.entries[0]
@@ -139,7 +138,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
expect(report.entries).toHaveLength(2)
const names = report.entries.map(e => e.supplier_name)
@@ -174,7 +173,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
expect(report.entries[0].supplier_name).toBe('Large')
expect(report.entries[1].supplier_name).toBe('Medium')
@@ -204,7 +203,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
expect(report.total_outstanding).toBe(8000)
expect(report.total_current).toBe(5000)
@@ -239,7 +238,7 @@ describe('generateSupplierLedger', () => {
},
]
const report = await generateSupplierLedger('user-1', '2024-06-15')
const report = await generateSupplierLedger(supabase, 'user-1', '2024-06-15')
expect(report.total_outstanding).toBe(100)
expect(report.total_current).toBe(100)
@@ -20,19 +20,18 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateReconciliation } from '../supplier-reconciliation'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateReconciliation', () => {
@@ -56,7 +55,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation('user-1', 'period-1')
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
// Supplier total: 5000 + 3000 = 8000
expect(result.supplier_ledger_total).toBe(8000)
@@ -84,7 +83,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation('user-1', 'period-1')
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(5000)
expect(result.account_2440_balance).toBe(7000)
@@ -98,7 +97,7 @@ describe('generateReconciliation', () => {
{ data: [], error: null },
]
const result = await generateReconciliation('user-1', 'period-1')
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(0)
@@ -117,7 +116,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation('user-1', 'period-1')
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(0)
expect(result.account_2440_balance).toBe(3000)
@@ -138,7 +137,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation('user-1', 'period-1')
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
// Balance = credits - debits = 15000 - 5000 - 3000 = 7000
expect(result.account_2440_balance).toBe(7000)
@@ -161,7 +160,7 @@ describe('generateReconciliation', () => {
},
]
const result = await generateReconciliation('user-1', 'period-1')
const result = await generateReconciliation(supabase, 'user-1', 'period-1')
expect(result.supplier_ledger_total).toBe(66.67)
expect(result.account_2440_balance).toBe(66.67)
+14 -15
View File
@@ -21,19 +21,18 @@ function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import { generateTrialBalance } from '../trial-balance'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
describe('generateTrialBalance', () => {
@@ -69,7 +68,7 @@ describe('generateTrialBalance', () => {
},
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(5000)
@@ -104,7 +103,7 @@ describe('generateTrialBalance', () => {
},
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(1000)
@@ -120,7 +119,7 @@ describe('generateTrialBalance', () => {
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
@@ -134,7 +133,7 @@ describe('generateTrialBalance', () => {
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.totalDebit).toBe(0)
@@ -168,7 +167,7 @@ describe('generateTrialBalance', () => {
},
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toHaveLength(2)
// Sorted by account number
@@ -199,7 +198,7 @@ describe('generateTrialBalance', () => {
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows[0].account_name).toBe('Konto 9999')
})
@@ -221,7 +220,7 @@ describe('generateTrialBalance', () => {
{ data: [], error: null },
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows[0].account_class).toBe(5)
})
@@ -252,7 +251,7 @@ describe('generateTrialBalance', () => {
},
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows[0].closing_debit).toBe(100)
expect(result.totalDebit).toBe(100)
@@ -292,7 +291,7 @@ describe('generateTrialBalance', () => {
},
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(999)
@@ -307,7 +306,7 @@ describe('generateTrialBalance', () => {
{ data: null, error: { message: 'DB error' } },
]
const result = await generateTrialBalance('user-1', 'period-1')
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
+14 -15
View File
@@ -20,13 +20,9 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
}
} as any
}
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => makeClient()),
}))
import {
calculatePeriodDates,
formatPeriodLabel,
@@ -35,10 +31,13 @@ import {
} from '../vat-declaration'
import type { VatDeclaration } from '@/types'
let supabase: ReturnType<typeof makeClient>
beforeEach(() => {
vi.clearAllMocks()
resultIdx = 0
results = []
supabase = makeClient()
})
// ============================================================
@@ -168,7 +167,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(0)
expect(result.rutor.ruta06).toBe(0)
@@ -195,7 +194,7 @@ describe('calculateVatDeclaration', () => {
{ data: [{ source_type: 'invoice_created' }, { source_type: 'invoice_created' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
@@ -218,7 +217,7 @@ describe('calculateVatDeclaration', () => {
{ data: [{ source_type: 'bank_transaction' }, { source_type: 'bank_transaction' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta48).toBe(370)
expect(result.transactionCount).toBe(2)
@@ -236,7 +235,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
// Both 2641 and 2645 debit balances sum into ruta48
expect(result.rutor.ruta48).toBe(700)
@@ -254,7 +253,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta39).toBe(8000)
expect(result.rutor.ruta40).toBe(12000)
@@ -276,7 +275,7 @@ describe('calculateVatDeclaration', () => {
{ data: [{ source_type: 'invoice_created' }, { source_type: 'credit_note' }], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
// Net: 2500 - 625 = 1875 output VAT, 10000 - 2500 = 7500 revenue
expect(result.rutor.ruta05).toBe(1875)
@@ -297,7 +296,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta48).toBe(350)
@@ -316,7 +315,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1)
expect(result.rutor.ruta49).toBe(-2500) // 500 - 3000
})
@@ -328,7 +327,7 @@ describe('calculateVatDeclaration', () => {
]
// Should not throw — parameter accepted but not used
const result = await calculateVatDeclaration('user-1', 'monthly', 2024, 1, 'cash')
const result = await calculateVatDeclaration(supabase, 'user-1', 'monthly', 2024, 1, 'cash')
expect(result.rutor.ruta49).toBe(0)
})
@@ -353,7 +352,7 @@ describe('calculateVatDeclaration', () => {
{ data: [], error: null },
]
const result = await calculateVatDeclaration('user-1', 'quarterly', 2024, 1)
const result = await calculateVatDeclaration(supabase, 'user-1', 'quarterly', 2024, 1)
expect(result.rutor.ruta05).toBe(2500)
expect(result.rutor.ruta06).toBe(600)
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
export interface ARInvoiceDetail {
invoice_id: string
@@ -37,10 +37,10 @@ export interface ARLedgerReport {
* BFL 5 kap. 4 § — sidoordnad bokföring: outstanding customer invoices with aging.
*/
export async function generateARLedger(
supabase: SupabaseClient,
userId: string,
asOfDate?: string
): Promise<ARLedgerReport> {
const supabase = await createClient()
const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/sent/overdue invoices with customer info
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
export interface ARReconciliationResult {
ar_ledger_total: number
@@ -12,10 +12,10 @@ export interface ARReconciliationResult {
* Account 1510 is debit-normal (asset): balance = debits - credits.
*/
export async function generateARReconciliation(
supabase: SupabaseClient,
userId: string,
periodId: string
): Promise<ARReconciliationResult> {
const supabase = await createClient()
// Get total outstanding from customer invoices
const { data: invoices } = await supabase
+3 -1
View File
@@ -1,3 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@/types'
@@ -9,10 +10,11 @@ import type { BalanceSheetReport, BalanceSheetSection, TrialBalanceRow } from '@
* - Eget kapital och skulder (2xxx): Equity and liabilities
*/
export async function generateBalanceSheet(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<BalanceSheetReport> {
const { rows } = await generateTrialBalance(userId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
// Filter to balance sheet accounts (class 1-2)
const balanceRows = rows.filter(
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface GeneralLedgerLine {
@@ -32,12 +32,12 @@ export interface GeneralLedgerReport {
* BFL 5 kap. 1 § — systematisk ordning: all transactions grouped by account.
*/
export async function generateGeneralLedger(
supabase: SupabaseClient,
userId: string,
periodId: string,
accountFrom?: string,
accountTo?: string
): Promise<GeneralLedgerReport> {
const supabase = await createClient()
// Get fiscal period dates
const { data: period } = await supabase
+3 -1
View File
@@ -1,3 +1,4 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import type { IncomeStatementReport, IncomeStatementSection, TrialBalanceRow } from '@/types'
@@ -11,10 +12,11 @@ import type { IncomeStatementReport, IncomeStatementSection, TrialBalanceRow } f
* - Årets resultat: Net result
*/
export async function generateIncomeStatement(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<IncomeStatementReport> {
const { rows } = await generateTrialBalance(userId, fiscalPeriodId)
const { rows } = await generateTrialBalance(supabase, userId, fiscalPeriodId)
// Filter to income/expense accounts (class 3-8)
const incomeExpenseRows = rows.filter(
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
export interface JournalRegisterLine {
@@ -33,10 +33,10 @@ export interface JournalRegisterReport {
* BFL 5 kap. 1 § — registreringsordning: all vouchers in chronological registration order.
*/
export async function generateJournalRegister(
supabase: SupabaseClient,
userId: string,
periodId: string
): Promise<JournalRegisterReport> {
const supabase = await createClient()
// Get fiscal period dates
const { data: period } = await supabase
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
export interface MonthlyBreakdownMonth {
label: string
@@ -24,10 +24,10 @@ const MONTH_LABELS = [
* - Class 4-7 (40xx-79xx) = expenses (debit side)
*/
export async function generateMonthlyBreakdown(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<MonthlyBreakdown> {
const supabase = await createClient()
// Get the fiscal period date range
const { data: period, error: periodError } = await supabase
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
FiscalPeriod,
@@ -155,10 +155,10 @@ function roundToKrona(value: number): number {
* Generate NE declaration for a fiscal period
*/
export async function generateNEDeclaration(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<NEDeclaration> {
const supabase = await createClient()
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } from '@/types'
@@ -12,10 +12,10 @@ import type { SIEExportOptions, JournalEntry, JournalEntryLine, BASAccount } fro
* Line format: #TAG field1 field2 ...
*/
export async function generateSIEExport(
supabase: SupabaseClient,
userId: string,
options: SIEExportOptions
): Promise<string> {
const supabase = await createClient()
// Fetch fiscal period
const { data: period } = await supabase
+3 -4
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { JournalEntry, JournalEntryLine } from '@/types'
@@ -35,10 +35,10 @@ export interface SRUCoverageStats {
* Returns a Map of sru_code → summed amount, plus per-account detail.
*/
export async function aggregateBalancesBySRU(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<Map<string, SRUBalance>> {
const supabase = await createClient()
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
@@ -118,8 +118,7 @@ export async function aggregateBalancesBySRU(
* Get SRU code coverage stats for a user's chart of accounts.
* Returns how many accounts have vs lack SRU codes.
*/
export async function getSRUCoverage(userId: string): Promise<SRUCoverageStats> {
const supabase = await createClient()
export async function getSRUCoverage(supabase: SupabaseClient, userId: string): Promise<SRUCoverageStats> {
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null }>(({ from, to }) =>
supabase
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
export interface SupplierLedgerEntry {
supplier_id: string
@@ -23,10 +23,10 @@ export interface SupplierLedgerReport {
* Generate supplier ledger (leverantörsreskontra) with aging analysis
*/
export async function generateSupplierLedger(
supabase: SupabaseClient,
userId: string,
asOfDate?: string
): Promise<SupplierLedgerReport> {
const supabase = await createClient()
const refDate = asOfDate ? new Date(asOfDate) : new Date()
// Fetch all unpaid/partially_paid supplier invoices
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
export interface ReconciliationResult {
supplier_ledger_total: number
@@ -11,10 +11,10 @@ export interface ReconciliationResult {
* Compare sum of open supplier invoices against account 2440 balance
*/
export async function generateReconciliation(
supabase: SupabaseClient,
userId: string,
periodId: string
): Promise<ReconciliationResult> {
const supabase = await createClient()
// Get total outstanding from supplier invoices
const { data: invoices } = await supabase
+5 -5
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { TrialBalanceRow } from '@/types'
@@ -9,6 +9,7 @@ import type { TrialBalanceRow } from '@/types'
* filtered by fiscal period. Verifies total debits = total credits.
*/
export async function generateTrialBalance(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<{
@@ -17,7 +18,6 @@ export async function generateTrialBalance(
totalCredit: number
isBalanced: boolean
}> {
const supabase = await createClient()
// Get all posted journal entry lines for this period, grouped by account
const { data, error } = await supabase.rpc('generate_trial_balance', {
@@ -27,7 +27,7 @@ export async function generateTrialBalance(
if (error) {
// Fallback: manual aggregation via SQL
return generateTrialBalanceManual(userId, fiscalPeriodId)
return generateTrialBalanceManual(supabase, userId, fiscalPeriodId)
}
if (data) {
@@ -43,13 +43,14 @@ export async function generateTrialBalance(
}
}
return generateTrialBalanceManual(userId, fiscalPeriodId)
return generateTrialBalanceManual(supabase, userId, fiscalPeriodId)
}
/**
* Manual trial balance generation using direct queries
*/
async function generateTrialBalanceManual(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<{
@@ -58,7 +59,6 @@ async function generateTrialBalanceManual(
totalCredit: number
isBalanced: boolean
}> {
const supabase = await createClient()
// Get all journal entry lines for posted entries in this period
const { data: entries, error: entriesError } = await supabase
+2 -2
View File
@@ -1,4 +1,4 @@
import { createClient } from '@/lib/supabase/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
VatDeclaration,
@@ -113,13 +113,13 @@ function round(value: number): number {
* but not used — the method is already baked into journal entry timing.
*/
export async function calculateVatDeclaration(
supabase: SupabaseClient,
userId: string,
periodType: VatPeriodType,
year: number,
period: number,
_accountingMethod: AccountingMethod = 'accrual'
): Promise<VatDeclaration> {
const supabase = await createClient()
const { start, end } = calculatePeriodDates(periodType, year, period)
// Fetch all posted journal entry lines on VAT-relevant accounts for the period
+2 -2
View File
@@ -12,8 +12,6 @@ import { makeJournalEntry, makeTransaction } from '@/tests/helpers'
// Mocks
// ---------------------------------------------------------------------------
vi.mock('@/lib/supabase/server')
const mockEvaluateMappingRules = vi.fn()
vi.mock('@/lib/bookkeeping/mapping-engine', () => ({
evaluateMappingRules: (...args: unknown[]) => mockEvaluateMappingRules(...args),
@@ -231,6 +229,7 @@ describe('ingestTransactions', () => {
expect(result.auto_matched_invoices).toBe(1)
expect(mockGetBestInvoiceMatch).toHaveBeenCalledWith(
expect.anything(), // supabase client
USER_ID,
expect.objectContaining({ id: 'tx-income' }),
0.50
@@ -300,6 +299,7 @@ describe('ingestTransactions', () => {
expect(result.auto_categorized).toBe(1)
expect(mockCreateTransactionJournalEntry).toHaveBeenCalledWith(
expect.anything(),
USER_ID,
expect.objectContaining({ id: 'tx-cat' }),
expect.objectContaining({ confidence: 0.85 })
+7 -2
View File
@@ -1,7 +1,7 @@
import { suggestCategory } from '@/lib/tax/expense-warnings'
import { getExpenseAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
import { findSimilarTemplates } from '@/lib/bookkeeping/template-embeddings'
import { extensionRegistry } from '@/lib/extensions/registry'
import type { Transaction, TransactionCategory, EntityType, MappingRule } from '@/types'
export interface SuggestedCategory {
@@ -257,7 +257,12 @@ export async function getSuggestedTemplates(
let matches: TemplateMatch[]
try {
matches = await findSimilarTemplates(transaction, entityType)
const aiExt = extensionRegistry.get('ai-categorization')
if (aiExt?.services?.findSimilarTemplates) {
matches = await aiExt.services.findSimilarTemplates(transaction, entityType)
} else {
matches = []
}
} catch {
matches = []
}
+3
View File
@@ -191,6 +191,7 @@ export async function ingestTransactions(
// OCR/reference matching is handled inside getBestInvoiceMatch
// (which calls findMatchingInvoices, which now checks references)
const bestMatch = await getBestInvoiceMatch(
supabase,
userId,
newTransaction as Transaction,
0.50
@@ -242,12 +243,14 @@ export async function ingestTransactions(
// 4. Evaluate mapping rules for auto-categorization
try {
const mappingResult = await evaluateMappingRules(
supabase,
userId,
newTransaction as Transaction
)
if (mappingResult.confidence >= 0.8 && !mappingResult.requires_review) {
const journalEntry = await createTransactionJournalEntry(
supabase,
userId,
newTransaction as Transaction,
mappingResult
+132
View File
@@ -0,0 +1,132 @@
/**
* Momsdeklaration box mapping for Swedish VAT returns.
*
* Maps BAS revenue accounts to the correct box (ruta) in the
* momsdeklaration filed with Skatteverket. Used by:
* - Export VAT Monitor (full box overview)
* - EU Sales List (cross-validation against box 35/39)
*
* Reference: Skatteverket momsdeklaration (SKV 4700)
* https://www.skatteverket.se/foretag/moms/deklareramoms/fyllaimomsdeklarationen
*/
/** Momsdeklaration box number */
export type MomsBox =
| '05' // Momspliktig forsaljning (taxable sales)
| '06' // Momspliktiga uttag (taxable withdrawals)
| '07' // Vinstmarginalbeskattning (margin scheme)
| '08' // Hyresinkomster frivillig beskattning (rental)
| '10' // Utgaende moms 25%
| '11' // Utgaende moms 12%
| '12' // Utgaende moms 6%
| '20' // Inkop varor fran EU
| '21' // Inkop tjanster fran EU
| '22' // Inkop tjanster utanfor EU
| '23' // Inkop varor Sverige omvand skattskyldighet
| '24' // Inkop tjanster Sverige omvand skattskyldighet
| '30' // Utgaende moms inkop 25%
| '31' // Utgaende moms inkop 12%
| '32' // Utgaende moms inkop 6%
| '35' // Varuforssaljning till annat EU-land
| '36' // Varuforssaljning utanfor EU (export)
| '37' // Mellanmans inkop trepartshandel
| '38' // Mellanmans forsaljning trepartshandel
| '39' // Tjansteforssaljning EU (huvudregeln)
| '40' // Ovrig forsaljning av tjanster utomlands
| '41' // Forsaljning omvand skattskyldighet Sverige
| '42' // Ovrig forsaljning m.m.
| '48' // Ingaende moms att dra av
| '49' // Moms att betala eller fa tillbaka
| '50' // Importbeskattningsunderlag
| '60' // Importmoms 25%
| '61' // Importmoms 12%
| '62' // Importmoms 6%
/** Map BAS revenue account to momsdeklaration box */
export const ACCOUNT_TO_BOX: Record<string, MomsBox> = {
// Domestic revenue (taxable) → Box 05
'3001': '05', // Forsaljning varor/tjanster 25%
'3002': '05', // Forsaljning varor/tjanster 12%
'3003': '05', // Forsaljning varor/tjanster 6%
// EU goods (reverse charge, VAT-free) → Box 35
'3108': '35', // Forsaljning varor till annat EU-land
'3521': '35', // Fakturerade frakter EU (follows goods treatment)
// Non-EU goods export (zero-rated) → Box 36
'3105': '36', // Forsaljning varor export utanfor EU
'3522': '36', // Fakturerade frakter export
// Triangular trade → Box 38
'3109': '38', // Mellanmans forsaljning trepartshandel
// EU services (reverse charge, main rule) → Box 39
'3308': '39', // Forsaljning tjanster EU
// Non-EU services → Box 40
'3305': '40', // Forsaljning tjanster export utanfor EU
// Output VAT → Boxes 10, 11, 12
'2611': '10', // Utgaende moms 25%
'2621': '11', // Utgaende moms 12%
'2631': '12', // Utgaende moms 6%
// Input VAT → Box 48
'2641': '48', // Ingaende moms
'2645': '48', // Beraknad ingaende moms (EU forvarv)
}
/** Swedish labels for each momsdeklaration box */
export const BOX_LABELS: Record<MomsBox, string> = {
'05': 'Momspliktig försäljning',
'06': 'Momspliktiga uttag',
'07': 'Vinstmarginalbeskattning',
'08': 'Hyresinkomster (frivillig beskattning)',
'10': 'Utgående moms 25%',
'11': 'Utgående moms 12%',
'12': 'Utgående moms 6%',
'20': 'Inköp varor från EU',
'21': 'Inköp tjänster från EU',
'22': 'Inköp tjänster utanför EU',
'23': 'Inköp varor Sverige (omvänd skattskyldighet)',
'24': 'Inköp tjänster Sverige (omvänd skattskyldighet)',
'30': 'Utgående moms på inköp 25%',
'31': 'Utgående moms på inköp 12%',
'32': 'Utgående moms på inköp 6%',
'35': 'Varuförsäljning till annat EU-land',
'36': 'Varuförsäljning utanför EU (export)',
'37': 'Mellanmans inköp vid trepartshandel',
'38': 'Mellanmans försäljning vid trepartshandel',
'39': 'Tjänsteförsäljning till EU (huvudregeln)',
'40': 'Övrig försäljning av tjänster utomlands',
'41': 'Försäljning med omvänd skattskyldighet (Sverige)',
'42': 'Övrig försäljning m.m.',
'48': 'Ingående moms att dra av',
'49': 'Moms att betala eller få tillbaka',
'50': 'Beskattningsunderlag vid import',
'60': 'Importmoms 25%',
'61': 'Importmoms 12%',
'62': 'Importmoms 6%',
}
/** Get the momsdeklaration box for a BAS account number */
export function getBoxForAccount(accountNumber: string): MomsBox | undefined {
return ACCOUNT_TO_BOX[accountNumber]
}
/** Get the Swedish label for a momsdeklaration box */
export function getBoxLabel(box: MomsBox): string {
return BOX_LABELS[box]
}
/** Boxes that represent VAT-exempt export/EU sales (no output VAT) */
export const EXPORT_BOXES: MomsBox[] = ['35', '36', '38', '39', '40']
/** Boxes that represent taxable domestic sales (have output VAT) */
export const DOMESTIC_BOXES: MomsBox[] = ['05', '06', '07', '08']
/** Boxes that represent output VAT */
export const OUTPUT_VAT_BOXES: MomsBox[] = ['10', '11', '12']
/** Boxes that represent input VAT */
export const INPUT_VAT_BOXES: MomsBox[] = ['48']