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:
co-authored by
Claude Opus 4.6
parent
39e407644d
commit
03b569d708
@@ -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
@@ -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[],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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, '\\$&')
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>> = {
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
// ============================================================
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,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')!
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']
|
||||
Reference in New Issue
Block a user