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:
Jakob Wennberg
2026-03-04 14:19:56 +01:00
co-authored by Claude Opus 4.6
parent a9bf24ce0b
commit 66a4027f1e
104 changed files with 4964 additions and 5501 deletions
@@ -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)
+77 -7
View File
@@ -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,
}
}