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
@@ -0,0 +1,54 @@
|
||||
import { createClient, createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
const DeleteAccountSchema = z.object({
|
||||
confirm: z.literal('RADERA'),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
const parsed = DeleteAccountSchema.safeParse(body)
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Skriv RADERA för att bekräfta kontoborttagning' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the auth user via service role — all data cascades via ON DELETE CASCADE
|
||||
const serviceClient = await createServiceClient()
|
||||
const { error } = await serviceClient.auth.admin.deleteUser(user.id)
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to delete user:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte radera kontot. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Account deletion error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Kunde inte radera kontot. Försök igen.' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
previewCurrencyRevaluation,
|
||||
executeCurrencyRevaluation,
|
||||
} from '@/lib/bookkeeping/currency-revaluation'
|
||||
|
||||
/**
|
||||
* GET: Preview currency revaluation for a fiscal period
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch period to get closing date
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const preview = await previewCurrencyRevaluation(supabase, user.id, period.period_end)
|
||||
return NextResponse.json({ data: preview })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to preview currency revaluation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST: Execute currency revaluation for a fiscal period
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch period to get closing date
|
||||
const { data: period, error: periodError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (periodError || !period) {
|
||||
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (period.is_closed) {
|
||||
return NextResponse.json({ error: 'Period is already closed' }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await executeCurrencyRevaluation(supabase, user.id, period.period_end, id)
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json({ data: null, message: 'No foreign currency items to revalue' })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to execute currency revaluation' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ export async function GET(request: Request) {
|
||||
.from('journal_entries')
|
||||
.select('*, lines:journal_entry_lines(*)', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('entry_date', { ascending: false })
|
||||
.order('voucher_number', { ascending: false })
|
||||
.order('voucher_series', { ascending: true })
|
||||
.order('voucher_number', { ascending: true })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (periodId) {
|
||||
|
||||
@@ -157,15 +157,21 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate invoice number with appropriate prefix
|
||||
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
const invoiceNumber = documentType === 'proforma'
|
||||
? `PF-${baseNumber}`
|
||||
: documentType === 'delivery_note'
|
||||
? `FS-${baseNumber}`
|
||||
// Generate document number from the appropriate sequence
|
||||
let invoiceNumber: string
|
||||
if (documentType === 'delivery_note') {
|
||||
const { data: dnNumber } = await supabase.rpc('generate_delivery_note_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
invoiceNumber = dnNumber
|
||||
} else {
|
||||
const { data: baseNumber } = await supabase.rpc('generate_invoice_number', {
|
||||
p_user_id: user.id,
|
||||
})
|
||||
invoiceNumber = documentType === 'proforma'
|
||||
? `PF-${baseNumber}`
|
||||
: baseNumber
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
generateSRUFile,
|
||||
sruFileToString,
|
||||
getSRUFilename,
|
||||
} from '@/lib/reports/sru-export/sru-generator'
|
||||
} from '@/lib/reports/ne-bilaga/sru-generator'
|
||||
|
||||
/**
|
||||
* GET /api/reports/ne-bilaga
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSRUCoverage } from '@/lib/reports/sru-export/sru-engine'
|
||||
|
||||
/**
|
||||
* GET /api/reports/sru-export/coverage
|
||||
*
|
||||
* Returns SRU code coverage stats: how many accounts have SRU codes vs total,
|
||||
* and a list of accounts missing codes.
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const coverage = await getSRUCoverage(supabase, user.id)
|
||||
return NextResponse.json({ data: coverage })
|
||||
} catch (err) {
|
||||
console.error('Error fetching SRU coverage:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to fetch SRU coverage' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { aggregateBalancesBySRU } from '@/lib/reports/sru-export/sru-engine'
|
||||
import {
|
||||
generateGenericSRU,
|
||||
getGenericSRUFilename,
|
||||
sruFileToString,
|
||||
validateSRUFile,
|
||||
SRU_CODE_DESCRIPTIONS,
|
||||
} from '@/lib/reports/sru-export/sru-generic-generator'
|
||||
import type { SRUFormType } from '@/lib/reports/sru-export/sru-generic-generator'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
* GET /api/reports/sru-export
|
||||
*
|
||||
* Generate SRU export from chart_of_accounts sru_code mappings.
|
||||
*
|
||||
* Query parameters:
|
||||
* - period_id: Fiscal period ID (required)
|
||||
* - format: 'json' (default) or 'sru' for downloadable file
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
const format = searchParams.get('format') || 'json'
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'period_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch company settings to determine entity type
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, company_name, org_number')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!settings) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Company settings not found. Complete onboarding first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Determine form type from entity type
|
||||
const formType: SRUFormType = settings.entity_type === 'aktiebolag' ? 'INK2' : 'NE'
|
||||
|
||||
// Fetch fiscal period for date range
|
||||
const { data: period } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('*')
|
||||
.eq('id', periodId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!period) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Fiscal period not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Aggregate balances by SRU code
|
||||
const sruBalances = await aggregateBalancesBySRU(supabase, user.id, periodId)
|
||||
|
||||
if (format === 'sru') {
|
||||
// Generate and return SRU file
|
||||
const sruFile = generateGenericSRU({
|
||||
formType,
|
||||
orgNumber: settings.org_number,
|
||||
companyName: settings.company_name || 'Okänt företag',
|
||||
fiscalYearStart: period.period_start,
|
||||
fiscalYearEnd: period.period_end,
|
||||
sruBalances,
|
||||
})
|
||||
|
||||
const validation = validateSRUFile(sruFile)
|
||||
if (!validation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ error: 'SRU validation failed', details: validation.errors },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const sruContent = sruFileToString(sruFile)
|
||||
const filename = getGenericSRUFilename(formType, settings.org_number, period.period_start)
|
||||
|
||||
return new NextResponse(sruContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Default: return JSON preview
|
||||
const balancesArray = Array.from(sruBalances.values())
|
||||
.sort((a, b) => a.sruCode.localeCompare(b.sruCode))
|
||||
.map((b) => ({
|
||||
sruCode: b.sruCode,
|
||||
description: SRU_CODE_DESCRIPTIONS[b.sruCode] || `SRU ${b.sruCode}`,
|
||||
amount: b.amount,
|
||||
accounts: b.accounts,
|
||||
}))
|
||||
|
||||
const warnings: string[] = []
|
||||
if (!period.is_closed) {
|
||||
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
|
||||
}
|
||||
if (balancesArray.length === 0) {
|
||||
warnings.push('Inga konton med SRU-koder har bokförda poster i denna period.')
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
formType,
|
||||
entityType: settings.entity_type as EntityType,
|
||||
companyName: settings.company_name,
|
||||
orgNumber: settings.org_number,
|
||||
fiscalYear: {
|
||||
id: period.id,
|
||||
name: period.name,
|
||||
start: period.period_start,
|
||||
end: period.period_end,
|
||||
},
|
||||
balances: balancesArray,
|
||||
warnings,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Error generating SRU export:', err)
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to generate SRU export' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export async function PUT(request: Request) {
|
||||
// Fetch current settings to check for tax-relevant changes
|
||||
const { data: oldSettings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month')
|
||||
.select('entity_type, moms_period, f_skatt, vat_registered, pays_salaries, fiscal_year_start_month, onboarding_complete')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
@@ -46,6 +46,12 @@ export async function PUT(request: Request) {
|
||||
if (!validation.success) return validation.response
|
||||
const body = validation.data
|
||||
|
||||
// Lock company_name and org_number after onboarding is complete
|
||||
if (oldSettings && (oldSettings as Record<string, unknown>).onboarding_complete === true) {
|
||||
delete (body as Record<string, unknown>).company_name
|
||||
delete (body as Record<string, unknown>).org_number
|
||||
}
|
||||
|
||||
// Validate: enskild firma must use calendar year (BFL 3 kap.)
|
||||
const effectiveEntityType = body.entity_type || oldSettings?.entity_type
|
||||
const effectiveFYStartMonth = body.fiscal_year_start_month ?? oldSettings?.fiscal_year_start_month
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 } from '@/lib/bookkeeping/booking-templates'
|
||||
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'
|
||||
@@ -158,6 +158,12 @@ export async function POST(
|
||||
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 {
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
|
||||
// Minimum confidence threshold — below this, suggestions are considered weak
|
||||
// and we trigger on-demand AI categorization to get better results.
|
||||
const WEAK_SUGGESTION_THRESHOLD = 0.55
|
||||
|
||||
/**
|
||||
* Check if suggestions are "weak" — only history-based fallbacks
|
||||
* with no strong rule/pattern/AI match.
|
||||
*/
|
||||
function hasWeakSuggestions(result: SuggestedCategory[]): boolean {
|
||||
if (result.length === 0) return true
|
||||
// Weak if the best suggestion is below threshold
|
||||
const bestConfidence = Math.max(...result.map((s) => s.confidence))
|
||||
if (bestConfidence < WEAK_SUGGESTION_THRESHOLD) return true
|
||||
// Weak if all suggestions are from history only (no rule/pattern/ai match)
|
||||
if (result.every((s) => s.source === 'history')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/suggest-categories
|
||||
* Batch endpoint for getting category suggestions for multiple transactions
|
||||
@@ -115,7 +96,6 @@ export async function POST(request: Request) {
|
||||
// Generate initial suggestions for each transaction
|
||||
const suggestions: Record<string, SuggestedCategory[]> = {}
|
||||
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
|
||||
const needsAiIds: string[] = []
|
||||
|
||||
for (const tx of transactions) {
|
||||
let result = getSuggestedCategories(
|
||||
@@ -128,14 +108,10 @@ export async function POST(request: Request) {
|
||||
const aiSuggestions = aiSuggestionsMap[tx.id]
|
||||
if (aiSuggestions && aiSuggestions.length > 0) {
|
||||
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
|
||||
} else if (hasWeakSuggestions(result)) {
|
||||
// No pre-computed AI suggestion AND rule-based suggestions are weak —
|
||||
// mark this transaction for on-demand AI categorization
|
||||
needsAiIds.push(tx.id)
|
||||
}
|
||||
|
||||
suggestions[tx.id] = result
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType)
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
|
||||
}
|
||||
|
||||
// Inject document template suggestions from matched inbox items
|
||||
@@ -183,63 +159,5 @@ export async function POST(request: Request) {
|
||||
// Non-blocking
|
||||
}
|
||||
|
||||
// Trigger on-demand AI categorization for transactions with weak suggestions
|
||||
if (needsAiIds.length > 0) {
|
||||
console.log(
|
||||
`[suggest-categories] ${needsAiIds.length} transactions have weak suggestions, triggering on-demand AI:`,
|
||||
needsAiIds.map((id) => {
|
||||
const tx = transactions.find((t) => t.id === id)
|
||||
return tx
|
||||
? { id, description: tx.description, merchant_name: tx.merchant_name, amount: tx.amount }
|
||||
: { id }
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (!aiExt?.services?.categorizeTransactions) {
|
||||
console.log('[suggest-categories] AI categorization extension not loaded, skipping on-demand AI')
|
||||
return NextResponse.json({ suggestions, template_suggestions })
|
||||
}
|
||||
const aiResults: Array<{ transactionId: string; category: string; basAccount: string; confidence: number; reasoning: string; isPrivate?: boolean; templateId?: string }> = await aiExt.services.categorizeTransactions(user.id, needsAiIds)
|
||||
|
||||
console.log(
|
||||
'[suggest-categories] AI categorization results:',
|
||||
aiResults.map((r) => ({
|
||||
id: r.transactionId,
|
||||
category: r.category,
|
||||
basAccount: r.basAccount,
|
||||
confidence: r.confidence,
|
||||
reasoning: r.reasoning,
|
||||
isPrivate: r.isPrivate,
|
||||
templateId: r.templateId,
|
||||
}))
|
||||
)
|
||||
|
||||
// Group AI results by transactionId (AI now returns 2 per transaction)
|
||||
const groupedAi: Record<string, { category: string; basAccount: string; confidence: number; reasoning: string }[]> = {}
|
||||
for (const aiResult of aiResults) {
|
||||
if (!groupedAi[aiResult.transactionId]) {
|
||||
groupedAi[aiResult.transactionId] = []
|
||||
}
|
||||
groupedAi[aiResult.transactionId].push({
|
||||
category: aiResult.category,
|
||||
basAccount: aiResult.basAccount,
|
||||
confidence: aiResult.confidence,
|
||||
reasoning: aiResult.reasoning,
|
||||
})
|
||||
}
|
||||
|
||||
for (const [txId, aiSuggestions] of Object.entries(groupedAi)) {
|
||||
const existing = suggestions[txId] || []
|
||||
const tx = transactions.find((t) => t.id === txId)
|
||||
suggestions[txId] = mergeAiSuggestions(existing, aiSuggestions, tx?.amount)
|
||||
}
|
||||
} catch (err) {
|
||||
// AI categorization is non-blocking — log and continue with existing suggestions
|
||||
console.error('[suggest-categories] On-demand AI categorization failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ suggestions, template_suggestions })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user