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
+57 -1
View File
@@ -23,7 +23,8 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { Customer, Currency, CreateInvoiceInput, InvoiceDocumentType } from '@/types'
import CustomerForm from '@/components/customers/CustomerForm'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType } from '@/types'
const itemSchema = z.object({
description: z.string().min(1, 'Beskrivning krävs'),
@@ -68,6 +69,8 @@ export default function NewInvoicePage() {
const [isSending, setIsSending] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [defaultNotes, setDefaultNotes] = useState<string | null>(null)
const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false)
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
const {
register,
@@ -166,6 +169,36 @@ export default function NewInvoicePage() {
setIsLoading(false)
}
async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreatingCustomer(true)
const response = await fetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const result = await response.json()
if (!response.ok) {
toast({
title: 'Fel',
description: result.error || 'Kunde inte skapa kund',
variant: 'destructive',
})
} else {
toast({
title: 'Kund skapad',
description: `${data.name} har lagts till`,
})
setCustomers([...customers, result.data])
setValue('customer_id', result.data.id)
setIsCreateCustomerOpen(false)
}
setIsCreatingCustomer(false)
}
const subtotal = watchItems.reduce((sum, item) => {
return sum + (item.quantity || 0) * (item.unit_price || 0)
}, 0)
@@ -366,6 +399,16 @@ export default function NewInvoicePage() {
</Select>
)}
/>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => setIsCreateCustomerOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Skapa kund
</Button>
{errors.customer_id && (
<p className="text-sm text-destructive mt-2">{errors.customer_id.message}</p>
)}
@@ -677,6 +720,19 @@ export default function NewInvoicePage() {
</ConfirmationDialog>
)}
{/* Create customer dialog */}
<Dialog open={isCreateCustomerOpen} onOpenChange={setIsCreateCustomerOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Lägg till kund</DialogTitle>
</DialogHeader>
<CustomerForm
onSubmit={handleCreateCustomer}
isLoading={isCreatingCustomer}
/>
</DialogContent>
</Dialog>
{/* Send now prompt dialog */}
<Dialog open={showSendPrompt} onOpenChange={(open) => {
if (!open && createdInvoiceId) {
+60 -1
View File
@@ -1 +1,60 @@
export { default } from '@/extensions/general/receipt-ocr/pages/ReceiptsPage'
'use client'
import dynamic from 'next/dynamic'
import { useExtensionToggle } from '@/lib/extensions/hooks'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Receipt, Loader2 } from 'lucide-react'
import Link from 'next/link'
const ReceiptsPageOCR = dynamic(
() => import('@/extensions/general/receipt-ocr/pages/ReceiptsPage'),
{ loading: () => <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> }
)
export default function ReceiptsPage() {
const { enabled, isLoading } = useExtensionToggle('general', 'receipt-ocr')
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (enabled) {
return <ReceiptsPageOCR />
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Kvitton</h1>
<p className="text-muted-foreground">
Hantera och granska dina kvitton
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Receipt className="h-5 w-5" />
Kvittoscanning
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Aktivera tillägget &quot;Kvittoscanning&quot; för att skanna kvitton med AI,
extrahera data automatiskt och matcha mot transaktioner.
</p>
<Button asChild>
<Link href="/extensions/general?highlight=receipt-ocr">
Aktivera i Tillägg
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
+33 -1
View File
@@ -1 +1,33 @@
export { default } from '@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'
'use client'
import dynamic from 'next/dynamic'
import { useExtensionToggle } from '@/lib/extensions/hooks'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { Loader2 } from 'lucide-react'
const ScanReceiptPageOCR = dynamic(
() => import('@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage'),
{ loading: () => <div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div> }
)
export default function ScanReceiptPage() {
const { enabled, isLoading } = useExtensionToggle('general', 'receipt-ocr')
const router = useRouter()
useEffect(() => {
if (!isLoading && !enabled) {
router.replace('/receipts')
}
}, [isLoading, enabled, router])
if (isLoading || !enabled) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
return <ScanReceiptPageOCR />
}
+2 -2
View File
@@ -8,8 +8,8 @@ import { Badge } from '@/components/ui/badge'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2, BookOpen, List, Users, ChevronDown, ChevronRight, ArrowLeftRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { SRUExportView } from '@/components/reports/SRUExportView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
import { TrialBalanceChart } from '@/components/reports/TrialBalanceChart'
import { VatCompositionChart } from '@/components/reports/VatCompositionChart'
+31 -195
View File
@@ -22,14 +22,14 @@ import {
Moon,
Monitor,
Palette,
Landmark,
} from 'lucide-react'
import { useTheme } from 'next-themes'
import type { CompanySettings, BankConnection } from '@/types'
import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings'
import type { CompanySettings } from '@/types'
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
import { BankSelector, type Bank } from '@/extensions/general/enable-banking/components/BankSelector'
import { BankConnectionStatus } from '@/extensions/general/enable-banking/components/BankConnectionStatus'
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
const NotificationPanel = getSettingsPanel('push-notifications')
const BankingPanel = getSettingsPanel('enable-banking')
export default function SettingsPage() {
const router = useRouter()
@@ -40,11 +40,7 @@ export default function SettingsPage() {
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [settings, setSettings] = useState<CompanySettings | null>(null)
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [isSyncing, setIsSyncing] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const [hasBankingExtension, setHasBankingExtension] = useState(false)
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
@@ -97,26 +93,22 @@ export default function SettingsPage() {
setSettings(settingsData)
// Check if Enable Banking extension is active by testing for bank connections
// If there are active connections, show the banking tab
const { data: connections } = await supabase
.from('bank_connections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
setBankConnections(connections || [])
// Check if Enable Banking extension is enabled via toggle API
// Check if Enable Banking extension is enabled
try {
const toggleRes = await fetch('/api/extensions/toggles/general/enable-banking')
if (toggleRes.ok) {
const { data } = await toggleRes.json()
setHasBankingExtension(data?.enabled || (connections && connections.length > 0) || false)
} else {
setHasBankingExtension((connections && connections.length > 0) || false)
setHasBankingExtension(data?.enabled || false)
}
} catch {
// If toggle check fails, also check for existing connections
const { data: connections } = await supabase
.from('bank_connections')
.select('id')
.eq('user_id', user.id)
.eq('status', 'active')
.limit(1)
setHasBankingExtension((connections && connections.length > 0) || false)
}
@@ -177,88 +169,6 @@ export default function SettingsPage() {
setIsSaving(false)
}
async function handleConnectBank(bankName: string, bankCountry: string) {
setIsConnecting(true)
try {
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: bankName, aspsp_country: bankCountry }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
// Redirect to bank authorization
window.location.href = data.authorization_url
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
variant: 'destructive',
})
setIsConnecting(false)
}
}
async function handleSyncTransactions(connectionId: string) {
setIsSyncing(true)
try {
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
toast({
title: 'Synkronisering klar',
description: `${data.imported} nya transaktioner importerade`,
})
fetchData()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
variant: 'destructive',
})
}
setIsSyncing(false)
}
async function handleDisconnectBank(connectionId: string) {
const { error } = await supabase
.from('bank_connections')
.update({ status: 'revoked' })
.eq('id', connectionId)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte koppla bort bank',
variant: 'destructive',
})
} else {
toast({
title: 'Bank bortkopplad',
description: 'Bankanslutningen har tagits bort',
})
fetchData()
}
}
async function handleLogout() {
await supabase.auth.signOut()
router.push('/login')
@@ -272,8 +182,6 @@ export default function SettingsPage() {
)
}
const activeConnections = bankConnections.filter((c) => c.status === 'active')
return (
<div className="space-y-6">
<div>
@@ -289,16 +197,18 @@ export default function SettingsPage() {
<Building className="mr-2 h-4 w-4" />
Företag
</TabsTrigger>
{hasBankingExtension && (
{hasBankingExtension && BankingPanel && (
<TabsTrigger value="banking">
<CreditCard className="mr-2 h-4 w-4" />
Bank (PSD2)
</TabsTrigger>
)}
<TabsTrigger value="notifications">
<Bell className="mr-2 h-4 w-4" />
Aviseringar
</TabsTrigger>
{NotificationPanel && (
<TabsTrigger value="notifications">
<Bell className="mr-2 h-4 w-4" />
Aviseringar
</TabsTrigger>
)}
<TabsTrigger value="calendar">
<Calendar className="mr-2 h-4 w-4" />
Kalender
@@ -525,93 +435,19 @@ export default function SettingsPage() {
</form>
</TabsContent>
{/* Banking settings (only shown when extension is active or connections exist) */}
{hasBankingExtension && (
{/* Banking settings — loaded dynamically from extension */}
{hasBankingExtension && BankingPanel && (
<TabsContent value="banking" className="space-y-6">
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Anslutna banker</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{activeConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
isSyncing={isSyncing}
/>
))}
</CardContent>
</Card>
)}
{/* Connect new bank */}
<Card>
<CardHeader>
<CardTitle>Anslut ny bank</CardTitle>
<CardDescription>
Välj din bank nedan för att koppla ditt konto via PSD2.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<BankSelector
onSelect={setSelectedBank}
isLoading={isConnecting}
/>
{selectedBank && (
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<Landmark className="h-5 w-5 text-primary" />
<div>
<p className="text-sm font-medium">{selectedBank.name}</p>
<p className="text-xs text-muted-foreground">{selectedBank.country}</p>
</div>
</div>
<Button
onClick={() => handleConnectBank(selectedBank.name, selectedBank.country)}
disabled={isConnecting}
>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ansluter...
</>
) : (
'Anslut bank'
)}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Info about PSD2 */}
<Card>
<CardHeader>
<CardTitle>Om bankintegration (PSD2)</CardTitle>
<CardDescription>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
bankfiler på importsidan.
</p>
</CardContent>
</Card>
<BankingPanel />
</TabsContent>
)}
{/* Notification settings */}
<TabsContent value="notifications">
<NotificationSettings />
</TabsContent>
{/* Notification settings — loaded dynamically from extension */}
{NotificationPanel && (
<TabsContent value="notifications">
<NotificationPanel />
</TabsContent>
)}
{/* Calendar feed settings */}
<TabsContent value="calendar">
+24
View File
@@ -231,6 +231,30 @@ function OnboardingPageContent() {
return
}
// Delete any existing fiscal periods that have no journal entries,
// so re-running onboarding with different dates doesn't create
// overlapping periods (DB exclusion constraint would reject it).
const { data: existingPeriods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', user.id)
if (existingPeriods && existingPeriods.length > 0) {
for (const ep of existingPeriods) {
const { count } = await supabase
.from('journal_entries')
.select('id', { count: 'exact', head: true })
.eq('fiscal_period_id', ep.id)
if (count === 0) {
await supabase
.from('fiscal_periods')
.delete()
.eq('id', ep.id)
}
}
}
await supabase.from('fiscal_periods').upsert({
user_id: user.id,
name: periodName,
@@ -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 }
)
}
}
+297
View File
@@ -0,0 +1,297 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { NEDeclaration } from '@/lib/reports/ne-bilaga/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function NEDeclarationView({ periodId }: { periodId: string }) {
const [data, setData] = useState<NEDeclaration | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchDeclaration = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/ne-bilaga?period_id=${periodId}`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta NE-bilaga')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/reports/ne-bilaga?period_id=${periodId}&format=sru`, '_blank')
}
// NE ruta labels
const rutaLabels: Record<string, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat',
}
// Categorize rutor
const revenueRutor = ['R1', 'R2', 'R3', 'R4'] as const
const expenseRutor = ['R5', 'R6', 'R7', 'R8', 'R9', 'R10'] as const
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">NE-bilaga (Enskild firma)</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
NE-bilagan används för att deklarera resultat från enskild näringsverksamhet.
Uppgifterna hämtas från bokföringen för valt räkenskapsår.
</p>
<div className="flex gap-2">
<Button onClick={fetchDeclaration} disabled={loading}>
{loading ? 'Laddar...' : 'Hämta NE-bilaga'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>
{data.companyInfo.companyName}
</CardTitle>
<Badge className="bg-blue-100 text-blue-800">
{data.fiscalYear.name}
</Badge>
</div>
{data.companyInfo.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.companyInfo.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* Revenue section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Intäkter</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{revenueRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa intäkter</td>
<td className="py-2 text-right">
{formatAmount(
data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Expenses section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Kostnader</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<tbody>
{expenseRutor.map((ruta) => {
const value = data.rutor[ruta]
const breakdown = data.breakdown[ruta]
return (
<NEDeclarationRow
key={ruta}
ruta={ruta}
label={rutaLabels[ruta]}
amount={value}
accounts={breakdown?.accounts || []}
isExpense
/>
)
})}
</tbody>
<tfoot>
<tr className="border-t-2 font-semibold">
<td className="py-2">Summa kostnader</td>
<td className="py-2 text-right">
-{formatAmount(
data.rutor.R5 + data.rutor.R6 + data.rutor.R7 +
data.rutor.R8 + data.rutor.R9 + data.rutor.R10
)} kr
</td>
</tr>
</tfoot>
</table>
</CardContent>
</Card>
{/* Result */}
<Card className="border-2">
<CardContent className="py-4">
<div className="flex justify-between items-center">
<div>
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">R11</span>
<span className="font-bold text-xl">Årets resultat</span>
</div>
<span
className={`text-2xl font-bold ${
data.rutor.R11 >= 0 ? 'text-green-600' : 'text-red-600'
}`}
>
{formatAmount(data.rutor.R11)} kr
</span>
</div>
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Hämta NE-bilaga&quot; för att generera deklarationsunderlaget.
</CardContent>
</Card>
)}
</div>
)
}
function NEDeclarationRow({
ruta,
label,
amount,
accounts,
isExpense,
}: {
ruta: string
label: string
amount: number
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
isExpense?: boolean
}) {
const [expanded, setExpanded] = useState(false)
// Don't show rows with zero values
if (amount === 0 && accounts.length === 0) return null
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2">
<span className="font-mono text-xs bg-muted px-1 rounded mr-2">{ruta}</span>
{label}
{accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">
{isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr
</td>
</tr>
{expanded && accounts.length > 0 && (
<tr>
<td colSpan={2} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">
{isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr
</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
+245
View File
@@ -0,0 +1,245 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { SRUExportResult } from '@/lib/reports/sru-export/types'
import type { SRUCoverageStats } from '@/lib/reports/sru-export/sru-engine'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function SRUExportView({ periodId }: { periodId: string }) {
const [data, setData] = useState<SRUExportResult | null>(null)
const [coverage, setCoverage] = useState<SRUCoverageStats | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchCoverage()
}, [])
async function fetchCoverage() {
try {
const res = await fetch('/api/reports/sru-export/coverage')
const result = await res.json()
if (result.data) {
setCoverage(result.data)
}
} catch {
// Coverage is optional, ignore errors
}
}
const fetchExport = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/reports/sru-export?period_id=${periodId}&format=json`)
const result = await res.json()
if (result.error) {
setError(result.error)
} else {
setData(result.data)
}
} catch {
setError('Kunde inte hämta SRU-export')
} finally {
setLoading(false)
}
}
const downloadSRU = () => {
window.open(`/api/reports/sru-export?period_id=${periodId}&format=sru`, '_blank')
}
const formLabel = data?.formType === 'INK2' ? 'INK2 (Aktiebolag)' : 'NE (Enskild firma)'
return (
<div className="space-y-4">
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-export</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning
till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform.
</p>
<div className="flex gap-2">
<Button onClick={fetchExport} disabled={loading}>
{loading ? 'Laddar...' : 'Förhandsgranska'}
</Button>
{data && (
<Button variant="outline" onClick={downloadSRU}>
<Download className="h-4 w-4 mr-2" />
Ladda ner SRU-fil
</Button>
)}
</div>
</CardContent>
</Card>
{/* Coverage warning */}
{coverage && coverage.accountsWithoutSRU > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
<p className="text-sm text-orange-800">
{coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod
({coverage.coveragePercent}% täckning).
Konton utan SRU-kod inkluderas inte i exporten.
</p>
{coverage.missingAccounts.length <= 5 && (
<ul className="text-xs text-orange-700 mt-1 space-y-0.5">
{coverage.missingAccounts.map((a) => (
<li key={a.accountNumber}>
{a.accountNumber} — {a.accountName}
</li>
))}
</ul>
)}
</div>
</div>
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-8 text-center text-destructive">
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
{error}
</CardContent>
</Card>
)}
{data && (
<>
{/* Warnings */}
{data.warnings.length > 0 && (
<Card className="border-orange-200 bg-orange-50">
<CardContent className="py-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
{data.warnings.map((warning, i) => (
<p key={i} className="text-sm text-orange-800">{warning}</p>
))}
</div>
</div>
</CardContent>
</Card>
)}
{/* Company + form info */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{data.companyName || 'Okänt företag'}</CardTitle>
<div className="flex gap-2">
<Badge className="bg-blue-100 text-blue-800">{formLabel}</Badge>
<Badge className="bg-blue-100 text-blue-800">{data.fiscalYear.name}</Badge>
</div>
</div>
{data.orgNumber && (
<p className="text-sm text-muted-foreground">
Org.nr: {data.orgNumber}
</p>
)}
</CardHeader>
</Card>
{/* SRU balances table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">SRU-poster</CardTitle>
</CardHeader>
<CardContent>
{data.balances.length === 0 ? (
<p className="text-sm text-muted-foreground">
Inga poster med belopp att exportera.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-2 w-20">SRU-kod</th>
<th className="py-2">Beskrivning</th>
<th className="py-2 w-32 text-right">Belopp</th>
</tr>
</thead>
<tbody>
{data.balances.map((b) => (
<SRUBalanceRow key={b.sruCode} balance={b} />
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</>
)}
{!data && !loading && !error && (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Klicka &quot;Förhandsgranska&quot; för att se SRU-uppgifter för valt räkenskapsår.
</CardContent>
</Card>
)}
</div>
)
}
function SRUBalanceRow({
balance,
}: {
balance: SRUExportResult['balances'][number]
}) {
const [expanded, setExpanded] = useState(false)
return (
<>
<tr
className="border-b cursor-pointer hover:bg-muted/50"
onClick={() => balance.accounts.length > 0 && setExpanded(!expanded)}
>
<td className="py-2 font-mono">{balance.sruCode}</td>
<td className="py-2">
{balance.description}
{balance.accounts.length > 0 && (
<span className="text-xs text-muted-foreground ml-2">
({balance.accounts.length} konton)
</span>
)}
</td>
<td className="py-2 text-right">{formatAmount(balance.amount)} kr</td>
</tr>
{expanded && balance.accounts.length > 0 && (
<tr>
<td colSpan={3} className="py-2 pl-8 bg-muted/30">
<table className="w-full text-xs">
<tbody>
{balance.accounts.map((acc) => (
<tr key={acc.accountNumber}>
<td className="py-1 w-16"><AccountNumber number={acc.accountNumber} name={acc.accountName} size="sm" /></td>
<td className="py-1">{acc.accountName}</td>
<td className="py-1 text-right">{formatAmount(acc.amount)} kr</td>
</tr>
))}
</tbody>
</table>
</td>
</tr>
)}
</>
)
}
@@ -163,7 +163,7 @@ export default function TransactionInboxCard({
) : null}
{topSuggestion.label}
{topSuggestion.account && (
<span className="ml-1 text-muted-foreground font-normal">
<span className="ml-1 opacity-70 font-normal">
({formatAccountWithName(topSuggestion.account)})
</span>
)}
@@ -212,30 +212,6 @@ export default function TransactionInboxCard({
</>
)}
{/* Private button */}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-muted-foreground"
onClick={() => onMarkPrivate(transaction.id)}
disabled={isProcessing || isDisabled}
>
Privat
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px]">
<p className="text-sm leading-relaxed">
{entityType === 'aktiebolag'
? 'Privat utgift med företagets kort \u2014 bokförs som skuld till ägaren (konto 2893)'
: 'Privat uttag \u2014 bokförs mot konto 2013 (Övriga egna uttag)'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Describe transaction */}
{onOpenDescribe && (
<Button
+13 -111
View File
@@ -1,113 +1,15 @@
/**
* EU member states reference data.
*
* Used by export extensions for:
* - Filtering EU vs non-EU customers
* - VIES VAT number validation (country prefix)
* - Intrastat partner country lookup
* - EC Sales List country grouping
* Re-export from core. EU country data now lives in lib/vat/eu-countries.ts.
* This file exists for backward compatibility with export extensions.
*/
export interface EUCountry {
code: string // ISO 3166-1 alpha-2
name: string // Swedish name
nameEn: string // English name
vatPrefix: string // VIES VAT number prefix
currency: string // Primary currency
}
/**
* All 27 EU member states (as of 2025).
* Sweden (SE) is included but should be filtered out for intra-community checks.
*/
export const EU_COUNTRIES: EUCountry[] = [
{ code: 'AT', name: 'Österrike', nameEn: 'Austria', vatPrefix: 'AT', currency: 'EUR' },
{ code: 'BE', name: 'Belgien', nameEn: 'Belgium', vatPrefix: 'BE', currency: 'EUR' },
{ code: 'BG', name: 'Bulgarien', nameEn: 'Bulgaria', vatPrefix: 'BG', currency: 'BGN' },
{ code: 'HR', name: 'Kroatien', nameEn: 'Croatia', vatPrefix: 'HR', currency: 'EUR' },
{ code: 'CY', name: 'Cypern', nameEn: 'Cyprus', vatPrefix: 'CY', currency: 'EUR' },
{ code: 'CZ', name: 'Tjeckien', nameEn: 'Czech Republic', vatPrefix: 'CZ', currency: 'CZK' },
{ code: 'DK', name: 'Danmark', nameEn: 'Denmark', vatPrefix: 'DK', currency: 'DKK' },
{ code: 'EE', name: 'Estland', nameEn: 'Estonia', vatPrefix: 'EE', currency: 'EUR' },
{ code: 'FI', name: 'Finland', nameEn: 'Finland', vatPrefix: 'FI', currency: 'EUR' },
{ code: 'FR', name: 'Frankrike', nameEn: 'France', vatPrefix: 'FR', currency: 'EUR' },
{ code: 'DE', name: 'Tyskland', nameEn: 'Germany', vatPrefix: 'DE', currency: 'EUR' },
{ code: 'GR', name: 'Grekland', nameEn: 'Greece', vatPrefix: 'EL', currency: 'EUR' },
{ code: 'HU', name: 'Ungern', nameEn: 'Hungary', vatPrefix: 'HU', currency: 'HUF' },
{ code: 'IE', name: 'Irland', nameEn: 'Ireland', vatPrefix: 'IE', currency: 'EUR' },
{ code: 'IT', name: 'Italien', nameEn: 'Italy', vatPrefix: 'IT', currency: 'EUR' },
{ code: 'LV', name: 'Lettland', nameEn: 'Latvia', vatPrefix: 'LV', currency: 'EUR' },
{ code: 'LT', name: 'Litauen', nameEn: 'Lithuania', vatPrefix: 'LT', currency: 'EUR' },
{ code: 'LU', name: 'Luxemburg', nameEn: 'Luxembourg', vatPrefix: 'LU', currency: 'EUR' },
{ code: 'MT', name: 'Malta', nameEn: 'Malta', vatPrefix: 'MT', currency: 'EUR' },
{ code: 'NL', name: 'Nederländerna', nameEn: 'Netherlands', vatPrefix: 'NL', currency: 'EUR' },
{ code: 'PL', name: 'Polen', nameEn: 'Poland', vatPrefix: 'PL', currency: 'PLN' },
{ code: 'PT', name: 'Portugal', nameEn: 'Portugal', vatPrefix: 'PT', currency: 'EUR' },
{ code: 'RO', name: 'Rumänien', nameEn: 'Romania', vatPrefix: 'RO', currency: 'RON' },
{ code: 'SK', name: 'Slovakien', nameEn: 'Slovakia', vatPrefix: 'SK', currency: 'EUR' },
{ code: 'SI', name: 'Slovenien', nameEn: 'Slovenia', vatPrefix: 'SI', currency: 'EUR' },
{ code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' },
{ code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' },
]
/** EU country codes excluding Sweden (for intra-community checks) */
export const EU_COUNTRY_CODES_EXCL_SE = EU_COUNTRIES
.filter(c => c.code !== 'SE')
.map(c => c.code)
/** All EU country codes including Sweden */
export const EU_COUNTRY_CODES = EU_COUNTRIES.map(c => c.code)
/**
* Build a lookup set of all known names/codes for EU countries (excluding Sweden).
* Handles ISO codes, English names, and Swedish names — all uppercased for matching.
*/
const EU_LOOKUP_EXCL_SE = new Set(
EU_COUNTRIES
.filter(c => c.code !== 'SE')
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
)
const EU_LOOKUP_INCL_SE = new Set(
EU_COUNTRIES
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
)
/**
* Check if a country value is an EU member state (excluding Sweden).
* Accepts ISO codes ("DE"), English names ("Germany"), or Swedish names ("Tyskland").
*/
export function isEUCountry(country: string): boolean {
return EU_LOOKUP_EXCL_SE.has(country.trim().toUpperCase())
}
/**
* Check if a country value is an EU member state (including Sweden).
* Accepts ISO codes, English names, or Swedish names.
*/
export function isEUCountryIncludingSE(country: string): boolean {
return EU_LOOKUP_INCL_SE.has(country.trim().toUpperCase())
}
/** Get EU country data by ISO code, English name, or Swedish name */
export function getEUCountry(country: string): EUCountry | undefined {
const upper = country.trim().toUpperCase()
return EU_COUNTRIES.find(
c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper
)
}
/** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */
export function getVatPrefix(countryCode: string): string | undefined {
return getEUCountry(countryCode)?.vatPrefix
}
/**
* Normalize a country value to its ISO 3166-1 alpha-2 code.
* Accepts ISO codes, English names, or Swedish names.
* Returns the input uppercased if no match is found.
*/
export function toCountryCode(country: string): string {
const found = getEUCountry(country)
return found ? found.code : country.trim().toUpperCase()
}
export {
type EUCountry,
EU_COUNTRIES,
EU_COUNTRY_CODES_EXCL_SE,
EU_COUNTRY_CODES,
isEUCountry,
isEUCountryIncludingSE,
getEUCountry,
getVatPrefix,
toCountryCode,
} from '@/lib/vat/eu-countries'
@@ -101,16 +101,16 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
/** Fallback template IDs when AI doesn't provide one */
const CATEGORY_DEFAULT_TEMPLATES: Record<string, string> = {
expense_representation: 'representation_external',
expense_equipment: 'equipment_tools',
expense_software: 'software_subscription',
expense_travel: 'travel_domestic',
expense_equipment: 'equipment_small',
expense_software: 'it_saas_subscription',
expense_travel: 'travel_transport',
expense_office: 'office_supplies_general',
expense_consumables: 'office_supplies_general',
expense_vehicle: 'travel_fuel',
expense_vehicle: 'vehicle_fuel',
expense_telecom: 'telecom_mobile',
expense_marketing: 'marketing_advertising',
expense_marketing: 'marketing_online_ads',
expense_education: 'education_course',
expense_professional_services: 'consulting_accounting',
expense_professional_services: 'prof_accounting',
}
/**
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import Script from 'next/script'
@@ -29,39 +29,44 @@ export function BankSelector({
sandbox = true, // Default to sandbox for development
}: BankSelectorProps) {
const widgetRef = useRef<HTMLElement | null>(null)
const [scriptLoaded, setScriptLoaded] = useState(false)
const [scriptReady, setScriptReady] = useState(false)
// Stable ref for onSelect so the event listener never goes stale
const onSelectRef = useRef(onSelect)
useEffect(() => {
if (!scriptLoaded) return
onSelectRef.current = onSelect
}, [onSelect])
const handleSelected = useCallback((e: Event) => {
const customEvent = e as CustomEvent
const detail = customEvent.detail as {
name: string
country: string
psuType: string
sandbox: boolean
}
onSelectRef.current({
name: detail.name,
country: detail.country,
})
}, [])
// Attach the event listener once the script is ready and the widget is in the DOM.
// Using onReady instead of onLoad ensures the script has fully executed and
// the custom element has been defined/upgraded before we interact with it.
useEffect(() => {
if (!scriptReady) return
const widget = widgetRef.current
if (!widget) return
const handleSelected = (e: Event) => {
const customEvent = e as CustomEvent
const detail = customEvent.detail as {
name: string
country: string
psuType: string
sandbox: boolean
}
onSelect({
name: detail.name,
country: detail.country,
})
}
widget.addEventListener('selected', handleSelected)
return () => {
widget.removeEventListener('selected', handleSelected)
}
}, [onSelect, scriptLoaded])
const handleScriptLoad = () => {
setScriptLoaded(true)
}
}, [scriptReady, handleSelected])
if (isLoading) {
return (
@@ -76,7 +81,7 @@ export function BankSelector({
<div className={cn('space-y-3', className)}>
<Script
src="https://tilisy.enablebanking.com/lib/widgets.umd.min.js"
onLoad={handleScriptLoad}
onReady={() => setScriptReady(true)}
strategy="afterInteractive"
/>
<link
@@ -84,15 +89,24 @@ export function BankSelector({
rel="stylesheet"
/>
{/* @ts-expect-error - Enable Banking custom element */}
<enablebanking-aspsp-list
ref={widgetRef}
country={country}
psu-type="business"
service="AIS"
{...(sandbox ? { sandbox: true } : {})}
style={{ minHeight: '200px' }}
/>
{/* Only render the custom element after the script has defined it,
so the browser can upgrade it immediately with full interactivity */}
{scriptReady ? (
// @ts-expect-error - Enable Banking custom element
<enablebanking-aspsp-list
ref={widgetRef}
country={country}
psu-type="business"
service="AIS"
{...(sandbox ? { sandbox: true } : {})}
style={{ minHeight: '200px' }}
/>
) : (
<div className="flex items-center justify-center" style={{ minHeight: '200px' }}>
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Laddar bankwidget...</span>
</div>
)}
<p className="text-xs text-muted-foreground text-center">
{sandbox ? 'Sandbox-läge: Använd testbanker för utveckling' : 'Välj din bank för att fortsätta'}
@@ -0,0 +1,218 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Landmark } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus'
import type { BankConnection } from '@/types'
/**
* Self-contained banking settings panel for the enable-banking extension.
* Loaded dynamically by the settings panel registry.
*/
export default function BankingSettingsPanel() {
const { toast } = useToast()
const supabase = createClient()
const [bankConnections, setBankConnections] = useState<BankConnection[]>([])
const [isSyncing, setIsSyncing] = useState(false)
const [isConnecting, setIsConnecting] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [selectedBank, setSelectedBank] = useState<Bank | null>(null)
useEffect(() => {
fetchConnections()
}, [])
async function fetchConnections() {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
const { data: connections } = await supabase
.from('bank_connections')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
setBankConnections(connections || [])
setIsLoading(false)
}
async function handleConnectBank(bankName: string, bankCountry: string) {
setIsConnecting(true)
try {
const response = await fetch('/api/extensions/ext/enable-banking/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspsp_name: bankName, aspsp_country: bankCountry }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
window.location.href = data.authorization_url
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ansluta bank',
variant: 'destructive',
})
setIsConnecting(false)
}
}
async function handleSyncTransactions(connectionId: string) {
setIsSyncing(true)
try {
const response = await fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error)
}
toast({
title: 'Synkronisering klar',
description: `${data.imported} nya transaktioner importerade`,
})
fetchConnections()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Synkronisering misslyckades',
variant: 'destructive',
})
}
setIsSyncing(false)
}
async function handleDisconnectBank(connectionId: string) {
const { error } = await supabase
.from('bank_connections')
.update({ status: 'revoked' })
.eq('id', connectionId)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte koppla bort bank',
variant: 'destructive',
})
} else {
toast({
title: 'Bank bortkopplad',
description: 'Bankanslutningen har tagits bort',
})
fetchConnections()
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-32">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
const activeConnections = bankConnections.filter((c) => c.status === 'active')
return (
<div className="space-y-6">
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Anslutna banker</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{activeConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
isSyncing={isSyncing}
/>
))}
</CardContent>
</Card>
)}
{/* Connect new bank */}
<Card>
<CardHeader>
<CardTitle>Anslut ny bank</CardTitle>
<CardDescription>
Välj din bank nedan för att koppla ditt konto via PSD2.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<BankSelector
onSelect={setSelectedBank}
isLoading={isConnecting}
/>
{selectedBank && (
<div className="flex items-center justify-between p-4 border rounded-lg bg-muted/50">
<div className="flex items-center gap-3">
<Landmark className="h-5 w-5 text-primary" />
<div>
<p className="text-sm font-medium">{selectedBank.name}</p>
<p className="text-xs text-muted-foreground">{selectedBank.country}</p>
</div>
</div>
<Button
onClick={() => handleConnectBank(selectedBank.name, selectedBank.country)}
disabled={isConnecting}
>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Ansluter...
</>
) : (
'Anslut bank'
)}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Info about PSD2 */}
<Card>
<CardHeader>
<CardTitle>Om bankintegration (PSD2)</CardTitle>
<CardDescription>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
bankfiler på importsidan.
</p>
</CardContent>
</Card>
</div>
)
}
+9 -42
View File
@@ -1,47 +1,14 @@
/**
* Invoice Inbox extension-specific types
* Invoice Inbox extension types.
*
* InvoiceExtractionResult, ExtractedInvoiceLineItem, and VatBreakdownItem
* now live in types/index.ts (used by core). Re-exported here for backward compat.
*/
export interface InvoiceExtractionResult {
supplier: {
name: string | null
orgNumber: string | null
vatNumber: string | null
address: string | null
bankgiro: string | null
plusgiro: string | null
}
invoice: {
invoiceNumber: string | null
invoiceDate: string | null
dueDate: string | null
paymentReference: string | null // OCR number or reference
currency: string
}
lineItems: ExtractedInvoiceLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
vatBreakdown: VatBreakdownItem[]
confidence: number
}
export interface ExtractedInvoiceLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
accountSuggestion: string | null
}
export interface VatBreakdownItem {
rate: number
base: number
amount: number
}
export type {
InvoiceExtractionResult,
ExtractedInvoiceLineItem,
VatBreakdownItem,
} from '@/types'
export interface SupplierMatchResult {
supplierId: string
+6 -343
View File
@@ -1,345 +1,8 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
FiscalPeriod,
JournalEntry,
JournalEntryLine,
} from '@/types'
import type {
NEDeclaration,
NEDeclarationRutor,
NEAccountMapping,
} from './types'
/**
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
*
* Maps BAS account balances to NE declaration rutor (R1-R11) for
* tax reporting to Skatteverket.
*
* Account mappings:
* R1: Försäljning med moms (3000-3599 excl 3100, 3700-3799)
* R2: Momsfria intäkter (3100, 3900-3969, 3970-3980, 3981-3999) - inkl gåvor utan motprestation
* R3: Bil/bostadsförmån (3200)
* R4: Ränteintäkter (8310-8330)
* R5: Varuinköp (4000-4990)
* R6: Övriga kostnader (5000-6990, 7970) - inkl avdragsgilla gåvor (5460)
* R7: Lönekostnader (7000-7699)
* R8: Räntekostnader (8400-8499)
* R9: Avskrivningar fastighet (7820)
* R10: Avskrivningar övrigt (7700-7899 excl 7820)
* R11: Årets resultat (calculated)
*
* Gift handling:
* - Gåvor MED motprestation: R1 (momspliktig bytestransaktion)
* - Gåvor UTAN motprestation: R2 via konto 3900
* - Avdragsgilla gåvor: R6 via konto 5460
* Re-export from core. NE engine now lives in lib/reports/ne-bilaga/ne-engine.ts.
*/
/**
* Account mapping configuration for NE declaration
*/
export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
{
ruta: 'R1',
description: 'Försäljning med moms (25%)',
accountRanges: [
{ start: '3000', end: '3599', exclude: ['3100'] },
{ start: '3700', end: '3799' },
],
isExpense: false,
},
{
ruta: 'R2',
description: 'Momsfria intäkter',
accountRanges: [
{ start: '3100', end: '3100' },
{ start: '3900', end: '3969' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
{ start: '3970', end: '3980' },
{ start: '3981', end: '3999' },
],
isExpense: false,
},
{
ruta: 'R3',
description: 'Bil/bostadsförmån',
accountRanges: [
{ start: '3200', end: '3299' },
],
isExpense: false,
},
{
ruta: 'R4',
description: 'Ränteintäkter',
accountRanges: [
{ start: '8310', end: '8330' },
],
isExpense: false,
},
{
ruta: 'R5',
description: 'Varuinköp',
accountRanges: [
{ start: '4000', end: '4990' },
],
isExpense: true,
},
{
ruta: 'R6',
description: 'Övriga kostnader',
accountRanges: [
{ start: '5000', end: '6990' },
{ start: '7970', end: '7970' },
],
isExpense: true,
},
{
ruta: 'R7',
description: 'Lönekostnader',
accountRanges: [
{ start: '7000', end: '7699' },
],
isExpense: true,
},
{
ruta: 'R8',
description: 'Räntekostnader',
accountRanges: [
{ start: '8400', end: '8499' },
],
isExpense: true,
},
{
ruta: 'R9',
description: 'Avskrivningar fastighet',
accountRanges: [
{ start: '7820', end: '7820' },
],
isExpense: true,
},
{
ruta: 'R10',
description: 'Avskrivningar övrigt',
accountRanges: [
{ start: '7700', end: '7899', exclude: ['7820'] },
],
isExpense: true,
},
]
/**
* Check if an account number falls within a mapping's ranges
*/
function isAccountInMapping(accountNumber: string, mapping: NEAccountMapping): boolean {
for (const range of mapping.accountRanges) {
const num = accountNumber
if (num >= range.start && num <= range.end) {
// Check exclusions
if (range.exclude && range.exclude.includes(num)) {
continue
}
return true
}
}
return false
}
/**
* Round to nearest krona (whole number) for NE declaration
*/
function roundToKrona(value: number): number {
return Math.round(value)
}
/**
* Generate NE declaration for a fiscal period
*/
export async function generateNEDeclaration(
userId: string,
fiscalPeriodId: string
): Promise<NEDeclaration> {
const supabase = await createClient()
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.single()
// Check if it's enskild firma
if (settings?.entity_type !== 'enskild_firma') {
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate balances per account
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
// Net amount: debit - credit
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Map account balances to NE rutor
const rutor: NEDeclarationRutor = {
R1: 0,
R2: 0,
R3: 0,
R4: 0,
R5: 0,
R6: 0,
R7: 0,
R8: 0,
R9: 0,
R10: 0,
R11: 0,
}
const breakdown: Record<keyof NEDeclarationRutor, {
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
total: number
}> = {
R1: { accounts: [], total: 0 },
R2: { accounts: [], total: 0 },
R3: { accounts: [], total: 0 },
R4: { accounts: [], total: 0 },
R5: { accounts: [], total: 0 },
R6: { accounts: [], total: 0 },
R7: { accounts: [], total: 0 },
R8: { accounts: [], total: 0 },
R9: { accounts: [], total: 0 },
R10: { accounts: [], total: 0 },
R11: { accounts: [], total: 0 },
}
const warnings: string[] = []
// Process each account balance
for (const [accountNumber, balance] of accountBalances) {
// Skip zero balances
if (Math.abs(balance) < 0.01) continue
// Find which ruta this account belongs to
for (const mapping of NE_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
// For revenue accounts (credit normal), negate the balance
// For expense accounts (debit normal), use as-is
// Net balance is debit - credit, so:
// - Revenue accounts have negative net balance (credit > debit)
// - Expense accounts have positive net balance (debit > credit)
const amount = mapping.isExpense ? balance : -balance
rutor[mapping.ruta] += amount
breakdown[mapping.ruta].accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: roundToKrona(amount),
})
break // Account matched, no need to check other mappings
}
}
}
// Round all rutor to whole numbers
for (const key of Object.keys(rutor) as (keyof NEDeclarationRutor)[]) {
if (key !== 'R11') {
rutor[key] = roundToKrona(rutor[key])
breakdown[key].total = rutor[key]
}
}
// Calculate R11 (Årets resultat)
// Result = Revenue (R1+R2+R3+R4) - Expenses (R5+R6+R7+R8+R9+R10)
const totalRevenue = rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4
const totalExpenses = rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10
rutor.R11 = totalRevenue - totalExpenses
breakdown.R11.total = rutor.R11
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (rutor.R11 === 0 && totalRevenue === 0) {
warnings.push('Inga bokförda intäkter eller kostnader hittades för perioden.')
}
return {
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
isClosed: period.is_closed,
},
rutor,
breakdown,
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
},
warnings,
}
}
/**
* Get totals for display
*/
export function getNEDeclarationTotals(declaration: NEDeclaration): {
totalRevenue: number
totalExpenses: number
netResult: number
} {
const { rutor } = declaration
return {
totalRevenue: rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4,
totalExpenses: rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10,
netResult: rutor.R11,
}
}
export {
NE_ACCOUNT_MAPPINGS,
generateNEDeclaration,
getNEDeclarationTotals,
} from '@/lib/reports/ne-bilaga/ne-engine'
+11 -80
View File
@@ -1,80 +1,11 @@
// NE-bilaga rutor (NE appendix boxes)
export interface NEDeclarationRutor {
R1: number // Försäljning med 25% moms (3000-3499 excl 3100)
R2: number // Momsfria intäkter (3100, 3970, 3980)
R3: number // Bil/bostadsförmån (3200)
R4: number // Ränteintäkter (8310-8330)
R5: number // Varuinköp (4000-4990)
R6: number // Övriga kostnader (5000-6990, 7970)
R7: number // Lönekostnader (7000-7699)
R8: number // Räntekostnader (8400-8499)
R9: number // Avskrivningar fastighet (7820)
R10: number // Avskrivningar övrigt (7700-7899 excl 7820)
R11: number // Årets resultat (beräknat)
}
// NE account mapping configuration
export interface NEAccountMapping {
ruta: keyof NEDeclarationRutor
description: string
accountRanges: Array<{
start: string
end: string
exclude?: string[]
}>
isExpense: boolean // true = debit normal, false = credit normal
}
// NE declaration response
export interface NEDeclaration {
fiscalYear: {
id: string
name: string
start: string
end: string
isClosed: boolean
}
rutor: NEDeclarationRutor
// Detailed breakdown per ruta
breakdown: Record<keyof NEDeclarationRutor, {
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
total: number
}>
// Company info for SRU
companyInfo: {
companyName: string
orgNumber: string | null
}
// Warnings
warnings: string[]
}
// SRU file format types
export interface SRURecord {
fieldCode: string
value: string | number
}
export interface SRUFile {
records: SRURecord[]
generatedAt: string
}
// Labels for NE rutor
export const NE_RUTA_LABELS: Record<keyof NEDeclarationRutor, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat'
}
/**
* Re-export from core. NE-bilaga types now live in lib/reports/ne-bilaga/types.ts.
*/
export {
type NEDeclarationRutor,
type NEAccountMapping,
type NEDeclaration,
type SRURecord,
type SRUFile,
NE_RUTA_LABELS,
} from '@/lib/reports/ne-bilaga/types'
+7 -207
View File
@@ -1,209 +1,9 @@
import type { NEDeclaration, SRUFile, SRURecord } from '@/extensions/ne-bilaga/types'
/**
* SRU File Generator
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
* submission to Skatteverket. The SRU format is used for tax declarations.
*
* Format specification:
* - Each line starts with # followed by field code
* - Values follow the field code
* - File must be plain text (ISO-8859-1 encoding traditionally, but UTF-8 is often accepted)
*
* NE-bilaga field codes:
* - #BLANKETT NE - Declares this is an NE form
* - #IDENTITET - Organization number + name
* - #UPPGIFT - Individual field values
* Re-export from core. SRU generator now lives in lib/reports/sru-export/sru-generator.ts.
*/
/**
* SRU field codes for NE declaration
* These are the official Skatteverket field codes for NE-bilaga
*/
const NE_SRU_FIELD_CODES: Record<string, string> = {
// Company identification
ORG_NUMBER: '201',
COMPANY_NAME: '202',
// NE rutor - Income
R1: '7310', // Försäljning och andra intäkter med moms
R2: '7311', // Momsfria intäkter (ej skattepliktiga)
R3: '7312', // Bil- och bostadsförmån m.m.
R4: '7313', // Ränteintäkter
// NE rutor - Expenses
R5: '7320', // Varuinköp
R6: '7321', // Övriga externa kostnader
R7: '7322', // Anställdas löner
R8: '7323', // Räntekostnader
R9: '7324', // Avskrivningar på byggnader och markanläggningar
R10: '7325', // Avskrivningar på maskiner och inventarier
// Result
R11: '7350', // Årets resultat
}
/**
* Generate SRU file content from NE declaration
*/
export function generateSRUFile(declaration: NEDeclaration): SRUFile {
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
value: formatSRUDate(now),
})
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: 'NE' })
// Company identification
if (declaration.companyInfo.orgNumber) {
// Remove any dashes from org number
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
records.push({
fieldCode: 'IDENTITET',
value: cleanOrgNumber,
})
}
// Fiscal year
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
})
// NE rutor values
const rutaEntries: [keyof typeof NE_SRU_FIELD_CODES, number][] = [
['R1', declaration.rutor.R1],
['R2', declaration.rutor.R2],
['R3', declaration.rutor.R3],
['R4', declaration.rutor.R4],
['R5', declaration.rutor.R5],
['R6', declaration.rutor.R6],
['R7', declaration.rutor.R7],
['R8', declaration.rutor.R8],
['R9', declaration.rutor.R9],
['R10', declaration.rutor.R10],
['R11', declaration.rutor.R11],
]
for (const [ruta, value] of rutaEntries) {
// Only include non-zero values
if (value !== 0) {
const fieldCode = NE_SRU_FIELD_CODES[ruta]
records.push({
fieldCode: 'UPPGIFT',
value: `${fieldCode} ${formatSRUAmount(value)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Convert SRU file to string content
*/
export function sruFileToString(sruFile: SRUFile): string {
const lines: string[] = []
for (const record of sruFile.records) {
if (record.value === '') {
lines.push(`#${record.fieldCode}`)
} else {
lines.push(`#${record.fieldCode} ${record.value}`)
}
}
// SRU files should end with a newline
return lines.join('\r\n') + '\r\n'
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
*/
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
/**
* Format fiscal year date range for SRU
*/
function formatSRUDateRange(startDate: string, endDate: string): string {
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
}
/**
* Format amount for SRU
* - Whole numbers (no decimals)
* - No thousands separator
* - Negative values with minus sign
*/
function formatSRUAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Validate SRU file content
*/
export function validateSRUFile(sruFile: SRUFile): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
// Check for required records
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
if (!hasHeader) {
errors.push('Missing PRODUKT header')
}
if (!hasBlankett) {
errors.push('Missing BLANKETT declaration')
}
if (!hasBlankettslut) {
errors.push('Missing BLANKETTSLUT')
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get filename for SRU file download
*/
export function getSRUFilename(declaration: NEDeclaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `NE_${orgNumber}_${year}.sru`
}
export {
generateSRUFile,
sruFileToString,
validateSRUFile,
getSRUFilename,
} from '@/lib/reports/sru-export/sru-generator'
+7 -147
View File
@@ -1,149 +1,9 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { JournalEntry, JournalEntryLine } from '@/types'
/**
* SRU aggregation engine
*
* Fetches posted journal entries for a fiscal period, computes net balance
* per account, and groups by sru_code from chart_of_accounts.
* Re-export from core. SRU engine now lives in lib/reports/sru-export/sru-engine.ts.
*/
export interface SRUBalance {
sruCode: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}
export interface SRUCoverageStats {
totalAccounts: number
accountsWithSRU: number
accountsWithoutSRU: number
coveragePercent: number
missingAccounts: Array<{
accountNumber: string
accountName: string
}>
}
/**
* Aggregate account balances by SRU code for a given fiscal period.
* Returns a Map of sru_code → summed amount, plus per-account detail.
*/
export async function aggregateBalancesBySRU(
userId: string,
fiscalPeriodId: string
): Promise<Map<string, SRUBalance>> {
const supabase = await createClient()
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts with SRU codes
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null; normal_balance: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code, normal_balance')
.eq('user_id', userId)
.eq('is_active', true)
.range(from, to)
)
// Build lookup maps
const accountSRUMap = new Map<string, string>()
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
if (acc.sru_code) {
accountSRUMap.set(acc.account_number, acc.sru_code)
}
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate net balances per account (debit - credit)
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Group balances by SRU code
const sruBalances = new Map<string, SRUBalance>()
for (const [accountNumber, balance] of accountBalances) {
if (Math.abs(balance) < 0.01) continue
const sruCode = accountSRUMap.get(accountNumber)
if (!sruCode) continue
let entry = sruBalances.get(sruCode)
if (!entry) {
entry = { sruCode, amount: 0, accounts: [] }
sruBalances.set(sruCode, entry)
}
entry.amount += balance
entry.accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: Math.round(balance),
})
}
// Round totals
for (const entry of sruBalances.values()) {
entry.amount = Math.round(entry.amount)
}
return sruBalances
}
/**
* Get SRU code coverage stats for a user's chart of accounts.
* Returns how many accounts have vs lack SRU codes.
*/
export async function getSRUCoverage(userId: string): Promise<SRUCoverageStats> {
const supabase = await createClient()
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
const withSRU = accounts.filter((a) => a.sru_code)
const withoutSRU = accounts.filter((a) => !a.sru_code)
return {
totalAccounts: accounts.length,
accountsWithSRU: withSRU.length,
accountsWithoutSRU: withoutSRU.length,
coveragePercent: accounts.length > 0
? Math.round((withSRU.length / accounts.length) * 100)
: 0,
missingAccounts: withoutSRU.map((a) => ({
accountNumber: a.account_number,
accountName: a.account_name,
})),
}
}
export {
type SRUBalance,
type SRUCoverageStats,
aggregateBalancesBySRU,
getSRUCoverage,
} from '@/lib/reports/sru-export/sru-engine'
+10 -138
View File
@@ -1,140 +1,12 @@
import type { SRUFile, SRURecord } from '@/extensions/ne-bilaga/types'
import { sruFileToString, validateSRUFile } from './lib/sru-generator'
import type { SRUBalance } from './sru-engine'
/**
* Generic SRU file generator
*
* Generates SRU files from aggregated SRU balances for any form type
* (NE for enskild firma, INK2 for aktiebolag).
*
* Reuses sruFileToString() and validateSRUFile() from the existing
* NE-specific generator.
* Re-export from core. SRU generic generator now lives in lib/reports/sru-export/sru-generic-generator.ts.
*/
export type SRUFormType = 'NE' | 'INK2'
export interface GenericSRUParams {
formType: SRUFormType
orgNumber: string | null
companyName: string
fiscalYearStart: string // YYYY-MM-DD
fiscalYearEnd: string // YYYY-MM-DD
sruBalances: Map<string, SRUBalance>
}
/**
* SRU code descriptions for display
*/
export const SRU_CODE_DESCRIPTIONS: Record<string, string> = {
// NE form (EF)
'7310': 'Försäljning med moms',
'7311': 'Momsfria intäkter',
'7312': 'Bil/bostadsförmån',
'7313': 'Ränteintäkter',
'7320': 'Varuinköp',
'7321': 'Övriga kostnader',
'7322': 'Lönekostnader',
'7323': 'Räntekostnader',
'7324': 'Avskrivningar fastighet',
'7325': 'Avskrivningar övrigt',
'7350': 'Årets resultat',
// INK2 form (AB) — balance sheet
'7201': 'Immateriella anläggningstillgångar',
'7202': 'Materiella anläggningstillgångar',
'7203': 'Finansiella anläggningstillgångar',
'7210': 'Varulager',
'7211': 'Kundfordringar',
'7212': 'Övriga omsättningstillgångar',
'7220': 'Aktiekapital',
'7221': 'Övrigt eget kapital',
'7222': 'Årets resultat',
'7230': 'Skulder',
'7231': 'Övriga skulder',
// INK2 form (AB) — income statement
'7330': 'Övriga externa kostnader',
'7340': 'Personalkostnader',
'7360': 'Övriga rörelsekostnader',
'7370': 'Finansiella poster',
'7380': 'Extraordinära poster',
}
/**
* Generate a generic SRU file from aggregated SRU balances.
*/
export function generateGenericSRU(params: GenericSRUParams): SRUFile {
const { formType, orgNumber, fiscalYearStart, fiscalYearEnd, sruBalances } = params
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({ fieldCode: 'SKAPAT', value: formatSRUDate(now) })
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: formType })
// Company identification
if (orgNumber) {
const cleanOrgNumber = orgNumber.replace(/-/g, '')
records.push({ fieldCode: 'IDENTITET', value: cleanOrgNumber })
}
// Fiscal year
const startSRU = fiscalYearStart.replace(/-/g, '')
const endSRU = fiscalYearEnd.replace(/-/g, '')
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${startSRU}-${endSRU}`,
})
// SRU balance entries — one #UPPGIFT per non-zero SRU code
const sortedEntries = Array.from(sruBalances.entries())
.sort(([a], [b]) => a.localeCompare(b))
for (const [sruCode, balance] of sortedEntries) {
if (balance.amount !== 0) {
records.push({
fieldCode: 'UPPGIFT',
value: `${sruCode} ${Math.round(balance.amount)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Get filename for generic SRU file download
*/
export function getGenericSRUFilename(
formType: SRUFormType,
orgNumber: string | null,
fiscalYearStart: string
): string {
const year = fiscalYearStart.substring(0, 4)
const cleanOrg = orgNumber?.replace(/-/g, '') || 'unknown'
return `${formType}_${cleanOrg}_${year}.sru`
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
// Re-export helpers from the existing SRU generator
export { sruFileToString, validateSRUFile }
export {
type SRUFormType,
type GenericSRUParams,
SRU_CODE_DESCRIPTIONS,
generateGenericSRU,
getGenericSRUFilename,
sruFileToString,
validateSRUFile,
} from '@/lib/reports/sru-export/sru-generic-generator'
+7 -37
View File
@@ -1,37 +1,7 @@
import type { EntityType } from '@/types'
// Generic SRU export types
export interface SRUExportResult {
formType: 'NE' | 'INK2'
entityType: EntityType
companyName: string | null
orgNumber: string | null
fiscalYear: {
id: string
name: string
start: string
end: string
}
balances: Array<{
sruCode: string
description: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}>
warnings: string[]
}
export interface SRUCoverageStats {
totalAccounts: number
accountsWithSRU: number
accountsWithoutSRU: number
coveragePercent: number
missingAccounts: Array<{
accountNumber: string
accountName: string
}>
}
/**
* Re-export from core. SRU export types now live in lib/reports/sru-export/types.ts.
*/
export {
type SRUExportResult,
type SRUCoverageStats,
} from '@/lib/reports/sru-export/types'
@@ -17,8 +17,8 @@ import {
// ============================================================
describe('BOOKING_TEMPLATES data integrity', () => {
it('has exactly 100 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(100)
it('has exactly 48 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(48)
})
it('all template IDs are unique', () => {
@@ -86,7 +86,7 @@ describe('getTemplateById', () => {
it('returns correct template for known ID', () => {
const t = getTemplateById('it_saas_subscription')
expect(t).toBeDefined()
expect(t!.name_sv).toBe('Programvara / SaaS-prenumeration')
expect(t!.name_sv).toBe('Programvara / SaaS')
expect(t!.debit_account).toBe('5420')
})
@@ -122,9 +122,9 @@ describe('getTemplatesByMcc', () => {
})
describe('getTemplateGroups', () => {
it('returns all 18 groups', () => {
it('returns all 17 groups', () => {
const groups = getTemplateGroups()
expect(groups).toHaveLength(18)
expect(groups).toHaveLength(17)
for (const g of groups) {
expect(g.group).toBeTruthy()
expect(g.label_sv).toBeTruthy()
@@ -136,7 +136,7 @@ describe('getTemplateGroups', () => {
it('every template is in exactly one group', () => {
const groups = getTemplateGroups()
const allTemplates = groups.flatMap((g) => g.templates)
expect(allTemplates).toHaveLength(100)
expect(allTemplates).toHaveLength(48)
})
})
@@ -175,7 +175,7 @@ describe('searchTemplates', () => {
})
it('supports multi-token search', () => {
const results = searchTemplates('digital annons')
const results = searchTemplates('annonsering marknadsföring')
expect(results.some((t) => t.id === 'marketing_online_ads')).toBe(true)
})
})
@@ -242,14 +242,14 @@ describe('findMatchingTemplates', () => {
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
})
it('returns max 5 results', () => {
it('returns max 10 results', () => {
const tx = makeTransaction({
amount: -100,
description: 'software subscription cloud hosting domain',
mcc_code: 5817,
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeLessThanOrEqual(20)
expect(matches.length).toBeLessThanOrEqual(10)
})
it('results are sorted by confidence descending', () => {
@@ -303,7 +303,7 @@ describe('buildMappingResultFromTemplate', () => {
})
it('produces valid MappingResult for expense with 6% VAT', () => {
const template = getTemplate('travel_train')
const template = getTemplate('travel_transport')
const tx = makeTransaction({ amount: -530 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
@@ -344,7 +344,7 @@ describe('buildMappingResultFromTemplate', () => {
})
it('produces output VAT lines for income with 25% VAT', () => {
const template = getTemplate('revenue_services_25')
const template = getTemplate('revenue_standard_25')
const tx = makeTransaction({ amount: 12500 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
@@ -356,7 +356,7 @@ describe('buildMappingResultFromTemplate', () => {
})
it('produces output VAT lines for income with 12% VAT', () => {
const template = getTemplate('revenue_products_12')
const template = getTemplate('revenue_reduced_12')
const tx = makeTransaction({ amount: 1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
@@ -365,16 +365,6 @@ describe('buildMappingResultFromTemplate', () => {
expect(result.vat_lines[0].credit_amount).toBe(120)
})
it('produces output VAT lines for income with 6% VAT', () => {
const template = getTemplate('revenue_products_6')
const tx = makeTransaction({ amount: 1060 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2631')
expect(result.vat_lines[0].credit_amount).toBe(60)
})
it('resolves AB-specific accounts for aktiebolag', () => {
const template = getTemplate('education_course')
const tx = makeTransaction({ amount: -5000 })
@@ -411,6 +401,6 @@ describe('buildMappingResultFromTemplate', () => {
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.description).toBe('Drivmedel: OKQ8 tankstation')
expect(result.description).toBe('Drivmedel & Laddning: OKQ8 tankstation')
})
})
@@ -93,7 +93,7 @@ describe('template-embeddings', () => {
expect(text).toContain('non_deductible')
})
it('generates text for all 100 templates without error', async () => {
it('generates text for all 45 templates without error', async () => {
const { buildEmbeddingText } = await import('../template-embeddings')
for (const template of BOOKING_TEMPLATES) {
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -90,9 +90,8 @@ export async function findFiscalPeriod(
): Promise<string | null> {
const supabase = await createClient()
// Use limit(1) instead of single() to handle overlapping fiscal periods
// gracefully. When multiple periods cover the same date, we pick the one
// with the latest start date (most specific / narrowest period).
// Overlapping periods are prevented by a DB exclusion constraint
// (migration 042). limit(1) is kept as a defensive measure.
const { data, error } = await supabase
.from('fiscal_periods')
.select('id')
+1 -1
View File
@@ -25,7 +25,7 @@ import { createHash } from 'crypto'
export const EMBEDDING_MODEL = 'text-embedding-3-small'
const EMBEDDING_LOGIC_VERSION = '1'
const MATCH_COUNT = 20
const MATCH_COUNT = 5
const MATCH_THRESHOLD = 0.5
/**
+14 -12
View File
@@ -128,21 +128,10 @@ export async function createNextPeriod(
throw new Error('Current fiscal period not found')
}
// Check if next period already exists
// Compute next period start (day after current end)
const nextStart = new Date(current.period_end)
nextStart.setDate(nextStart.getDate() + 1)
const { data: existing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.eq('period_start', nextStart.toISOString().split('T')[0])
.maybeSingle()
if (existing) {
throw new Error('Next fiscal period already exists')
}
// Compute period length from current period to handle broken fiscal years
const currentStart = new Date(current.period_start)
const currentEnd = new Date(current.period_end)
@@ -168,6 +157,19 @@ export async function createNextPeriod(
throw new Error(durationError)
}
// Check for overlapping periods
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', nextEndStr)
.gte('period_end', nextStartStr)
.limit(1)
if (overlapping && overlapping.length > 0) {
throw new Error('Next fiscal period already exists or overlaps with an existing period')
}
// Generate name: e.g. "FY 2025" or "FY 2025/2026"
const startYear = nextStart.getFullYear()
const endYear = nextEnd.getFullYear()
@@ -6,7 +6,7 @@ import {
formatExtractionSummary,
} from '../invoice-inbox-utils'
import type { InboxItemStatus } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import type { InvoiceExtractionResult } from '@/types'
describe('getStatusLabel', () => {
const cases: [InboxItemStatus, string][] = [
+2 -2
View File
@@ -11,8 +11,8 @@ describe('sectors registry', () => {
expect(SECTORS.length).toBe(7)
})
it('should have 22 total extensions', () => {
expect(getAllExtensions().length).toBe(22)
it('should have 24 total extensions', () => {
expect(getAllExtensions().length).toBe(24)
})
it('should have unique slugs within each sector', () => {
+1 -2
View File
@@ -1,5 +1,4 @@
import type { InboxItemStatus } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
import type { InboxItemStatus, InvoiceExtractionResult } from '@/types'
const STATUS_LABELS: Record<InboxItemStatus, string> = {
pending: 'Väntar',
-4
View File
@@ -2,8 +2,6 @@ import { extensionRegistry } from './registry'
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
import { pushNotificationsExtension } from '@/extensions/general/push-notifications'
import { sruExportExtension } from '@/extensions/sru-export'
import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/general/ai-chat'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { calendarExtension } from '@/extensions/general/calendar'
@@ -26,8 +24,6 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension,
neBilagaExtension,
aiChatExtension,
invoiceInboxExtension,
calendarExtension,
@@ -0,0 +1,27 @@
import dynamic from 'next/dynamic'
import type { ComponentType } from 'react'
/**
* Settings panel registry
*
* Maps extension IDs to dynamically imported settings panel components.
* This allows the core settings page to render extension-provided settings
* panels without directly importing from extension directories.
*/
const SETTINGS_PANELS: Record<string, ComponentType> = {
'push-notifications': dynamic(
() => import('@/extensions/general/push-notifications/NotificationSettings').then(m => ({ default: m.NotificationSettings }))
),
'enable-banking': dynamic(
() => import('@/extensions/general/enable-banking/components/BankingSettingsPanel')
),
}
/**
* Get the settings panel component for an extension.
* Returns null if the extension has no registered settings panel.
*/
export function getSettingsPanel(extensionId: string): ComponentType | null {
return SETTINGS_PANELS[extensionId] ?? null
}
+19 -4
View File
@@ -101,8 +101,8 @@ async function ensureFiscalPeriod(
startDate: Date,
endDate: Date
): Promise<string> {
// Check for existing fiscal period
const { data: existing } = await supabase
// Check for an existing period that contains the SIE date range
const { data: containing } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
@@ -110,8 +110,23 @@ async function ensureFiscalPeriod(
.gte('period_end', formatDate(endDate))
.single()
if (existing) {
return existing.id
if (containing) {
return containing.id
}
// Check for any overlapping period (DB exclusion constraint would reject
// a new insert that overlaps). Use the overlapping period instead.
const { data: overlapping } = await supabase
.from('fiscal_periods')
.select('id')
.eq('user_id', userId)
.lte('period_start', formatDate(endDate))
.gte('period_end', formatDate(startDate))
.order('period_start', { ascending: false })
.limit(1)
if (overlapping && overlapping.length > 0) {
return overlapping[0].id
}
// Create new fiscal period
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
import type { NEAccountMapping } from '@/lib/reports/ne-bilaga/types'
/**
* Helper to check if an account falls into a specific ruta
*/
function findRutaForAccount(accountNumber: string): string | null {
for (const mapping of NE_ACCOUNT_MAPPINGS) {
for (const range of mapping.accountRanges) {
if (accountNumber >= range.start && accountNumber <= range.end) {
if (range.exclude && range.exclude.includes(accountNumber)) {
continue
}
return mapping.ruta
}
}
}
return null
}
describe('NE Account Mappings', () => {
describe('R1 - Försäljning med moms', () => {
it('includes standard revenue accounts 3001-3003', () => {
expect(findRutaForAccount('3001')).toBe('R1')
expect(findRutaForAccount('3002')).toBe('R1')
expect(findRutaForAccount('3003')).toBe('R1')
})
it('excludes 3100 (momsfria intäkter)', () => {
expect(findRutaForAccount('3100')).not.toBe('R1')
})
it('includes 3500 (Fakturerade kostnader)', () => {
expect(findRutaForAccount('3500')).toBe('R1')
})
it('includes 3500-3599 range', () => {
expect(findRutaForAccount('3510')).toBe('R1')
expect(findRutaForAccount('3599')).toBe('R1')
})
it('includes 3700-3799 (Lämnade rabatter)', () => {
expect(findRutaForAccount('3700')).toBe('R1')
expect(findRutaForAccount('3731')).toBe('R1')
expect(findRutaForAccount('3799')).toBe('R1')
})
})
describe('R2 - Momsfria intäkter', () => {
it('includes 3100', () => {
expect(findRutaForAccount('3100')).toBe('R2')
})
it('includes 3900 (Övriga rörelseintäkter)', () => {
expect(findRutaForAccount('3900')).toBe('R2')
})
it('includes 3910 (Hyresintäkter)', () => {
expect(findRutaForAccount('3910')).toBe('R2')
})
it('includes 3920 (Provisionsintäkter)', () => {
expect(findRutaForAccount('3920')).toBe('R2')
})
it('includes 3950 (Återvunna kundfordringar)', () => {
expect(findRutaForAccount('3950')).toBe('R2')
})
it('includes 3960 (Valutakursvinster)', () => {
expect(findRutaForAccount('3960')).toBe('R2')
})
it('includes 3970-3980 range', () => {
expect(findRutaForAccount('3970')).toBe('R2')
expect(findRutaForAccount('3980')).toBe('R2')
})
it('includes 3981-3999 range', () => {
expect(findRutaForAccount('3981')).toBe('R2')
expect(findRutaForAccount('3990')).toBe('R2')
expect(findRutaForAccount('3999')).toBe('R2')
})
})
describe('accounts are not silently dropped', () => {
it('3500 is mapped (not dropped)', () => {
expect(findRutaForAccount('3500')).not.toBeNull()
})
it('3700 is mapped (not dropped)', () => {
expect(findRutaForAccount('3700')).not.toBeNull()
})
it('3910 is mapped (not dropped)', () => {
expect(findRutaForAccount('3910')).not.toBeNull()
})
it('3960 is mapped (not dropped)', () => {
expect(findRutaForAccount('3960')).not.toBeNull()
})
it('3990 is mapped (not dropped)', () => {
expect(findRutaForAccount('3990')).not.toBeNull()
})
})
describe('no overlap between R1 and R2', () => {
it('3100 is in R2 not R1', () => {
expect(findRutaForAccount('3100')).toBe('R2')
})
it('3001 is in R1 not R2', () => {
expect(findRutaForAccount('3001')).toBe('R1')
})
it('3900 is in R2 not R1', () => {
expect(findRutaForAccount('3900')).toBe('R2')
})
})
})
+345
View File
@@ -0,0 +1,345 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
FiscalPeriod,
JournalEntry,
JournalEntryLine,
} from '@/types'
import type {
NEDeclaration,
NEDeclarationRutor,
NEAccountMapping,
} from './types'
/**
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
*
* Maps BAS account balances to NE declaration rutor (R1-R11) for
* tax reporting to Skatteverket.
*
* Account mappings:
* R1: Försäljning med moms (3000-3599 excl 3100, 3700-3799)
* R2: Momsfria intäkter (3100, 3900-3969, 3970-3980, 3981-3999) - inkl gåvor utan motprestation
* R3: Bil/bostadsförmån (3200)
* R4: Ränteintäkter (8310-8330)
* R5: Varuinköp (4000-4990)
* R6: Övriga kostnader (5000-6990, 7970) - inkl avdragsgilla gåvor (5460)
* R7: Lönekostnader (7000-7699)
* R8: Räntekostnader (8400-8499)
* R9: Avskrivningar fastighet (7820)
* R10: Avskrivningar övrigt (7700-7899 excl 7820)
* R11: Årets resultat (calculated)
*
* Gift handling:
* - Gåvor MED motprestation: R1 (momspliktig bytestransaktion)
* - Gåvor UTAN motprestation: R2 via konto 3900
* - Avdragsgilla gåvor: R6 via konto 5460
*/
/**
* Account mapping configuration for NE declaration
*/
export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
{
ruta: 'R1',
description: 'Försäljning med moms (25%)',
accountRanges: [
{ start: '3000', end: '3599', exclude: ['3100'] },
{ start: '3700', end: '3799' },
],
isExpense: false,
},
{
ruta: 'R2',
description: 'Momsfria intäkter',
accountRanges: [
{ start: '3100', end: '3100' },
{ start: '3900', end: '3969' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
{ start: '3970', end: '3980' },
{ start: '3981', end: '3999' },
],
isExpense: false,
},
{
ruta: 'R3',
description: 'Bil/bostadsförmån',
accountRanges: [
{ start: '3200', end: '3299' },
],
isExpense: false,
},
{
ruta: 'R4',
description: 'Ränteintäkter',
accountRanges: [
{ start: '8310', end: '8330' },
],
isExpense: false,
},
{
ruta: 'R5',
description: 'Varuinköp',
accountRanges: [
{ start: '4000', end: '4990' },
],
isExpense: true,
},
{
ruta: 'R6',
description: 'Övriga kostnader',
accountRanges: [
{ start: '5000', end: '6990' },
{ start: '7970', end: '7970' },
],
isExpense: true,
},
{
ruta: 'R7',
description: 'Lönekostnader',
accountRanges: [
{ start: '7000', end: '7699' },
],
isExpense: true,
},
{
ruta: 'R8',
description: 'Räntekostnader',
accountRanges: [
{ start: '8400', end: '8499' },
],
isExpense: true,
},
{
ruta: 'R9',
description: 'Avskrivningar fastighet',
accountRanges: [
{ start: '7820', end: '7820' },
],
isExpense: true,
},
{
ruta: 'R10',
description: 'Avskrivningar övrigt',
accountRanges: [
{ start: '7700', end: '7899', exclude: ['7820'] },
],
isExpense: true,
},
]
/**
* Check if an account number falls within a mapping's ranges
*/
function isAccountInMapping(accountNumber: string, mapping: NEAccountMapping): boolean {
for (const range of mapping.accountRanges) {
const num = accountNumber
if (num >= range.start && num <= range.end) {
// Check exclusions
if (range.exclude && range.exclude.includes(num)) {
continue
}
return true
}
}
return false
}
/**
* Round to nearest krona (whole number) for NE declaration
*/
function roundToKrona(value: number): number {
return Math.round(value)
}
/**
* Generate NE declaration for a fiscal period
*/
export async function generateNEDeclaration(
userId: string,
fiscalPeriodId: string
): Promise<NEDeclaration> {
const supabase = await createClient()
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.single()
// Check if it's enskild firma
if (settings?.entity_type !== 'enskild_firma') {
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts for account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
.range(from, to)
)
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate balances per account
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
// Net amount: debit - credit
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Map account balances to NE rutor
const rutor: NEDeclarationRutor = {
R1: 0,
R2: 0,
R3: 0,
R4: 0,
R5: 0,
R6: 0,
R7: 0,
R8: 0,
R9: 0,
R10: 0,
R11: 0,
}
const breakdown: Record<keyof NEDeclarationRutor, {
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
total: number
}> = {
R1: { accounts: [], total: 0 },
R2: { accounts: [], total: 0 },
R3: { accounts: [], total: 0 },
R4: { accounts: [], total: 0 },
R5: { accounts: [], total: 0 },
R6: { accounts: [], total: 0 },
R7: { accounts: [], total: 0 },
R8: { accounts: [], total: 0 },
R9: { accounts: [], total: 0 },
R10: { accounts: [], total: 0 },
R11: { accounts: [], total: 0 },
}
const warnings: string[] = []
// Process each account balance
for (const [accountNumber, balance] of accountBalances) {
// Skip zero balances
if (Math.abs(balance) < 0.01) continue
// Find which ruta this account belongs to
for (const mapping of NE_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
// For revenue accounts (credit normal), negate the balance
// For expense accounts (debit normal), use as-is
// Net balance is debit - credit, so:
// - Revenue accounts have negative net balance (credit > debit)
// - Expense accounts have positive net balance (debit > credit)
const amount = mapping.isExpense ? balance : -balance
rutor[mapping.ruta] += amount
breakdown[mapping.ruta].accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: roundToKrona(amount),
})
break // Account matched, no need to check other mappings
}
}
}
// Round all rutor to whole numbers
for (const key of Object.keys(rutor) as (keyof NEDeclarationRutor)[]) {
if (key !== 'R11') {
rutor[key] = roundToKrona(rutor[key])
breakdown[key].total = rutor[key]
}
}
// Calculate R11 (Årets resultat)
// Result = Revenue (R1+R2+R3+R4) - Expenses (R5+R6+R7+R8+R9+R10)
const totalRevenue = rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4
const totalExpenses = rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10
rutor.R11 = totalRevenue - totalExpenses
breakdown.R11.total = rutor.R11
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (rutor.R11 === 0 && totalRevenue === 0) {
warnings.push('Inga bokförda intäkter eller kostnader hittades för perioden.')
}
return {
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
isClosed: period.is_closed,
},
rutor,
breakdown,
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
},
warnings,
}
}
/**
* Get totals for display
*/
export function getNEDeclarationTotals(declaration: NEDeclaration): {
totalRevenue: number
totalExpenses: number
netResult: number
} {
const { rutor } = declaration
return {
totalRevenue: rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4,
totalExpenses: rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10,
netResult: rutor.R11,
}
}
+80
View File
@@ -0,0 +1,80 @@
// NE-bilaga rutor (NE appendix boxes)
export interface NEDeclarationRutor {
R1: number // Försäljning med 25% moms (3000-3499 excl 3100)
R2: number // Momsfria intäkter (3100, 3970, 3980)
R3: number // Bil/bostadsförmån (3200)
R4: number // Ränteintäkter (8310-8330)
R5: number // Varuinköp (4000-4990)
R6: number // Övriga kostnader (5000-6990, 7970)
R7: number // Lönekostnader (7000-7699)
R8: number // Räntekostnader (8400-8499)
R9: number // Avskrivningar fastighet (7820)
R10: number // Avskrivningar övrigt (7700-7899 excl 7820)
R11: number // Årets resultat (beräknat)
}
// NE account mapping configuration
export interface NEAccountMapping {
ruta: keyof NEDeclarationRutor
description: string
accountRanges: Array<{
start: string
end: string
exclude?: string[]
}>
isExpense: boolean // true = debit normal, false = credit normal
}
// NE declaration response
export interface NEDeclaration {
fiscalYear: {
id: string
name: string
start: string
end: string
isClosed: boolean
}
rutor: NEDeclarationRutor
// Detailed breakdown per ruta
breakdown: Record<keyof NEDeclarationRutor, {
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
total: number
}>
// Company info for SRU
companyInfo: {
companyName: string
orgNumber: string | null
}
// Warnings
warnings: string[]
}
// SRU file format types
export interface SRURecord {
fieldCode: string
value: string | number
}
export interface SRUFile {
records: SRURecord[]
generatedAt: string
}
// Labels for NE rutor
export const NE_RUTA_LABELS: Record<keyof NEDeclarationRutor, string> = {
R1: 'Försäljning med moms (25%)',
R2: 'Momsfria intäkter',
R3: 'Bil/bostadsförmån',
R4: 'Ränteintäkter',
R5: 'Varuinköp',
R6: 'Övriga kostnader',
R7: 'Lönekostnader',
R8: 'Räntekostnader',
R9: 'Avskrivningar fastighet',
R10: 'Avskrivningar övriga tillgångar',
R11: 'Årets resultat'
}
+149
View File
@@ -0,0 +1,149 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { JournalEntry, JournalEntryLine } from '@/types'
/**
* SRU aggregation engine
*
* Fetches posted journal entries for a fiscal period, computes net balance
* per account, and groups by sru_code from chart_of_accounts.
*/
export interface SRUBalance {
sruCode: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}
export interface SRUCoverageStats {
totalAccounts: number
accountsWithSRU: number
accountsWithoutSRU: number
coveragePercent: number
missingAccounts: Array<{
accountNumber: string
accountName: string
}>
}
/**
* Aggregate account balances by SRU code for a given fiscal period.
* Returns a Map of sru_code → summed amount, plus per-account detail.
*/
export async function aggregateBalancesBySRU(
userId: string,
fiscalPeriodId: string
): Promise<Map<string, SRUBalance>> {
const supabase = await createClient()
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts with SRU codes
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null; normal_balance: string }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code, normal_balance')
.eq('user_id', userId)
.eq('is_active', true)
.range(from, to)
)
// Build lookup maps
const accountSRUMap = new Map<string, string>()
const accountNameMap = new Map<string, string>()
for (const acc of accounts) {
if (acc.sru_code) {
accountSRUMap.set(acc.account_number, acc.sru_code)
}
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate net balances per account (debit - credit)
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Group balances by SRU code
const sruBalances = new Map<string, SRUBalance>()
for (const [accountNumber, balance] of accountBalances) {
if (Math.abs(balance) < 0.01) continue
const sruCode = accountSRUMap.get(accountNumber)
if (!sruCode) continue
let entry = sruBalances.get(sruCode)
if (!entry) {
entry = { sruCode, amount: 0, accounts: [] }
sruBalances.set(sruCode, entry)
}
entry.amount += balance
entry.accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: Math.round(balance),
})
}
// Round totals
for (const entry of sruBalances.values()) {
entry.amount = Math.round(entry.amount)
}
return sruBalances
}
/**
* Get SRU code coverage stats for a user's chart of accounts.
* Returns how many accounts have vs lack SRU codes.
*/
export async function getSRUCoverage(userId: string): Promise<SRUCoverageStats> {
const supabase = await createClient()
const accounts = await fetchAllRows<{ account_number: string; account_name: string; sru_code: string | null }>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, sru_code')
.eq('user_id', userId)
.eq('is_active', true)
.order('account_number')
.range(from, to)
)
const withSRU = accounts.filter((a) => a.sru_code)
const withoutSRU = accounts.filter((a) => !a.sru_code)
return {
totalAccounts: accounts.length,
accountsWithSRU: withSRU.length,
accountsWithoutSRU: withoutSRU.length,
coveragePercent: accounts.length > 0
? Math.round((withSRU.length / accounts.length) * 100)
: 0,
missingAccounts: withoutSRU.map((a) => ({
accountNumber: a.account_number,
accountName: a.account_name,
})),
}
}
+209
View File
@@ -0,0 +1,209 @@
import type { NEDeclaration, SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
/**
* SRU File Generator
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
* submission to Skatteverket. The SRU format is used for tax declarations.
*
* Format specification:
* - Each line starts with # followed by field code
* - Values follow the field code
* - File must be plain text (ISO-8859-1 encoding traditionally, but UTF-8 is often accepted)
*
* NE-bilaga field codes:
* - #BLANKETT NE - Declares this is an NE form
* - #IDENTITET - Organization number + name
* - #UPPGIFT - Individual field values
*/
/**
* SRU field codes for NE declaration
* These are the official Skatteverket field codes for NE-bilaga
*/
const NE_SRU_FIELD_CODES: Record<string, string> = {
// Company identification
ORG_NUMBER: '201',
COMPANY_NAME: '202',
// NE rutor - Income
R1: '7310', // Försäljning och andra intäkter med moms
R2: '7311', // Momsfria intäkter (ej skattepliktiga)
R3: '7312', // Bil- och bostadsförmån m.m.
R4: '7313', // Ränteintäkter
// NE rutor - Expenses
R5: '7320', // Varuinköp
R6: '7321', // Övriga externa kostnader
R7: '7322', // Anställdas löner
R8: '7323', // Räntekostnader
R9: '7324', // Avskrivningar på byggnader och markanläggningar
R10: '7325', // Avskrivningar på maskiner och inventarier
// Result
R11: '7350', // Årets resultat
}
/**
* Generate SRU file content from NE declaration
*/
export function generateSRUFile(declaration: NEDeclaration): SRUFile {
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
value: formatSRUDate(now),
})
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: 'NE' })
// Company identification
if (declaration.companyInfo.orgNumber) {
// Remove any dashes from org number
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
records.push({
fieldCode: 'IDENTITET',
value: cleanOrgNumber,
})
}
// Fiscal year
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
})
// NE rutor values
const rutaEntries: [keyof typeof NE_SRU_FIELD_CODES, number][] = [
['R1', declaration.rutor.R1],
['R2', declaration.rutor.R2],
['R3', declaration.rutor.R3],
['R4', declaration.rutor.R4],
['R5', declaration.rutor.R5],
['R6', declaration.rutor.R6],
['R7', declaration.rutor.R7],
['R8', declaration.rutor.R8],
['R9', declaration.rutor.R9],
['R10', declaration.rutor.R10],
['R11', declaration.rutor.R11],
]
for (const [ruta, value] of rutaEntries) {
// Only include non-zero values
if (value !== 0) {
const fieldCode = NE_SRU_FIELD_CODES[ruta]
records.push({
fieldCode: 'UPPGIFT',
value: `${fieldCode} ${formatSRUAmount(value)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Convert SRU file to string content
*/
export function sruFileToString(sruFile: SRUFile): string {
const lines: string[] = []
for (const record of sruFile.records) {
if (record.value === '') {
lines.push(`#${record.fieldCode}`)
} else {
lines.push(`#${record.fieldCode} ${record.value}`)
}
}
// SRU files should end with a newline
return lines.join('\r\n') + '\r\n'
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
*/
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
/**
* Format fiscal year date range for SRU
*/
function formatSRUDateRange(startDate: string, endDate: string): string {
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
}
/**
* Format amount for SRU
* - Whole numbers (no decimals)
* - No thousands separator
* - Negative values with minus sign
*/
function formatSRUAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Validate SRU file content
*/
export function validateSRUFile(sruFile: SRUFile): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
// Check for required records
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
if (!hasHeader) {
errors.push('Missing PRODUKT header')
}
if (!hasBlankett) {
errors.push('Missing BLANKETT declaration')
}
if (!hasBlankettslut) {
errors.push('Missing BLANKETTSLUT')
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get filename for SRU file download
*/
export function getSRUFilename(declaration: NEDeclaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `NE_${orgNumber}_${year}.sru`
}
@@ -0,0 +1,140 @@
import type { SRUFile, SRURecord } from '@/lib/reports/ne-bilaga/types'
import { sruFileToString, validateSRUFile } from './sru-generator'
import type { SRUBalance } from './sru-engine'
/**
* Generic SRU file generator
*
* Generates SRU files from aggregated SRU balances for any form type
* (NE for enskild firma, INK2 for aktiebolag).
*
* Reuses sruFileToString() and validateSRUFile() from the existing
* NE-specific generator.
*/
export type SRUFormType = 'NE' | 'INK2'
export interface GenericSRUParams {
formType: SRUFormType
orgNumber: string | null
companyName: string
fiscalYearStart: string // YYYY-MM-DD
fiscalYearEnd: string // YYYY-MM-DD
sruBalances: Map<string, SRUBalance>
}
/**
* SRU code descriptions for display
*/
export const SRU_CODE_DESCRIPTIONS: Record<string, string> = {
// NE form (EF)
'7310': 'Försäljning med moms',
'7311': 'Momsfria intäkter',
'7312': 'Bil/bostadsförmån',
'7313': 'Ränteintäkter',
'7320': 'Varuinköp',
'7321': 'Övriga kostnader',
'7322': 'Lönekostnader',
'7323': 'Räntekostnader',
'7324': 'Avskrivningar fastighet',
'7325': 'Avskrivningar övrigt',
'7350': 'Årets resultat',
// INK2 form (AB) — balance sheet
'7201': 'Immateriella anläggningstillgångar',
'7202': 'Materiella anläggningstillgångar',
'7203': 'Finansiella anläggningstillgångar',
'7210': 'Varulager',
'7211': 'Kundfordringar',
'7212': 'Övriga omsättningstillgångar',
'7220': 'Aktiekapital',
'7221': 'Övrigt eget kapital',
'7222': 'Årets resultat',
'7230': 'Skulder',
'7231': 'Övriga skulder',
// INK2 form (AB) — income statement
'7330': 'Övriga externa kostnader',
'7340': 'Personalkostnader',
'7360': 'Övriga rörelsekostnader',
'7370': 'Finansiella poster',
'7380': 'Extraordinära poster',
}
/**
* Generate a generic SRU file from aggregated SRU balances.
*/
export function generateGenericSRU(params: GenericSRUParams): SRUFile {
const { formType, orgNumber, fiscalYearStart, fiscalYearEnd, sruBalances } = params
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({ fieldCode: 'SKAPAT', value: formatSRUDate(now) })
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: formType })
// Company identification
if (orgNumber) {
const cleanOrgNumber = orgNumber.replace(/-/g, '')
records.push({ fieldCode: 'IDENTITET', value: cleanOrgNumber })
}
// Fiscal year
const startSRU = fiscalYearStart.replace(/-/g, '')
const endSRU = fiscalYearEnd.replace(/-/g, '')
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${startSRU}-${endSRU}`,
})
// SRU balance entries — one #UPPGIFT per non-zero SRU code
const sortedEntries = Array.from(sruBalances.entries())
.sort(([a], [b]) => a.localeCompare(b))
for (const [sruCode, balance] of sortedEntries) {
if (balance.amount !== 0) {
records.push({
fieldCode: 'UPPGIFT',
value: `${sruCode} ${Math.round(balance.amount)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Get filename for generic SRU file download
*/
export function getGenericSRUFilename(
formType: SRUFormType,
orgNumber: string | null,
fiscalYearStart: string
): string {
const year = fiscalYearStart.substring(0, 4)
const cleanOrg = orgNumber?.replace(/-/g, '') || 'unknown'
return `${formType}_${cleanOrg}_${year}.sru`
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
// Re-export helpers from the existing SRU generator
export { sruFileToString, validateSRUFile }
+28
View File
@@ -0,0 +1,28 @@
import type { EntityType } from '@/types'
// Generic SRU export types
export interface SRUExportResult {
formType: 'NE' | 'INK2'
entityType: EntityType
companyName: string | null
orgNumber: string | null
fiscalYear: {
id: string
name: string
start: string
end: string
}
balances: Array<{
sruCode: string
description: string
amount: number
accounts: Array<{
accountNumber: string
accountName: string
amount: number
}>
}>
warnings: string[]
}
export { type SRUCoverageStats } from './sru-engine'
+138
View File
@@ -132,6 +132,8 @@ describe('ingestTransactions', () => {
const raw = makeRaw({ amount: -100 })
const inserted = makeTransaction({ id: 'tx-1', external_id: raw.external_id })
// Booked transaction map query (no booked transactions)
enqueue({ data: [], error: null })
// Dedup check returns null (no existing row)
enqueue({ data: null, error: null })
// Insert returns the new transaction
@@ -154,6 +156,8 @@ describe('ingestTransactions', () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw()
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup check returns an existing record
enqueue({ data: { id: 'existing-tx-1' }, error: null })
@@ -171,6 +175,8 @@ describe('ingestTransactions', () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw()
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup check: no duplicate
enqueue({ data: null, error: null })
// Insert fails
@@ -195,6 +201,8 @@ describe('ingestTransactions', () => {
external_id: raw.external_id,
})
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert returns the new transaction
@@ -233,6 +241,8 @@ describe('ingestTransactions', () => {
external_id: raw.external_id,
})
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -260,6 +270,8 @@ describe('ingestTransactions', () => {
})
const journalEntry = makeJournalEntry({ id: 'je-1' })
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -294,6 +306,8 @@ describe('ingestTransactions', () => {
external_id: raw.external_id,
})
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -321,6 +335,8 @@ describe('ingestTransactions', () => {
external_id: raw.external_id,
})
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -347,6 +363,8 @@ describe('ingestTransactions', () => {
const inserted1 = makeTransaction({ id: 'tx-a', amount: -100 })
const inserted2 = makeTransaction({ id: 'tx-b', amount: -200 })
// Booked transaction map query
enqueue({ data: [], error: null })
// Transaction 1: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: inserted1, error: null })
@@ -382,6 +400,8 @@ describe('ingestTransactions', () => {
external_id: 'ext-new',
})
// Booked transaction map query
enqueue({ data: [], error: null })
// Transaction rawNew: dedup (no match), insert OK
enqueue({ data: null, error: null })
enqueue({ data: insertedNew, error: null })
@@ -452,6 +472,8 @@ describe('ingestTransactions', () => {
const raw = makeRaw({ amount: 1000 })
const inserted = makeTransaction({ id: 'tx-inv-err', amount: 1000 })
// Booked transaction map query
enqueue({ data: [], error: null })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
@@ -474,6 +496,8 @@ describe('ingestTransactions', () => {
const raw = makeRaw({ amount: -400 })
const inserted = makeTransaction({ id: 'tx-cat-err', amount: -400 })
// Booked transaction map query
enqueue({ data: [], error: null })
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
@@ -522,6 +546,8 @@ describe('ingestTransactions', () => {
confidence: 0.95,
})
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -562,6 +588,8 @@ describe('ingestTransactions', () => {
])
mockTryReconcileTransaction.mockReturnValue(null)
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -587,6 +615,8 @@ describe('ingestTransactions', () => {
mockFetchUnlinkedGLLines.mockRejectedValue(new Error('RPC error'))
// Booked transaction map query
enqueue({ data: [], error: null })
// Dedup: no duplicate
enqueue({ data: null, error: null })
// Insert
@@ -600,4 +630,112 @@ describe('ingestTransactions', () => {
expect(result.reconciled).toBe(0)
expect(result.errors).toBe(0)
})
// -----------------------------------------------------------------------
// Content-based dedup: cross-source duplicate detection
// -----------------------------------------------------------------------
it('skips transactions that match already-booked ones by date+amount', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'psd2_conn123_tx456',
date: '2024-06-15',
amount: -250,
})
// Booked transaction map returns a booked tx with same date+amount
enqueue({
data: [{ date: '2024-06-15', amount: -250 }],
error: null,
})
// external_id dedup: no match (different source)
enqueue({ data: null, error: null })
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.duplicates).toBe(1)
expect(result.imported).toBe(0)
})
it('imports transactions when booked ones have different amounts', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({
external_id: 'psd2_conn123_tx789',
date: '2024-06-15',
amount: -300,
})
const inserted = makeTransaction({ id: 'tx-new', amount: -300 })
// Booked transaction map: same date but different amount
enqueue({
data: [{ date: '2024-06-15', amount: -250 }],
error: null,
})
// external_id dedup: no match
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
})
it('handles multiple booked transactions with same date+amount correctly', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
// Three incoming transactions with the same date+amount
const raw1 = makeRaw({ external_id: 'psd2_a', date: '2024-06-15', amount: -100 })
const raw2 = makeRaw({ external_id: 'psd2_b', date: '2024-06-15', amount: -100 })
const raw3 = makeRaw({ external_id: 'psd2_c', date: '2024-06-15', amount: -100 })
const inserted = makeTransaction({ id: 'tx-new', amount: -100 })
// Booked map: 2 existing booked transactions with same date+amount
// So 2 of the 3 incoming should be skipped, 1 should be imported
enqueue({
data: [
{ date: '2024-06-15', amount: -100 },
{ date: '2024-06-15', amount: -100 },
],
error: null,
})
// raw1: external_id dedup (no match) -> content dedup matches (bookedCount=2 -> 1)
enqueue({ data: null, error: null })
// raw2: external_id dedup (no match) -> content dedup matches (bookedCount=1 -> 0)
enqueue({ data: null, error: null })
// raw3: external_id dedup (no match) -> content dedup exhausted -> insert
enqueue({ data: null, error: null })
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw1, raw2, raw3])
expect(result.duplicates).toBe(2)
expect(result.imported).toBe(1)
})
it('continues normally when booked transaction map query fails', async () => {
const { supabase, enqueue } = createQueueMockSupabase()
const raw = makeRaw({ amount: -200 })
const inserted = makeTransaction({ id: 'tx-mapfail', amount: -200 })
// Booked map query throws (caught by try/catch in buildBookedTransactionMap)
enqueue({ error: { message: 'Query failed' } })
// external_id dedup: no match
enqueue({ data: null, error: null })
// Insert
enqueue({ data: inserted, error: null })
mockEvaluateMappingRules.mockResolvedValue(makeMappingResult({ confidence: 0.5 }))
const result = await ingestTransactions(supabase as never, USER_ID, [raw])
expect(result.imported).toBe(1)
expect(result.duplicates).toBe(0)
})
})
+57
View File
@@ -9,11 +9,53 @@ import type { Transaction, RawTransaction, IngestResult } from '@/types'
// Re-export types for backward compatibility
export type { RawTransaction, IngestResult } from '@/types'
/**
* Build a lookup map of already-booked transactions keyed by "date|amount".
* Each key maps to the number of booked transactions with that date+amount
* combination, allowing correct dedup when multiple transactions share
* the same date and amount.
*/
async function buildBookedTransactionMap(
supabase: SupabaseClient,
userId: string,
rawTransactions: RawTransaction[]
): Promise<Map<string, number>> {
const map = new Map<string, number>()
if (rawTransactions.length === 0) return map
const dates = rawTransactions.map((t) => t.date).sort()
const dateFrom = dates[0]
const dateTo = dates[dates.length - 1]
try {
const { data: booked } = await supabase
.from('transactions')
.select('date, amount')
.eq('user_id', userId)
.not('journal_entry_id', 'is', null)
.gte('date', dateFrom)
.lte('date', dateTo)
if (booked) {
for (const tx of booked) {
const key = `${tx.date}|${tx.amount}`
map.set(key, (map.get(key) || 0) + 1)
}
}
} catch {
// Non-critical — content-based dedup will be skipped
}
return map
}
/**
* Generic transaction ingestion pipeline.
*
* Handles:
* 1. Deduplication via external_id
* 1b. Content-based dedup via date+amount against already-booked transactions
* (catches cross-source duplicates, e.g. CSV import then PSD2 sync)
* 2. Insert into transactions table
* 3. OCR/reference-based invoice matching (highest confidence)
* 4. Amount+customer fallback invoice matching
@@ -37,6 +79,11 @@ export async function ingestTransactions(
transaction_ids: [],
}
// Pre-fetch booked transactions for content-based dedup (date+amount)
// This catches cross-source duplicates (e.g. same transaction imported
// via CSV and then again via PSD2 with different external_id)
const bookedMap = await buildBookedTransactionMap(supabase, userId, rawTransactions)
// Pre-fetch unlinked GL lines for reconciliation (non-critical)
let glLinePool: UnlinkedGLLine[] = []
try {
@@ -59,6 +106,16 @@ export async function ingestTransactions(
continue
}
// 1b. Content-based dedup: skip if an already-booked transaction
// exists with the same date and amount (cross-source duplicate)
const contentKey = `${raw.date}|${raw.amount}`
const bookedCount = bookedMap.get(contentKey) || 0
if (bookedCount > 0) {
bookedMap.set(contentKey, bookedCount - 1)
result.duplicates++
continue
}
// 2. Insert new transaction
const { data: newTransaction, error: insertError } = await supabase
.from('transactions')
+113
View File
@@ -0,0 +1,113 @@
/**
* EU member states reference data.
*
* Used by core VAT logic and export extensions for:
* - Filtering EU vs non-EU customers
* - VIES VAT number validation (country prefix)
* - Intrastat partner country lookup
* - EC Sales List country grouping
*/
export interface EUCountry {
code: string // ISO 3166-1 alpha-2
name: string // Swedish name
nameEn: string // English name
vatPrefix: string // VIES VAT number prefix
currency: string // Primary currency
}
/**
* All 27 EU member states (as of 2025).
* Sweden (SE) is included but should be filtered out for intra-community checks.
*/
export const EU_COUNTRIES: EUCountry[] = [
{ code: 'AT', name: 'Österrike', nameEn: 'Austria', vatPrefix: 'AT', currency: 'EUR' },
{ code: 'BE', name: 'Belgien', nameEn: 'Belgium', vatPrefix: 'BE', currency: 'EUR' },
{ code: 'BG', name: 'Bulgarien', nameEn: 'Bulgaria', vatPrefix: 'BG', currency: 'BGN' },
{ code: 'HR', name: 'Kroatien', nameEn: 'Croatia', vatPrefix: 'HR', currency: 'EUR' },
{ code: 'CY', name: 'Cypern', nameEn: 'Cyprus', vatPrefix: 'CY', currency: 'EUR' },
{ code: 'CZ', name: 'Tjeckien', nameEn: 'Czech Republic', vatPrefix: 'CZ', currency: 'CZK' },
{ code: 'DK', name: 'Danmark', nameEn: 'Denmark', vatPrefix: 'DK', currency: 'DKK' },
{ code: 'EE', name: 'Estland', nameEn: 'Estonia', vatPrefix: 'EE', currency: 'EUR' },
{ code: 'FI', name: 'Finland', nameEn: 'Finland', vatPrefix: 'FI', currency: 'EUR' },
{ code: 'FR', name: 'Frankrike', nameEn: 'France', vatPrefix: 'FR', currency: 'EUR' },
{ code: 'DE', name: 'Tyskland', nameEn: 'Germany', vatPrefix: 'DE', currency: 'EUR' },
{ code: 'GR', name: 'Grekland', nameEn: 'Greece', vatPrefix: 'EL', currency: 'EUR' },
{ code: 'HU', name: 'Ungern', nameEn: 'Hungary', vatPrefix: 'HU', currency: 'HUF' },
{ code: 'IE', name: 'Irland', nameEn: 'Ireland', vatPrefix: 'IE', currency: 'EUR' },
{ code: 'IT', name: 'Italien', nameEn: 'Italy', vatPrefix: 'IT', currency: 'EUR' },
{ code: 'LV', name: 'Lettland', nameEn: 'Latvia', vatPrefix: 'LV', currency: 'EUR' },
{ code: 'LT', name: 'Litauen', nameEn: 'Lithuania', vatPrefix: 'LT', currency: 'EUR' },
{ code: 'LU', name: 'Luxemburg', nameEn: 'Luxembourg', vatPrefix: 'LU', currency: 'EUR' },
{ code: 'MT', name: 'Malta', nameEn: 'Malta', vatPrefix: 'MT', currency: 'EUR' },
{ code: 'NL', name: 'Nederländerna', nameEn: 'Netherlands', vatPrefix: 'NL', currency: 'EUR' },
{ code: 'PL', name: 'Polen', nameEn: 'Poland', vatPrefix: 'PL', currency: 'PLN' },
{ code: 'PT', name: 'Portugal', nameEn: 'Portugal', vatPrefix: 'PT', currency: 'EUR' },
{ code: 'RO', name: 'Rumänien', nameEn: 'Romania', vatPrefix: 'RO', currency: 'RON' },
{ code: 'SK', name: 'Slovakien', nameEn: 'Slovakia', vatPrefix: 'SK', currency: 'EUR' },
{ code: 'SI', name: 'Slovenien', nameEn: 'Slovenia', vatPrefix: 'SI', currency: 'EUR' },
{ code: 'ES', name: 'Spanien', nameEn: 'Spain', vatPrefix: 'ES', currency: 'EUR' },
{ code: 'SE', name: 'Sverige', nameEn: 'Sweden', vatPrefix: 'SE', currency: 'SEK' },
]
/** EU country codes excluding Sweden (for intra-community checks) */
export const EU_COUNTRY_CODES_EXCL_SE = EU_COUNTRIES
.filter(c => c.code !== 'SE')
.map(c => c.code)
/** All EU country codes including Sweden */
export const EU_COUNTRY_CODES = EU_COUNTRIES.map(c => c.code)
/**
* Build a lookup set of all known names/codes for EU countries (excluding Sweden).
* Handles ISO codes, English names, and Swedish names — all uppercased for matching.
*/
const EU_LOOKUP_EXCL_SE = new Set(
EU_COUNTRIES
.filter(c => c.code !== 'SE')
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
)
const EU_LOOKUP_INCL_SE = new Set(
EU_COUNTRIES
.flatMap(c => [c.code, c.name, c.nameEn].map(s => s.toUpperCase()))
)
/**
* Check if a country value is an EU member state (excluding Sweden).
* Accepts ISO codes ("DE"), English names ("Germany"), or Swedish names ("Tyskland").
*/
export function isEUCountry(country: string): boolean {
return EU_LOOKUP_EXCL_SE.has(country.trim().toUpperCase())
}
/**
* Check if a country value is an EU member state (including Sweden).
* Accepts ISO codes, English names, or Swedish names.
*/
export function isEUCountryIncludingSE(country: string): boolean {
return EU_LOOKUP_INCL_SE.has(country.trim().toUpperCase())
}
/** Get EU country data by ISO code, English name, or Swedish name */
export function getEUCountry(country: string): EUCountry | undefined {
const upper = country.trim().toUpperCase()
return EU_COUNTRIES.find(
c => c.code === upper || c.name.toUpperCase() === upper || c.nameEn.toUpperCase() === upper
)
}
/** Get the VIES VAT prefix for a country (note: Greece uses 'EL' not 'GR') */
export function getVatPrefix(countryCode: string): string | undefined {
return getEUCountry(countryCode)?.vatPrefix
}
/**
* Normalize a country value to its ISO 3166-1 alpha-2 code.
* Accepts ISO codes, English names, or Swedish names.
* Returns the input uppercased if no match is found.
*/
export function toCountryCode(country: string): string {
const found = getEUCountry(country)
return found ? found.code : country.trim().toUpperCase()
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createLogger } from '@/lib/logger'
import { EU_COUNTRIES } from '@/extensions/export/shared/eu-countries'
import { EU_COUNTRIES } from '@/lib/vat/eu-countries'
import type { VatValidationResult } from '@/types'
const log = createLogger('vies-client')
@@ -0,0 +1,69 @@
-- Prevent overlapping fiscal periods per user.
--
-- Root cause: the UNIQUE(user_id, period_start, period_end) constraint only
-- blocked exact-duplicate date pairs, not overlapping ranges. Multiple code
-- paths (onboarding re-runs, SIE import, manual API) could create periods
-- with different but overlapping dates for the same user.
--
-- Fix: enable btree_gist, consolidate existing overlaps, add an EXCLUDE
-- constraint using daterange overlap (&&).
-- 1. Enable btree_gist for exclusion constraint support (uuid + daterange)
CREATE EXTENSION IF NOT EXISTS btree_gist;
-- 2. Temporarily disable immutability triggers to allow fiscal period
-- consolidation. This is a structural data fix (moving entries between
-- periods), not an accounting modification.
ALTER TABLE public.journal_entries DISABLE TRIGGER enforce_journal_entry_immutability;
-- 3. Consolidate overlapping fiscal periods: for each pair belonging to the
-- same user, move all references from the older period to the newer one,
-- then delete the older period.
DO $$
DECLARE
overlap RECORD;
BEGIN
FOR overlap IN
SELECT DISTINCT ON (a.id)
a.id AS old_id,
b.id AS new_id
FROM public.fiscal_periods a
JOIN public.fiscal_periods b
ON a.user_id = b.user_id
AND a.id != b.id
AND a.period_start <= b.period_end
AND a.period_end >= b.period_start
AND a.created_at < b.created_at
ORDER BY a.id
LOOP
UPDATE public.journal_entries
SET fiscal_period_id = overlap.new_id
WHERE fiscal_period_id = overlap.old_id;
UPDATE public.sie_imports
SET fiscal_period_id = overlap.new_id
WHERE fiscal_period_id = overlap.old_id;
UPDATE public.voucher_sequences
SET fiscal_period_id = overlap.new_id
WHERE fiscal_period_id = overlap.old_id;
UPDATE public.fiscal_periods
SET previous_period_id = overlap.new_id
WHERE previous_period_id = overlap.old_id;
DELETE FROM public.fiscal_periods WHERE id = overlap.old_id;
END LOOP;
END $$;
-- 4. Re-enable the immutability trigger
ALTER TABLE public.journal_entries ENABLE TRIGGER enforce_journal_entry_immutability;
-- 5. Add exclusion constraint: no two fiscal periods for the same user
-- may have overlapping date ranges.
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT no_overlapping_fiscal_periods
EXCLUDE USING gist (
user_id WITH =,
daterange(period_start, period_end, '[]') WITH &&
);
@@ -0,0 +1,15 @@
-- Enforce BFL 3 kap. month boundary rules at the database level.
-- period_start must be the 1st of a month.
-- period_end must be the last day of its month.
-- Using NOT VALID so existing non-conforming rows are not blocked,
-- but all future inserts/updates must comply.
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT fiscal_period_start_first_of_month
CHECK (EXTRACT(DAY FROM period_start) = 1)
NOT VALID;
ALTER TABLE public.fiscal_periods
ADD CONSTRAINT fiscal_period_end_last_of_month
CHECK (period_end = (date_trunc('month', period_end) + interval '1 month - 1 day')::date)
NOT VALID;
+43
View File
@@ -1912,3 +1912,46 @@ export interface IngestResult {
errors: number
transaction_ids: string[]
}
// ── Invoice extraction (used by invoice-inbox extension and core utils) ──
export interface InvoiceExtractionResult {
supplier: {
name: string | null
orgNumber: string | null
vatNumber: string | null
address: string | null
bankgiro: string | null
plusgiro: string | null
}
invoice: {
invoiceNumber: string | null
invoiceDate: string | null
dueDate: string | null
paymentReference: string | null
currency: string
}
lineItems: ExtractedInvoiceLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
vatBreakdown: VatBreakdownItem[]
confidence: number
}
export interface ExtractedInvoiceLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
accountSuggestion: string | null
}
export interface VatBreakdownItem {
rate: number
base: number
amount: number
}