feat: consolidate booking templates from 100 to 48 and improve codebase

Reduce booking template library to eliminate duplicate suggestions when
users describe transactions. Templates with identical accounting treatment
(same account + VAT) are merged, keywords consolidated, and the entire
subscriptions group is eliminated. Also includes prior work on reports,
extensions, and transaction improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-02-25 10:21:10 +01:00
co-authored by Claude Opus 4.6
parent 9d684d7e9f
commit 6956a757f3
53 changed files with 3067 additions and 2940 deletions
@@ -43,6 +43,22 @@ export async function POST(request: Request) {
return NextResponse.json({ error: durationError }, { status: 400 })
}
// Check for overlapping periods
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('user_id', user.id)
.lte('period_start', body.period_end)
.gte('period_end', body.period_start)
.limit(1)
if (overlapping && overlapping.length > 0) {
return NextResponse.json(
{ error: `Overlaps with existing period: ${overlapping[0].name}` },
{ status: 409 }
)
}
const { data, error } = await supabase
.from('fiscal_periods')
.insert({
+3 -63
View File
@@ -1,69 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateNEDeclaration } from '@/extensions/ne-bilaga/ne-engine'
import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/extensions/sru-export/lib/sru-generator'
/**
* GET /api/extensions/ne-bilaga
*
* Generate NE declaration (NE-bilaga) for enskild firma.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for SRU file download
*
* Returns:
* - JSON: NE declaration with rutor R1-R11 and breakdown
* - SRU: Downloadable SRU file for Skatteverket submission
* Legacy route — redirects to /api/reports/ne-bilaga
*/
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 {
const declaration = await generateNEDeclaration(user.id, periodId)
if (format === 'sru') {
// Generate and return SRU file
const sruFile = generateSRUFile(declaration)
const sruContent = sruFileToString(sruFile)
const filename = getSRUFilename(declaration)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
// Default: return JSON
return NextResponse.json({ data: declaration })
} catch (err) {
console.error('Error generating NE declaration:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate NE declaration' },
{ status: 500 }
)
}
const { search } = new URL(request.url)
return NextResponse.redirect(new URL(`/api/reports/ne-bilaga${search}`, request.url), 308)
}
@@ -1,29 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSRUCoverage } from '@/extensions/sru-export/sru-engine'
/**
* GET /api/extensions/sru-export/coverage
*
* Returns SRU code coverage stats: how many accounts have SRU codes vs total,
* and a list of accounts missing codes.
* Legacy route — redirects to /api/reports/sru-export/coverage
*/
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(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 }
)
}
export async function GET(request: Request) {
return NextResponse.redirect(new URL('/api/reports/sru-export/coverage', request.url), 308)
}
+3 -144
View File
@@ -1,150 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { aggregateBalancesBySRU } from '@/extensions/sru-export/sru-engine'
import {
generateGenericSRU,
getGenericSRUFilename,
sruFileToString,
validateSRUFile,
SRU_CODE_DESCRIPTIONS,
} from '@/extensions/sru-export/sru-generator'
import type { SRUFormType } from '@/extensions/sru-export/sru-generator'
import type { EntityType } from '@/types'
/**
* GET /api/extensions/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
* Legacy route — redirects to /api/reports/sru-export
*/
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(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 }
)
}
const { search } = new URL(request.url)
return NextResponse.redirect(new URL(`/api/reports/sru-export${search}`, request.url), 308)
}
+69
View File
@@ -0,0 +1,69 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { generateNEDeclaration } from '@/lib/reports/ne-bilaga/ne-engine'
import {
generateSRUFile,
sruFileToString,
getSRUFilename,
} from '@/lib/reports/sru-export/sru-generator'
/**
* GET /api/reports/ne-bilaga
*
* Generate NE declaration (NE-bilaga) for enskild firma.
*
* Query parameters:
* - period_id: Fiscal period ID (required)
* - format: 'json' (default) or 'sru' for SRU file download
*
* Returns:
* - JSON: NE declaration with rutor R1-R11 and breakdown
* - SRU: Downloadable SRU file for Skatteverket submission
*/
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 {
const declaration = await generateNEDeclaration(user.id, periodId)
if (format === 'sru') {
// Generate and return SRU file
const sruFile = generateSRUFile(declaration)
const sruContent = sruFileToString(sruFile)
const filename = getSRUFilename(declaration)
return new NextResponse(sruContent, {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
},
})
}
// Default: return JSON
return NextResponse.json({ data: declaration })
} catch (err) {
console.error('Error generating NE declaration:', err)
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to generate NE declaration' },
{ status: 500 }
)
}
}
@@ -0,0 +1,29 @@
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(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 }
)
}
}
+150
View File
@@ -0,0 +1,150 @@
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(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 }
)
}
}