New Base func
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { validateBalance } from '../engine'
|
||||
import type { CreateJournalEntryLineInput } from '@/types'
|
||||
|
||||
describe('validateBalance', () => {
|
||||
it('balanced entry (debit == credit) → valid: true', () => {
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
|
||||
]
|
||||
|
||||
const result = validateBalance(lines)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.totalDebit).toBe(1000)
|
||||
expect(result.totalCredit).toBe(1000)
|
||||
})
|
||||
|
||||
it('unbalanced entry → valid: false', () => {
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
|
||||
]
|
||||
|
||||
const result = validateBalance(lines)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.totalDebit).toBe(1000)
|
||||
expect(result.totalCredit).toBe(500)
|
||||
})
|
||||
|
||||
it('zero amounts → valid: false (roundedDebit must be > 0)', () => {
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 0 },
|
||||
]
|
||||
|
||||
const result = validateBalance(lines)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.totalDebit).toBe(0)
|
||||
expect(result.totalCredit).toBe(0)
|
||||
})
|
||||
|
||||
it('floating point edge case (33.33 + 33.33 + 33.34) → valid: true', () => {
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 33.34, credit_amount: 0 },
|
||||
{ account_number: '3001', debit_amount: 0, credit_amount: 100 },
|
||||
]
|
||||
|
||||
const result = validateBalance(lines)
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.totalDebit).toBe(100)
|
||||
expect(result.totalCredit).toBe(100)
|
||||
})
|
||||
|
||||
it('single line (only debit, no credit) → valid: false', () => {
|
||||
const lines: CreateJournalEntryLineInput[] = [
|
||||
{ account_number: '1930', debit_amount: 500, credit_amount: 0 },
|
||||
]
|
||||
|
||||
const result = validateBalance(lines)
|
||||
expect(result.valid).toBe(false)
|
||||
})
|
||||
})
|
||||
+253
-30
@@ -1,4 +1,5 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
@@ -30,6 +31,7 @@ export function validateBalance(lines: CreateJournalEntryLineInput[]): {
|
||||
|
||||
/**
|
||||
* Get the next voucher number for a user/period/series
|
||||
* Uses the concurrent-safe INSERT ON CONFLICT implementation in the database
|
||||
*/
|
||||
export async function getNextVoucherNumber(
|
||||
userId: string,
|
||||
@@ -104,9 +106,168 @@ export async function findFiscalPeriod(
|
||||
return data.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Build line insert objects from input lines, resolving account IDs and
|
||||
* including tax_code, cost_center, project dimensions
|
||||
*/
|
||||
function buildLineInserts(
|
||||
entryId: string,
|
||||
lines: CreateJournalEntryLineInput[],
|
||||
accountIdMap: Map<string, string>
|
||||
) {
|
||||
return lines.map((line, index) => ({
|
||||
journal_entry_id: entryId,
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
||||
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
||||
currency: line.currency || 'SEK',
|
||||
amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: line.line_description || null,
|
||||
tax_code: line.tax_code || null,
|
||||
cost_center: line.cost_center || null,
|
||||
project: line.project || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a draft journal entry with lines (no voucher number assigned yet)
|
||||
* The entry stays in 'draft' status until commitEntry() is called.
|
||||
*/
|
||||
export async function createDraftEntry(
|
||||
userId: string,
|
||||
input: CreateJournalEntryInput
|
||||
): Promise<JournalEntry> {
|
||||
// Validate balance
|
||||
const balance = validateBalance(input.lines)
|
||||
if (!balance.valid) {
|
||||
throw new Error(
|
||||
`Journal entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, userId, input.lines)
|
||||
|
||||
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
|
||||
const { data: entry, error: entryError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: input.fiscal_period_id,
|
||||
voucher_number: 0,
|
||||
voucher_series: input.voucher_series || 'A',
|
||||
entry_date: input.entry_date,
|
||||
description: input.description,
|
||||
source_type: input.source_type,
|
||||
source_id: input.source_id || null,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
throw new Error(`Failed to create draft journal entry: ${entryError?.message}`)
|
||||
}
|
||||
|
||||
// Insert journal entry lines with dimensions
|
||||
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
|
||||
|
||||
const { error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(lineInserts)
|
||||
|
||||
if (linesError) {
|
||||
await supabase.from('journal_entries').delete().eq('id', entry.id)
|
||||
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
|
||||
}
|
||||
|
||||
// Fetch complete entry with lines
|
||||
const { data: completeEntry } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', entry.id)
|
||||
.single()
|
||||
|
||||
const result = completeEntry as JournalEntry
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.drafted',
|
||||
payload: { entry: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a draft entry: assigns voucher number and transitions to 'posted'
|
||||
* Triggers balance validation and sets committed_at via DB triggers
|
||||
*/
|
||||
export async function commitEntry(
|
||||
userId: string,
|
||||
entryId: string
|
||||
): Promise<JournalEntry> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch the draft entry
|
||||
const { data: entry, error: fetchError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*')
|
||||
.eq('id', entryId)
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'draft')
|
||||
.single()
|
||||
|
||||
if (fetchError || !entry) {
|
||||
throw new Error('Draft journal entry not found')
|
||||
}
|
||||
|
||||
// Assign voucher number
|
||||
const voucherNumber = await getNextVoucherNumber(
|
||||
userId,
|
||||
entry.fiscal_period_id,
|
||||
entry.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Update to posted with voucher number
|
||||
// DB triggers will: validate balance, set committed_at, write audit log
|
||||
const { error: postError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({
|
||||
voucher_number: voucherNumber,
|
||||
status: 'posted',
|
||||
})
|
||||
.eq('id', entryId)
|
||||
|
||||
if (postError) {
|
||||
throw new Error(`Failed to commit journal entry: ${postError.message}`)
|
||||
}
|
||||
|
||||
// Fetch complete posted entry with lines
|
||||
const { data: completeEntry } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', entryId)
|
||||
.single()
|
||||
|
||||
const result = completeEntry as JournalEntry
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a journal entry with lines (verifikation)
|
||||
* Validates balance, resolves account IDs, assigns voucher number, inserts atomically
|
||||
* Convenience wrapper: creates draft + commits in one step.
|
||||
* Validates balance, resolves account IDs, assigns voucher number, inserts atomically.
|
||||
*/
|
||||
export async function createJournalEntry(
|
||||
userId: string,
|
||||
@@ -153,19 +314,8 @@ export async function createJournalEntry(
|
||||
throw new Error(`Failed to create journal entry: ${entryError?.message}`)
|
||||
}
|
||||
|
||||
// Insert journal entry lines (round amounts to 2 decimal places to avoid floating point issues)
|
||||
const lineInserts = input.lines.map((line, index) => ({
|
||||
journal_entry_id: entry.id,
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
||||
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
||||
currency: line.currency || 'SEK',
|
||||
amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: line.line_description || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
// Insert journal entry lines with dimensions
|
||||
const lineInserts = buildLineInserts(entry.id, input.lines, accountIdMap)
|
||||
|
||||
const { error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
@@ -177,7 +327,7 @@ export async function createJournalEntry(
|
||||
throw new Error(`Failed to create journal entry lines: ${linesError.message}`)
|
||||
}
|
||||
|
||||
// Post the entry (triggers balance validation in DB)
|
||||
// Post the entry (triggers balance validation + committed_at in DB)
|
||||
const { data: postedEntry, error: postError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
@@ -199,11 +349,19 @@ export async function createJournalEntry(
|
||||
.eq('id', entry.id)
|
||||
.single()
|
||||
|
||||
return completeEntry as JournalEntry
|
||||
const result = completeEntry as JournalEntry
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reversal entry for an existing journal entry
|
||||
* Sets reversed_by_id/reverses_id links for compliance tracking
|
||||
*/
|
||||
export async function reverseEntry(
|
||||
userId: string,
|
||||
@@ -229,7 +387,7 @@ export async function reverseEntry(
|
||||
|
||||
const lines = (original.lines as JournalEntryLine[]) || []
|
||||
|
||||
// Create reversed lines (swap debit and credit)
|
||||
// Create reversed lines (swap debit and credit, preserve dimensions)
|
||||
const reversedLines: CreateJournalEntryLineInput[] = lines.map((line) => ({
|
||||
account_number: line.account_number,
|
||||
debit_amount: line.credit_amount,
|
||||
@@ -240,24 +398,89 @@ export async function reverseEntry(
|
||||
? -line.amount_in_currency
|
||||
: undefined,
|
||||
exchange_rate: line.exchange_rate || undefined,
|
||||
tax_code: line.tax_code || undefined,
|
||||
cost_center: line.cost_center || undefined,
|
||||
project: line.project || undefined,
|
||||
}))
|
||||
|
||||
// Create reversal entry
|
||||
const reversalEntry = await createJournalEntry(userId, {
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
description: `Makulering: ${original.description}`,
|
||||
source_type: original.source_type,
|
||||
source_id: original.source_id,
|
||||
voucher_series: original.voucher_series,
|
||||
lines: reversedLines,
|
||||
})
|
||||
// Get voucher number for the reversal
|
||||
const voucherNumber = await getNextVoucherNumber(
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Mark original as reversed
|
||||
// Resolve account IDs
|
||||
const accountIdMap = await resolveAccountIds(supabase, userId, reversedLines)
|
||||
|
||||
// Create reversal entry with reverses_id link
|
||||
const { data: reversalEntry, error: reversalError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: voucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
description: `Makulering: ${original.description}`,
|
||||
source_type: 'storno',
|
||||
source_id: original.source_id || null,
|
||||
reverses_id: entryId,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (reversalError || !reversalEntry) {
|
||||
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
|
||||
}
|
||||
|
||||
// Insert reversal lines with dimensions
|
||||
const lineInserts = buildLineInserts(reversalEntry.id, reversedLines, accountIdMap)
|
||||
|
||||
const { error: linesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(lineInserts)
|
||||
|
||||
if (linesError) {
|
||||
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
|
||||
throw new Error(`Failed to create reversal lines: ${linesError.message}`)
|
||||
}
|
||||
|
||||
// Post the reversal entry
|
||||
const { error: postError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.eq('id', reversalEntry.id)
|
||||
|
||||
if (postError) {
|
||||
await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id)
|
||||
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
|
||||
throw new Error(`Failed to post reversal entry: ${postError.message}`)
|
||||
}
|
||||
|
||||
// Mark original as reversed with reversed_by_id link
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'reversed' })
|
||||
.update({
|
||||
status: 'reversed',
|
||||
reversed_by_id: reversalEntry.id,
|
||||
})
|
||||
.eq('id', entryId)
|
||||
|
||||
return reversalEntry
|
||||
// Fetch complete reversal entry with lines
|
||||
const { data: completeEntry } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', reversalEntry.id)
|
||||
.single()
|
||||
|
||||
const result = completeEntry as JournalEntry
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { AuditLogEntry, AuditAction } from '@/types'
|
||||
|
||||
/**
|
||||
* Audit Service - Read-only service for the audit log
|
||||
*
|
||||
* The audit log is written exclusively by database triggers (SECURITY DEFINER).
|
||||
* This service provides read access for compliance reporting and investigation.
|
||||
*/
|
||||
|
||||
export interface AuditLogFilters {
|
||||
action?: AuditAction
|
||||
table_name?: string
|
||||
record_id?: string
|
||||
from_date?: string
|
||||
to_date?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated audit log entries for a user
|
||||
*/
|
||||
export async function getAuditLog(
|
||||
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
|
||||
|
||||
let query = supabase
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + pageSize - 1)
|
||||
|
||||
if (filters.action) {
|
||||
query = query.eq('action', filters.action)
|
||||
}
|
||||
if (filters.table_name) {
|
||||
query = query.eq('table_name', filters.table_name)
|
||||
}
|
||||
if (filters.record_id) {
|
||||
query = query.eq('record_id', filters.record_id)
|
||||
}
|
||||
if (filters.from_date) {
|
||||
query = query.gte('created_at', filters.from_date)
|
||||
}
|
||||
if (filters.to_date) {
|
||||
query = query.lte('created_at', filters.to_date)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch audit log: ${error.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
data: (data as AuditLogEntry[]) || [],
|
||||
count: count ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full history of a single record (all mutations)
|
||||
*/
|
||||
export async function getEntityHistory(
|
||||
userId: string,
|
||||
tableName: string,
|
||||
recordId: string
|
||||
): Promise<AuditLogEntry[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('table_name', tableName)
|
||||
.eq('record_id', recordId)
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch entity history: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as AuditLogEntry[]) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace the correction chain for a journal entry:
|
||||
* original → storno (reversal) → corrected entry
|
||||
*/
|
||||
export async function getCorrectionChain(
|
||||
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
|
||||
.from('journal_entries')
|
||||
.select('id, reverses_id, reversed_by_id, correction_of_id')
|
||||
.eq('id', journalEntryId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (entryError || !entry) {
|
||||
throw new Error('Journal entry not found')
|
||||
}
|
||||
|
||||
// Collect all related entry IDs
|
||||
const relatedIds = new Set<string>([entry.id])
|
||||
if (entry.reverses_id) relatedIds.add(entry.reverses_id)
|
||||
if (entry.reversed_by_id) relatedIds.add(entry.reversed_by_id)
|
||||
if (entry.correction_of_id) relatedIds.add(entry.correction_of_id)
|
||||
|
||||
// Also look for entries that reference this one
|
||||
const { data: referencing } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.or(`reverses_id.eq.${journalEntryId},reversed_by_id.eq.${journalEntryId},correction_of_id.eq.${journalEntryId}`)
|
||||
|
||||
for (const ref of referencing || []) {
|
||||
relatedIds.add(ref.id)
|
||||
}
|
||||
|
||||
// Fetch audit log entries for all related IDs
|
||||
const { data, error } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('table_name', 'journal_entries')
|
||||
.in('record_id', Array.from(relatedIds))
|
||||
.order('created_at', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch correction chain: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as AuditLogEntry[]) || []
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeFiscalPeriod } from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// Mock — separate client (no .then) from query builder (thenable)
|
||||
// ============================================================
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
// Thenable for chains awaited without .single()
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
// Client has NO .then — won't be consumed by `await createClient()`
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { lockPeriod, closePeriod, createNextPeriod } from '../period-service'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
})
|
||||
|
||||
describe('lockPeriod', () => {
|
||||
it('sets locked_at and emits period.locked', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', locked_at: null, is_closed: false })
|
||||
const lockedPeriod = { ...period, locked_at: '2024-12-31T23:59:59Z' }
|
||||
|
||||
results = [
|
||||
{ data: period, error: null }, // fetch
|
||||
{ data: lockedPeriod, error: null }, // update
|
||||
]
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('period.locked', handler)
|
||||
|
||||
const result = await lockPeriod('user-1', 'fp-1')
|
||||
|
||||
expect(result.locked_at).toBeTruthy()
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects already-locked period', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
locked_at: '2024-06-01T00:00:00Z',
|
||||
is_closed: false,
|
||||
})
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
await expect(lockPeriod('user-1', 'fp-1')).rejects.toThrow('already locked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('closePeriod', () => {
|
||||
it('requires period is locked and has closing_entry_id', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
locked_at: '2024-12-31T23:59:59Z',
|
||||
is_closed: false,
|
||||
closing_entry_id: 'ce-1',
|
||||
})
|
||||
const closedPeriod = { ...period, is_closed: true, closed_at: '2024-12-31T23:59:59Z' }
|
||||
|
||||
results = [
|
||||
{ data: period, error: null },
|
||||
{ data: closedPeriod, error: null },
|
||||
]
|
||||
|
||||
const result = await closePeriod('user-1', 'fp-1')
|
||||
expect(result.is_closed).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects if not locked', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
locked_at: null,
|
||||
is_closed: false,
|
||||
closing_entry_id: 'ce-1',
|
||||
})
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow('must be locked')
|
||||
})
|
||||
|
||||
it('rejects if no closing_entry_id', async () => {
|
||||
const period = makeFiscalPeriod({
|
||||
id: 'fp-1',
|
||||
locked_at: '2024-12-31T23:59:59Z',
|
||||
is_closed: false,
|
||||
closing_entry_id: null,
|
||||
})
|
||||
|
||||
results = [{ data: period, error: null }]
|
||||
|
||||
await expect(closePeriod('user-1', 'fp-1')).rejects.toThrow(
|
||||
'Year-end closing must be executed'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNextPeriod', () => {
|
||||
it('calculates correct dates for standard (Jan-Dec) fiscal year', async () => {
|
||||
const current = makeFiscalPeriod({
|
||||
id: 'fp-2024',
|
||||
period_start: '2024-01-01',
|
||||
period_end: '2024-12-31',
|
||||
})
|
||||
|
||||
const nextPeriod = makeFiscalPeriod({
|
||||
id: 'fp-2025',
|
||||
name: 'FY 2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
previous_period_id: 'fp-2024',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: current, error: null }, // fetch current
|
||||
{ data: null, error: null }, // check if next exists (maybeSingle)
|
||||
{ data: nextPeriod, error: null }, // insert
|
||||
]
|
||||
|
||||
const result = await createNextPeriod('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')
|
||||
})
|
||||
|
||||
it('calculates correct dates for broken (Jul-Jun) fiscal year', async () => {
|
||||
const current = makeFiscalPeriod({
|
||||
id: 'fp-2024',
|
||||
period_start: '2023-07-01',
|
||||
period_end: '2024-06-30',
|
||||
})
|
||||
|
||||
const nextPeriod = makeFiscalPeriod({
|
||||
id: 'fp-2025',
|
||||
name: 'FY 2024/2025',
|
||||
period_start: '2024-07-01',
|
||||
period_end: '2025-06-30',
|
||||
previous_period_id: 'fp-2024',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: current, error: null },
|
||||
{ data: null, error: null },
|
||||
{ data: nextPeriod, error: null },
|
||||
]
|
||||
|
||||
const result = await createNextPeriod('user-1', 'fp-2024')
|
||||
expect(result.period_start).toBe('2024-07-01')
|
||||
expect(result.period_end).toBe('2025-06-30')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeJournalEntry, makeJournalEntryLine } from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// Mock — separate client (no .then) from query builder (thenable)
|
||||
// ============================================================
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'in', 'insert', 'update', 'delete']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
|
||||
import { correctEntry } from '../storno-service'
|
||||
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
|
||||
// Reset the mock implementations after clearAllMocks
|
||||
vi.mocked(validateBalance).mockReturnValue({ valid: true, totalDebit: 1000, totalCredit: 1000 })
|
||||
let voucherNum = 0
|
||||
vi.mocked(getNextVoucherNumber).mockImplementation(async () => ++voucherNum)
|
||||
})
|
||||
|
||||
describe('correctEntry', () => {
|
||||
const originalEntry = makeJournalEntry({
|
||||
id: 'orig-1',
|
||||
status: 'posted',
|
||||
description: 'Test purchase',
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
lines: [
|
||||
makeJournalEntryLine({ account_number: '5410', debit_amount: 1000, credit_amount: 0 }),
|
||||
makeJournalEntryLine({ account_number: '1930', debit_amount: 0, credit_amount: 1000 }),
|
||||
],
|
||||
})
|
||||
|
||||
const correctedLines = [
|
||||
{ account_number: '5420', debit_amount: 1200, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1200 },
|
||||
]
|
||||
|
||||
function setupResults() {
|
||||
const reversalEntry = makeJournalEntry({ id: 'reversal-1', reverses_id: 'orig-1' })
|
||||
const correctedEntry = makeJournalEntry({ id: 'corrected-1', correction_of_id: 'orig-1' })
|
||||
|
||||
results = [
|
||||
// 0: fetch original
|
||||
{ data: originalEntry, error: null },
|
||||
// 1: insert reversal entry
|
||||
{ data: reversalEntry, error: null },
|
||||
// 2: insert reversal lines (thenable, no .single())
|
||||
{ data: null, error: null },
|
||||
// 3: update reversal to posted (thenable)
|
||||
{ data: null, error: null },
|
||||
// 4: mark original as reversed (thenable)
|
||||
{ data: null, error: null },
|
||||
// 5: fetch accounts for corrected lines
|
||||
{ data: [{ id: 'acc-5420', account_number: '5420' }, { id: 'acc-1930', account_number: '1930' }], error: null },
|
||||
// 6: insert corrected entry
|
||||
{ data: correctedEntry, error: null },
|
||||
// 7: insert corrected lines (thenable)
|
||||
{ data: null, error: null },
|
||||
// 8: update corrected to posted (thenable)
|
||||
{ data: null, error: null },
|
||||
// 9: fetch final reversal
|
||||
{ data: { ...reversalEntry, lines: [] }, error: null },
|
||||
// 10: fetch final corrected
|
||||
{ data: { ...correctedEntry, lines: correctedLines }, error: null },
|
||||
]
|
||||
}
|
||||
|
||||
it('creates reversal with swapped debit/credit lines', async () => {
|
||||
setupResults()
|
||||
const result = await correctEntry('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)
|
||||
expect(result.reversal.id).toBe('reversal-1')
|
||||
expect(result.corrected.id).toBe('corrected-1')
|
||||
expect(result.corrected.correction_of_id).toBe('orig-1')
|
||||
})
|
||||
|
||||
it('validates balance of corrected lines (rejects unbalanced)', async () => {
|
||||
vi.mocked(validateBalance).mockReturnValueOnce({
|
||||
valid: false,
|
||||
totalDebit: 1200,
|
||||
totalCredit: 1000,
|
||||
})
|
||||
|
||||
await expect(
|
||||
correctEntry('user-1', 'orig-1', [
|
||||
{ account_number: '5420', debit_amount: 1200, credit_amount: 0 },
|
||||
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
|
||||
])
|
||||
).rejects.toThrow('not balanced')
|
||||
})
|
||||
|
||||
it('emits journal_entry.corrected event', async () => {
|
||||
setupResults()
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('journal_entry.corrected', handler)
|
||||
|
||||
await correctEntry('user-1', 'orig-1', correctedLines)
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-1' })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeFiscalPeriod } from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// Mock — separate client (no .then) from query builder (thenable)
|
||||
// ============================================================
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||
generateTrialBalance: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/reports/income-statement', () => ({
|
||||
generateIncomeStatement: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../period-service', () => ({
|
||||
lockPeriod: vi.fn(),
|
||||
closePeriod: vi.fn(),
|
||||
createNextPeriod: vi.fn(),
|
||||
}))
|
||||
|
||||
import { validateYearEndReadiness, previewYearEndClosing } from '../year-end-service'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
})
|
||||
|
||||
describe('validateYearEndReadiness', () => {
|
||||
it('returns errors when drafts exist', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
results = [
|
||||
// 0: fetch period (.single)
|
||||
{ data: period, error: null },
|
||||
// 1: count drafts (thenable chain) — count: 3
|
||||
{ data: null, error: null, count: 3 },
|
||||
// 2: count posted entries (thenable chain) — count: 10
|
||||
{ data: null, error: null, count: 10 },
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 0,
|
||||
totalCredit: 0,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness('user-1', 'fp-1')
|
||||
expect(result.ready).toBe(false)
|
||||
expect(result.errors.some((e: string) => e.includes('draft'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns errors when trial balance is unbalanced', async () => {
|
||||
const period = makeFiscalPeriod({ id: 'fp-1', is_closed: false, closing_entry_id: null })
|
||||
|
||||
results = [
|
||||
{ data: period, error: null },
|
||||
{ data: null, error: null, count: 0 }, // no drafts
|
||||
{ data: null, error: null, count: 5 }, // some posted
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: false,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 9500,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness('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)
|
||||
})
|
||||
|
||||
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')
|
||||
const builder = makeBuilder()
|
||||
const client = {
|
||||
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 = [
|
||||
{ data: period, error: null },
|
||||
{ data: null, error: null, count: 0 },
|
||||
{ data: null, error: null, count: 5 },
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [],
|
||||
isBalanced: true,
|
||||
totalDebit: 10000,
|
||||
totalCredit: 10000,
|
||||
} as never)
|
||||
|
||||
const result = await validateYearEndReadiness('user-1', 'fp-1')
|
||||
expect(result.warnings.some((w: string) => w.includes('gap'))).toBe(true)
|
||||
expect(result.voucherGaps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('previewYearEndClosing', () => {
|
||||
it('calculates net result from class 3-8 accounts', async () => {
|
||||
results = [
|
||||
// 0: fetch company_settings (.single)
|
||||
{ data: { entity_type: 'aktiebolag' }, error: null },
|
||||
]
|
||||
|
||||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||||
net_result: 150000,
|
||||
} as never)
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [
|
||||
{ account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 500000 },
|
||||
{ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 200000, closing_credit: 0 },
|
||||
{ account_number: '6570', account_name: 'Bankavgifter', account_class: 6, closing_debit: 150000, closing_credit: 0 },
|
||||
],
|
||||
isBalanced: true,
|
||||
totalDebit: 350000,
|
||||
totalCredit: 500000,
|
||||
} as never)
|
||||
|
||||
const preview = await previewYearEndClosing('user-1', 'fp-1')
|
||||
|
||||
expect(preview.netResult).toBe(150000)
|
||||
expect(preview.closingAccount).toBe('2099')
|
||||
expect(preview.closingAccountName).toBe('Årets resultat')
|
||||
expect(preview.closingLines.length).toBeGreaterThanOrEqual(3)
|
||||
expect(preview.resultAccountSummary).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses 2010 for EF entity type', async () => {
|
||||
results = [
|
||||
{ data: { entity_type: 'enskild_firma' }, error: null },
|
||||
]
|
||||
|
||||
vi.mocked(generateIncomeStatement).mockResolvedValue({ net_result: 50000 } as never)
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
rows: [
|
||||
{ account_number: '3001', account_name: 'Intäkter', account_class: 3, closing_debit: 0, closing_credit: 100000 },
|
||||
{ account_number: '5010', account_name: 'Kostnader', account_class: 5, closing_debit: 50000, closing_credit: 0 },
|
||||
],
|
||||
isBalanced: true,
|
||||
totalDebit: 50000,
|
||||
totalCredit: 100000,
|
||||
} as never)
|
||||
|
||||
const preview = await previewYearEndClosing('user-1', 'fp-1')
|
||||
|
||||
expect(preview.closingAccount).toBe('2010')
|
||||
expect(preview.closingAccountName).toBe('Eget kapital')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { FiscalPeriod, PeriodStatus } from '@/types'
|
||||
|
||||
/**
|
||||
* Lock a fiscal period — prevents new journal entries from being posted.
|
||||
* Requires: period exists, belongs to user, not already locked/closed.
|
||||
*/
|
||||
export async function lockPeriod(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch period
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
if (period.is_closed) {
|
||||
throw new Error('Period is already closed')
|
||||
}
|
||||
|
||||
if (period.locked_at) {
|
||||
throw new Error('Period is already locked')
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ locked_at: new Date().toISOString() })
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
throw new Error(`Failed to lock period: ${updateError?.message}`)
|
||||
}
|
||||
|
||||
const result = updated as FiscalPeriod
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'period.locked',
|
||||
payload: { period: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a fiscal period — marks it as permanently closed.
|
||||
* Requires: period is locked AND closing_entry_id is set (year-end must run first).
|
||||
*/
|
||||
export async function closePeriod(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
if (period.is_closed) {
|
||||
throw new Error('Period is already closed')
|
||||
}
|
||||
|
||||
if (!period.locked_at) {
|
||||
throw new Error('Period must be locked before closing')
|
||||
}
|
||||
|
||||
if (!period.closing_entry_id) {
|
||||
throw new Error('Year-end closing must be executed before closing the period')
|
||||
}
|
||||
|
||||
const { data: updated, error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({
|
||||
is_closed: true,
|
||||
closed_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (updateError || !updated) {
|
||||
throw new Error(`Failed to close period: ${updateError?.message}`)
|
||||
}
|
||||
|
||||
return updated as FiscalPeriod
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the next fiscal period following the current one.
|
||||
* Computes dates based on the current period's length (handles brutet räkenskapsår).
|
||||
* Sets previous_period_id for chain validation.
|
||||
*/
|
||||
export async function createNextPeriod(
|
||||
userId: string,
|
||||
currentPeriodId: string
|
||||
): Promise<FiscalPeriod> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', currentPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !current) {
|
||||
throw new Error('Current fiscal period not found')
|
||||
}
|
||||
|
||||
// Check if next period already exists
|
||||
const nextStart = new Date(current.period_end)
|
||||
nextStart.setDate(nextStart.getDate() + 1)
|
||||
|
||||
const { data: existing } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('period_start', nextStart.toISOString().split('T')[0])
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Next fiscal period already exists')
|
||||
}
|
||||
|
||||
// Compute period length from current period to handle broken fiscal years
|
||||
const currentStart = new Date(current.period_start)
|
||||
const currentEnd = new Date(current.period_end)
|
||||
|
||||
// Calculate months difference
|
||||
const monthsDiff =
|
||||
(currentEnd.getFullYear() - currentStart.getFullYear()) * 12 +
|
||||
(currentEnd.getMonth() - currentStart.getMonth())
|
||||
|
||||
// Next period end: add same number of months from next start, then go to end of that month
|
||||
const nextEnd = new Date(nextStart)
|
||||
nextEnd.setMonth(nextEnd.getMonth() + monthsDiff)
|
||||
// Go to end of the month
|
||||
nextEnd.setMonth(nextEnd.getMonth() + 1)
|
||||
nextEnd.setDate(0)
|
||||
|
||||
const nextStartStr = nextStart.toISOString().split('T')[0]
|
||||
const nextEndStr = nextEnd.toISOString().split('T')[0]
|
||||
|
||||
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
|
||||
const startYear = nextStart.getFullYear()
|
||||
const endYear = nextEnd.getFullYear()
|
||||
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
|
||||
|
||||
const { data: newPeriod, error: insertError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
name,
|
||||
period_start: nextStartStr,
|
||||
period_end: nextEndStr,
|
||||
previous_period_id: currentPeriodId,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError || !newPeriod) {
|
||||
throw new Error(`Failed to create next period: ${insertError?.message}`)
|
||||
}
|
||||
|
||||
return newPeriod as FiscalPeriod
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status summary for a fiscal period.
|
||||
*/
|
||||
export async function getPeriodStatus(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<PeriodStatus> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// Count draft entries in this period
|
||||
const { count: draftCount } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'draft')
|
||||
|
||||
// Check if next period exists
|
||||
const nextStart = new Date(period.period_end)
|
||||
nextStart.setDate(nextStart.getDate() + 1)
|
||||
|
||||
const { data: nextPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('previous_period_id', fiscalPeriodId)
|
||||
.maybeSingle()
|
||||
|
||||
return {
|
||||
is_locked: !!period.locked_at,
|
||||
is_closed: period.is_closed,
|
||||
has_closing_entry: !!period.closing_entry_id,
|
||||
has_opening_balances: period.opening_balances_set,
|
||||
draft_count: draftCount ?? 0,
|
||||
next_period_exists: !!nextPeriod,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type {
|
||||
CreateJournalEntryLineInput,
|
||||
JournalEntry,
|
||||
JournalEntryLine,
|
||||
} from '@/types'
|
||||
import { validateBalance, getNextVoucherNumber } from '@/lib/bookkeeping/engine'
|
||||
|
||||
/**
|
||||
* Storno Service - 3-step correction flow per Bokföringslagen
|
||||
*
|
||||
* Swedish bookkeeping law requires that committed entries cannot be modified.
|
||||
* To correct an error, you must:
|
||||
* 1. Create a storno (reversal) entry that nullifies the original
|
||||
* 2. Create a corrected entry with the right data
|
||||
* 3. Link all three via reverses_id, reversed_by_id, correction_of_id
|
||||
*/
|
||||
|
||||
/**
|
||||
* Correct an existing posted journal entry using the storno method.
|
||||
*
|
||||
* Returns: { reversal, corrected } - the two new entries created
|
||||
*/
|
||||
export async function correctEntry(
|
||||
userId: string,
|
||||
originalEntryId: string,
|
||||
correctedLines: CreateJournalEntryLineInput[]
|
||||
): Promise<{ reversal: JournalEntry; corrected: JournalEntry }> {
|
||||
// Validate the corrected lines are balanced
|
||||
const balance = validateBalance(correctedLines)
|
||||
if (!balance.valid) {
|
||||
throw new Error(
|
||||
`Corrected entry is not balanced: debits (${balance.totalDebit}) != credits (${balance.totalCredit})`
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch original entry with lines
|
||||
const { data: original, error: fetchError } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', originalEntryId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !original) {
|
||||
throw new Error('Original journal entry not found')
|
||||
}
|
||||
|
||||
if (original.status !== 'posted') {
|
||||
throw new Error('Can only correct posted entries')
|
||||
}
|
||||
|
||||
const originalLines = (original.lines as JournalEntryLine[]) || []
|
||||
|
||||
// ===== Step 1: Create storno (reversal) entry =====
|
||||
const reversalVoucherNumber = await getNextVoucherNumber(
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
const { data: reversalEntry, error: reversalError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: reversalVoucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
description: `Storno: ${original.description}`,
|
||||
source_type: 'storno',
|
||||
reverses_id: originalEntryId,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (reversalError || !reversalEntry) {
|
||||
throw new Error(`Failed to create reversal entry: ${reversalError?.message}`)
|
||||
}
|
||||
|
||||
// Insert reversed lines (swap debit and credit)
|
||||
const reversalLineInserts = originalLines.map((line, index) => ({
|
||||
journal_entry_id: reversalEntry.id,
|
||||
account_number: line.account_number,
|
||||
account_id: line.account_id || null,
|
||||
debit_amount: Math.round((Number(line.credit_amount) || 0) * 100) / 100,
|
||||
credit_amount: Math.round((Number(line.debit_amount) || 0) * 100) / 100,
|
||||
currency: line.currency || 'SEK',
|
||||
amount_in_currency: line.amount_in_currency ? -Number(line.amount_in_currency) : null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: `Storno: ${line.line_description || ''}`,
|
||||
tax_code: line.tax_code || null,
|
||||
cost_center: line.cost_center || null,
|
||||
project: line.project || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: reversalLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(reversalLineInserts)
|
||||
|
||||
if (reversalLinesError) {
|
||||
await supabase.from('journal_entries').delete().eq('id', reversalEntry.id)
|
||||
throw new Error(`Failed to create reversal lines: ${reversalLinesError.message}`)
|
||||
}
|
||||
|
||||
// Post the reversal entry
|
||||
const { error: postReversalError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.eq('id', reversalEntry.id)
|
||||
|
||||
if (postReversalError) {
|
||||
throw new Error(`Failed to post reversal entry: ${postReversalError.message}`)
|
||||
}
|
||||
|
||||
// Mark original as reversed
|
||||
await supabase
|
||||
.from('journal_entries')
|
||||
.update({
|
||||
status: 'reversed',
|
||||
reversed_by_id: reversalEntry.id,
|
||||
})
|
||||
.eq('id', originalEntryId)
|
||||
|
||||
// ===== Step 2: Create corrected entry =====
|
||||
const correctedVoucherNumber = await getNextVoucherNumber(
|
||||
userId,
|
||||
original.fiscal_period_id,
|
||||
original.voucher_series || 'A'
|
||||
)
|
||||
|
||||
// Resolve account IDs for corrected lines
|
||||
const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))]
|
||||
const { data: accounts } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('id, account_number')
|
||||
.eq('user_id', userId)
|
||||
.in('account_number', accountNumbers)
|
||||
|
||||
const accountIdMap = new Map<string, string>()
|
||||
for (const account of accounts || []) {
|
||||
accountIdMap.set(account.account_number, account.id)
|
||||
}
|
||||
|
||||
const { data: correctedEntry, error: correctedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
fiscal_period_id: original.fiscal_period_id,
|
||||
voucher_number: correctedVoucherNumber,
|
||||
voucher_series: original.voucher_series || 'A',
|
||||
entry_date: new Date().toISOString().split('T')[0],
|
||||
description: `Rättelse: ${original.description}`,
|
||||
source_type: 'correction',
|
||||
correction_of_id: originalEntryId,
|
||||
status: 'draft',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (correctedError || !correctedEntry) {
|
||||
throw new Error(`Failed to create corrected entry: ${correctedError?.message}`)
|
||||
}
|
||||
|
||||
// Insert corrected lines
|
||||
const correctedLineInserts = correctedLines.map((line, index) => ({
|
||||
journal_entry_id: correctedEntry.id,
|
||||
account_number: line.account_number,
|
||||
account_id: accountIdMap.get(line.account_number) || null,
|
||||
debit_amount: Math.round((line.debit_amount || 0) * 100) / 100,
|
||||
credit_amount: Math.round((line.credit_amount || 0) * 100) / 100,
|
||||
currency: line.currency || 'SEK',
|
||||
amount_in_currency: line.amount_in_currency
|
||||
? Math.round(line.amount_in_currency * 100) / 100
|
||||
: null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: line.line_description || null,
|
||||
tax_code: line.tax_code || null,
|
||||
cost_center: line.cost_center || null,
|
||||
project: line.project || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
|
||||
const { error: correctedLinesError } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.insert(correctedLineInserts)
|
||||
|
||||
if (correctedLinesError) {
|
||||
await supabase.from('journal_entries').delete().eq('id', correctedEntry.id)
|
||||
throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`)
|
||||
}
|
||||
|
||||
// Post the corrected entry
|
||||
const { error: postCorrectedError } = await supabase
|
||||
.from('journal_entries')
|
||||
.update({ status: 'posted' })
|
||||
.eq('id', correctedEntry.id)
|
||||
|
||||
if (postCorrectedError) {
|
||||
throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`)
|
||||
}
|
||||
|
||||
// ===== Step 3: Fetch complete entries =====
|
||||
const { data: finalReversal } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', reversalEntry.id)
|
||||
.single()
|
||||
|
||||
const { data: finalCorrected } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)')
|
||||
.eq('id', correctedEntry.id)
|
||||
.single()
|
||||
|
||||
const result = {
|
||||
reversal: finalReversal as JournalEntry,
|
||||
corrected: finalCorrected as JournalEntry,
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.corrected',
|
||||
payload: {
|
||||
original: original as JournalEntry,
|
||||
storno: result.reversal,
|
||||
corrected: result.corrected,
|
||||
userId,
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
|
||||
import type {
|
||||
YearEndValidation,
|
||||
YearEndPreview,
|
||||
YearEndResult,
|
||||
CreateJournalEntryLineInput,
|
||||
FiscalPeriod,
|
||||
JournalEntry,
|
||||
VoucherGap,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Validate whether a fiscal period is ready for year-end closing.
|
||||
* Returns blocking errors and informational warnings.
|
||||
*/
|
||||
export async function validateYearEndReadiness(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<YearEndValidation> {
|
||||
const supabase = await createClient()
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
// Fetch the period
|
||||
const { data: period, error: fetchError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !period) {
|
||||
return {
|
||||
ready: false,
|
||||
errors: ['Fiscal period not found'],
|
||||
warnings: [],
|
||||
draftCount: 0,
|
||||
voucherGaps: [],
|
||||
trialBalanceBalanced: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check: period not already closed
|
||||
if (period.is_closed) {
|
||||
errors.push('Period is already closed')
|
||||
}
|
||||
|
||||
// Check: closing entry doesn't already exist
|
||||
if (period.closing_entry_id) {
|
||||
errors.push('Year-end closing entry already exists for this period')
|
||||
}
|
||||
|
||||
// Check: no draft entries
|
||||
const { count: draftCount } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'draft')
|
||||
|
||||
const drafts = draftCount ?? 0
|
||||
if (drafts > 0) {
|
||||
errors.push(`${drafts} draft journal entries must be posted or deleted before closing`)
|
||||
}
|
||||
|
||||
// Check: voucher continuity
|
||||
let voucherGaps: VoucherGap[] = []
|
||||
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
|
||||
p_user_id: userId,
|
||||
p_fiscal_period_id: fiscalPeriodId,
|
||||
p_series: 'A',
|
||||
})
|
||||
|
||||
if (!gapsError && gaps && gaps.length > 0) {
|
||||
voucherGaps = gaps as VoucherGap[]
|
||||
warnings.push(
|
||||
`Voucher number gaps detected: ${voucherGaps.map((g) => `${g.gap_start}-${g.gap_end}`).join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
// Check: trial balance is balanced
|
||||
const trialBalance = await generateTrialBalance(userId, fiscalPeriodId)
|
||||
const trialBalanceBalanced = trialBalance.isBalanced
|
||||
|
||||
if (!trialBalanceBalanced) {
|
||||
errors.push(
|
||||
`Trial balance is not balanced: debit=${trialBalance.totalDebit}, credit=${trialBalance.totalCredit}`
|
||||
)
|
||||
}
|
||||
|
||||
// Check: at least some entries exist
|
||||
const { count: entryCount } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('status', 'posted')
|
||||
|
||||
if ((entryCount ?? 0) === 0) {
|
||||
warnings.push('No posted journal entries in this period')
|
||||
}
|
||||
|
||||
return {
|
||||
ready: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
draftCount: drafts,
|
||||
voucherGaps,
|
||||
trialBalanceBalanced,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview year-end closing without persisting anything.
|
||||
* Shows the net result, closing account, and the journal entry lines that would be created.
|
||||
*/
|
||||
export async function previewYearEndClosing(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<YearEndPreview> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get entity type to determine closing account
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
const entityType = settings?.entity_type ?? 'aktiebolag'
|
||||
const closingAccount = entityType === 'enskild_firma' ? '2010' : '2099'
|
||||
const closingAccountName =
|
||||
entityType === 'enskild_firma'
|
||||
? 'Eget kapital'
|
||||
: 'Årets resultat'
|
||||
|
||||
// Get income statement for net result
|
||||
const incomeStatement = await generateIncomeStatement(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 resultAccounts = rows.filter(
|
||||
(r) => r.account_class >= 3 && r.account_class <= 8
|
||||
)
|
||||
|
||||
// Build closing lines: zero each result account
|
||||
const closingLines: CreateJournalEntryLineInput[] = []
|
||||
const resultAccountSummary: { account_number: string; account_name: string; amount: number }[] = []
|
||||
|
||||
for (const account of resultAccounts) {
|
||||
const netBalance = account.closing_debit - account.closing_credit
|
||||
|
||||
if (Math.abs(netBalance) < 0.005) continue
|
||||
|
||||
resultAccountSummary.push({
|
||||
account_number: account.account_number,
|
||||
account_name: account.account_name,
|
||||
amount: netBalance,
|
||||
})
|
||||
|
||||
// To zero this account: reverse its net balance
|
||||
if (netBalance > 0) {
|
||||
// Account has debit balance → credit it to zero
|
||||
closingLines.push({
|
||||
account_number: account.account_number,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(netBalance * 100) / 100,
|
||||
line_description: `Closing: ${account.account_name}`,
|
||||
})
|
||||
} else {
|
||||
// Account has credit balance → debit it to zero
|
||||
closingLines.push({
|
||||
account_number: account.account_number,
|
||||
debit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Closing: ${account.account_name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Final line: transfer net result to closing account (2099/2010)
|
||||
// Net result = revenue - expenses + financial
|
||||
// If positive (profit): credit to equity (2099/2010)
|
||||
// If negative (loss): debit to equity (2099/2010)
|
||||
const totalClosingDebit = closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalClosingCredit = closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const balancingAmount = Math.round(Math.abs(totalClosingDebit - totalClosingCredit) * 100) / 100
|
||||
|
||||
if (balancingAmount > 0.005) {
|
||||
if (totalClosingDebit > totalClosingCredit) {
|
||||
// More debits than credits → need credit on closing account
|
||||
closingLines.push({
|
||||
account_number: closingAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: balancingAmount,
|
||||
line_description: `Årets resultat → ${closingAccountName}`,
|
||||
})
|
||||
} else {
|
||||
// More credits than debits → need debit on closing account
|
||||
closingLines.push({
|
||||
account_number: closingAccount,
|
||||
debit_amount: balancingAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Årets resultat → ${closingAccountName}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
netResult,
|
||||
closingAccount,
|
||||
closingAccountName,
|
||||
closingLines,
|
||||
resultAccountSummary,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute year-end closing for a fiscal period.
|
||||
*
|
||||
* 1. Validate readiness
|
||||
* 2. Create closing entry (zeros class 3-8 accounts)
|
||||
* 3. Set closing_entry_id on the period
|
||||
* 4. Lock the period
|
||||
* 5. Close the period
|
||||
* 6. Create next fiscal period
|
||||
* 7. Generate opening balances in next period
|
||||
*/
|
||||
export async function executeYearEndClosing(
|
||||
userId: string,
|
||||
fiscalPeriodId: string
|
||||
): Promise<YearEndResult> {
|
||||
// 1. Validate readiness
|
||||
const validation = await validateYearEndReadiness(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')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (!period) {
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// 2. Get closing preview
|
||||
const preview = await previewYearEndClosing(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, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: period.period_end,
|
||||
description: `Årsbokslut ${period.name}`,
|
||||
source_type: 'year_end',
|
||||
voucher_series: 'A',
|
||||
lines: preview.closingLines,
|
||||
})
|
||||
|
||||
// 4. Update fiscal period with closing_entry_id
|
||||
const { error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ closing_entry_id: closingEntry.id })
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (updateError) {
|
||||
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// 5. Lock the period
|
||||
await lockPeriod(userId, fiscalPeriodId)
|
||||
|
||||
// 6. Close the period
|
||||
await closePeriod(userId, fiscalPeriodId)
|
||||
|
||||
// 7. Create next period
|
||||
const nextPeriod = await createNextPeriod(userId, fiscalPeriodId)
|
||||
|
||||
// 8. Generate opening balances in next period
|
||||
const openingBalanceEntry = await generateOpeningBalances(
|
||||
userId,
|
||||
fiscalPeriodId,
|
||||
nextPeriod.id
|
||||
)
|
||||
|
||||
// Fetch the now-closed period for the event payload
|
||||
const { data: closedPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (closedPeriod) {
|
||||
await eventBus.emit({
|
||||
type: 'period.year_closed',
|
||||
payload: { period: closedPeriod as FiscalPeriod, userId },
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
closingEntry,
|
||||
nextPeriod,
|
||||
openingBalanceEntry,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate opening balance entries in the next period from the closed period's
|
||||
* balance sheet accounts (class 1-2).
|
||||
*
|
||||
* Each account's closing balance becomes its opening balance.
|
||||
* The entry must be balanced (total debit openings = total credit openings).
|
||||
*/
|
||||
export async function generateOpeningBalances(
|
||||
userId: string,
|
||||
closedPeriodId: string,
|
||||
nextPeriodId: string
|
||||
): Promise<JournalEntry> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get next period for the entry date
|
||||
const { data: nextPeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', nextPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (!nextPeriod) {
|
||||
throw new Error('Next fiscal period not found')
|
||||
}
|
||||
|
||||
// Get trial balance of closed period (includes the closing entry)
|
||||
const { rows } = await generateTrialBalance(userId, closedPeriodId)
|
||||
|
||||
// Filter to balance sheet accounts (class 1-2) with non-zero closing balance
|
||||
const balanceSheetAccounts = rows.filter(
|
||||
(r) => r.account_class >= 1 && r.account_class <= 2
|
||||
)
|
||||
|
||||
const openingLines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
for (const account of balanceSheetAccounts) {
|
||||
const netBalance = account.closing_debit - account.closing_credit
|
||||
|
||||
if (Math.abs(netBalance) < 0.005) continue
|
||||
|
||||
if (netBalance > 0) {
|
||||
// Debit balance → opening debit
|
||||
openingLines.push({
|
||||
account_number: account.account_number,
|
||||
debit_amount: Math.round(netBalance * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående balans: ${account.account_name}`,
|
||||
})
|
||||
} else {
|
||||
// Credit balance → opening credit
|
||||
openingLines.push({
|
||||
account_number: account.account_number,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
|
||||
line_description: `Ingående balans: ${account.account_name}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (openingLines.length === 0) {
|
||||
throw new Error('No balance sheet accounts with non-zero closing balance')
|
||||
}
|
||||
|
||||
// Verify balance before creating
|
||||
const totalDebit = openingLines.reduce((sum, l) => sum + l.debit_amount, 0)
|
||||
const totalCredit = openingLines.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
|
||||
if (Math.abs(totalDebit - totalCredit) > 0.01) {
|
||||
throw new Error(
|
||||
`Opening balances are not balanced: debit=${totalDebit}, credit=${totalCredit}`
|
||||
)
|
||||
}
|
||||
|
||||
// Create opening balance entry in next period
|
||||
const openingEntry = await createJournalEntry(userId, {
|
||||
fiscal_period_id: nextPeriodId,
|
||||
entry_date: nextPeriod.period_start,
|
||||
description: `Ingående balans ${nextPeriod.name}`,
|
||||
source_type: 'opening_balance',
|
||||
voucher_series: 'A',
|
||||
lines: openingLines,
|
||||
})
|
||||
|
||||
// Mark next period with opening balance entry
|
||||
const { error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({
|
||||
opening_balance_entry_id: openingEntry.id,
|
||||
opening_balances_set: true,
|
||||
})
|
||||
.eq('id', nextPeriodId)
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (updateError) {
|
||||
throw new Error(`Failed to set opening_balance_entry_id: ${updateError.message}`)
|
||||
}
|
||||
|
||||
return openingEntry
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { makeDocumentAttachment } from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// Mock — separate client (no .then) from query builder (thenable)
|
||||
// ============================================================
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient(storageOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
storage: {
|
||||
from: vi.fn().mockReturnValue({
|
||||
upload: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||
download: vi.fn().mockResolvedValue({
|
||||
data: new Blob(['test content']),
|
||||
error: null,
|
||||
}),
|
||||
remove: vi.fn().mockResolvedValue({ data: [], error: null }),
|
||||
getPublicUrl: vi.fn().mockReturnValue({
|
||||
data: { publicUrl: 'https://example.com/file.pdf' },
|
||||
}),
|
||||
...storageOverrides,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { uploadDocument, createNewVersion, verifyIntegrity } from '../document-service'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
eventBus.clear()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
// Reset the mock to use default makeClient
|
||||
vi.mocked(createClient).mockImplementation(async () => makeClient() as never)
|
||||
})
|
||||
|
||||
describe('uploadDocument', () => {
|
||||
it('computes SHA-256 hash, stores metadata, emits document.uploaded', async () => {
|
||||
const doc = makeDocumentAttachment({
|
||||
id: 'doc-1',
|
||||
file_name: 'test.pdf',
|
||||
sha256_hash: 'computed-hash',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: doc, error: null }, // insert record
|
||||
]
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('document.uploaded', handler)
|
||||
|
||||
const buffer = new TextEncoder().encode('test content').buffer
|
||||
const result = await uploadDocument('user-1', {
|
||||
name: 'test.pdf',
|
||||
buffer: buffer as ArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
})
|
||||
|
||||
expect(result.id).toBe('doc-1')
|
||||
expect(result.file_name).toBe('test.pdf')
|
||||
expect(handler).toHaveBeenCalledOnce()
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
document: expect.objectContaining({ id: 'doc-1' }),
|
||||
userId: 'user-1',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNewVersion', () => {
|
||||
it('increments version and supersedes previous', async () => {
|
||||
const current = makeDocumentAttachment({
|
||||
id: 'doc-1',
|
||||
version: 1,
|
||||
is_current_version: true,
|
||||
original_id: null,
|
||||
})
|
||||
const newVersion = makeDocumentAttachment({
|
||||
id: 'doc-2',
|
||||
version: 2,
|
||||
is_current_version: true,
|
||||
original_id: 'doc-1',
|
||||
})
|
||||
|
||||
results = [
|
||||
{ data: current, error: null }, // fetch current
|
||||
{ data: newVersion, error: null }, // insert new version
|
||||
]
|
||||
|
||||
const buffer = new TextEncoder().encode('new content').buffer
|
||||
const result = await createNewVersion('user-1', 'doc-1', {
|
||||
name: 'test-v2.pdf',
|
||||
buffer: buffer as ArrayBuffer,
|
||||
type: 'application/pdf',
|
||||
})
|
||||
|
||||
expect(result.version).toBe(2)
|
||||
expect(result.original_id).toBe('doc-1')
|
||||
expect(result.is_current_version).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyIntegrity', () => {
|
||||
it('returns valid when hashes match', async () => {
|
||||
const content = 'test content for integrity check'
|
||||
const buffer = new TextEncoder().encode(content)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const expectedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
results = [
|
||||
{ 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
|
||||
)
|
||||
|
||||
const result = await verifyIntegrity('user-1', 'doc-1')
|
||||
expect(result.valid).toBe(true)
|
||||
expect(result.storedHash).toBe(expectedHash)
|
||||
expect(result.computedHash).toBe(expectedHash)
|
||||
})
|
||||
|
||||
it('returns invalid when hashes do not match', async () => {
|
||||
results = [
|
||||
{ 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 result = await verifyIntegrity('user-1', 'doc-1')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.storedHash).toBe('stored-hash-abc')
|
||||
expect(result.computedHash).not.toBe('stored-hash-abc')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
|
||||
|
||||
/**
|
||||
* Document Service - WORM-style document archive
|
||||
*
|
||||
* Handles document upload with SHA-256 integrity, version chains,
|
||||
* and linking to journal entries. Deletion is blocked by DB triggers
|
||||
* for documents linked to committed entries.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file buffer
|
||||
*/
|
||||
async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a document and create a record with SHA-256 integrity hash
|
||||
*/
|
||||
export async function uploadDocument(
|
||||
userId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type?: string },
|
||||
metadata: {
|
||||
upload_source?: DocumentUploadSource
|
||||
journal_entry_id?: string
|
||||
journal_entry_line_id?: string
|
||||
} = {}
|
||||
): Promise<DocumentAttachment> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
|
||||
// Generate storage path
|
||||
const timestamp = Date.now()
|
||||
const storagePath = `documents/${userId}/${timestamp}_${file.name}`
|
||||
|
||||
// Upload to Supabase Storage
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, file.buffer, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error(`Failed to upload document: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
// Create document record
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.buffer.byteLength,
|
||||
mime_type: file.type || null,
|
||||
sha256_hash: sha256Hash,
|
||||
version: 1,
|
||||
is_current_version: true,
|
||||
uploaded_by: userId,
|
||||
upload_source: metadata.upload_source || 'file_upload',
|
||||
digitization_date: new Date().toISOString(),
|
||||
journal_entry_id: metadata.journal_entry_id || null,
|
||||
journal_entry_line_id: metadata.journal_entry_line_id || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
// Clean up uploaded file on record creation failure
|
||||
await supabase.storage.from('documents').remove([storagePath])
|
||||
throw new Error(`Failed to create document record: ${error.message}`)
|
||||
}
|
||||
|
||||
const result = data as DocumentAttachment
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'document.uploaded',
|
||||
payload: { document: result, userId },
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new version of an existing document (WORM: old version is superseded)
|
||||
*/
|
||||
export async function createNewVersion(
|
||||
userId: string,
|
||||
originalId: string,
|
||||
file: { name: string; buffer: ArrayBuffer; type?: string }
|
||||
): Promise<DocumentAttachment> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch the original/current version
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('id', originalId)
|
||||
.eq('user_id', userId)
|
||||
.eq('is_current_version', true)
|
||||
.single()
|
||||
|
||||
if (fetchError || !current) {
|
||||
throw new Error('Original document not found or not the current version')
|
||||
}
|
||||
|
||||
const rootOriginalId = current.original_id || current.id
|
||||
const newVersion = current.version + 1
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
|
||||
// Upload new file
|
||||
const timestamp = Date.now()
|
||||
const storagePath = `documents/${userId}/${timestamp}_v${newVersion}_${file.name}`
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, file.buffer, {
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
upsert: false,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
throw new Error(`Failed to upload new version: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
// Create new version record
|
||||
const { data: newDoc, error: insertError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.buffer.byteLength,
|
||||
mime_type: file.type || null,
|
||||
sha256_hash: sha256Hash,
|
||||
version: newVersion,
|
||||
original_id: rootOriginalId,
|
||||
is_current_version: true,
|
||||
uploaded_by: userId,
|
||||
upload_source: current.upload_source,
|
||||
digitization_date: new Date().toISOString(),
|
||||
journal_entry_id: current.journal_entry_id,
|
||||
journal_entry_line_id: current.journal_entry_line_id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (insertError) {
|
||||
await supabase.storage.from('documents').remove([storagePath])
|
||||
throw new Error(`Failed to create new version record: ${insertError.message}`)
|
||||
}
|
||||
|
||||
// Mark old version as superseded
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
is_current_version: false,
|
||||
superseded_by_id: newDoc.id,
|
||||
})
|
||||
.eq('id', current.id)
|
||||
|
||||
return newDoc as DocumentAttachment
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an existing document to a journal entry
|
||||
*/
|
||||
export async function linkToJournalEntry(
|
||||
userId: string,
|
||||
documentId: string,
|
||||
journalEntryId: string,
|
||||
journalEntryLineId?: string
|
||||
): Promise<DocumentAttachment> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
journal_entry_id: journalEntryId,
|
||||
journal_entry_line_id: journalEntryLineId || null,
|
||||
})
|
||||
.eq('id', documentId)
|
||||
.eq('user_id', userId)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to link document: ${error.message}`)
|
||||
}
|
||||
|
||||
return data as DocumentAttachment
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify document integrity by re-hashing and comparing
|
||||
*/
|
||||
export async function verifyIntegrity(
|
||||
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
|
||||
.from('document_attachments')
|
||||
.select('storage_path, sha256_hash')
|
||||
.eq('id', documentId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (docError || !doc) {
|
||||
throw new Error('Document not found')
|
||||
}
|
||||
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
throw new Error(`Failed to download document: ${downloadError?.message}`)
|
||||
}
|
||||
|
||||
// Re-compute hash
|
||||
const buffer = await fileData.arrayBuffer()
|
||||
const computedHash = await computeSHA256(buffer)
|
||||
|
||||
return {
|
||||
valid: computedHash === doc.sha256_hash,
|
||||
storedHash: doc.sha256_hash,
|
||||
computedHash,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeTaxCode } from '@/tests/helpers'
|
||||
|
||||
// ============================================================
|
||||
// Mock — separate client (no .then) from query builder (thenable)
|
||||
// ============================================================
|
||||
|
||||
let resultIdx: number
|
||||
let results: Array<{ data?: unknown; error?: unknown; count?: number | null }>
|
||||
|
||||
function makeBuilder() {
|
||||
const b: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'not', 'or', 'order', 'limit', 'is']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.maybeSingle = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
b.then = (resolve: (v: unknown) => void) => resolve(results[resultIdx++] ?? { data: null, error: null })
|
||||
return b
|
||||
}
|
||||
|
||||
function makeClient() {
|
||||
return {
|
||||
from: vi.fn().mockImplementation(() => makeBuilder()),
|
||||
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(async () => makeClient()),
|
||||
}))
|
||||
|
||||
import { getTaxCodeByCode, calculateMomsFromTaxCodes } from '../tax-code-service'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resultIdx = 0
|
||||
results = []
|
||||
})
|
||||
|
||||
describe('getTaxCodeByCode', () => {
|
||||
it('prefers user code over system code', async () => {
|
||||
const userCode = makeTaxCode({
|
||||
id: 'tc-user',
|
||||
user_id: 'user-1',
|
||||
code: 'MP1',
|
||||
description: 'Custom 25% moms',
|
||||
rate: 25,
|
||||
})
|
||||
|
||||
results = [{ data: userCode, error: null }]
|
||||
|
||||
const result = await getTaxCodeByCode('user-1', 'MP1')
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.user_id).toBe('user-1')
|
||||
expect(result!.description).toBe('Custom 25% moms')
|
||||
})
|
||||
|
||||
it('returns null when code does not exist', async () => {
|
||||
results = [{ data: null, error: { code: 'PGRST116' } }]
|
||||
|
||||
const result = await getTaxCodeByCode('user-1', 'NONEXISTENT')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateMomsFromTaxCodes', () => {
|
||||
it('aggregates correctly to moms boxes', async () => {
|
||||
const mp1 = makeTaxCode({
|
||||
code: 'MP1',
|
||||
rate: 25,
|
||||
moms_basis_boxes: ['05'],
|
||||
moms_tax_boxes: ['10'],
|
||||
moms_input_boxes: [],
|
||||
is_output_vat: true,
|
||||
})
|
||||
const ip1 = makeTaxCode({
|
||||
code: 'IP1',
|
||||
rate: 25,
|
||||
moms_basis_boxes: [],
|
||||
moms_tax_boxes: [],
|
||||
moms_input_boxes: ['48'],
|
||||
is_output_vat: false,
|
||||
})
|
||||
|
||||
const lines = [
|
||||
{ tax_code: 'MP1', debit_amount: 0, credit_amount: 10000, journal_entry_id: 'je1', journal_entries: {} },
|
||||
{ tax_code: 'MP1', debit_amount: 0, credit_amount: 5000, journal_entry_id: 'je2', journal_entries: {} },
|
||||
{ tax_code: 'IP1', debit_amount: 2500, credit_amount: 0, journal_entry_id: 'je3', journal_entries: {} },
|
||||
]
|
||||
|
||||
results = [
|
||||
// 0: journal lines query (thenable — no .single())
|
||||
{ data: lines, error: null },
|
||||
// 1: getTaxCodes query (thenable — no .single())
|
||||
{ data: [mp1, ip1], error: null },
|
||||
]
|
||||
|
||||
const result = await calculateMomsFromTaxCodes('user-1', '2024-01-01', '2024-12-31')
|
||||
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
// Results should be sorted by box
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i].box >= result[i - 1].box).toBe(true)
|
||||
}
|
||||
// Check that we have the expected boxes
|
||||
const boxes = result.map((r) => r.box)
|
||||
expect(boxes).toContain('05')
|
||||
expect(boxes).toContain('10')
|
||||
expect(boxes).toContain('48')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { TaxCode } from '@/types'
|
||||
|
||||
/**
|
||||
* Tax Code Service
|
||||
*
|
||||
* Manages decoupled tax codes for momsdeklaration.
|
||||
* Tax codes map journal entry lines to specific moms rutor (boxes)
|
||||
* on the Swedish VAT declaration form.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all active tax codes for a user (including system codes)
|
||||
*/
|
||||
export async function getTaxCodes(userId: string): Promise<TaxCode[]> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tax_codes')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${userId},user_id.is.null`)
|
||||
.order('code')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch tax codes: ${error.message}`)
|
||||
}
|
||||
|
||||
return (data as TaxCode[]) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single tax code by code string
|
||||
*/
|
||||
export async function getTaxCodeByCode(
|
||||
userId: string,
|
||||
code: string
|
||||
): Promise<TaxCode | null> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Prefer user-specific code over system code
|
||||
const { data, error } = await supabase
|
||||
.from('tax_codes')
|
||||
.select('*')
|
||||
.eq('code', code)
|
||||
.or(`user_id.eq.${userId},user_id.is.null`)
|
||||
.order('user_id', { ascending: false, nullsFirst: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return data as TaxCode
|
||||
}
|
||||
|
||||
/**
|
||||
* Moms box result from tax code aggregation
|
||||
*/
|
||||
export interface MomsBoxResult {
|
||||
/** Ruta number (e.g. '05', '10', '48') */
|
||||
box: string
|
||||
/** Sum of amounts for this box */
|
||||
amount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate momsdeklaration from journal entry lines grouped by tax_code,
|
||||
* then mapped via the tax_codes table to moms boxes.
|
||||
*
|
||||
* This is the new, tax-code-driven approach that replaces the hardcoded
|
||||
* category-based VAT calculation.
|
||||
*/
|
||||
export async function calculateMomsFromTaxCodes(
|
||||
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
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
tax_code,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry_id,
|
||||
journal_entries!inner (
|
||||
user_id,
|
||||
entry_date,
|
||||
status,
|
||||
fiscal_period_id
|
||||
)
|
||||
`)
|
||||
.not('tax_code', 'is', null)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', periodStart)
|
||||
.lte('journal_entries.entry_date', periodEnd)
|
||||
|
||||
if (linesError) {
|
||||
throw new Error(`Failed to fetch journal lines: ${linesError.message}`)
|
||||
}
|
||||
|
||||
// Fetch all tax codes for lookup
|
||||
const taxCodes = await getTaxCodes(userId)
|
||||
const taxCodeMap = new Map<string, TaxCode>()
|
||||
for (const tc of taxCodes) {
|
||||
// User codes take precedence over system codes
|
||||
if (!taxCodeMap.has(tc.code) || tc.user_id) {
|
||||
taxCodeMap.set(tc.code, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate amounts by moms box
|
||||
const boxTotals = new Map<string, number>()
|
||||
|
||||
for (const line of lines || []) {
|
||||
if (!line.tax_code) continue
|
||||
|
||||
const taxCode = taxCodeMap.get(line.tax_code)
|
||||
if (!taxCode) continue
|
||||
|
||||
const netAmount = Number(line.debit_amount || 0) - Number(line.credit_amount || 0)
|
||||
const absAmount = Math.abs(netAmount)
|
||||
|
||||
// For output VAT: debit_amount goes to basis boxes, tax amount to tax boxes
|
||||
// For input VAT: the amount goes to input boxes
|
||||
const allBoxes = [
|
||||
...taxCode.moms_basis_boxes,
|
||||
...taxCode.moms_tax_boxes,
|
||||
...taxCode.moms_input_boxes,
|
||||
]
|
||||
|
||||
for (const box of allBoxes) {
|
||||
const current = boxTotals.get(box) || 0
|
||||
boxTotals.set(box, current + absAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to result array
|
||||
const results: MomsBoxResult[] = []
|
||||
for (const [box, amount] of boxTotals) {
|
||||
results.push({
|
||||
box,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
})
|
||||
}
|
||||
|
||||
return results.sort((a, b) => a.box.localeCompare(b.box))
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed tax codes for a user by calling the database function
|
||||
*/
|
||||
export async function seedTaxCodes(userId: string): Promise<void> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { error } = await supabase.rpc('seed_tax_codes_for_user', {
|
||||
p_user_id: userId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to seed tax codes: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { eventBus } from '../bus'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
const fakeEntry = { id: 'e1' } as JournalEntry
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
describe('EventBus', () => {
|
||||
it('on() subscribes a handler and returns an unsubscribe function', () => {
|
||||
const handler = vi.fn()
|
||||
const unsub = eventBus.on('journal_entry.drafted', handler)
|
||||
|
||||
expect(typeof unsub).toBe('function')
|
||||
})
|
||||
|
||||
it('emit() calls all handlers for that event type', async () => {
|
||||
const handler1 = vi.fn()
|
||||
const handler2 = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', handler1)
|
||||
eventBus.on('journal_entry.committed', handler2)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler1).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
|
||||
expect(handler2).toHaveBeenCalledWith({ entry: fakeEntry, userId: 'u1' })
|
||||
})
|
||||
|
||||
it('emit() uses Promise.allSettled — a failing handler does not crash others', async () => {
|
||||
const failingHandler = vi.fn().mockRejectedValue(new Error('boom'))
|
||||
const goodHandler = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', failingHandler)
|
||||
eventBus.on('journal_entry.committed', goodHandler)
|
||||
|
||||
// Should not throw
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(failingHandler).toHaveBeenCalled()
|
||||
expect(goodHandler).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emit() with no handlers is a no-op', async () => {
|
||||
// Should not throw
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.drafted',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
})
|
||||
|
||||
it('unsubscribe removes the handler, future emits do not call it', async () => {
|
||||
const handler = vi.fn()
|
||||
const unsub = eventBus.on('journal_entry.committed', handler)
|
||||
|
||||
unsub()
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clear() removes all handlers', async () => {
|
||||
const handler1 = vi.fn()
|
||||
const handler2 = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', handler1)
|
||||
eventBus.on('journal_entry.drafted', handler2)
|
||||
|
||||
eventBus.clear()
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.drafted',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled()
|
||||
expect(handler2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handlers for different event types do not interfere', async () => {
|
||||
const committedHandler = vi.fn()
|
||||
const draftedHandler = vi.fn()
|
||||
|
||||
eventBus.on('journal_entry.committed', committedHandler)
|
||||
eventBus.on('journal_entry.drafted', draftedHandler)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: fakeEntry, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(committedHandler).toHaveBeenCalledOnce()
|
||||
expect(draftedHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { CoreEvent, CoreEventType, EventHandler } from './types'
|
||||
|
||||
// Internal handler type — loose enough for the Map, but type-safe at the public API
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyHandler = (payload: any) => Promise<void> | void
|
||||
|
||||
/**
|
||||
* In-process event bus.
|
||||
*
|
||||
* - Handlers run concurrently via Promise.allSettled (failing handler never crashes emitter)
|
||||
* - Module-level singleton (persists across requests in same process)
|
||||
* - One-way: core services emit, extensions subscribe
|
||||
*/
|
||||
class EventBus {
|
||||
private handlers = new Map<string, Set<AnyHandler>>()
|
||||
|
||||
/**
|
||||
* Subscribe to an event type.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
on<T extends CoreEventType>(
|
||||
eventType: T,
|
||||
handler: EventHandler<T>
|
||||
): () => void {
|
||||
if (!this.handlers.has(eventType)) {
|
||||
this.handlers.set(eventType, new Set())
|
||||
}
|
||||
|
||||
const handlerSet = this.handlers.get(eventType)!
|
||||
handlerSet.add(handler as AnyHandler)
|
||||
|
||||
return () => {
|
||||
handlerSet.delete(handler as AnyHandler)
|
||||
if (handlerSet.size === 0) {
|
||||
this.handlers.delete(eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all registered handlers.
|
||||
* Uses Promise.allSettled so a failing handler never crashes the emitter.
|
||||
*/
|
||||
async emit(event: CoreEvent): Promise<void> {
|
||||
const handlerSet = this.handlers.get(event.type)
|
||||
if (!handlerSet || handlerSet.size === 0) return
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
[...handlerSet].map((handler) => handler(event.payload))
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.error(
|
||||
`[EventBus] Handler failed for "${event.type}":`,
|
||||
result.reason
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all handlers (useful for testing).
|
||||
*/
|
||||
clear(): void {
|
||||
this.handlers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton */
|
||||
export const eventBus = new EventBus()
|
||||
@@ -0,0 +1,8 @@
|
||||
export { eventBus } from './bus'
|
||||
export type {
|
||||
CoreEvent,
|
||||
CoreEventType,
|
||||
EventPayload,
|
||||
EventHandler,
|
||||
EventSubscription,
|
||||
} from './types'
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
JournalEntry,
|
||||
Invoice,
|
||||
Transaction,
|
||||
Customer,
|
||||
FiscalPeriod,
|
||||
DocumentAttachment,
|
||||
Receipt,
|
||||
CreditNote,
|
||||
CAMT053Statement,
|
||||
CAMT054Notification,
|
||||
AuditSecurityEvent,
|
||||
} from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Core Event Types — discriminated union of all system events
|
||||
// ============================================================
|
||||
|
||||
export type CoreEvent =
|
||||
// Bookkeeping
|
||||
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string } }
|
||||
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string } }
|
||||
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string } }
|
||||
// Documents
|
||||
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string } }
|
||||
// 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: '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;
|
||||
documentId: string | null;
|
||||
confidence: number;
|
||||
userId: string;
|
||||
}}
|
||||
| { type: 'receipt.matched'; payload: {
|
||||
receipt: Receipt;
|
||||
transaction: Transaction;
|
||||
confidence: number;
|
||||
autoMatched: boolean;
|
||||
userId: string;
|
||||
}}
|
||||
| { type: 'receipt.confirmed'; payload: {
|
||||
receipt: Receipt;
|
||||
businessTotal: number;
|
||||
privateTotal: number;
|
||||
userId: string;
|
||||
}}
|
||||
// Audit
|
||||
| { type: 'audit.security_event'; payload: { event: AuditSecurityEvent; userId: string } }
|
||||
|
||||
// ============================================================
|
||||
// Helper Types
|
||||
// ============================================================
|
||||
|
||||
/** All possible event type strings */
|
||||
export type CoreEventType = CoreEvent['type']
|
||||
|
||||
/** Extract the payload type for a given event type */
|
||||
export type EventPayload<T extends CoreEventType> = Extract<CoreEvent, { type: T }>['payload']
|
||||
|
||||
/** Handler function for a specific event type */
|
||||
export type EventHandler<T extends CoreEventType> = (payload: EventPayload<T>) => Promise<void> | void
|
||||
|
||||
/** Subscription: event type + handler */
|
||||
export interface EventSubscription<T extends CoreEventType = CoreEventType> {
|
||||
eventType: T
|
||||
handler: EventHandler<T>
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { extensionRegistry } from '../registry'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { Extension } from '../types'
|
||||
|
||||
beforeEach(() => {
|
||||
extensionRegistry.clear()
|
||||
eventBus.clear()
|
||||
})
|
||||
|
||||
function makeExtension(overrides: Partial<Extension> = {}): Extension {
|
||||
return {
|
||||
id: 'test-ext',
|
||||
name: 'Test Extension',
|
||||
version: '1.0.0',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('ExtensionRegistry', () => {
|
||||
it('register() stores extension, queryable via get() and getAll()', () => {
|
||||
const ext = makeExtension()
|
||||
extensionRegistry.register(ext)
|
||||
|
||||
expect(extensionRegistry.get('test-ext')).toBe(ext)
|
||||
expect(extensionRegistry.getAll()).toEqual([ext])
|
||||
})
|
||||
|
||||
it('register() wires event handlers to the bus', async () => {
|
||||
const handler = vi.fn()
|
||||
const ext = makeExtension({
|
||||
id: 'event-ext',
|
||||
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
||||
})
|
||||
|
||||
extensionRegistry.register(ext)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
})
|
||||
|
||||
it('register() skips duplicate registration (same id)', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const ext1 = makeExtension()
|
||||
const ext2 = makeExtension({ name: 'Duplicate' })
|
||||
|
||||
extensionRegistry.register(ext1)
|
||||
extensionRegistry.register(ext2)
|
||||
|
||||
// Original is kept
|
||||
expect(extensionRegistry.get('test-ext')!.name).toBe('Test Extension')
|
||||
expect(extensionRegistry.getAll()).toHaveLength(1)
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('unregister() removes extension and unsubscribes handlers', async () => {
|
||||
const handler = vi.fn()
|
||||
const ext = makeExtension({
|
||||
id: 'removable',
|
||||
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
||||
})
|
||||
|
||||
extensionRegistry.register(ext)
|
||||
extensionRegistry.unregister('removable')
|
||||
|
||||
expect(extensionRegistry.get('removable')).toBeUndefined()
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('getByCapability() filters correctly', () => {
|
||||
const ext1 = makeExtension({
|
||||
id: 'with-settings',
|
||||
settingsPanel: { label: 'Test', path: '/test' },
|
||||
})
|
||||
const ext2 = makeExtension({ id: 'without-settings' })
|
||||
|
||||
extensionRegistry.register(ext1)
|
||||
extensionRegistry.register(ext2)
|
||||
|
||||
const withSettings = extensionRegistry.getByCapability('settingsPanel')
|
||||
expect(withSettings).toHaveLength(1)
|
||||
expect(withSettings[0].id).toBe('with-settings')
|
||||
})
|
||||
|
||||
it('clear() removes all extensions and unsubscribes all handlers', async () => {
|
||||
const handler1 = vi.fn()
|
||||
const handler2 = vi.fn()
|
||||
|
||||
extensionRegistry.register(
|
||||
makeExtension({
|
||||
id: 'ext1',
|
||||
eventHandlers: [{ eventType: 'journal_entry.committed', handler: handler1 }],
|
||||
})
|
||||
)
|
||||
extensionRegistry.register(
|
||||
makeExtension({
|
||||
id: 'ext2',
|
||||
eventHandlers: [{ eventType: 'journal_entry.drafted', handler: handler2 }],
|
||||
})
|
||||
)
|
||||
|
||||
extensionRegistry.clear()
|
||||
|
||||
expect(extensionRegistry.getAll()).toHaveLength(0)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.drafted',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler1).not.toHaveBeenCalled()
|
||||
expect(handler2).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
export { extensionRegistry } from './registry'
|
||||
export { loadExtensions } from './loader'
|
||||
export type {
|
||||
Extension,
|
||||
RouteDefinition,
|
||||
ApiRouteDefinition,
|
||||
SidebarItem,
|
||||
ReportDefinition,
|
||||
SettingsPanelDefinition,
|
||||
TaxCodeDefinition,
|
||||
DimensionDefinition,
|
||||
MappingRuleTypeDefinition,
|
||||
ExtensionEventHandler,
|
||||
ExtensionContext,
|
||||
} from './types'
|
||||
@@ -0,0 +1,30 @@
|
||||
import { extensionRegistry } from './registry'
|
||||
import { receiptOcrExtension } from '@/extensions/receipt-ocr'
|
||||
import { aiCategorizationExtension } from '@/extensions/ai-categorization'
|
||||
import type { Extension } from './types'
|
||||
|
||||
/**
|
||||
* 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,
|
||||
]
|
||||
|
||||
let loaded = false
|
||||
|
||||
/**
|
||||
* Load and register all first-party extensions.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function loadExtensions(): void {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
|
||||
for (const extension of FIRST_PARTY_EXTENSIONS) {
|
||||
extensionRegistry.register(extension)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
import type { Extension } from './types'
|
||||
|
||||
/**
|
||||
* Extension Registry — singleton that manages extension lifecycle.
|
||||
*
|
||||
* - register() stores extension and wires event handlers to the bus
|
||||
* - unregister() unhooks handlers and removes extension
|
||||
* - getAll(), get(), getByCapability() for querying
|
||||
*/
|
||||
class ExtensionRegistry {
|
||||
private extensions = new Map<string, Extension>()
|
||||
private unsubscribers = new Map<string, (() => void)[]>()
|
||||
|
||||
/**
|
||||
* Register an extension: store it and wire its event handlers to the bus.
|
||||
*/
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
console.warn(`[ExtensionRegistry] Extension "${extension.id}" already registered, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
this.extensions.set(extension.id, extension)
|
||||
|
||||
// Wire event handlers to the bus
|
||||
const unsubs: (() => void)[] = []
|
||||
if (extension.eventHandlers) {
|
||||
for (const { eventType, handler } of extension.eventHandlers) {
|
||||
// Cast is safe: the handler is stored by eventType key, so it only receives matching payloads
|
||||
const unsub = eventBus.on(eventType as CoreEventType, handler)
|
||||
unsubs.push(unsub)
|
||||
}
|
||||
}
|
||||
this.unsubscribers.set(extension.id, unsubs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister an extension: unhook all event handlers and remove.
|
||||
*/
|
||||
unregister(extensionId: string): void {
|
||||
const unsubs = this.unsubscribers.get(extensionId)
|
||||
if (unsubs) {
|
||||
for (const unsub of unsubs) {
|
||||
unsub()
|
||||
}
|
||||
this.unsubscribers.delete(extensionId)
|
||||
}
|
||||
this.extensions.delete(extensionId)
|
||||
}
|
||||
|
||||
/** Get all registered extensions. */
|
||||
getAll(): Extension[] {
|
||||
return [...this.extensions.values()]
|
||||
}
|
||||
|
||||
/** Get a specific extension by ID. */
|
||||
get(id: string): Extension | undefined {
|
||||
return this.extensions.get(id)
|
||||
}
|
||||
|
||||
/** Get all extensions that have a specific capability. */
|
||||
getByCapability(key: keyof Extension): Extension[] {
|
||||
return [...this.extensions.values()].filter(
|
||||
(ext) => ext[key] !== undefined && ext[key] !== null
|
||||
)
|
||||
}
|
||||
|
||||
/** Clear all extensions (useful for testing). */
|
||||
clear(): void {
|
||||
for (const id of this.extensions.keys()) {
|
||||
this.unregister(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level singleton */
|
||||
export const extensionRegistry = new ExtensionRegistry()
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
|
||||
// ============================================================
|
||||
// Extension Interface & Supporting Types
|
||||
// ============================================================
|
||||
|
||||
/** A route exposed by an extension (page route) */
|
||||
export interface RouteDefinition {
|
||||
path: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** An API route exposed by an extension */
|
||||
export interface ApiRouteDefinition {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
path: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
|
||||
/** Sidebar navigation item added by an extension */
|
||||
export interface SidebarItem {
|
||||
label: string
|
||||
icon?: string
|
||||
path: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
/** Report type added by an extension */
|
||||
export interface ReportDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Settings panel exposed by an extension */
|
||||
export interface SettingsPanelDefinition {
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/** Tax code definition added by an extension */
|
||||
export interface TaxCodeDefinition {
|
||||
code: string
|
||||
rate: number
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Dimension type definition added by an extension */
|
||||
export interface DimensionDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Mapping rule type added by an extension */
|
||||
export interface MappingRuleTypeDefinition {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** Event handler registration for an extension */
|
||||
export interface ExtensionEventHandler {
|
||||
eventType: CoreEventType
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (payload: any) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks */
|
||||
export interface ExtensionContext {
|
||||
userId: string
|
||||
extensionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension interface — the contract for all add-ons.
|
||||
*
|
||||
* Extensions declare what they provide (routes, event handlers, sidebar items, etc.)
|
||||
* and the registry wires them into the system.
|
||||
*/
|
||||
export interface Extension {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
|
||||
// Surfaces
|
||||
routes?: RouteDefinition[]
|
||||
apiRoutes?: ApiRouteDefinition[]
|
||||
sidebarItems?: SidebarItem[]
|
||||
eventHandlers?: ExtensionEventHandler[]
|
||||
mappingRuleTypes?: MappingRuleTypeDefinition[]
|
||||
reportTypes?: ReportDefinition[]
|
||||
settingsPanel?: SettingsPanelDefinition
|
||||
taxCodes?: TaxCodeDefinition[]
|
||||
dimensionTypes?: DimensionDefinition[]
|
||||
|
||||
// Lifecycle hooks
|
||||
onInstall?(ctx: ExtensionContext): Promise<void>
|
||||
onUninstall?(ctx: ExtensionContext): Promise<void>
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { loadExtensions } from '@/lib/extensions/loader'
|
||||
|
||||
/**
|
||||
* Ensure the system is initialized (extensions loaded).
|
||||
* Called from API routes that emit events.
|
||||
* Idempotent — safe to call multiple times.
|
||||
*/
|
||||
export function ensureInitialized(): void {
|
||||
loadExtensions()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mapSuggestedCategory,
|
||||
getBASAccount,
|
||||
categorizeLineItem,
|
||||
processLineItems,
|
||||
calculateReceiptSplit,
|
||||
getDefaultClassification,
|
||||
} from '../receipt-categorizer'
|
||||
import type { ExtractedLineItem } from '@/types'
|
||||
|
||||
describe('mapSuggestedCategory', () => {
|
||||
it('maps AI categories to TransactionCategory', () => {
|
||||
expect(mapSuggestedCategory('equipment')).toBe('expense_equipment')
|
||||
expect(mapSuggestedCategory('software')).toBe('expense_software')
|
||||
expect(mapSuggestedCategory('travel')).toBe('expense_travel')
|
||||
expect(mapSuggestedCategory('office')).toBe('expense_office')
|
||||
expect(mapSuggestedCategory('marketing')).toBe('expense_marketing')
|
||||
expect(mapSuggestedCategory('professional_services')).toBe('expense_professional_services')
|
||||
expect(mapSuggestedCategory('education')).toBe('expense_education')
|
||||
expect(mapSuggestedCategory('other')).toBe('expense_other')
|
||||
})
|
||||
|
||||
it('returns null for unknown categories', () => {
|
||||
expect(mapSuggestedCategory('nonexistent')).toBeNull()
|
||||
expect(mapSuggestedCategory(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getBASAccount', () => {
|
||||
it('returns correct BAS account per category', () => {
|
||||
expect(getBASAccount('expense_equipment')).toBe('5410')
|
||||
expect(getBASAccount('expense_software')).toBe('5420')
|
||||
expect(getBASAccount('expense_travel')).toBe('5800')
|
||||
expect(getBASAccount('expense_office')).toBe('5010')
|
||||
expect(getBASAccount('expense_marketing')).toBe('5910')
|
||||
expect(getBASAccount('expense_professional_services')).toBe('6530')
|
||||
expect(getBASAccount('expense_education')).toBe('6991')
|
||||
expect(getBASAccount('expense_bank_fees')).toBe('6570')
|
||||
expect(getBASAccount('income_services')).toBe('3001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('categorizeLineItem', () => {
|
||||
it('keyword patterns match Swedish terms — dator → expense_equipment', () => {
|
||||
const result = categorizeLineItem('MacBook Pro dator')
|
||||
expect(result.category).toBe('expense_equipment')
|
||||
expect(result.confidence).toBe(0.7)
|
||||
})
|
||||
|
||||
it('matches software patterns', () => {
|
||||
const result = categorizeLineItem('Adobe Creative Cloud prenumeration')
|
||||
expect(result.category).toBe('expense_software')
|
||||
})
|
||||
|
||||
it('matches travel patterns', () => {
|
||||
const result = categorizeLineItem('SJ tåg Stockholm-Malmö')
|
||||
expect(result.category).toBe('expense_travel')
|
||||
})
|
||||
|
||||
it('returns null category for unrecognized descriptions', () => {
|
||||
const result = categorizeLineItem('xyzzy foobarbaz')
|
||||
expect(result.category).toBeNull()
|
||||
expect(result.confidence).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('processLineItems', () => {
|
||||
it('prefers AI suggestion over pattern match', () => {
|
||||
const items: ExtractedLineItem[] = [
|
||||
{
|
||||
description: 'MacBook Pro dator', // pattern → equipment
|
||||
quantity: 1,
|
||||
unitPrice: 15000,
|
||||
lineTotal: 15000,
|
||||
vatRate: 25,
|
||||
suggestedCategory: 'software', // AI says software
|
||||
confidence: 0.9,
|
||||
},
|
||||
]
|
||||
|
||||
const result = processLineItems(items)
|
||||
expect(result[0].category).toBe('expense_software') // AI wins
|
||||
expect(result[0].basAccount).toBe('5420')
|
||||
})
|
||||
|
||||
it('falls back to pattern match when no AI suggestion', () => {
|
||||
const items: ExtractedLineItem[] = [
|
||||
{
|
||||
description: 'MacBook Pro dator',
|
||||
quantity: 1,
|
||||
unitPrice: 15000,
|
||||
lineTotal: 15000,
|
||||
vatRate: 25,
|
||||
suggestedCategory: null,
|
||||
},
|
||||
]
|
||||
|
||||
const result = processLineItems(items)
|
||||
expect(result[0].category).toBe('expense_equipment') // pattern match
|
||||
expect(result[0].basAccount).toBe('5410')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateReceiptSplit', () => {
|
||||
it('correct business/private/unclassified totals', () => {
|
||||
const items = [
|
||||
{ lineTotal: 100, is_business: true as boolean | null },
|
||||
{ lineTotal: 50, is_business: false as boolean | null },
|
||||
{ lineTotal: 25, is_business: null as boolean | null },
|
||||
]
|
||||
|
||||
const result = calculateReceiptSplit(items)
|
||||
expect(result.businessTotal).toBe(100)
|
||||
expect(result.privateTotal).toBe(50)
|
||||
expect(result.unclassifiedTotal).toBe(25)
|
||||
// 100 / 175 * 100 = 57.142... → 57.1
|
||||
expect(result.businessPercentage).toBeCloseTo(57.1, 1)
|
||||
})
|
||||
|
||||
it('handles rounding correctly', () => {
|
||||
const items = [
|
||||
{ lineTotal: 33.333, is_business: true as boolean | null },
|
||||
{ lineTotal: 66.667, is_business: false as boolean | null },
|
||||
]
|
||||
|
||||
const result = calculateReceiptSplit(items)
|
||||
expect(result.businessTotal).toBe(33.33)
|
||||
expect(result.privateTotal).toBe(66.67)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDefaultClassification', () => {
|
||||
it('Systembolaget defaults to private', () => {
|
||||
const result = getDefaultClassification(false, true)
|
||||
expect(result.defaultIsBusiness).toBe(false)
|
||||
expect(result.requiresReview).toBe(true)
|
||||
expect(result.warningMessage).toContain('Alkohol')
|
||||
})
|
||||
|
||||
it('restaurant requires review', () => {
|
||||
const result = getDefaultClassification(true, false)
|
||||
expect(result.defaultIsBusiness).toBeNull()
|
||||
expect(result.requiresReview).toBe(true)
|
||||
expect(result.warningMessage).toContain('Restaurangbesök')
|
||||
})
|
||||
|
||||
it('non-restaurant, non-systembolaget has no warning', () => {
|
||||
const result = getDefaultClassification(false, false)
|
||||
expect(result.defaultIsBusiness).toBeNull()
|
||||
expect(result.requiresReview).toBe(false)
|
||||
expect(result.warningMessage).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
findTransactionMatches,
|
||||
autoMatchReceipts,
|
||||
filterUnmatchedTransactions,
|
||||
filterUnmatchedReceipts,
|
||||
} from '../receipt-matcher'
|
||||
import { makeReceipt, makeTransaction } from '@/tests/helpers'
|
||||
|
||||
describe('findTransactionMatches', () => {
|
||||
it('exact date + exact amount → high confidence', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 299,
|
||||
merchant_name: 'ICA Maxi',
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -299,
|
||||
merchant_name: 'ICA Maxi',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0].confidence).toBeGreaterThanOrEqual(0.8)
|
||||
expect(matches[0].dateVariance).toBe(0)
|
||||
})
|
||||
|
||||
it('date within ±3 days → matches with lower confidence', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: '',
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-17',
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1)
|
||||
expect(matches[0].dateVariance).toBeCloseTo(2, 0)
|
||||
})
|
||||
|
||||
it('date outside ±3 days → no match', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: '',
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-25',
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('amount within 5% tolerance → matches', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 1000,
|
||||
merchant_name: '',
|
||||
})
|
||||
// 4% off = 960
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -960,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('amount outside tolerance → no match', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 1000,
|
||||
merchant_name: '',
|
||||
is_foreign_merchant: false,
|
||||
})
|
||||
// 10% off = 900 (way above 5%)
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -900,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('merchant name similarity boosts confidence', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: 'Coop Konsum',
|
||||
})
|
||||
|
||||
const txWithMerchant = makeTransaction({
|
||||
id: 'tx-with',
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
merchant_name: 'Coop Konsum Stockholm',
|
||||
receipt_id: null,
|
||||
})
|
||||
const txWithout = makeTransaction({
|
||||
id: 'tx-without',
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
})
|
||||
|
||||
const matchesWithMerchant = findTransactionMatches(receipt, [txWithMerchant])
|
||||
const matchesWithout = findTransactionMatches(receipt, [txWithout])
|
||||
|
||||
// Both should match since date+amount are exact
|
||||
expect(matchesWithMerchant.length).toBeGreaterThanOrEqual(1)
|
||||
expect(matchesWithout.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Merchant match should have higher confidence
|
||||
expect(matchesWithMerchant[0].confidence).toBeGreaterThan(
|
||||
matchesWithout[0].confidence
|
||||
)
|
||||
})
|
||||
|
||||
it('skips already-matched transactions (receipt_id set)', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 299,
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -299,
|
||||
receipt_id: 'already-matched',
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips income transactions (amount >= 0)', () => {
|
||||
const receipt = makeReceipt({
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 299,
|
||||
})
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: 299, // income, positive
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const matches = findTransactionMatches(receipt, transactions)
|
||||
expect(matches).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoMatchReceipts', () => {
|
||||
it('returns matches above threshold', () => {
|
||||
const receipts = [
|
||||
makeReceipt({
|
||||
id: 'r1',
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: 'Coop',
|
||||
matched_transaction_id: null,
|
||||
}),
|
||||
]
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
id: 'tx1',
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
merchant_name: 'Coop',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = autoMatchReceipts(receipts, transactions, 0.5)
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].receipt.id).toBe('r1')
|
||||
expect(results[0].match.confidence).toBeGreaterThanOrEqual(0.5)
|
||||
})
|
||||
|
||||
it('respects custom threshold', () => {
|
||||
const receipts = [
|
||||
makeReceipt({
|
||||
id: 'r1',
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
merchant_name: '',
|
||||
matched_transaction_id: null,
|
||||
}),
|
||||
]
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
id: 'tx1',
|
||||
date: '2024-06-17', // 2 days off, no merchant → moderate confidence
|
||||
amount: -500,
|
||||
merchant_name: '',
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
// With a very high threshold, it should not match
|
||||
const highThreshold = autoMatchReceipts(receipts, transactions, 0.99)
|
||||
expect(highThreshold).toHaveLength(0)
|
||||
|
||||
// With a lower threshold, it should match
|
||||
const lowThreshold = autoMatchReceipts(receipts, transactions, 0.4)
|
||||
expect(lowThreshold).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips already-matched receipts', () => {
|
||||
const receipts = [
|
||||
makeReceipt({
|
||||
id: 'r1',
|
||||
receipt_date: '2024-06-15',
|
||||
total_amount: 500,
|
||||
matched_transaction_id: 'existing-tx',
|
||||
}),
|
||||
]
|
||||
const transactions = [
|
||||
makeTransaction({
|
||||
date: '2024-06-15',
|
||||
amount: -500,
|
||||
receipt_id: null,
|
||||
}),
|
||||
]
|
||||
|
||||
const results = autoMatchReceipts(receipts, transactions)
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterUnmatchedTransactions', () => {
|
||||
it('returns only unmatched expenses', () => {
|
||||
const transactions = [
|
||||
makeTransaction({ id: 't1', receipt_id: null, amount: -100 }),
|
||||
makeTransaction({ id: 't2', receipt_id: 'r1', amount: -200 }), // matched
|
||||
makeTransaction({ id: 't3', receipt_id: null, amount: 300 }), // income
|
||||
]
|
||||
|
||||
const result = filterUnmatchedTransactions(transactions)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('t1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterUnmatchedReceipts', () => {
|
||||
it('returns only confirmed unmatched receipts', () => {
|
||||
const receipts = [
|
||||
makeReceipt({ id: 'r1', status: 'confirmed', matched_transaction_id: null }),
|
||||
makeReceipt({ id: 'r2', status: 'confirmed', matched_transaction_id: 'tx1' }),
|
||||
makeReceipt({ id: 'r3', status: 'extracted', matched_transaction_id: null }),
|
||||
]
|
||||
|
||||
const result = filterUnmatchedReceipts(receipts)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('r1')
|
||||
})
|
||||
})
|
||||
@@ -45,6 +45,21 @@ export async function generateSIEExport(
|
||||
.eq('status', 'posted')
|
||||
.order('voucher_number')
|
||||
|
||||
// Fetch cost centers and projects for dimension records
|
||||
const { data: costCenters } = await supabase
|
||||
.from('cost_centers')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('code')
|
||||
|
||||
const { data: projects } = await supabase
|
||||
.from('projects')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
.order('code')
|
||||
|
||||
const lines: string[] = []
|
||||
const now = new Date()
|
||||
|
||||
@@ -66,10 +81,34 @@ export async function generateSIEExport(
|
||||
// Use date strings directly to avoid timezone conversion issues
|
||||
lines.push(`#RAR 0 ${dateStringToSIE(period.period_start)} ${dateStringToSIE(period.period_end)}`)
|
||||
|
||||
// === Dimension definitions ===
|
||||
// SIE standard: dimension 1 = kostnadsställe, dimension 6 = projekt
|
||||
const hasCostCenters = costCenters && costCenters.length > 0
|
||||
const hasProjects = projects && projects.length > 0
|
||||
|
||||
if (hasCostCenters) {
|
||||
lines.push('#DIM 1 "Kostnadsställe"')
|
||||
}
|
||||
if (hasProjects) {
|
||||
lines.push('#DIM 6 "Projekt"')
|
||||
}
|
||||
|
||||
// === Dimension objects (#OBJEKT) ===
|
||||
for (const cc of costCenters || []) {
|
||||
lines.push(`#OBJEKT 1 "${escapeQuotes(cc.code)}" "${escapeQuotes(cc.name)}"`)
|
||||
}
|
||||
for (const proj of projects || []) {
|
||||
lines.push(`#OBJEKT 6 "${escapeQuotes(proj.code)}" "${escapeQuotes(proj.name)}"`)
|
||||
}
|
||||
|
||||
// === Chart of accounts ===
|
||||
for (const account of (accounts as BASAccount[]) || []) {
|
||||
lines.push(`#KONTO ${account.account_number} "${escapeQuotes(account.account_name)}"`)
|
||||
// SRU codes could be added here if available
|
||||
|
||||
// #SRU records from chart_of_accounts.sru_code
|
||||
if (account.sru_code) {
|
||||
lines.push(`#SRU ${account.account_number} ${account.sru_code}`)
|
||||
}
|
||||
}
|
||||
|
||||
// === Opening balances (IB) ===
|
||||
@@ -96,7 +135,17 @@ export async function generateSIEExport(
|
||||
? ` "${escapeQuotes(line.line_description)}"`
|
||||
: ''
|
||||
|
||||
lines.push(`\t#TRANS ${line.account_number} {} ${formatAmount(amount)} ${entryDate}${lineDesc}`)
|
||||
// Build dimension object list for #TRANS line
|
||||
const dimParts: string[] = []
|
||||
if (line.cost_center) {
|
||||
dimParts.push(`1 "${escapeQuotes(line.cost_center)}"`)
|
||||
}
|
||||
if (line.project) {
|
||||
dimParts.push(`6 "${escapeQuotes(line.project)}"`)
|
||||
}
|
||||
const objList = dimParts.length > 0 ? `{${dimParts.join(' ')}}` : '{}'
|
||||
|
||||
lines.push(`\t#TRANS ${line.account_number} ${objList} ${formatAmount(amount)} ${entryDate}${lineDesc}`)
|
||||
}
|
||||
|
||||
lines.push('}')
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
Invoice,
|
||||
Transaction,
|
||||
Receipt,
|
||||
TaxCode,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
@@ -419,3 +420,125 @@ export function formatPeriodLabel(
|
||||
return `${year}`
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tax-code-driven VAT declaration (new approach)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Calculate VAT declaration using tax codes from journal entry lines.
|
||||
*
|
||||
* This is the new, tax-code-driven approach that sums journal_entry_lines
|
||||
* grouped by tax_code, then maps via the tax_codes table to moms boxes.
|
||||
* Falls back to the legacy invoice/transaction/receipt approach for
|
||||
* lines without tax codes.
|
||||
*/
|
||||
export async function calculateVatDeclarationFromTaxCodes(
|
||||
userId: string,
|
||||
periodType: VatPeriodType,
|
||||
year: number,
|
||||
period: number
|
||||
): Promise<VatDeclaration> {
|
||||
const supabase = await createClient()
|
||||
const { start, end } = calculatePeriodDates(periodType, year, period)
|
||||
|
||||
// Fetch tax codes for this user (including system codes)
|
||||
const { data: taxCodesData } = await supabase
|
||||
.from('tax_codes')
|
||||
.select('*')
|
||||
.or(`user_id.eq.${userId},user_id.is.null`)
|
||||
|
||||
const taxCodes = (taxCodesData as TaxCode[]) || []
|
||||
const taxCodeMap = new Map<string, TaxCode>()
|
||||
for (const tc of taxCodes) {
|
||||
if (!taxCodeMap.has(tc.code) || tc.user_id) {
|
||||
taxCodeMap.set(tc.code, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch posted journal entry lines with tax_code in the period
|
||||
const { data: lines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select(`
|
||||
tax_code,
|
||||
debit_amount,
|
||||
credit_amount,
|
||||
journal_entry_id,
|
||||
journal_entries!inner (
|
||||
user_id,
|
||||
entry_date,
|
||||
status
|
||||
)
|
||||
`)
|
||||
.not('tax_code', 'is', null)
|
||||
.eq('journal_entries.user_id', userId)
|
||||
.eq('journal_entries.status', 'posted')
|
||||
.gte('journal_entries.entry_date', start)
|
||||
.lte('journal_entries.entry_date', end)
|
||||
|
||||
// Aggregate amounts by moms box
|
||||
const boxTotals = new Map<string, number>()
|
||||
|
||||
for (const line of lines || []) {
|
||||
if (!line.tax_code) continue
|
||||
|
||||
const taxCode = taxCodeMap.get(line.tax_code)
|
||||
if (!taxCode) continue
|
||||
|
||||
const amount = Math.abs(Number(line.debit_amount || 0) - Number(line.credit_amount || 0))
|
||||
|
||||
// Map to all relevant boxes
|
||||
for (const box of [...taxCode.moms_basis_boxes, ...taxCode.moms_tax_boxes, ...taxCode.moms_input_boxes]) {
|
||||
const current = boxTotals.get(box) || 0
|
||||
boxTotals.set(box, current + amount)
|
||||
}
|
||||
}
|
||||
|
||||
// Build rutor from box totals
|
||||
const rutor: VatDeclarationRutor = {
|
||||
ruta05: round(boxTotals.get('05') || 0),
|
||||
ruta06: round(boxTotals.get('06') || 0),
|
||||
ruta07: round(boxTotals.get('07') || 0),
|
||||
ruta10: round(boxTotals.get('10') || 0),
|
||||
ruta11: round(boxTotals.get('11') || 0),
|
||||
ruta12: round(boxTotals.get('12') || 0),
|
||||
ruta39: round(boxTotals.get('39') || 0),
|
||||
ruta40: round(boxTotals.get('40') || 0),
|
||||
ruta48: round(boxTotals.get('48') || 0),
|
||||
ruta49: 0,
|
||||
}
|
||||
|
||||
const totalOutputVat = round(rutor.ruta05 + rutor.ruta06 + rutor.ruta07)
|
||||
rutor.ruta49 = round(totalOutputVat - rutor.ruta48)
|
||||
|
||||
return {
|
||||
period: {
|
||||
type: periodType,
|
||||
year,
|
||||
period,
|
||||
start,
|
||||
end,
|
||||
},
|
||||
rutor,
|
||||
invoiceCount: 0,
|
||||
transactionCount: (lines || []).length,
|
||||
breakdown: {
|
||||
invoices: {
|
||||
ruta05: 0,
|
||||
ruta06: 0,
|
||||
ruta07: 0,
|
||||
ruta10: 0,
|
||||
ruta11: 0,
|
||||
ruta12: 0,
|
||||
ruta39: 0,
|
||||
ruta40: 0,
|
||||
},
|
||||
transactions: {
|
||||
ruta48: 0,
|
||||
},
|
||||
receipts: {
|
||||
ruta48: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface SuggestedCategory {
|
||||
label: string
|
||||
account: string | null
|
||||
confidence: number
|
||||
source: 'mapping_rule' | 'pattern' | 'history'
|
||||
source: 'mapping_rule' | 'pattern' | 'history' | 'ai'
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
@@ -148,3 +148,32 @@ function accountToCategory(account: string, amount: number): string | null {
|
||||
}
|
||||
return expenseMap[account] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge AI-generated suggestions into existing suggestion list.
|
||||
* Deduplicates by category, preserving the higher-confidence entry.
|
||||
*/
|
||||
export function mergeAiSuggestions(
|
||||
existing: SuggestedCategory[],
|
||||
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
|
||||
): SuggestedCategory[] {
|
||||
const seen = new Set<string>(existing.map((s) => s.category))
|
||||
const merged = [...existing]
|
||||
|
||||
for (const ai of aiSuggestions) {
|
||||
if (seen.has(ai.category)) continue
|
||||
seen.add(ai.category)
|
||||
|
||||
merged.push({
|
||||
category: ai.category as TransactionCategory,
|
||||
label: CATEGORY_LABELS[ai.category] || ai.category,
|
||||
account: ai.basAccount || null,
|
||||
confidence: ai.confidence,
|
||||
source: 'ai',
|
||||
})
|
||||
}
|
||||
|
||||
return merged
|
||||
.sort((a, b) => b.confidence - a.confidence)
|
||||
.slice(0, 5)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user