New Base func
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user