- 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>
376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import { createClient } from '@/lib/supabase/server'
|
|
import { NextResponse } from 'next/server'
|
|
import { eventBus } from '@/lib/events'
|
|
import { ensureInitialized } from '@/lib/init'
|
|
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
|
import { getTemplateById, buildMappingResultFromTemplate, validateTemplateForEntity } from '@/lib/bookkeeping/booking-templates'
|
|
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
|
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
|
import { validateBody } from '@/lib/api/validate'
|
|
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
|
|
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
|
|
|
ensureInitialized()
|
|
|
|
/**
|
|
* Ensure a fiscal period exists for the given date, create one if needed
|
|
*/
|
|
async function ensureFiscalPeriod(
|
|
supabase: Awaited<ReturnType<typeof createClient>>,
|
|
userId: string,
|
|
date: string,
|
|
fiscalYearStartMonth: number = 1
|
|
): Promise<boolean> {
|
|
// Check if a fiscal period already covers this date
|
|
const { data: existing } = await supabase
|
|
.from('fiscal_periods')
|
|
.select('id')
|
|
.eq('user_id', userId)
|
|
.lte('period_start', date)
|
|
.gte('period_end', date)
|
|
.eq('is_closed', false)
|
|
.limit(1)
|
|
|
|
if (existing && existing.length > 0) {
|
|
return true
|
|
}
|
|
|
|
// Compute fiscal year period based on start month
|
|
const txDate = new Date(date)
|
|
const txMonth = txDate.getMonth() + 1
|
|
const txYear = txDate.getFullYear()
|
|
|
|
let periodStartYear: number
|
|
if (fiscalYearStartMonth === 1) {
|
|
periodStartYear = txYear
|
|
} else if (txMonth >= fiscalYearStartMonth) {
|
|
periodStartYear = txYear
|
|
} else {
|
|
periodStartYear = txYear - 1
|
|
}
|
|
|
|
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
|
|
const periodStart = `${periodStartYear}-${startMonth}-01`
|
|
|
|
// Period ends the day before the next fiscal year starts
|
|
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
|
|
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
|
|
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
|
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
|
|
|
const periodName = fiscalYearStartMonth === 1
|
|
? `Räkenskapsår ${periodStartYear}`
|
|
: `Räkenskapsår ${periodStartYear}/${endYear}`
|
|
|
|
const { error } = await supabase
|
|
.from('fiscal_periods')
|
|
.upsert({
|
|
user_id: userId,
|
|
name: periodName,
|
|
period_start: periodStart,
|
|
period_end: periodEnd,
|
|
}, {
|
|
onConflict: 'user_id,period_start,period_end',
|
|
})
|
|
|
|
if (error) {
|
|
console.error('Failed to create fiscal period:', error)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
const supabase = await createClient()
|
|
const { id } = await params
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
}
|
|
|
|
// Parse and validate request body
|
|
const validation = await validateBody(request, CategorizeTransactionSchema)
|
|
if (!validation.success) return validation.response
|
|
const body = validation.data
|
|
const { is_business, category } = body
|
|
|
|
// Fetch the transaction (validates ownership)
|
|
const { data: transaction, error: fetchError } = await supabase
|
|
.from('transactions')
|
|
.select('*')
|
|
.eq('id', id)
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
if (fetchError || !transaction) {
|
|
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
|
}
|
|
|
|
// If already has a journal entry, just update category and is_business (skip journal entry creation)
|
|
if (transaction.journal_entry_id) {
|
|
const finalCat: TransactionCategory = is_business
|
|
? (category || 'uncategorized')
|
|
: 'private'
|
|
|
|
const { error: updateErr } = await supabase
|
|
.from('transactions')
|
|
.update({
|
|
is_business,
|
|
category: finalCat,
|
|
})
|
|
.eq('id', id)
|
|
|
|
if (updateErr) {
|
|
return NextResponse.json(
|
|
{ error: 'Failed to update transaction' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
journal_entry_created: false,
|
|
journal_entry_id: transaction.journal_entry_id,
|
|
journal_entry_error: null,
|
|
category: finalCat,
|
|
already_had_journal_entry: true,
|
|
})
|
|
}
|
|
|
|
// Fetch company settings to get entity type and fiscal year start
|
|
const { data: settings } = await supabase
|
|
.from('company_settings')
|
|
.select('entity_type, fiscal_year_start_month')
|
|
.eq('user_id', user.id)
|
|
.single()
|
|
|
|
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
|
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
|
|
|
|
// Determine the category to use
|
|
let finalCategory: TransactionCategory
|
|
if (body.template_id) {
|
|
const template = getTemplateById(body.template_id)
|
|
if (template) {
|
|
// Hard entity guard — reject templates that don't match the user's entity type
|
|
const entityValidation = validateTemplateForEntity(template, entityType)
|
|
if (!entityValidation.valid) {
|
|
return NextResponse.json({ error: entityValidation.error }, { status: 400 })
|
|
}
|
|
|
|
finalCategory = is_business ? template.fallback_category : 'private'
|
|
console.log(`[categorize] tx=${id} using template="${body.template_id}" (${template.name_sv}) → category=${finalCategory}, debit=${template.debit_account}, credit=${template.credit_account}, vat=${template.vat_treatment}`)
|
|
} else {
|
|
return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 })
|
|
}
|
|
} else {
|
|
finalCategory = is_business ? (category || 'uncategorized') : 'private'
|
|
console.log(`[categorize] tx=${id} using category="${finalCategory}" vat=${body.vat_treatment || 'default'} account_override=${body.account_override || 'none'}`)
|
|
}
|
|
|
|
if (body.inbox_item_id) {
|
|
console.log(`[categorize] tx=${id} will confirm inbox item=${body.inbox_item_id} and link document`)
|
|
}
|
|
|
|
// Build mapping result from template or category
|
|
let mappingResult
|
|
if (body.template_id) {
|
|
const template = getTemplateById(body.template_id)!
|
|
mappingResult = buildMappingResultFromTemplate(
|
|
template,
|
|
transaction as Transaction,
|
|
entityType
|
|
)
|
|
} else {
|
|
mappingResult = buildMappingResultFromCategory(
|
|
finalCategory,
|
|
transaction as Transaction,
|
|
is_business,
|
|
entityType,
|
|
body.vat_treatment
|
|
)
|
|
}
|
|
|
|
console.log(`[categorize] tx=${id} mapping result:`, {
|
|
debit: mappingResult.debit_account,
|
|
credit: mappingResult.credit_account,
|
|
vatLines: mappingResult.vat_lines.map((v) => `${v.account_number} debit=${v.debit_amount} credit=${v.credit_amount}`),
|
|
})
|
|
|
|
// Apply account override if provided (only for business transactions)
|
|
if (is_business && body.account_override) {
|
|
// Validate the account exists in the user's chart of accounts
|
|
const { data: accountExists } = await supabase
|
|
.from('chart_of_accounts')
|
|
.select('account_number, account_class')
|
|
.eq('user_id', user.id)
|
|
.eq('account_number', body.account_override)
|
|
.single()
|
|
|
|
if (!accountExists) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid account number' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Apply override: expenses override debit account, income overrides credit account
|
|
if (transaction.amount < 0) {
|
|
mappingResult.debit_account = body.account_override
|
|
} else {
|
|
mappingResult.credit_account = body.account_override
|
|
}
|
|
|
|
// If override account is a liability/equity account (class 2), clear VAT lines
|
|
if (accountExists.account_class === 2) {
|
|
mappingResult.vat_lines = []
|
|
}
|
|
}
|
|
|
|
// Validate that both accounts are present before proceeding
|
|
if (!mappingResult.debit_account || !mappingResult.credit_account) {
|
|
return NextResponse.json(
|
|
{ error: 'Invalid account mapping: debit and credit accounts are required' },
|
|
{ status: 400 }
|
|
)
|
|
}
|
|
|
|
// Ensure fiscal period exists for the transaction date
|
|
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
|
|
|
|
// Try to create journal entry
|
|
let journalEntryCreated = false
|
|
let journalEntryId: string | null = null
|
|
let journalEntryError: string | null = null
|
|
|
|
try {
|
|
const journalEntry = await createTransactionJournalEntry(
|
|
supabase,
|
|
user.id,
|
|
transaction as Transaction,
|
|
mappingResult
|
|
)
|
|
|
|
if (journalEntry) {
|
|
journalEntryCreated = true
|
|
journalEntryId = journalEntry.id
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create journal entry:', err)
|
|
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
|
|
// Continue - we still want to save the categorization
|
|
}
|
|
|
|
// Save mapping rule for future auto-categorization (only for business expenses with merchant)
|
|
if (is_business && transaction.merchant_name) {
|
|
try {
|
|
await saveUserMappingRule(
|
|
supabase,
|
|
user.id,
|
|
transaction.merchant_name,
|
|
mappingResult.debit_account,
|
|
mappingResult.credit_account,
|
|
!is_business,
|
|
body.user_description,
|
|
body.template_id
|
|
)
|
|
} catch (err) {
|
|
console.error('Failed to save mapping rule:', err)
|
|
// Non-critical, continue
|
|
}
|
|
}
|
|
|
|
// Link receipt document to journal entry if both exist
|
|
if (journalEntryId && transaction.receipt_id) {
|
|
try {
|
|
const { data: receipt } = await supabase
|
|
.from('receipts')
|
|
.select('document_id')
|
|
.eq('id', transaction.receipt_id)
|
|
.single()
|
|
|
|
if (receipt?.document_id) {
|
|
await supabase
|
|
.from('document_attachments')
|
|
.update({ journal_entry_id: journalEntryId })
|
|
.eq('id', receipt.document_id)
|
|
.eq('user_id', user.id)
|
|
}
|
|
} catch (linkErr) {
|
|
console.error('[categorize] Failed to link receipt document:', linkErr)
|
|
}
|
|
}
|
|
|
|
// Confirm matched inbox item and link its document to the journal entry
|
|
if (body.inbox_item_id) {
|
|
try {
|
|
await supabase
|
|
.from('invoice_inbox_items')
|
|
.update({ status: 'confirmed' })
|
|
.eq('id', body.inbox_item_id)
|
|
.eq('user_id', user.id)
|
|
|
|
// Link inbox item's document to the journal entry
|
|
if (journalEntryId) {
|
|
const { data: inboxItem } = await supabase
|
|
.from('invoice_inbox_items')
|
|
.select('document_id')
|
|
.eq('id', body.inbox_item_id)
|
|
.single()
|
|
|
|
if (inboxItem?.document_id) {
|
|
await supabase
|
|
.from('document_attachments')
|
|
.update({ journal_entry_id: journalEntryId })
|
|
.eq('id', inboxItem.document_id)
|
|
.eq('user_id', user.id)
|
|
}
|
|
}
|
|
} catch (inboxErr) {
|
|
console.error('[categorize] Failed to update inbox item:', inboxErr)
|
|
}
|
|
}
|
|
|
|
// Update the transaction
|
|
const { error: updateError } = await supabase
|
|
.from('transactions')
|
|
.update({
|
|
is_business,
|
|
category: finalCategory,
|
|
journal_entry_id: journalEntryId,
|
|
})
|
|
.eq('id', id)
|
|
|
|
if (updateError) {
|
|
console.error('Failed to update transaction:', updateError)
|
|
return NextResponse.json(
|
|
{ error: 'Failed to update transaction' },
|
|
{ status: 500 }
|
|
)
|
|
}
|
|
|
|
await eventBus.emit({
|
|
type: 'transaction.categorized',
|
|
payload: {
|
|
transaction: transaction as Transaction,
|
|
account: mappingResult.debit_account,
|
|
taxCode: mappingResult.vat_lines[0]?.account_number || '',
|
|
userId: user.id,
|
|
},
|
|
})
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
journal_entry_created: journalEntryCreated,
|
|
journal_entry_id: journalEntryId,
|
|
journal_entry_error: journalEntryError,
|
|
category: finalCategory,
|
|
})
|
|
}
|