feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)
Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr, invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/ Anthropic/OpenAI deps) to simplify core and reduce bundle size. Restructure monolithic settings page into dedicated sub-pages (company, bookkeeping, invoicing, tax, banking, api, account, team, templates) with shared layout and sidebar navigation. Add atomic commit_journal_entry RPC so voucher number increment and status update happen in a single transaction — prevents burned numbers on constraint failures. Add continuity check report and voucher gap explanation tracking. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e89f2c402d
commit
d0b3f21bde
@@ -359,7 +359,7 @@ function SIEImportWizard() {
|
||||
const data = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
if (data.error === 'duplicate') {
|
||||
if (data.error === 'duplicate' || data.error === 'duplicate_period') {
|
||||
setError(data.message)
|
||||
} else if (data.error === 'validation') {
|
||||
setError(`${data.message}: ${data.errors?.join(', ') || 'Unknown validation error'}`)
|
||||
|
||||
@@ -29,10 +29,12 @@ export default function KpiPage() {
|
||||
const { data: periodsData } = await periodsRes.json()
|
||||
const { data: prefsData } = await prefsRes.json()
|
||||
|
||||
setPeriods(periodsData || [])
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const activePeriods = (periodsData || []).filter((p: FiscalPeriod) => p.period_start <= today)
|
||||
setPeriods(activePeriods)
|
||||
if (prefsData) setPreferences(prefsData)
|
||||
if (periodsData && periodsData.length > 0) {
|
||||
setSelectedPeriod(periodsData[0].id)
|
||||
if (activePeriods.length > 0) {
|
||||
setSelectedPeriod(activePeriods[0].id)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta data')
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
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> }
|
||||
)
|
||||
|
||||
const ocrEnabled = ENABLED_EXTENSION_IDS.has('receipt-ocr')
|
||||
import { Receipt } from 'lucide-react'
|
||||
|
||||
export default function ReceiptsPage() {
|
||||
if (ocrEnabled) {
|
||||
return <ReceiptsPageOCR />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -32,19 +17,13 @@ export default function ReceiptsPage() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Receipt className="h-5 w-5" />
|
||||
Kvittoscanning
|
||||
Kvitton
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Aktivera tillägget "Kvittoscanning" för att skanna kvitton med AI,
|
||||
extrahera data automatiskt och matcha mot transaktioner.
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kvittoscanning är inte tillgängligt just nu.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/extensions/general?highlight=receipt-ocr">
|
||||
Aktivera i Tillägg
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
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> }
|
||||
)
|
||||
|
||||
const ocrEnabled = ENABLED_EXTENSION_IDS.has('receipt-ocr')
|
||||
|
||||
export default function ScanReceiptPage() {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!ocrEnabled) {
|
||||
router.replace('/receipts')
|
||||
}
|
||||
router.replace('/receipts')
|
||||
}, [router])
|
||||
|
||||
if (!ocrEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <ScanReceiptPageOCR />
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -81,9 +81,11 @@ export default function ReportsPage() {
|
||||
async function fetchPeriods() {
|
||||
const res = await fetch('/api/bookkeeping/fiscal-periods')
|
||||
const { data } = await res.json()
|
||||
setPeriods(data || [])
|
||||
if (data && data.length > 0) {
|
||||
setSelectedPeriod(data[0].id)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const activePeriods = (data || []).filter((p: FiscalPeriod) => p.period_start <= today)
|
||||
setPeriods(activePeriods)
|
||||
if (activePeriods.length > 0) {
|
||||
setSelectedPeriod(activePeriods[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sun, Moon, Monitor, LogOut } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { AccountDangerZone } from '@/components/settings/AccountDangerZone'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
|
||||
export default function AccountSettingsPage() {
|
||||
const router = useRouter()
|
||||
const supabase = createClient()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
|
||||
const { settings } = useSettings()
|
||||
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Appearance */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Utseende
|
||||
</h2>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', label: 'Ljust', icon: Sun },
|
||||
{ value: 'dark', label: 'Mörkt', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const).map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<SecuritySettings />
|
||||
</div>
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && (
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<CalendarFeedSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout */}
|
||||
<section className="border-t border-border/8 pt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontoinställningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Logga ut</p>
|
||||
<p className="text-sm text-muted-foreground">Logga ut från ditt konto</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logga ut
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Delete account — only for non-sandbox */}
|
||||
{!settings?.is_sandbox && <AccountDangerZone />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
|
||||
export default function ApiSettingsPage() {
|
||||
return <ApiKeysPanel />
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { AlertTriangle, CreditCard, ExternalLink } from 'lucide-react'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
|
||||
export default function BankingSettingsPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
|
||||
useEffect(() => {
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||
})
|
||||
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
if (connectionId) {
|
||||
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.imported > 0) {
|
||||
toast({
|
||||
title: 'Transaktioner hämtade',
|
||||
description: `${data.imported} transaktioner importerade`,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
router.replace('/settings/banking')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
const errorMsg = decodeURIComponent(bankError)
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setBankConnectionError(errorMsg)
|
||||
router.replace('/settings/banking')
|
||||
}
|
||||
}, [searchParams, router, toast])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBankConnectionError(null)}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Stäng"
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<BankingPanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Gå till Tillägg
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
|
||||
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function BookkeepingSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const autoLockValue = formData.get('auto_lock_period_days') as string
|
||||
const lockedThrough = (formData.get('bookkeeping_locked_through') as string) || null
|
||||
const accountingMethod = (formData.get('accounting_method') as string) || 'accrual'
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
bookkeeping_locked_through: lockedThrough,
|
||||
auto_lock_period_days: autoLockValue === 'none' ? null : parseInt(autoLockValue),
|
||||
accounting_method: accountingMethod,
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
{/* Accounting method */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Bokföringsmetod
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">Metod</Label>
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Period locking */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<PeriodLockingSettings settings={settings} />
|
||||
</div>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* Voucher series — read-only, no form submit needed */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<VoucherSeriesManager />
|
||||
</div>
|
||||
|
||||
{/* Cross-links */}
|
||||
<div className="border-t border-border/8 pt-8 space-y-3">
|
||||
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Relaterat
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Link
|
||||
href="/bookkeeping"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Räkenskapsår och ingående balanser
|
||||
</Link>
|
||||
<Link
|
||||
href="/bookkeeping"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Kontoplan (BAS)
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import { CompanyInfoForm } from '@/components/settings/CompanyInfoForm'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
import { LogoUpload } from '@/components/settings/LogoUpload'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function CompanySettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const updates: Record<string, unknown> = {
|
||||
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
|
||||
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
|
||||
address_line1: formData.get('address_line1') as string,
|
||||
postal_code: formData.get('postal_code') as string,
|
||||
city: formData.get('city') as string,
|
||||
phone: (formData.get('phone') as string) || '',
|
||||
email: (formData.get('email') as string) || '',
|
||||
website: (formData.get('website') as string) || '',
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<CompanyInfoForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<LogoUpload
|
||||
logoUrl={settings.logo_url}
|
||||
onUpdate={(url) => updateSettings({ logo_url: url })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<CompanyMembersSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { BankDetailsForm, validateBankFields } from '@/components/settings/BankDetailsForm'
|
||||
import { InvoiceSettingsForm } from '@/components/settings/InvoiceSettingsForm'
|
||||
import { PdfPrintSettings } from '@/components/settings/PdfPrintSettings'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function InvoicingSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
const { toast } = useToast()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const bankErrors = validateBankFields(formData)
|
||||
if (bankErrors.length > 0) {
|
||||
toast({
|
||||
title: 'Kontrollera bankuppgifter',
|
||||
description: bankErrors.map(e => e.message).join(', '),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return {}
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
bank_name: formData.get('bank_name') as string,
|
||||
clearing_number: formData.get('clearing_number') as string,
|
||||
account_number: formData.get('account_number') as string,
|
||||
bankgiro: (formData.get('bankgiro') as string) || null,
|
||||
invoice_prefix: (formData.get('invoice_prefix') as string) || null,
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<BankDetailsForm settings={settings} />
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<InvoiceSettingsForm settings={settings} />
|
||||
</div>
|
||||
</SettingsFormWrapper>
|
||||
|
||||
{/* PDF settings — saves individually via toggle switches */}
|
||||
<div className="border-t border-border/8 pt-8">
|
||||
<PdfPrintSettings settings={settings} onUpdate={updateSettings} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { SettingsNav } from '@/components/settings/SettingsSidebar'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
|
||||
const TAB_TO_ROUTE: Record<string, string> = {
|
||||
company: '/settings/company',
|
||||
invoicing: '/settings/invoicing',
|
||||
bookkeeping: '/settings/bookkeeping',
|
||||
tax: '/settings/tax',
|
||||
team: '/settings/team',
|
||||
banking: '/settings/banking',
|
||||
templates: '/settings/templates',
|
||||
account: '/settings/account',
|
||||
api: '/settings/api',
|
||||
}
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { company } = useCompany()
|
||||
const [isSandbox, setIsSandbox] = useState(false)
|
||||
|
||||
// Fetch sandbox status
|
||||
useEffect(() => {
|
||||
if (!company?.id) return
|
||||
const supabase = createClient()
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('is_sandbox')
|
||||
.eq('company_id', company.id)
|
||||
.single()
|
||||
.then(({ data }) => {
|
||||
if (data?.is_sandbox) setIsSandbox(true)
|
||||
})
|
||||
}, [company?.id])
|
||||
|
||||
// Handle legacy ?tab= URLs
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab')
|
||||
if (tab && TAB_TO_ROUTE[tab]) {
|
||||
router.replace(TAB_TO_ROUTE[tab])
|
||||
}
|
||||
}, [searchParams, router])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
Hantera ditt företag och konto
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsNav isSandbox={isSandbox} />
|
||||
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,862 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Loader2,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sun,
|
||||
Moon,
|
||||
Monitor,
|
||||
ExternalLink,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
import type { CompanySettings } from '@/types'
|
||||
import { validateBankgiroNumber, formatBankgiroNumber } from '@/lib/bankgiro/luhn'
|
||||
import { BankNameCombobox } from '@/components/settings/BankNameCombobox'
|
||||
import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings'
|
||||
import { SupportLink } from '@/components/ui/support-link'
|
||||
import { getSettingsPanel } from '@/lib/extensions/settings-panel-registry'
|
||||
import { SecuritySettings } from '@/components/settings/SecuritySettings'
|
||||
import { ApiKeysPanel } from '@/components/settings/ApiKeysPanel'
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
import { TeamPanel } from '@/components/settings/TeamPanel'
|
||||
import { CompanyMembersSection } from '@/components/settings/CompanyMembersSection'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
const BankingPanel = getSettingsPanel('enable-banking')
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
const { company, isTeamMember } = useCompany()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<CompanySettings | null>(null)
|
||||
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
|
||||
const hasCalendarExtension = ENABLED_EXTENSION_IDS.has('calendar')
|
||||
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
|
||||
const [bankConnectionError, setBankConnectionError] = useState<string | null>(null)
|
||||
const [bankgiroError, setBankgiroError] = useState<string | null>(null)
|
||||
const [clearingError, setClearingError] = useState<string | null>(null)
|
||||
const [accountNumberError, setAccountNumberError] = useState<string | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('')
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
const hasCompany = !!company
|
||||
const defaultTab = hasCompany ? 'company' : (isTeamMember ? 'team' : 'account')
|
||||
const initialTab = searchParams.get('tab') || defaultTab
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
const settingsTabs = [
|
||||
{ value: 'company', label: 'Företag', show: hasCompany },
|
||||
{ value: 'team', label: 'Lag', show: isTeamMember },
|
||||
{ value: 'banking', label: 'Bank (PSD2)', show: hasCompany && !settings?.is_sandbox && hasBankingExtension },
|
||||
{ value: 'templates', label: 'Mallar', show: hasCompany },
|
||||
{ value: 'account', label: 'Konto', show: true },
|
||||
{ value: 'api', label: 'API', show: hasCompany && hasMcpExtension },
|
||||
].filter(t => t.show)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
async function fetchData() {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (company?.id) {
|
||||
const settingsRes = await supabase.from('company_settings').select('*').eq('company_id', company.id).single()
|
||||
setSettings(settingsRes.data)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
|
||||
// Handle callback messages
|
||||
const bankConnected = searchParams.get('bank_connected')
|
||||
const bankError = searchParams.get('bank_error')
|
||||
|
||||
if (bankConnected === 'true') {
|
||||
toast({
|
||||
title: 'Bank ansluten!',
|
||||
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
|
||||
})
|
||||
|
||||
// Auto-sync transactions after connection
|
||||
const connectionId = searchParams.get('connection_id')
|
||||
if (connectionId) {
|
||||
fetch('/api/extensions/ext/enable-banking/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.imported > 0) {
|
||||
toast({
|
||||
title: 'Transaktioner hämtade',
|
||||
description: `${data.imported} transaktioner importerade`,
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
router.replace('/settings?tab=banking')
|
||||
}
|
||||
|
||||
if (bankError) {
|
||||
const errorMsg = decodeURIComponent(bankError)
|
||||
toast({
|
||||
title: 'Anslutning misslyckades',
|
||||
description: errorMsg,
|
||||
variant: 'destructive',
|
||||
})
|
||||
setBankConnectionError(errorMsg)
|
||||
setActiveTab('banking')
|
||||
router.replace('/settings?tab=banking')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (!settings) return
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
|
||||
// Validate bank fields before saving
|
||||
const clearingVal = (formData.get('clearing_number') as string || '').trim()
|
||||
const accountVal = (formData.get('account_number') as string || '').trim()
|
||||
const bankgiroVal = (formData.get('bankgiro') as string || '').trim()
|
||||
|
||||
let hasErrors = false
|
||||
|
||||
if (clearingVal && !/^\d{4,5}$/.test(clearingVal)) {
|
||||
setClearingError('Clearingnummer måste vara 4-5 siffror')
|
||||
hasErrors = true
|
||||
} else {
|
||||
setClearingError(null)
|
||||
}
|
||||
|
||||
if (accountVal && !/^\d{6,12}$/.test(accountVal)) {
|
||||
setAccountNumberError('Kontonummer måste vara 6-12 siffror')
|
||||
hasErrors = true
|
||||
} else {
|
||||
setAccountNumberError(null)
|
||||
}
|
||||
|
||||
if (bankgiroVal && !validateBankgiroNumber(bankgiroVal)) {
|
||||
setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)')
|
||||
hasErrors = true
|
||||
} else {
|
||||
setBankgiroError(null)
|
||||
}
|
||||
|
||||
if (hasErrors) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
// Disabled inputs are excluded from FormData by the browser,
|
||||
// so only include company_name/org_number when not locked
|
||||
const updates: Record<string, unknown> = {
|
||||
...(formData.has('company_name') && { company_name: formData.get('company_name') as string }),
|
||||
...(formData.has('org_number') && { org_number: formData.get('org_number') as string }),
|
||||
address_line1: formData.get('address_line1') as string,
|
||||
postal_code: formData.get('postal_code') as string,
|
||||
city: formData.get('city') as string,
|
||||
bank_name: formData.get('bank_name') as string,
|
||||
clearing_number: formData.get('clearing_number') as string,
|
||||
account_number: formData.get('account_number') as string,
|
||||
bankgiro: (formData.get('bankgiro') as string) || null,
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
invoice_prefix: formData.get('invoice_prefix') as string || null,
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
accounting_method: formData.get('accounting_method') as string || 'accrual',
|
||||
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Kunde inte spara inställningar')
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'Sparat',
|
||||
description: 'Dina inställningar har uppdaterats',
|
||||
})
|
||||
setSettings({ ...settings, ...updates } as typeof settings)
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte spara inställningar',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
if (deleteConfirmText !== 'RADERA') return
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/account/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm: 'RADERA' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Kunde inte radera kontot')
|
||||
}
|
||||
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Kunde inte radera kontot',
|
||||
description: error instanceof Error ? error.message : 'Försök igen.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-72 mt-2 animate-pulse" />
|
||||
</div>
|
||||
<div className="h-10 bg-muted rounded w-96 animate-pulse" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="h-5 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-4 bg-muted rounded w-56 mt-1 animate-pulse" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-24 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-32 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-16 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-20 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-12 animate-pulse" />
|
||||
<div className="h-10 bg-muted rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Inställningar</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina företags- och kontoinställningar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
{/* Mobile: dropdown selector */}
|
||||
<div className="sm:hidden">
|
||||
<Select value={activeTab} onValueChange={setActiveTab}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{settingsTabs.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Desktop: tab pills */}
|
||||
<TabsList className="hidden sm:inline-flex flex-wrap h-auto gap-1">
|
||||
{settingsTabs.map(t => (
|
||||
<TabsTrigger key={t.value} value={t.value}>{t.label}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{/* Company settings */}
|
||||
<TabsContent value="company">
|
||||
<form onSubmit={handleSaveSettings} className="space-y-6">
|
||||
{/* Företagsuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Företagsuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Namn, organisationsnummer och adress
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="company_name">Företagsnamn</Label>
|
||||
<Input
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
defaultValue={settings?.company_name || ''}
|
||||
disabled={settings?.onboarding_complete === true}
|
||||
/>
|
||||
{settings?.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
name="org_number"
|
||||
defaultValue={settings?.org_number || ''}
|
||||
disabled={settings?.onboarding_complete === true}
|
||||
/>
|
||||
{settings?.onboarding_complete && (
|
||||
<p className="text-xs text-muted-foreground">Kan inte ändras efter att kontot skapats</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Adress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
name="address_line1"
|
||||
defaultValue={settings?.address_line1 || ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
defaultValue={settings?.postal_code || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input
|
||||
id="city"
|
||||
name="city"
|
||||
defaultValue={settings?.city || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Bankuppgifter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bankuppgifter</CardTitle>
|
||||
<CardDescription>
|
||||
Betalningsuppgifter som visas på dina fakturor
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Bank</Label>
|
||||
<BankNameCombobox
|
||||
defaultValue={settings?.bank_name || ''}
|
||||
enableBankingEnabled={hasBankingExtension}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clearing_number">Clearing</Label>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
name="clearing_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXX"
|
||||
maxLength={5}
|
||||
defaultValue={settings?.clearing_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) {
|
||||
setClearingError(null)
|
||||
return
|
||||
}
|
||||
if (!/^\d{4,5}$/.test(val)) {
|
||||
setClearingError('Clearingnummer måste vara 4-5 siffror')
|
||||
} else {
|
||||
setClearingError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{clearingError && (
|
||||
<p className="text-xs text-destructive">{clearingError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
name="account_number"
|
||||
inputMode="numeric"
|
||||
placeholder="XXXXXXX"
|
||||
maxLength={12}
|
||||
defaultValue={settings?.account_number || ''}
|
||||
onChange={(e) => {
|
||||
e.target.value = e.target.value.replace(/\D/g, '')
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) {
|
||||
setAccountNumberError(null)
|
||||
return
|
||||
}
|
||||
if (!/^\d{6,12}$/.test(val)) {
|
||||
setAccountNumberError('Kontonummer måste vara 6-12 siffror')
|
||||
} else {
|
||||
setAccountNumberError(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{accountNumberError && (
|
||||
<p className="text-xs text-destructive">{accountNumberError}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-xs space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input
|
||||
id="bankgiro"
|
||||
name="bankgiro"
|
||||
placeholder="XXX-XXXX"
|
||||
defaultValue={settings?.bankgiro || ''}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (!val) {
|
||||
setBankgiroError(null)
|
||||
return
|
||||
}
|
||||
if (validateBankgiroNumber(val)) {
|
||||
e.target.value = formatBankgiroNumber(val)
|
||||
setBankgiroError(null)
|
||||
} else {
|
||||
setBankgiroError('Ogiltigt bankgironummer (7-8 siffror med kontrollsiffra)')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{bankgiroError && (
|
||||
<p className="text-xs text-destructive">{bankgiroError}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fakturainställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fakturainställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Numrering, betalningsvillkor och bokföringsmetod
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden enligt BFL. Mindre aktiebolag kan välja kontantmetoden.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_notes">Standardtext på fakturor</Label>
|
||||
<Textarea
|
||||
id="invoice_default_notes"
|
||||
name="invoice_default_notes"
|
||||
rows={3}
|
||||
placeholder="T.ex. betalningsvillkor, leveransinfo..."
|
||||
defaultValue={settings?.invoice_default_notes || ''}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Texten föreslås automatiskt i anteckningsfältet vid ny faktura.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Skatteinställningar */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skatteinställningar</CardTitle>
|
||||
<CardDescription>
|
||||
Preliminärskatt och F-skatt
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preliminary_tax_monthly">
|
||||
Månatlig preliminärskatt (F-skatt)
|
||||
</Label>
|
||||
<Input
|
||||
id="preliminary_tax_monthly"
|
||||
name="preliminary_tax_monthly"
|
||||
type="number"
|
||||
defaultValue={settings?.preliminary_tax_monthly || ''}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara ändringar'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
<CompanyMembersSection />
|
||||
</TabsContent>
|
||||
|
||||
{/* Team management */}
|
||||
<TabsContent value="team">
|
||||
<TeamPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* Banking settings — loaded dynamically from extension, hidden for sandbox */}
|
||||
{!settings?.is_sandbox && (
|
||||
<TabsContent value="banking" className="space-y-6">
|
||||
{bankConnectionError && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-destructive/20 bg-destructive/10 p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-destructive">{bankConnectionError}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link> istället.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBankConnectionError(null)}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Stäng"
|
||||
>
|
||||
<span className="text-lg leading-none">×</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasBankingExtension && BankingPanel ? (
|
||||
<BankingPanel />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CreditCard className="h-10 w-10 text-muted-foreground/40 mb-4" />
|
||||
<p className="font-medium mb-1">Bankintegration (PSD2) är inte aktiverad</p>
|
||||
<p className="text-sm text-muted-foreground mb-4 max-w-md">
|
||||
Aktivera tillägget Enable Banking för att koppla ditt bankkonto och automatiskt hämta transaktioner.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/extensions">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Gå till Tillägg
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Counterparty templates */}
|
||||
<TabsContent value="templates">
|
||||
<CounterpartyTemplatesPanel />
|
||||
</TabsContent>
|
||||
|
||||
{/* API keys */}
|
||||
{hasMcpExtension && (
|
||||
<TabsContent value="api">
|
||||
<ApiKeysPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Account — security, appearance, calendar, logout, delete */}
|
||||
<TabsContent value="account" className="space-y-6">
|
||||
{/* Appearance */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Utseende</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mounted && (
|
||||
<div className="flex gap-3">
|
||||
{([
|
||||
{ value: 'light', label: 'Ljust', icon: Sun },
|
||||
{ value: 'dark', label: 'Mörkt', icon: Moon },
|
||||
{ value: 'system', label: 'System', icon: Monitor },
|
||||
] as const).map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`flex items-center gap-2 rounded-lg border-2 px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||
theme === value
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/40'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<SecuritySettings />
|
||||
|
||||
{/* Calendar feed */}
|
||||
{hasCalendarExtension && <CalendarFeedSettings />}
|
||||
|
||||
{/* Logout & delete */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kontoinställningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">Logga ut</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Logga ut från ditt konto
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Logga ut
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!settings?.is_sandbox && <Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Radera konto</CardTitle>
|
||||
<CardDescription>
|
||||
Permanent borttagning av ditt konto och all data
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4">
|
||||
<div className="flex gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="space-y-2 text-sm">
|
||||
<p className="font-medium text-destructive">Varning: Denna åtgärd kan inte ångras</p>
|
||||
<p className="text-muted-foreground">
|
||||
All din data raderas permanent — bokföring, fakturor, verifikationer, dokument och inställningar.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Enligt bokföringslagen (BFL 7 kap. 2§) ska räkenskapsinformation bevaras i 7 år. Du ansvarar själv för att exportera och arkivera din bokföringsdata innan du raderar kontot.
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Har du frågor?{' '}
|
||||
<SupportLink variant="inline" subject="Fråga om kontoradering" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Button variant="outline" className="w-full sm:w-auto min-h-11" asChild>
|
||||
<Link href="/reports?type=sie">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Exportera bokföringsdata (SIE)
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full sm:w-auto min-h-11"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Radera mitt konto
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Delete account confirmation dialog */}
|
||||
<Dialog open={showDeleteDialog} onOpenChange={(open) => {
|
||||
setShowDeleteDialog(open)
|
||||
if (!open) setDeleteConfirmText('')
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Radera konto permanent</DialogTitle>
|
||||
<DialogDescription>
|
||||
All din data raderas permanent. Skriv <strong>RADERA</strong> nedan för att bekräfta.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-confirm">Bekräfta genom att skriva RADERA</Label>
|
||||
<Input
|
||||
id="delete-confirm"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="RADERA"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteConfirmText('')
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteAccount}
|
||||
disabled={deleteConfirmText !== 'RADERA' || isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Raderar...
|
||||
</>
|
||||
) : (
|
||||
'Radera permanent'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
redirect('/settings/company')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { TaxSettingsForm } from '@/components/settings/TaxSettingsForm'
|
||||
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
|
||||
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
import type { CompanySettings } from '@/types'
|
||||
|
||||
export default function TaxSettingsPage() {
|
||||
const { settings, isLoading, updateSettings } = useSettings()
|
||||
|
||||
if (isLoading || !settings) return <SettingsLoadingSkeleton />
|
||||
|
||||
function handleSave(formData: FormData) {
|
||||
const updates: Record<string, unknown> = {
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
}
|
||||
updateSettings(updates as Partial<CompanySettings>)
|
||||
return updates
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsFormWrapper onSave={handleSave} className="space-y-8">
|
||||
<TaxSettingsForm settings={settings} />
|
||||
</SettingsFormWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { TeamPanel } from '@/components/settings/TeamPanel'
|
||||
|
||||
export default function TeamSettingsPage() {
|
||||
return <TeamPanel />
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
|
||||
|
||||
export default function TemplatesSettingsPage() {
|
||||
return <CounterpartyTemplatesPanel />
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, InvoiceInboxItem, EntityType, LinePatternEntry } from '@/types'
|
||||
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
||||
|
||||
interface QuickReviewState {
|
||||
transaction: TransactionWithInvoice
|
||||
@@ -814,7 +813,7 @@ export default function TransactionsPage() {
|
||||
onMarkPrivate={handleMarkPrivate}
|
||||
onOpenMatchDialog={openMatchDialog}
|
||||
onOpenCategoryDialog={openCategoryDialog}
|
||||
onOpenDescribe={ENABLED_EXTENSION_IDS.has('ai-categorization') ? openDescribeDialog : undefined}
|
||||
onOpenDescribe={openDescribeDialog}
|
||||
onOpenQuickReview={handleOpenQuickReview}
|
||||
onOpenTemplateReview={handleOpenTemplateReview}
|
||||
onToggleSelect={toggleBatchSelect}
|
||||
|
||||
@@ -108,23 +108,6 @@ export default function PrivacyPolicyPage() {
|
||||
<td className="py-2 pr-4">Globalt CDN (EU-regioner tillgängliga)</td>
|
||||
<td className="py-2">EU Data Residency</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Anthropic</td>
|
||||
<td className="py-2 pr-4">
|
||||
Kvitto-OCR (receipt-ocr), transaktionskategorisering (ai-categorization),
|
||||
AI-chattassistent (ai-chat)
|
||||
</td>
|
||||
<td className="py-2 pr-4">USA</td>
|
||||
<td className="py-2">SCCs (standardavtalsklausuler)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">OpenAI</td>
|
||||
<td className="py-2 pr-4">
|
||||
Embedding-generering för likhetssökning (transaktionsmallar, kunskapsbas)
|
||||
</td>
|
||||
<td className="py-2 pr-4">USA</td>
|
||||
<td className="py-2">SCCs (standardavtalsklausuler)</td>
|
||||
</tr>
|
||||
<tr className="border-b">
|
||||
<td className="py-2 pr-4 font-medium">Enable Banking</td>
|
||||
<td className="py-2 pr-4">PSD2-bankkontouppkoppling</td>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) return authError
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
if (!aiExt?.services?.seedAllTemplateEmbeddings || !aiExt?.services?.getSchemaVersion) {
|
||||
return NextResponse.json(
|
||||
{ error: 'ai-categorization extension not loaded' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const { seeded, errors } = await aiExt.services.seedAllTemplateEmbeddings()
|
||||
const schemaVersion = await aiExt.services.getSchemaVersion()
|
||||
|
||||
return NextResponse.json({
|
||||
success: errors.length === 0,
|
||||
seeded,
|
||||
errors,
|
||||
schema_version: schemaVersion,
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: `Seeding failed: ${error instanceof Error ? error.message : 'Unknown error'}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/extensions/ai-consent', () => ({
|
||||
AI_EXTENSIONS: ['receipt-ocr', 'ai-categorization', 'ai-chat'],
|
||||
hasAiConsent: vi.fn(),
|
||||
grantAiConsent: vi.fn(),
|
||||
revokeAiConsent: vi.fn(),
|
||||
isAiExtension: vi.fn((id: string) =>
|
||||
['receipt-ocr', 'ai-categorization', 'ai-chat'].includes(id)
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { hasAiConsent, grantAiConsent, revokeAiConsent } from '@/lib/extensions/ai-consent'
|
||||
import { GET, POST, DELETE } from '../route'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockHasAiConsent = vi.mocked(hasAiConsent)
|
||||
const mockGrantAiConsent = vi.mocked(grantAiConsent)
|
||||
const mockRevokeAiConsent = vi.mocked(revokeAiConsent)
|
||||
|
||||
function mockAuth(userId: string | null) {
|
||||
mockCreateClient.mockResolvedValue({
|
||||
auth: {
|
||||
getUser: vi.fn().mockResolvedValue({
|
||||
data: { user: userId ? { id: userId } : null },
|
||||
}),
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/ai-consent', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
const { status } = await parseJsonResponse(await GET())
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns consent status for all AI extensions', async () => {
|
||||
mockAuth('user-1')
|
||||
mockHasAiConsent
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ data: Record<string, boolean> }>(
|
||||
await GET()
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data).toEqual({
|
||||
'receipt-ocr': true,
|
||||
'ai-categorization': false,
|
||||
'ai-chat': true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/ai-consent', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
body: { extension_id: 'receipt-ocr' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('grants consent for valid AI extension', async () => {
|
||||
mockAuth('user-1')
|
||||
mockGrantAiConsent.mockResolvedValue(undefined)
|
||||
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
body: { extension_id: 'receipt-ocr' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
|
||||
await POST(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.consented).toBe(true)
|
||||
expect(mockGrantAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'receipt-ocr')
|
||||
})
|
||||
|
||||
it('returns 400 for non-AI extension', async () => {
|
||||
mockAuth('user-1')
|
||||
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'POST',
|
||||
body: { extension_id: 'enable-banking' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await POST(req))
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/ai-consent', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth(null)
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'DELETE',
|
||||
body: { extension_id: 'ai-chat' },
|
||||
})
|
||||
const { status } = await parseJsonResponse(await DELETE(req))
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('revokes consent for valid AI extension', async () => {
|
||||
mockAuth('user-1')
|
||||
mockRevokeAiConsent.mockResolvedValue(undefined)
|
||||
|
||||
const req = createMockRequest('/api/ai-consent', {
|
||||
method: 'DELETE',
|
||||
body: { extension_id: 'ai-chat' },
|
||||
})
|
||||
const { status, body } = await parseJsonResponse<{ data: { consented: boolean } }>(
|
||||
await DELETE(req)
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.consented).toBe(false)
|
||||
expect(mockRevokeAiConsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', 'ai-chat')
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
AI_EXTENSIONS,
|
||||
hasAiConsent,
|
||||
grantAiConsent,
|
||||
revokeAiConsent,
|
||||
isAiExtension,
|
||||
} from '@/lib/extensions/ai-consent'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const statuses: Record<string, boolean> = {}
|
||||
for (const ext of AI_EXTENSIONS) {
|
||||
statuses[ext] = await hasAiConsent(supabase, companyId, ext)
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: statuses })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body = await request.json()
|
||||
const { extension_id } = body
|
||||
|
||||
if (!extension_id || !isAiExtension(extension_id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or non-AI extension_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await grantAiConsent(supabase, user.id, companyId, extension_id)
|
||||
return NextResponse.json({ data: { consented: true } })
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const body = await request.json()
|
||||
const { extension_id } = body
|
||||
|
||||
if (!extension_id || !isAiExtension(extension_id)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or non-AI extension_id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await revokeAiConsent(supabase, user.id, companyId, extension_id)
|
||||
return NextResponse.json({ data: { consented: false } })
|
||||
}
|
||||
@@ -48,7 +48,42 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: durationError }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check for overlapping periods
|
||||
// Enforce continuity: new period must chain from the latest existing period (BFL 3:1)
|
||||
const { data: latest } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('period_end, is_closed')
|
||||
.eq('company_id', companyId)
|
||||
.order('period_end', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (latest) {
|
||||
const prev = new Date(latest.period_end + 'T00:00:00')
|
||||
prev.setDate(prev.getDate() + 1)
|
||||
const expectedStart = prev.toISOString().split('T')[0]
|
||||
if (body.period_start !== expectedStart) {
|
||||
return NextResponse.json(
|
||||
{ error: `Period must start on ${expectedStart} (day after latest period ends)` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce: max one unclosed period (no skipping ahead)
|
||||
const { count: openCount } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_closed', false)
|
||||
|
||||
if (openCount && openCount > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot create a new period while an unclosed period exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
// Defense-in-depth: check for overlapping periods
|
||||
const { data: overlapping } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name')
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
// Mock supabase
|
||||
const mockFrom = vi.fn()
|
||||
const mockRpc = vi.fn()
|
||||
const mockAuth = vi.fn()
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn().mockResolvedValue({
|
||||
from: (...args: unknown[]) => mockFrom(...args),
|
||||
rpc: (...args: unknown[]) => mockRpc(...args),
|
||||
auth: { getUser: () => mockAuth() },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
import { GET, POST } from '../route'
|
||||
|
||||
function mockChain(result: { data?: unknown; error?: unknown; count?: number | null }) {
|
||||
const chain: Record<string, unknown> = {}
|
||||
for (const m of ['select', 'eq', 'neq', 'in', 'insert', 'update', 'upsert', 'order', 'limit', 'single', 'maybeSingle']) {
|
||||
chain[m] = vi.fn().mockReturnValue(chain)
|
||||
}
|
||||
chain.single = vi.fn().mockResolvedValue(result)
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(result)
|
||||
// Make the chain thenable so await resolves to result
|
||||
chain.then = (resolve: (v: unknown) => void) => resolve(result)
|
||||
return chain
|
||||
}
|
||||
|
||||
function makeRequest(url: string, options?: RequestInit) {
|
||||
return new Request(url, options)
|
||||
}
|
||||
|
||||
async function parseJson(response: Response) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET /api/bookkeeping/voucher-gaps', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=fp-1')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
})
|
||||
|
||||
it('returns 400 when fiscal_period_id is missing', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps')
|
||||
const response = await GET(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns empty result when no voucher sequences exist', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
mockFrom.mockReturnValue(mockChain({ data: [], error: null }))
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.gaps).toEqual([])
|
||||
expect(body.data.totalGaps).toBe(0)
|
||||
expect(body.data.unexplainedGaps).toBe(0)
|
||||
})
|
||||
|
||||
it('returns gaps with explanations', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
let fromCallCount = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
fromCallCount++
|
||||
if (fromCallCount === 1) {
|
||||
// voucher_sequences query
|
||||
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
|
||||
}
|
||||
if (fromCallCount === 2) {
|
||||
// gap_explanations query
|
||||
return mockChain({
|
||||
data: [{ id: 'exp-1', voucher_series: 'A', gap_start: 3, gap_end: 3, explanation: 'Transient error', user_id: 'user-1', created_at: '2026-01-01' }],
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
return mockChain({ data: null, error: null })
|
||||
})
|
||||
|
||||
mockRpc.mockResolvedValue({
|
||||
data: [{ gap_start: 3, gap_end: 3 }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.totalGaps).toBe(1)
|
||||
expect(body.data.unexplainedGaps).toBe(0)
|
||||
expect(body.data.gaps[0].explanation).toBeTruthy()
|
||||
expect(body.data.gaps[0].explanation.explanation).toBe('Transient error')
|
||||
})
|
||||
|
||||
it('returns unexplained gaps', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
let fromCallCount = 0
|
||||
mockFrom.mockImplementation(() => {
|
||||
fromCallCount++
|
||||
if (fromCallCount === 1) {
|
||||
return mockChain({ data: [{ voucher_series: 'A' }], error: null })
|
||||
}
|
||||
if (fromCallCount === 2) {
|
||||
return mockChain({ data: [], error: null })
|
||||
}
|
||||
return mockChain({ data: null, error: null })
|
||||
})
|
||||
|
||||
mockRpc.mockResolvedValue({
|
||||
data: [{ gap_start: 5, gap_end: 7 }],
|
||||
error: null,
|
||||
})
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps?fiscal_period_id=f47ac10b-58cc-4372-a567-0e02b2c3d479')
|
||||
const response = await GET(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.totalGaps).toBe(1)
|
||||
expect(body.data.unexplainedGaps).toBe(1)
|
||||
expect(body.data.gaps[0].explanation).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/bookkeeping/voucher-gaps', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: null } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Transient DB error during commit',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when explanation is empty', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: '',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('saves gap explanation successfully', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
const savedRow = {
|
||||
id: 'exp-1',
|
||||
company_id: 'company-1',
|
||||
user_id: 'user-1',
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
voucher_series: 'A',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Failed commit during bank sync',
|
||||
created_at: '2026-04-01',
|
||||
updated_at: '2026-04-01',
|
||||
}
|
||||
|
||||
mockFrom.mockReturnValue(mockChain({ data: savedRow, error: null }))
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Failed commit during bank sync',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data.explanation).toBe('Failed commit during bank sync')
|
||||
})
|
||||
|
||||
it('returns 403 when user lacks permission', async () => {
|
||||
mockAuth.mockResolvedValue({ data: { user: { id: 'user-1' } } })
|
||||
|
||||
mockFrom.mockReturnValue(mockChain({ data: null, error: { code: '42501', message: 'permission denied' } }))
|
||||
|
||||
const request = makeRequest('http://localhost/api/bookkeeping/voucher-gaps', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fiscal_period_id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
explanation: 'Test explanation',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
const response = await POST(request)
|
||||
const body = await parseJson(response)
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(body.error).toContain('owners and admins')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBody, validateQuery } from '@/lib/api/validate'
|
||||
import { VoucherGapQuerySchema, SaveGapExplanationSchema } from '@/lib/api/schemas'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = validateQuery(request, VoucherGapQuerySchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series } = validation.data
|
||||
|
||||
// Get all series used in this period (or filter to specific series)
|
||||
let seriesQuery = supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscal_period_id)
|
||||
|
||||
if (voucher_series) {
|
||||
seriesQuery = seriesQuery.eq('voucher_series', voucher_series)
|
||||
}
|
||||
|
||||
const { data: seriesRows } = await seriesQuery
|
||||
|
||||
if (!seriesRows || seriesRows.length === 0) {
|
||||
return NextResponse.json({
|
||||
data: { gaps: [], totalGaps: 0, unexplainedGaps: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
// Detect gaps per series
|
||||
const allGaps: Array<{
|
||||
series: string
|
||||
gap_start: number
|
||||
gap_end: number
|
||||
explanation: { id: string; explanation: string; user_id: string; created_at: string } | null
|
||||
}> = []
|
||||
|
||||
for (const row of seriesRows) {
|
||||
const { data: gaps, error: gapsError } = await supabase.rpc('detect_voucher_gaps', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: fiscal_period_id,
|
||||
p_series: row.voucher_series,
|
||||
})
|
||||
|
||||
if (!gapsError && gaps && gaps.length > 0) {
|
||||
for (const gap of gaps as Array<{ gap_start: number; gap_end: number }>) {
|
||||
allGaps.push({
|
||||
series: row.voucher_series,
|
||||
gap_start: gap.gap_start,
|
||||
gap_end: gap.gap_end,
|
||||
explanation: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch existing explanations and match them
|
||||
if (allGaps.length > 0) {
|
||||
const { data: explanations } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.select('id, voucher_series, gap_start, gap_end, explanation, user_id, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('fiscal_period_id', fiscal_period_id)
|
||||
|
||||
if (explanations) {
|
||||
const explanationMap = new Map(
|
||||
explanations.map((e) => [`${e.voucher_series}:${e.gap_start}:${e.gap_end}`, e])
|
||||
)
|
||||
|
||||
for (const gap of allGaps) {
|
||||
const key = `${gap.series}:${gap.gap_start}:${gap.gap_end}`
|
||||
const match = explanationMap.get(key)
|
||||
if (match) {
|
||||
gap.explanation = {
|
||||
id: match.id,
|
||||
explanation: match.explanation,
|
||||
user_id: match.user_id,
|
||||
created_at: match.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unexplained = allGaps.filter((g) => !g.explanation).length
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
gaps: allGaps,
|
||||
totalGaps: allGaps.length,
|
||||
unexplainedGaps: unexplained,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const validation = await validateBody(request, SaveGapExplanationSchema)
|
||||
if (!validation.success) return validation.response
|
||||
const { fiscal_period_id, voucher_series, gap_start, gap_end, explanation } = validation.data
|
||||
|
||||
// Upsert explanation (RLS enforces owner/admin role)
|
||||
const { data, error } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.upsert(
|
||||
{
|
||||
company_id: companyId,
|
||||
user_id: user.id,
|
||||
fiscal_period_id,
|
||||
voucher_series,
|
||||
gap_start,
|
||||
gap_end,
|
||||
explanation,
|
||||
},
|
||||
{ onConflict: 'company_id,fiscal_period_id,voucher_series,gap_start,gap_end' }
|
||||
)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '42501') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only company owners and admins can document gap explanations' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -291,7 +291,7 @@ async function sendConsentExpiryNotification(
|
||||
const emailData = {
|
||||
bankName: connection.bank_name as string,
|
||||
daysUntilExpiry: daysLeft,
|
||||
renewalUrl: `${baseUrl}/settings?tab=banking`,
|
||||
renewalUrl: `${baseUrl}/settings/banking`,
|
||||
companyName: companySettings?.company_name || 'gnubok',
|
||||
isExpired,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { hasAiConsent, isAiExtension } from '@/lib/extensions/ai-consent'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { ApiRouteDefinition } from '@/lib/extensions/types'
|
||||
|
||||
@@ -43,7 +42,7 @@ function matchPath(
|
||||
* Catch-all route for extension-declared API routes.
|
||||
*
|
||||
* URL scheme: /api/extensions/ext/{extensionId}/{...routePath}
|
||||
* Example: /api/extensions/ext/receipt-ocr/abc123/confirm → POST /:id/confirm
|
||||
* Example: /api/extensions/ext/mcp-server/mcp → POST /mcp
|
||||
*
|
||||
* - Looks up the extension in the registry
|
||||
* - Checks the extension toggle (disabled → 403)
|
||||
@@ -120,17 +119,6 @@ async function handleRequest(
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
// AI consent check
|
||||
if (isAiExtension(extensionId)) {
|
||||
const consented = await hasAiConsent(supabase, companyId, extensionId)
|
||||
if (!consented) {
|
||||
return NextResponse.json(
|
||||
{ error: 'AI consent required', code: 'AI_CONSENT_REQUIRED' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// If path params were extracted, create a new Request with them as search params
|
||||
let handlerRequest = request
|
||||
if (Object.keys(extractedParams).length > 0) {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createQueuedMockSupabase, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
// Mock server-only
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
// Hoisted mocks to avoid reference-before-initialization
|
||||
const { mockVerify, mockServiceClientFn } = vi.hoisted(() => {
|
||||
return {
|
||||
mockVerify: vi.fn(),
|
||||
mockServiceClientFn: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock svix
|
||||
vi.mock('svix', () => ({
|
||||
Webhook: class MockWebhook {
|
||||
verify = mockVerify
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Supabase SSR
|
||||
vi.mock('@supabase/ssr', () => ({
|
||||
createServerClient: (...args: unknown[]) => mockServiceClientFn(...args),
|
||||
}))
|
||||
|
||||
// Mock email handler
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/email-handler', () => ({
|
||||
parseInboundPayload: vi.fn(),
|
||||
extractAttachments: vi.fn(),
|
||||
resolveUserFromEmail: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock unified document analyzer
|
||||
vi.mock('@/lib/ai/document-analyzer', () => ({
|
||||
analyzeDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock supplier matcher
|
||||
vi.mock('@/extensions/general/invoice-inbox/lib/supplier-matcher', () => ({
|
||||
matchSupplier: vi.fn(),
|
||||
}))
|
||||
|
||||
// Mock receipt pipeline
|
||||
vi.mock('@/extensions/general/receipt-ocr/lib/receipt-pipeline', () => ({
|
||||
processReceiptFromDocument: vi.fn(),
|
||||
}))
|
||||
|
||||
import { POST } from '../route'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
|
||||
const mockParseInboundPayload = vi.mocked(parseInboundPayload)
|
||||
const mockExtractAttachments = vi.mocked(extractAttachments)
|
||||
const mockResolveUserFromEmail = vi.mocked(resolveUserFromEmail)
|
||||
|
||||
describe('Invoice Inbox Webhook Route', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.RESEND_WEBHOOK_SECRET = 'test-secret'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://localhost:54321'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'test-key'
|
||||
})
|
||||
|
||||
function makeWebhookRequest(body: unknown = {}) {
|
||||
const bodyStr = JSON.stringify(body)
|
||||
return new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'svix-id': 'msg_test123',
|
||||
'svix-timestamp': String(Math.floor(Date.now() / 1000)),
|
||||
'svix-signature': 'v1,test-signature',
|
||||
},
|
||||
body: bodyStr,
|
||||
})
|
||||
}
|
||||
|
||||
it('returns 400 when webhook headers are missing', async () => {
|
||||
const request = new Request('http://localhost:3000/api/extensions/invoice-inbox/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
})
|
||||
|
||||
const response = await POST(request)
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 401 when signature verification fails', async () => {
|
||||
mockVerify.mockImplementation(() => {
|
||||
throw new Error('Invalid signature')
|
||||
})
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'test@test.com', to: 'inbox@co.com' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 when payload is invalid', async () => {
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue(null)
|
||||
|
||||
const response = await POST(makeWebhookRequest({}))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404 when user not found for email', async () => {
|
||||
const { supabase } = createQueuedMockSupabase()
|
||||
mockServiceClientFn.mockReturnValue(supabase)
|
||||
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue({
|
||||
from: 'supplier@test.com',
|
||||
to: 'unknown@inbox.com',
|
||||
subject: 'Invoice',
|
||||
html: null,
|
||||
text: null,
|
||||
attachments: [],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
mockResolveUserFromEmail.mockResolvedValue(null)
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'unknown@inbox.com' }))
|
||||
const { status } = await parseJsonResponse(response)
|
||||
expect(status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns success with 0 processed when no attachments', async () => {
|
||||
const { supabase, enqueueMany } = createQueuedMockSupabase()
|
||||
mockServiceClientFn.mockReturnValue(supabase)
|
||||
|
||||
mockVerify.mockReturnValue(undefined)
|
||||
mockParseInboundPayload.mockReturnValue({
|
||||
from: 'supplier@test.com',
|
||||
to: 'inbox@myco.com',
|
||||
subject: 'No attachments',
|
||||
html: null,
|
||||
text: null,
|
||||
attachments: [],
|
||||
created_at: '2024-06-15T10:00:00Z',
|
||||
})
|
||||
mockExtractAttachments.mockReturnValue([])
|
||||
mockResolveUserFromEmail.mockResolvedValue({ userId: 'user-1', companyId: 'company-1' })
|
||||
|
||||
// Insert inbox item with error status
|
||||
enqueueMany([
|
||||
{ data: { id: 'item-1' }, error: null },
|
||||
])
|
||||
|
||||
const response = await POST(makeWebhookRequest({ from: 'supplier@test.com', to: 'inbox@myco.com' }))
|
||||
const { status, body } = await parseJsonResponse<{ data: { processed: number } }>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.processed).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,290 +0,0 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { Webhook } from 'svix'
|
||||
import { parseInboundPayload, extractAttachments, resolveUserFromEmail } from '@/extensions/general/invoice-inbox/lib/email-handler'
|
||||
import { matchSupplier } from '@/extensions/general/invoice-inbox/lib/supplier-matcher'
|
||||
import { analyzeDocument } from '@/lib/ai/document-analyzer'
|
||||
import { processReceiptFromDocument } from '@/extensions/general/receipt-ocr/lib/receipt-pipeline'
|
||||
import crypto from 'crypto'
|
||||
|
||||
function createServiceClient() {
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() { return [] },
|
||||
setAll() { },
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build raw email payload for BFL 7 kap. 2§ archiving.
|
||||
* Includes full email headers and body — excludes binary attachment content.
|
||||
*/
|
||||
function buildRawEmailPayload(body: Record<string, unknown>, payload: { from: string; to: string; subject: string; created_at: string }): Record<string, unknown> {
|
||||
return {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
created_at: payload.created_at,
|
||||
text: body.text ?? null,
|
||||
html: body.html ?? null,
|
||||
headers: body.headers ?? null,
|
||||
message_id: body.message_id ?? null,
|
||||
in_reply_to: body.in_reply_to ?? null,
|
||||
references: body.references ?? null,
|
||||
archived_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify webhook signature
|
||||
const webhookSecret = process.env.RESEND_WEBHOOK_SECRET
|
||||
if (!webhookSecret) {
|
||||
console.error('[document-inbox] RESEND_WEBHOOK_SECRET not configured')
|
||||
return NextResponse.json({ error: 'Webhook not configured' }, { status: 500 })
|
||||
}
|
||||
|
||||
const svixId = request.headers.get('svix-id')
|
||||
const svixTimestamp = request.headers.get('svix-timestamp')
|
||||
const svixSignature = request.headers.get('svix-signature')
|
||||
|
||||
if (!svixId || !svixTimestamp || !svixSignature) {
|
||||
return NextResponse.json({ error: 'Missing webhook headers' }, { status: 400 })
|
||||
}
|
||||
|
||||
const rawBody = await request.text()
|
||||
|
||||
try {
|
||||
const wh = new Webhook(webhookSecret)
|
||||
wh.verify(rawBody, {
|
||||
'svix-id': svixId,
|
||||
'svix-timestamp': svixTimestamp,
|
||||
'svix-signature': svixSignature,
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body = JSON.parse(rawBody)
|
||||
const payload = parseInboundPayload(body)
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
|
||||
}
|
||||
|
||||
const supabase = createServiceClient()
|
||||
|
||||
// Resolve user from recipient email
|
||||
const resolved = await resolveUserFromEmail(payload.to, supabase)
|
||||
|
||||
if (!resolved) {
|
||||
console.warn(`[document-inbox] No user found for email: ${payload.to}`)
|
||||
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const { userId, companyId } = resolved
|
||||
|
||||
// Build raw email payload for BFL 7:2 archiving (no binary attachment content)
|
||||
const rawEmailPayload = buildRawEmailPayload(body, payload)
|
||||
|
||||
// Extract file attachments
|
||||
const attachments = extractAttachments(payload)
|
||||
|
||||
if (attachments.length === 0) {
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'error',
|
||||
source: 'email',
|
||||
email_from: payload.from,
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
error_message: 'No supported attachments found',
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: { processed: 0, message: 'No attachments' } })
|
||||
}
|
||||
|
||||
const processed: string[] = []
|
||||
|
||||
for (const attachment of attachments) {
|
||||
try {
|
||||
const buffer = Buffer.from(attachment.content, 'base64')
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex')
|
||||
|
||||
// Upload to storage
|
||||
const storagePath = `documents/${userId}/inbox/${Date.now()}-${attachment.filename}`
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, { contentType: attachment.content_type })
|
||||
|
||||
if (uploadError) {
|
||||
console.error('[document-inbox] Upload failed:', uploadError)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create document attachment
|
||||
const { data: document, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: attachment.filename,
|
||||
file_size_bytes: buffer.length,
|
||||
mime_type: attachment.content_type,
|
||||
sha256_hash: hash,
|
||||
upload_source: 'email',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (docError || !document) continue
|
||||
|
||||
// Unified classify + extract in a single Claude call
|
||||
let documentType: 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown' = 'supplier_invoice'
|
||||
let unifiedResult: Awaited<ReturnType<typeof analyzeDocument>> | null = null
|
||||
try {
|
||||
unifiedResult = await analyzeDocument(attachment.content, attachment.content_type)
|
||||
documentType = unifiedResult.classification.type
|
||||
console.log(`[document-inbox] Classified as ${documentType} (confidence: ${unifiedResult.classification.confidence})`)
|
||||
} catch (classifyErr) {
|
||||
console.error('[document-inbox] Classification failed, defaulting to supplier_invoice:', classifyErr)
|
||||
}
|
||||
|
||||
// Create inbox item with document type and raw email payload
|
||||
const { data: inboxItem, error: itemError } = await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.insert({
|
||||
company_id: companyId,
|
||||
user_id: userId,
|
||||
status: 'processing',
|
||||
source: 'email',
|
||||
email_from: payload.from,
|
||||
email_subject: payload.subject,
|
||||
email_received_at: payload.created_at,
|
||||
document_id: document.id,
|
||||
document_type: documentType,
|
||||
raw_email_payload: rawEmailPayload,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (itemError || !inboxItem) continue
|
||||
|
||||
// Route based on document type
|
||||
try {
|
||||
switch (documentType) {
|
||||
case 'supplier_invoice': {
|
||||
// Use pre-extracted invoice data from unified call
|
||||
const extraction = unifiedResult?.invoice
|
||||
if (!extraction) {
|
||||
throw new Error('No invoice extraction available')
|
||||
}
|
||||
|
||||
const isReverseCharge = unifiedResult?.classification.isReverseCharge ?? false
|
||||
|
||||
// Store reverse charge flag in extracted data
|
||||
const extractedData = {
|
||||
...(extraction as unknown as Record<string, unknown>),
|
||||
isReverseCharge,
|
||||
}
|
||||
|
||||
// Supplier matching
|
||||
let matchedSupplierId: string | null = null
|
||||
const { data: suppliers } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (suppliers && suppliers.length > 0) {
|
||||
const match = matchSupplier(extraction, suppliers)
|
||||
if (match && match.confidence >= 0.7) {
|
||||
matchedSupplierId = match.supplierId
|
||||
}
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: extractedData,
|
||||
confidence: extraction.confidence,
|
||||
matched_supplier_id: matchedSupplierId,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'receipt': {
|
||||
// Use pre-extracted receipt data from unified call
|
||||
const { data: urlData } = supabase.storage.from('documents').getPublicUrl(storagePath)
|
||||
|
||||
const result = await processReceiptFromDocument(supabase, userId, companyId, attachment.content, attachment.content_type, {
|
||||
documentId: document.id,
|
||||
source: 'email',
|
||||
emailFrom: payload.from,
|
||||
storageUrl: urlData.publicUrl,
|
||||
preExtracted: unifiedResult?.receipt ?? undefined,
|
||||
})
|
||||
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
linked_receipt_id: result.receipt.id,
|
||||
confidence: result.receipt.extraction_confidence,
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'government_letter': {
|
||||
// Store with status ready for manual review
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({
|
||||
status: 'ready',
|
||||
extracted_data: {
|
||||
sender: payload.from,
|
||||
subject: payload.subject,
|
||||
body: typeof body.text === 'string' ? body.text : null,
|
||||
},
|
||||
})
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'unknown':
|
||||
default: {
|
||||
// Store with status ready for manual handling
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'ready' })
|
||||
.eq('id', inboxItem.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Processing failed'
|
||||
await supabase
|
||||
.from('invoice_inbox_items')
|
||||
.update({ status: 'error', error_message: message })
|
||||
.eq('id', inboxItem.id)
|
||||
}
|
||||
|
||||
processed.push(inboxItem.id)
|
||||
} catch (err) {
|
||||
console.error('[document-inbox] Processing attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { processed: processed.length, ids: processed } })
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
calculateFileHash,
|
||||
} from '@/lib/import/sie-parser'
|
||||
import { suggestMappings, getMappingStats, isSystemAccount } from '@/lib/import/account-mapper'
|
||||
import { generateImportPreview, checkDuplicateImport } from '@/lib/import/sie-import'
|
||||
import { generateImportPreview, checkDuplicateImport, checkDuplicatePeriodImport } from '@/lib/import/sie-import'
|
||||
import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-data'
|
||||
import type { SIEAccountMappingRecord } from '@/lib/import/types'
|
||||
|
||||
@@ -55,12 +55,12 @@ export async function POST(request: Request) {
|
||||
// Decode to string
|
||||
const content = decodeBuffer(arrayBuffer, encoding)
|
||||
|
||||
// Check for duplicate import
|
||||
const duplicate = await checkDuplicateImport(supabase, user.id, content)
|
||||
// Check for duplicate import (by file hash)
|
||||
const duplicate = await checkDuplicateImport(supabase, companyId, content)
|
||||
if (duplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate',
|
||||
message: `This file has already been imported on ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
|
||||
message: `Denna fil har redan importerats ${duplicate.imported_at ? new Date(duplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'}`,
|
||||
importId: duplicate.id,
|
||||
}, { status: 409 })
|
||||
}
|
||||
@@ -68,6 +68,23 @@ export async function POST(request: Request) {
|
||||
// Parse the SIE file
|
||||
const parsed = parseSIEFile(content)
|
||||
|
||||
// Check for existing import covering the same fiscal period
|
||||
if (parsed.stats.fiscalYearStart && parsed.stats.fiscalYearEnd) {
|
||||
const periodDuplicate = await checkDuplicatePeriodImport(
|
||||
supabase,
|
||||
companyId,
|
||||
parsed.stats.fiscalYearStart,
|
||||
parsed.stats.fiscalYearEnd
|
||||
)
|
||||
if (periodDuplicate) {
|
||||
return NextResponse.json({
|
||||
error: 'duplicate_period',
|
||||
message: `En SIE-import för perioden ${parsed.stats.fiscalYearStart} – ${parsed.stats.fiscalYearEnd} finns redan (importerad ${periodDuplicate.imported_at ? new Date(periodDuplicate.imported_at).toLocaleDateString('sv-SE') : 'okänt datum'})`,
|
||||
importId: periodDuplicate.id,
|
||||
}, { status: 409 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the parsed data
|
||||
const validation = validateSIEFile(parsed)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { validateBalanceContinuity } from '@/lib/reports/continuity-check'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
/**
|
||||
* GET: Validate IB/UB continuity for a fiscal period.
|
||||
* Query param: period_id (required)
|
||||
*/
|
||||
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 companyId = await requireCompanyId(supabase, user.id)
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const periodId = searchParams.get('period_id')
|
||||
|
||||
if (!periodId) {
|
||||
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await validateBalanceContinuity(supabase, companyId, periodId)
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
{ error: err instanceof Error ? err.message : 'Failed to validate continuity' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
|
||||
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'Ingen fil angiven' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: 'Otillåten filtyp. Tillåtna: PNG, JPG, SVG, WebP.' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
return NextResponse.json({ error: 'Filen är för stor (max 2 MB).' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
const ext = file.name.split('.').pop() || 'png'
|
||||
const storagePath = `logos/${companyId}/logo.${ext}`
|
||||
|
||||
// Upload (upsert to replace existing)
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.upload(storagePath, buffer, {
|
||||
contentType: file.type,
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: `Uppladdning misslyckades: ${uploadError.message}` }, { status: 500 })
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage
|
||||
.from('documents')
|
||||
.getPublicUrl(storagePath)
|
||||
|
||||
// Update company settings
|
||||
const { error: updateError } = await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: urlData.publicUrl })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera inställningar' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { logo_url: urlData.publicUrl } })
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
|
||||
const companyId = await requireCompanyId(supabase, user.id)
|
||||
if (!companyId) return NextResponse.json({ error: 'No company' }, { status: 403 })
|
||||
|
||||
// Get current logo path
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('logo_url')
|
||||
.eq('company_id', companyId)
|
||||
.single()
|
||||
|
||||
if (settings?.logo_url) {
|
||||
// Extract storage path from URL
|
||||
const url = new URL(settings.logo_url)
|
||||
const pathMatch = url.pathname.match(/\/object\/public\/documents\/(.+)/)
|
||||
if (pathMatch) {
|
||||
await supabase.storage.from('documents').remove([pathMatch[1]])
|
||||
}
|
||||
}
|
||||
|
||||
// Clear logo_url
|
||||
await supabase
|
||||
.from('company_settings')
|
||||
.update({ logo_url: null })
|
||||
.eq('company_id', companyId)
|
||||
|
||||
return NextResponse.json({ data: { logo_url: null } })
|
||||
}
|
||||
@@ -14,21 +14,13 @@ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
|
||||
vi.mock('@/lib/bookkeeping/counterparty-templates', () => ({
|
||||
findCounterpartyTemplate: vi.fn().mockResolvedValue(null),
|
||||
buildMappingResultFromCounterpartyTemplate: vi.fn(),
|
||||
formatCounterpartyName: vi.fn((name: string) => name),
|
||||
}))
|
||||
|
||||
// Mock extension registry
|
||||
const mockFindSimilarTemplates = vi.fn().mockResolvedValue([])
|
||||
vi.mock('@/lib/extensions/registry', () => ({
|
||||
extensionRegistry: {
|
||||
get: vi.fn().mockReturnValue({
|
||||
id: 'ai-categorization',
|
||||
name: 'AI',
|
||||
version: '1.0.0',
|
||||
services: {
|
||||
findSimilarTemplates: (...args: unknown[]) => mockFindSimilarTemplates(...args),
|
||||
},
|
||||
}),
|
||||
},
|
||||
// Mock booking templates
|
||||
const mockFindMatchingTemplates = vi.fn().mockReturnValue([])
|
||||
vi.mock('@/lib/bookkeeping/booking-templates', () => ({
|
||||
findMatchingTemplates: (...args: unknown[]) => mockFindMatchingTemplates(...args),
|
||||
}))
|
||||
|
||||
// Mock Supabase
|
||||
@@ -121,7 +113,7 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
amount: -450,
|
||||
})
|
||||
|
||||
mockFindSimilarTemplates.mockResolvedValueOnce([
|
||||
mockFindMatchingTemplates.mockReturnValueOnce([
|
||||
{
|
||||
template: {
|
||||
id: 'restaurant_dining',
|
||||
@@ -131,6 +123,12 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
debit_account: '6071',
|
||||
credit_account: '1930',
|
||||
description_sv: 'Representation - restaurang',
|
||||
vat_rate: 0.12,
|
||||
vat_treatment: 'reduced_12',
|
||||
deductibility: 'conditional',
|
||||
deductibility_note_sv: null,
|
||||
special_rules_sv: null,
|
||||
risk_level: 'MEDIUM',
|
||||
},
|
||||
confidence: 0.82,
|
||||
},
|
||||
@@ -162,23 +160,16 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.templates).toHaveLength(1)
|
||||
expect(body.data.needs_more_detail).toBe(false)
|
||||
expect(body.data.ai_suggestion).toBeNull()
|
||||
expect(body.data.user_description).toBe('business lunch with client')
|
||||
expect(body.data.batch_candidate_count).toBe(3)
|
||||
expect(body.data.merchant_name).toBe('Restaurant XYZ')
|
||||
|
||||
// Verify findSimilarTemplates was called with the user description
|
||||
expect(mockFindSimilarTemplates).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tx-1' }),
|
||||
'enskild_firma',
|
||||
10,
|
||||
'business lunch with client'
|
||||
)
|
||||
})
|
||||
|
||||
it('sets needs_more_detail when confidence is low', async () => {
|
||||
const tx = makeTransaction({ id: 'tx-2', merchant_name: null })
|
||||
|
||||
mockFindSimilarTemplates.mockResolvedValueOnce([
|
||||
mockFindMatchingTemplates.mockReturnValueOnce([
|
||||
{
|
||||
template: {
|
||||
id: 'misc',
|
||||
@@ -188,6 +179,12 @@ describe('POST /api/transactions/[id]/describe', () => {
|
||||
debit_account: '6991',
|
||||
credit_account: '1930',
|
||||
description_sv: 'Okategoriserad utgift',
|
||||
vat_rate: 0,
|
||||
vat_treatment: null,
|
||||
deductibility: 'full',
|
||||
deductibility_note_sv: null,
|
||||
special_rules_sv: null,
|
||||
risk_level: 'LOW',
|
||||
},
|
||||
confidence: 0.4,
|
||||
},
|
||||
|
||||
@@ -3,73 +3,13 @@ import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { DescribeTransactionSchema } from '@/lib/api/schemas'
|
||||
import { extensionRegistry } from '@/lib/extensions/registry'
|
||||
import { findMatchingTemplates, type TemplateMatch } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findMatchingTemplates } from '@/lib/bookkeeping/booking-templates'
|
||||
import { findCounterpartyTemplate, buildMappingResultFromCounterpartyTemplate, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction, EntityType, VatTreatment } from '@/types'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
|
||||
interface DescriptionAnalysisInput {
|
||||
description: string
|
||||
transactionAmount: number
|
||||
transactionDate: string
|
||||
transactionDescription: string
|
||||
merchantName: string | null
|
||||
currency: string
|
||||
entityType: EntityType
|
||||
}
|
||||
|
||||
interface DescriptionAnalysisResult {
|
||||
debitAccount: string
|
||||
creditAccount: string
|
||||
vatTreatment: VatTreatment | null
|
||||
category: string
|
||||
confidence: number
|
||||
reasoning: string
|
||||
warnings: string[]
|
||||
templateId: string | null
|
||||
}
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
async function getTemplateMatches(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<TemplateMatch[]> {
|
||||
if (aiExt?.services?.findSimilarTemplates) {
|
||||
return aiExt.services.findSimilarTemplates(transaction, entityType, 10, description)
|
||||
}
|
||||
return findMatchingTemplates(transaction, entityType)
|
||||
}
|
||||
|
||||
async function getAiAnalysis(
|
||||
aiExt: Extension | undefined,
|
||||
transaction: Transaction,
|
||||
entityType: EntityType,
|
||||
description: string
|
||||
): Promise<DescriptionAnalysisResult | null> {
|
||||
if (!aiExt?.services?.analyzeDescription) return null
|
||||
|
||||
try {
|
||||
const input: DescriptionAnalysisInput = {
|
||||
description,
|
||||
transactionAmount: transaction.amount,
|
||||
transactionDate: transaction.date,
|
||||
transactionDescription: transaction.description,
|
||||
merchantName: transaction.merchant_name,
|
||||
currency: transaction.currency,
|
||||
entityType,
|
||||
}
|
||||
return await aiExt.services.analyzeDescription(input)
|
||||
} catch (error) {
|
||||
console.error('[describe] AI analysis failed, continuing with templates only:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
@@ -110,13 +50,10 @@ export async function POST(
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
const aiExt = extensionRegistry.get('ai-categorization')
|
||||
|
||||
// Run template matching, counterparty lookup, and AI analysis in parallel
|
||||
const [templates, counterpartyMatch, aiSuggestion] = await Promise.all([
|
||||
getTemplateMatches(aiExt, transaction as Transaction, entityType, description),
|
||||
// Run template matching and counterparty lookup in parallel
|
||||
const [templates, counterpartyMatch] = await Promise.all([
|
||||
findMatchingTemplates(transaction as Transaction, entityType),
|
||||
findCounterpartyTemplate(supabase, user.id, transaction as Transaction),
|
||||
getAiAnalysis(aiExt, transaction as Transaction, entityType, description),
|
||||
])
|
||||
|
||||
// Build counterparty suggestion if matched
|
||||
@@ -147,8 +84,7 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
// AI or counterparty match rescues weak templates
|
||||
const needsMoreDetail = (aiSuggestion || counterpartySuggestion)
|
||||
const needsMoreDetail = counterpartySuggestion
|
||||
? false
|
||||
: templates.length === 0 || templates[0].confidence < 0.55
|
||||
|
||||
@@ -185,16 +121,7 @@ export async function POST(
|
||||
risk_level: m.template.risk_level,
|
||||
})),
|
||||
counterparty_match: counterpartySuggestion,
|
||||
ai_suggestion: aiSuggestion ? {
|
||||
debit_account: aiSuggestion.debitAccount,
|
||||
credit_account: aiSuggestion.creditAccount,
|
||||
vat_treatment: aiSuggestion.vatTreatment,
|
||||
category: aiSuggestion.category,
|
||||
confidence: aiSuggestion.confidence,
|
||||
reasoning: aiSuggestion.reasoning,
|
||||
warnings: aiSuggestion.warnings,
|
||||
template_id: aiSuggestion.templateId,
|
||||
} : null,
|
||||
ai_suggestion: null,
|
||||
needs_more_detail: needsMoreDetail,
|
||||
user_description: description,
|
||||
batch_candidate_count: batchCandidateCount,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { getSuggestedCategories, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { findCounterpartyTemplatesBatch, formatCounterpartyName, toCounterpartyTemplateId } from '@/lib/bookkeeping/counterparty-templates'
|
||||
import { requireCompanyId } from '@/lib/company/context'
|
||||
import type { Transaction, EntityType } from '@/types'
|
||||
@@ -65,30 +65,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pre-computed AI suggestions for these transactions
|
||||
const aiKeys = ids.map((id: string) => `suggestion:${id}`)
|
||||
const { data: aiRecords } = await supabase
|
||||
.from('extension_data')
|
||||
.select('key, value')
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'ai-categorization')
|
||||
.in('key', aiKeys)
|
||||
|
||||
type AiSuggestion = { category: string; basAccount: string; confidence: number; reasoning: string }
|
||||
const aiSuggestionsMap: Record<string, AiSuggestion[]> = {}
|
||||
if (aiRecords) {
|
||||
for (const record of aiRecords) {
|
||||
const txId = record.key.replace('suggestion:', '')
|
||||
const value = record.value
|
||||
// Handle both single object (old) and array (new) storage formats
|
||||
if (Array.isArray(value)) {
|
||||
aiSuggestionsMap[txId] = value as AiSuggestion[]
|
||||
} else {
|
||||
aiSuggestionsMap[txId] = [value as AiSuggestion]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch entity type for template matching
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
@@ -105,19 +81,11 @@ export async function POST(request: Request) {
|
||||
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
|
||||
|
||||
for (const tx of transactions) {
|
||||
let result = getSuggestedCategories(
|
||||
suggestions[tx.id] = getSuggestedCategories(
|
||||
tx as Transaction,
|
||||
mappingRules || [],
|
||||
categoryHistory
|
||||
)
|
||||
|
||||
// Merge pre-computed AI suggestions if available
|
||||
const aiSuggestions = aiSuggestionsMap[tx.id]
|
||||
if (aiSuggestions && aiSuggestions.length > 0) {
|
||||
result = mergeAiSuggestions(result, aiSuggestions, tx.amount)
|
||||
}
|
||||
|
||||
suggestions[tx.id] = result
|
||||
template_suggestions[tx.id] = await getSuggestedTemplates(tx as Transaction, entityType, mappingRules || undefined)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user