From 6956a757f30e6c55d75a49d8c664cfc69b35735e Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 25 Feb 2026 10:21:10 +0100 Subject: [PATCH] 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 --- app/(dashboard)/invoices/new/page.tsx | 58 +- app/(dashboard)/receipts/page.tsx | 61 +- app/(dashboard)/receipts/scan/page.tsx | 34 +- app/(dashboard)/reports/page.tsx | 4 +- app/(dashboard)/settings/page.tsx | 226 +-- app/(onboarding)/onboarding/page.tsx | 24 + app/api/bookkeeping/fiscal-periods/route.ts | 16 + app/api/extensions/ne-bilaga/route.ts | 66 +- .../extensions/sru-export/coverage/route.ts | 27 +- app/api/extensions/sru-export/route.ts | 147 +- app/api/reports/ne-bilaga/route.ts | 69 + app/api/reports/sru-export/coverage/route.ts | 29 + app/api/reports/sru-export/route.ts | 150 ++ components/reports/NEDeclarationView.tsx | 297 ++++ components/reports/SRUExportView.tsx | 245 +++ .../transactions/TransactionInboxCard.tsx | 26 +- extensions/export/shared/eu-countries.ts | 124 +- .../general/ai-categorization/categorizer.ts | 12 +- .../components/BankSelector.tsx | 80 +- .../components/BankingSettingsPanel.tsx | 218 +++ extensions/general/invoice-inbox/types.ts | 51 +- extensions/ne-bilaga/ne-engine.ts | 349 +--- extensions/ne-bilaga/types.ts | 91 +- extensions/sru-export/lib/sru-generator.ts | 214 +-- extensions/sru-export/sru-engine.ts | 154 +- extensions/sru-export/sru-generator.ts | 148 +- extensions/sru-export/types.ts | 44 +- .../__tests__/booking-templates.test.ts | 36 +- .../__tests__/template-embeddings.test.ts | 2 +- lib/bookkeeping/booking-templates.ts | 1399 ++--------------- lib/bookkeeping/engine.ts | 5 +- lib/bookkeeping/template-embeddings.ts | 2 +- lib/core/bookkeeping/period-service.ts | 26 +- .../__tests__/invoice-inbox-utils.test.ts | 2 +- lib/extensions/__tests__/sectors.test.ts | 4 +- lib/extensions/invoice-inbox-utils.ts | 3 +- lib/extensions/loader.ts | 4 - lib/extensions/settings-panel-registry.tsx | 27 + lib/import/sie-import.ts | 23 +- .../ne-bilaga/__tests__/ne-engine.test.ts | 122 ++ lib/reports/ne-bilaga/ne-engine.ts | 345 ++++ lib/reports/ne-bilaga/types.ts | 80 + lib/reports/sru-export/sru-engine.ts | 149 ++ lib/reports/sru-export/sru-generator.ts | 209 +++ .../sru-export/sru-generic-generator.ts | 140 ++ lib/reports/sru-export/types.ts | 28 + lib/transactions/__tests__/ingest.test.ts | 138 ++ lib/transactions/ingest.ts | 57 + lib/vat/eu-countries.ts | 113 ++ lib/vat/vies-client.ts | 2 +- ...042_prevent_overlapping_fiscal_periods.sql | 69 + ...enforce_fiscal_period_month_boundaries.sql | 15 + types/index.ts | 43 + 53 files changed, 3067 insertions(+), 2940 deletions(-) create mode 100644 app/api/reports/ne-bilaga/route.ts create mode 100644 app/api/reports/sru-export/coverage/route.ts create mode 100644 app/api/reports/sru-export/route.ts create mode 100644 components/reports/NEDeclarationView.tsx create mode 100644 components/reports/SRUExportView.tsx create mode 100644 extensions/general/enable-banking/components/BankingSettingsPanel.tsx create mode 100644 lib/extensions/settings-panel-registry.tsx create mode 100644 lib/reports/ne-bilaga/__tests__/ne-engine.test.ts create mode 100644 lib/reports/ne-bilaga/ne-engine.ts create mode 100644 lib/reports/ne-bilaga/types.ts create mode 100644 lib/reports/sru-export/sru-engine.ts create mode 100644 lib/reports/sru-export/sru-generator.ts create mode 100644 lib/reports/sru-export/sru-generic-generator.ts create mode 100644 lib/reports/sru-export/types.ts create mode 100644 lib/vat/eu-countries.ts create mode 100644 supabase/migrations/20240101000042_prevent_overlapping_fiscal_periods.sql create mode 100644 supabase/migrations/20240101000043_enforce_fiscal_period_month_boundaries.sql diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index ee07c535..943af255 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -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(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() { )} /> + {errors.customer_id && (

{errors.customer_id.message}

)} @@ -677,6 +720,19 @@ export default function NewInvoicePage() { )} + {/* Create customer dialog */} + + + + Lägg till kund + + + + + {/* Send now prompt dialog */} { if (!open && createdInvoiceId) { diff --git a/app/(dashboard)/receipts/page.tsx b/app/(dashboard)/receipts/page.tsx index aefd4cb6..40f5eeaf 100644 --- a/app/(dashboard)/receipts/page.tsx +++ b/app/(dashboard)/receipts/page.tsx @@ -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: () =>
} +) + +export default function ReceiptsPage() { + const { enabled, isLoading } = useExtensionToggle('general', 'receipt-ocr') + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (enabled) { + return + } + + return ( +
+
+

Kvitton

+

+ Hantera och granska dina kvitton +

+
+ + + + + + Kvittoscanning + + + +

+ Aktivera tillägget "Kvittoscanning" för att skanna kvitton med AI, + extrahera data automatiskt och matcha mot transaktioner. +

+ +
+
+
+ ) +} diff --git a/app/(dashboard)/receipts/scan/page.tsx b/app/(dashboard)/receipts/scan/page.tsx index c185df95..dc226b74 100644 --- a/app/(dashboard)/receipts/scan/page.tsx +++ b/app/(dashboard)/receipts/scan/page.tsx @@ -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: () =>
} +) + +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 ( +
+ +
+ ) + } + + return +} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 2fafeac1..ad6ca167 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -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' diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index fda05f0d..ae4e950a 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -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(null) - const [bankConnections, setBankConnections] = useState([]) - const [isSyncing, setIsSyncing] = useState(false) - const [isConnecting, setIsConnecting] = useState(false) const [hasBankingExtension, setHasBankingExtension] = useState(false) - const [selectedBank, setSelectedBank] = useState(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 (
@@ -289,16 +197,18 @@ export default function SettingsPage() { Företag - {hasBankingExtension && ( + {hasBankingExtension && BankingPanel && ( Bank (PSD2) )} - - - Aviseringar - + {NotificationPanel && ( + + + Aviseringar + + )} Kalender @@ -525,93 +435,19 @@ export default function SettingsPage() { - {/* Banking settings (only shown when extension is active or connections exist) */} - {hasBankingExtension && ( + {/* Banking settings — loaded dynamically from extension */} + {hasBankingExtension && BankingPanel && ( - {/* Connected banks */} - {activeConnections.length > 0 && ( - - - Anslutna banker - - - {activeConnections.map((connection) => ( - - ))} - - - )} - - {/* Connect new bank */} - - - Anslut ny bank - - Välj din bank nedan för att koppla ditt konto via PSD2. - - - - - {selectedBank && ( -
-
- -
-

{selectedBank.name}

-

{selectedBank.country}

-
-
- -
- )} -
-
- - {/* Info about PSD2 */} - - - Om bankintegration (PSD2) - - Automatisk import av transaktioner via PSD2 open banking. - Samtycket gäller i 90 dagar och behöver sedan förnyas. - - - -

- 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. -

-
-
+
)} - {/* Notification settings */} - - - + {/* Notification settings — loaded dynamically from extension */} + {NotificationPanel && ( + + + + )} {/* Calendar feed settings */} diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index a1a64115..ede66081 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -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, diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index b0b8712b..90a69b6c 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -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({ diff --git a/app/api/extensions/ne-bilaga/route.ts b/app/api/extensions/ne-bilaga/route.ts index a92f626f..b661eca4 100644 --- a/app/api/extensions/ne-bilaga/route.ts +++ b/app/api/extensions/ne-bilaga/route.ts @@ -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) } diff --git a/app/api/extensions/sru-export/coverage/route.ts b/app/api/extensions/sru-export/coverage/route.ts index 43a27f9f..0b3806b4 100644 --- a/app/api/extensions/sru-export/coverage/route.ts +++ b/app/api/extensions/sru-export/coverage/route.ts @@ -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) } diff --git a/app/api/extensions/sru-export/route.ts b/app/api/extensions/sru-export/route.ts index 16625e4f..2bc72758 100644 --- a/app/api/extensions/sru-export/route.ts +++ b/app/api/extensions/sru-export/route.ts @@ -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) } diff --git a/app/api/reports/ne-bilaga/route.ts b/app/api/reports/ne-bilaga/route.ts new file mode 100644 index 00000000..8737edfe --- /dev/null +++ b/app/api/reports/ne-bilaga/route.ts @@ -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 } + ) + } +} diff --git a/app/api/reports/sru-export/coverage/route.ts b/app/api/reports/sru-export/coverage/route.ts new file mode 100644 index 00000000..64508eac --- /dev/null +++ b/app/api/reports/sru-export/coverage/route.ts @@ -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 } + ) + } +} diff --git a/app/api/reports/sru-export/route.ts b/app/api/reports/sru-export/route.ts new file mode 100644 index 00000000..bfacb828 --- /dev/null +++ b/app/api/reports/sru-export/route.ts @@ -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 } + ) + } +} diff --git a/components/reports/NEDeclarationView.tsx b/components/reports/NEDeclarationView.tsx new file mode 100644 index 00000000..eaa8140a --- /dev/null +++ b/components/reports/NEDeclarationView.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 = { + 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 ( +
+ {/* Info card */} + + + NE-bilaga (Enskild firma) + + +

+ 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. +

+
+ + {data && ( + + )} +
+
+
+ + {error && ( + + + + {error} + + + )} + + {data && ( + <> + {/* Warnings */} + {data.warnings.length > 0 && ( + + +
+ +
+ {data.warnings.map((warning, i) => ( +

{warning}

+ ))} +
+
+
+
+ )} + + {/* Company info */} + + +
+ + {data.companyInfo.companyName} + + + {data.fiscalYear.name} + +
+ {data.companyInfo.orgNumber && ( +

+ Org.nr: {data.companyInfo.orgNumber} +

+ )} +
+
+ + {/* Revenue section */} + + + Intäkter + + + + + {revenueRutor.map((ruta) => { + const value = data.rutor[ruta] + const breakdown = data.breakdown[ruta] + return ( + + ) + })} + + + + + + + +
Summa intäkter + {formatAmount( + data.rutor.R1 + data.rutor.R2 + data.rutor.R3 + data.rutor.R4 + )} kr +
+
+
+ + {/* Expenses section */} + + + Kostnader + + + + + {expenseRutor.map((ruta) => { + const value = data.rutor[ruta] + const breakdown = data.breakdown[ruta] + return ( + + ) + })} + + + + + + + +
Summa kostnader + -{formatAmount( + data.rutor.R5 + data.rutor.R6 + data.rutor.R7 + + data.rutor.R8 + data.rutor.R9 + data.rutor.R10 + )} kr +
+
+
+ + {/* Result */} + + +
+
+ R11 + Årets resultat +
+ = 0 ? 'text-green-600' : 'text-red-600' + }`} + > + {formatAmount(data.rutor.R11)} kr + +
+
+
+ + )} + + {!data && !loading && !error && ( + + + Klicka "Hämta NE-bilaga" för att generera deklarationsunderlaget. + + + )} +
+ ) +} + +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 ( + <> + accounts.length > 0 && setExpanded(!expanded)} + > + + {ruta} + {label} + {accounts.length > 0 && ( + + ({accounts.length} konton) + + )} + + + {isExpense && amount > 0 ? '-' : ''}{formatAmount(Math.abs(amount))} kr + + + {expanded && accounts.length > 0 && ( + + + + + {accounts.map((acc) => ( + + + + + + ))} + +
{acc.accountName} + {isExpense && acc.amount > 0 ? '-' : ''}{formatAmount(Math.abs(acc.amount))} kr +
+ + + )} + + ) +} diff --git a/components/reports/SRUExportView.tsx b/components/reports/SRUExportView.tsx new file mode 100644 index 00000000..88d373f0 --- /dev/null +++ b/components/reports/SRUExportView.tsx @@ -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(null) + const [coverage, setCoverage] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+ {/* Info card */} + + + SRU-export + + +

+ Generera SRU-fil (Standardiserat Räkenskapsutdrag) för elektronisk inlämning + till Skatteverket. Blanketttyp bestäms automatiskt utifrån företagsform. +

+
+ + {data && ( + + )} +
+
+
+ + {/* Coverage warning */} + {coverage && coverage.accountsWithoutSRU > 0 && ( + + +
+ +
+

+ {coverage.accountsWithoutSRU} av {coverage.totalAccounts} konton saknar SRU-kod + ({coverage.coveragePercent}% täckning). + Konton utan SRU-kod inkluderas inte i exporten. +

+ {coverage.missingAccounts.length <= 5 && ( +
    + {coverage.missingAccounts.map((a) => ( +
  • + {a.accountNumber} — {a.accountName} +
  • + ))} +
+ )} +
+
+
+
+ )} + + {error && ( + + + + {error} + + + )} + + {data && ( + <> + {/* Warnings */} + {data.warnings.length > 0 && ( + + +
+ +
+ {data.warnings.map((warning, i) => ( +

{warning}

+ ))} +
+
+
+
+ )} + + {/* Company + form info */} + + +
+ {data.companyName || 'Okänt företag'} +
+ {formLabel} + {data.fiscalYear.name} +
+
+ {data.orgNumber && ( +

+ Org.nr: {data.orgNumber} +

+ )} +
+
+ + {/* SRU balances table */} + + + SRU-poster + + + {data.balances.length === 0 ? ( +

+ Inga poster med belopp att exportera. +

+ ) : ( + + + + + + + + + + {data.balances.map((b) => ( + + ))} + +
SRU-kodBeskrivningBelopp
+ )} +
+
+ + )} + + {!data && !loading && !error && ( + + + Klicka "Förhandsgranska" för att se SRU-uppgifter för valt räkenskapsår. + + + )} +
+ ) +} + +function SRUBalanceRow({ + balance, +}: { + balance: SRUExportResult['balances'][number] +}) { + const [expanded, setExpanded] = useState(false) + + return ( + <> + balance.accounts.length > 0 && setExpanded(!expanded)} + > + {balance.sruCode} + + {balance.description} + {balance.accounts.length > 0 && ( + + ({balance.accounts.length} konton) + + )} + + {formatAmount(balance.amount)} kr + + {expanded && balance.accounts.length > 0 && ( + + + + + {balance.accounts.map((acc) => ( + + + + + + ))} + +
{acc.accountName}{formatAmount(acc.amount)} kr
+ + + )} + + ) +} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 7c6eeaf5..dd6028f9 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -163,7 +163,7 @@ export default function TransactionInboxCard({ ) : null} {topSuggestion.label} {topSuggestion.account && ( - + ({formatAccountWithName(topSuggestion.account)}) )} @@ -212,30 +212,6 @@ export default function TransactionInboxCard({ )} - {/* Private button */} - - - - - - -

- {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)'} -

-
-
-
- {/* Describe transaction */} {onOpenDescribe && (