feat: BAS data overhaul, currency revaluation, expenses, UI polish, and cleanup
- Update BAS account catalog with comprehensive SRU codes and K2 flags - Add currency revaluation service with tests and API route - Add expenses page and account deletion API - Enhance booking templates with new patterns and improved tests - Improve transaction categorization with template picker and description matching - Polish dashboard, onboarding, import, and transaction UIs - Refactor year-end service for multi-step closing - Move SRU generator to ne-bilaga, remove standalone SRU export - Remove unused dev docs, mock data, and extension hooks - Add invoice delivery note sequences migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a9bf24ce0b
commit
66a4027f1e
@@ -11,7 +11,7 @@ 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']) {
|
||||
for (const m of ['select', 'eq', 'insert', 'update', 'delete', 'lte', 'gte', 'in', 'neq', 'not', 'or', 'order', 'limit', 'is']) {
|
||||
b[m] = vi.fn().mockReturnValue(b)
|
||||
}
|
||||
b.single = vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null })
|
||||
@@ -39,6 +39,18 @@ vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/currency-revaluation', () => ({
|
||||
previewCurrencyRevaluation: vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
lines: [],
|
||||
closingRates: {},
|
||||
totalGain: 0,
|
||||
totalLoss: 0,
|
||||
netEffect: 0,
|
||||
}),
|
||||
executeCurrencyRevaluation: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('../period-service', () => ({
|
||||
lockPeriod: vi.fn(),
|
||||
closePeriod: vi.fn(),
|
||||
@@ -67,6 +79,12 @@ describe('validateYearEndReadiness', () => {
|
||||
{ data: null, error: null, count: 3 },
|
||||
// 2: count posted entries (thenable chain) — count: 10
|
||||
{ data: null, error: null, count: 10 },
|
||||
// 3: count revaluation entries — count: 0
|
||||
{ data: null, error: null, count: 0 },
|
||||
// 4: count fx receivables — count: 0
|
||||
{ data: null, error: null, count: 0 },
|
||||
// 5: count fx payables — count: 0
|
||||
{ data: null, error: null, count: 0 },
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
@@ -89,6 +107,9 @@ describe('validateYearEndReadiness', () => {
|
||||
{ data: period, error: null },
|
||||
{ data: null, error: null, count: 0 }, // no drafts
|
||||
{ data: null, error: null, count: 5 }, // some posted
|
||||
{ data: null, error: null, count: 0 }, // no revaluation
|
||||
{ data: null, error: null, count: 0 }, // no fx receivables
|
||||
{ data: null, error: null, count: 0 }, // no fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
@@ -121,8 +142,11 @@ describe('validateYearEndReadiness', () => {
|
||||
resultIdx = 0
|
||||
results = [
|
||||
{ data: period, error: null },
|
||||
{ data: null, error: null, count: 0 },
|
||||
{ data: null, error: null, count: 5 },
|
||||
{ data: null, error: null, count: 0 }, // no drafts
|
||||
{ data: null, error: null, count: 5 }, // some posted
|
||||
{ data: null, error: null, count: 0 }, // no revaluation
|
||||
{ data: null, error: null, count: 0 }, // no fx receivables
|
||||
{ data: null, error: null, count: 0 }, // no fx payables
|
||||
]
|
||||
|
||||
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||
@@ -143,6 +167,8 @@ describe('previewYearEndClosing', () => {
|
||||
results = [
|
||||
// 0: fetch company_settings (.single)
|
||||
{ data: { entity_type: 'aktiebolag' }, error: null },
|
||||
// 1: fetch fiscal period for closing date (.single)
|
||||
{ data: { period_end: '2024-12-31' }, error: null },
|
||||
]
|
||||
|
||||
vi.mocked(generateIncomeStatement).mockResolvedValue({
|
||||
@@ -173,6 +199,8 @@ describe('previewYearEndClosing', () => {
|
||||
it('uses 2010 for EF entity type', async () => {
|
||||
results = [
|
||||
{ data: { entity_type: 'enskild_firma' }, error: null },
|
||||
// fetch fiscal period for closing date (.single)
|
||||
{ data: { period_end: '2024-12-31' }, error: null },
|
||||
]
|
||||
|
||||
vi.mocked(generateIncomeStatement).mockResolvedValue({ net_result: 50000 } as never)
|
||||
|
||||
@@ -4,6 +4,10 @@ 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 {
|
||||
previewCurrencyRevaluation,
|
||||
executeCurrencyRevaluation,
|
||||
} from '@/lib/bookkeeping/currency-revaluation'
|
||||
import type {
|
||||
YearEndValidation,
|
||||
YearEndPreview,
|
||||
@@ -105,6 +109,40 @@ export async function validateYearEndReadiness(
|
||||
warnings.push('No posted journal entries in this period')
|
||||
}
|
||||
|
||||
// Check: foreign currency items exist but haven't been revalued
|
||||
const { count: revalCount } = await supabase
|
||||
.from('journal_entries')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('fiscal_period_id', fiscalPeriodId)
|
||||
.eq('source_type', 'currency_revaluation')
|
||||
.eq('status', 'posted')
|
||||
|
||||
if ((revalCount ?? 0) === 0) {
|
||||
// Check if there are any open foreign currency items
|
||||
const { count: fxReceivables } = await supabase
|
||||
.from('invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.neq('currency', 'SEK')
|
||||
.not('exchange_rate', 'is', null)
|
||||
|
||||
const { count: fxPayables } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['registered', 'approved', 'overdue', 'partially_paid'])
|
||||
.neq('currency', 'SEK')
|
||||
.not('exchange_rate', 'is', null)
|
||||
|
||||
if (((fxReceivables ?? 0) + (fxPayables ?? 0)) > 0) {
|
||||
warnings.push(
|
||||
'Open foreign currency items exist but have not been revalued (ÅRL 4:13)'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ready: errors.length === 0,
|
||||
errors,
|
||||
@@ -212,12 +250,33 @@ export async function previewYearEndClosing(
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fiscal period for closing date
|
||||
const { data: periodData } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_end')
|
||||
.eq('id', fiscalPeriodId)
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
let currencyRevaluation = null
|
||||
if (periodData) {
|
||||
const revalPreview = await previewCurrencyRevaluation(
|
||||
supabase,
|
||||
userId,
|
||||
periodData.period_end
|
||||
)
|
||||
if (revalPreview.items.length > 0) {
|
||||
currencyRevaluation = revalPreview
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
netResult,
|
||||
closingAccount,
|
||||
closingAccountName,
|
||||
closingLines,
|
||||
resultAccountSummary,
|
||||
currencyRevaluation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,14 +314,24 @@ export async function executeYearEndClosing(
|
||||
throw new Error('Fiscal period not found')
|
||||
}
|
||||
|
||||
// 2. Get closing preview
|
||||
// 2. Execute currency revaluation BEFORE closing entry
|
||||
// Revaluation posts to 3960/7960 (class 3/7 result accounts) which
|
||||
// the closing entry then zeros out.
|
||||
const revaluationResult = await executeCurrencyRevaluation(
|
||||
supabase,
|
||||
userId,
|
||||
period.period_end,
|
||||
fiscalPeriodId
|
||||
)
|
||||
|
||||
// 3. Get closing preview (now includes revaluation effects in trial balance)
|
||||
const preview = await previewYearEndClosing(supabase, userId, fiscalPeriodId)
|
||||
|
||||
if (preview.closingLines.length === 0) {
|
||||
throw new Error('No result accounts to close — period has no activity')
|
||||
}
|
||||
|
||||
// 3. Create closing entry via the journal engine
|
||||
// 4. Create closing entry via the journal engine
|
||||
const closingEntry = await createJournalEntry(supabase, userId, {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: period.period_end,
|
||||
@@ -272,7 +341,7 @@ export async function executeYearEndClosing(
|
||||
lines: preview.closingLines,
|
||||
})
|
||||
|
||||
// 4. Update fiscal period with closing_entry_id
|
||||
// 5. Update fiscal period with closing_entry_id
|
||||
const { error: updateError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.update({ closing_entry_id: closingEntry.id })
|
||||
@@ -283,16 +352,16 @@ export async function executeYearEndClosing(
|
||||
throw new Error(`Failed to set closing_entry_id: ${updateError.message}`)
|
||||
}
|
||||
|
||||
// 5. Lock the period
|
||||
// 6. Lock the period
|
||||
await lockPeriod(supabase, userId, fiscalPeriodId)
|
||||
|
||||
// 6. Close the period
|
||||
// 7. Close the period
|
||||
await closePeriod(supabase, userId, fiscalPeriodId)
|
||||
|
||||
// 7. Create next period
|
||||
// 8. Create next period
|
||||
const nextPeriod = await createNextPeriod(supabase, userId, fiscalPeriodId)
|
||||
|
||||
// 8. Generate opening balances in next period
|
||||
// 9. Generate opening balances in next period
|
||||
const openingBalanceEntry = await generateOpeningBalances(
|
||||
supabase,
|
||||
userId,
|
||||
@@ -319,6 +388,7 @@ export async function executeYearEndClosing(
|
||||
closingEntry,
|
||||
nextPeriod,
|
||||
openingBalanceEntry,
|
||||
revaluationEntry: revaluationResult?.entry ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user