New Base func

This commit is contained in:
Jakob Wennberg
2026-02-19 09:48:02 +01:00
parent afc9f69def
commit cdf1dcc4c8
73 changed files with 11036 additions and 51 deletions
+146
View File
@@ -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')
})
})
+235
View File
@@ -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,
}
}
+237
View File
@@ -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
}
+424
View File
@@ -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')
})
})
+243
View File
@@ -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')
})
})
+167
View File
@@ -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}`)
}
}