diff --git a/app/(dashboard)/receipts/page.tsx b/app/(dashboard)/receipts/page.tsx index 4aad8998..aefd4cb6 100644 --- a/app/(dashboard)/receipts/page.tsx +++ b/app/(dashboard)/receipts/page.tsx @@ -1 +1 @@ -export { default } from '@/extensions/receipt-ocr/pages/ReceiptsPage' +export { default } from '@/extensions/general/receipt-ocr/pages/ReceiptsPage' diff --git a/app/(dashboard)/receipts/scan/page.tsx b/app/(dashboard)/receipts/scan/page.tsx index 3d99fe62..c185df95 100644 --- a/app/(dashboard)/receipts/scan/page.tsx +++ b/app/(dashboard)/receipts/scan/page.tsx @@ -1 +1 @@ -export { default } from '@/extensions/receipt-ocr/pages/scan/ScanReceiptPage' +export { default } from '@/extensions/general/receipt-ocr/pages/scan/ScanReceiptPage' diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 0068f4a4..a1072947 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -23,9 +23,14 @@ import { LogOut, Bell, Calendar, + Sun, + Moon, + Monitor, + Palette, } from 'lucide-react' +import { useTheme } from 'next-themes' import type { CompanySettings, BankConnection } from '@/types' -import { NotificationSettings } from '@/extensions/push-notifications/NotificationSettings' +import { NotificationSettings } from '@/extensions/general/push-notifications/NotificationSettings' import { CalendarFeedSettings } from '@/components/settings/CalendarFeedSettings' export default function SettingsPage() { @@ -41,6 +46,12 @@ export default function SettingsPage() { const [isSyncing, setIsSyncing] = useState(false) const [isConnecting, setIsConnecting] = useState(false) const [hasBankingExtension, setHasBankingExtension] = useState(false) + const { theme, setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) useEffect(() => { fetchData() @@ -311,6 +322,10 @@ export default function SettingsPage() { Kalender + + + Utseende + Konto @@ -625,6 +640,103 @@ export default function SettingsPage() { + {/* Appearance settings */} + + + + Utseende + + Välj hur applikationen ska se ut + + + + {mounted && ( +
+ {/* Light */} + + + {/* Dark */} + + + {/* System */} + +
+ )} +
+
+
+ {/* Account settings */} diff --git a/app/(onboarding)/onboarding/page.tsx b/app/(onboarding)/onboarding/page.tsx index 46ff54e1..39d7dfa5 100644 --- a/app/(onboarding)/onboarding/page.tsx +++ b/app/(onboarding)/onboarding/page.tsx @@ -1,10 +1,9 @@ 'use client' -import { useState, useEffect, useMemo, Suspense } from 'react' +import { useState, useEffect, Suspense } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { createClient } from '@/lib/supabase/client' import { Progress } from '@/components/ui/progress' -import { Button } from '@/components/ui/button' import { useToast } from '@/components/ui/use-toast' import { Loader2 } from 'lucide-react' import type { CompanySettings, EntityType, MomsPeriod } from '@/types' @@ -13,13 +12,18 @@ import Step1EntityType from '@/components/onboarding/Step1EntityType' import Step2CompanyDetails from '@/components/onboarding/Step2CompanyDetails' import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration' import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax' -import Step6ConnectBank from '@/components/onboarding/Step6ConnectBank' +import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank' +import Step6SectorSelection from '@/components/onboarding/Step2SectorSelection' +import Step7ExtensionSuggestions from '@/components/onboarding/Step3ExtensionSuggestions' + const STEP_TITLES = [ 'Verksamhetsform', 'Företagsuppgifter', 'Skatteregistrering', 'F-skatt', 'Anslut bank', + 'Bransch', + 'Tillägg', ] export default function OnboardingPage() { @@ -40,8 +44,9 @@ function OnboardingPageContent() { const [isSaving, setIsSaving] = useState(false) const [currentStep, setCurrentStep] = useState(1) const [settings, setSettings] = useState>({}) + const [sectorSlug, setSectorSlug] = useState(null) - const totalSteps = 5 + const totalSteps = 7 const stepTitles = STEP_TITLES // Load existing settings on mount @@ -63,6 +68,9 @@ function OnboardingPageContent() { if (data) { setSettings(data) setCurrentStep(data.onboarding_step || 1) + if ((data as Record).sector_slug) { + setSectorSlug((data as Record).sector_slug as string) + } } setIsLoading(false) @@ -74,13 +82,13 @@ function OnboardingPageContent() { // Handle bank_connected callback from PSD2 flow useEffect(() => { if (searchParams.get('bank_connected') === 'true') { - saveSettings({ onboarding_complete: true }).then((success) => { + toast({ + title: 'Bank ansluten!', + description: 'Din bank har kopplats.', + }) + saveSettings({}, 6).then((success) => { if (success) { - toast({ - title: 'Bank ansluten!', - description: 'Din bank är kopplad och profilen är redo.', - }) - router.push('/') + setCurrentStep(6) } }) } @@ -249,18 +257,6 @@ function OnboardingPageContent() { } } - const handleComplete = async () => { - const success = await saveSettings({ onboarding_complete: true }) - - if (success) { - toast({ - title: 'Välkommen!', - description: 'Din profil är nu redo.', - }) - router.push('/') - } - } - if (isLoading) { return (
@@ -271,6 +267,41 @@ function OnboardingPageContent() { const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100 + const handleSectorNext = async (data: { sector_slug: string | null }) => { + setSectorSlug(data.sector_slug) + const nextStep = currentStep + 1 + const settingsUpdate: Partial = {} + if (data.sector_slug) { + ;(settingsUpdate as Record).sector_slug = data.sector_slug + } + const success = await saveSettings(settingsUpdate, nextStep) + if (success) { + setCurrentStep(nextStep) + } + } + + const handleExtensionsNext = async (data: { enabled_extensions: { sector_slug: string; extension_slug: string }[] }) => { + // Insert extension toggles if any were selected + if (data.enabled_extensions.length > 0) { + try { + const { data: { user } } = await supabase.auth.getUser() + if (user) { + const rows = data.enabled_extensions.map(ext => ({ + user_id: user.id, + sector_slug: ext.sector_slug, + extension_slug: ext.extension_slug, + enabled: true, + })) + await supabase.from('extension_toggles').upsert(rows, { + onConflict: 'user_id,sector_slug,extension_slug', + }) + } + } catch (err) { + console.error('Failed to save extension toggles:', err) + } + } + } + const renderSteps = () => ( <> {currentStep === 1 && ( @@ -327,7 +358,7 @@ function OnboardingPageContent() { )} {currentStep === 5 && ( - { if (data) { - await saveSettings({ ...data, onboarding_complete: true }) - } else { - await saveSettings({ onboarding_complete: true }) + await saveSettings(data, 6) } + setCurrentStep(6) + }} + onBack={handleBack} + onSkip={() => setCurrentStep(6)} + isSaving={isSaving} + /> + )} + + {currentStep === 6 && ( + + )} + + {currentStep === 7 && ( + { + await handleExtensionsNext(data) + await saveSettings({ onboarding_complete: true }, totalSteps) toast({ title: 'Välkommen!', description: 'Din profil är nu redo.', @@ -348,7 +399,6 @@ function OnboardingPageContent() { router.push('/') }} onBack={handleBack} - onSkip={handleComplete} isSaving={isSaving} /> )} diff --git a/app/api/bookkeeping/account-totals/route.ts b/app/api/bookkeeping/account-totals/route.ts new file mode 100644 index 00000000..c13749b8 --- /dev/null +++ b/app/api/bookkeeping/account-totals/route.ts @@ -0,0 +1,132 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const from = searchParams.get('from') + const to = searchParams.get('to') + const dateFrom = searchParams.get('date_from') + const dateTo = searchParams.get('date_to') + const groupBy = searchParams.get('group_by') + + if (!from || !to) { + return NextResponse.json( + { error: 'from and to account numbers are required' }, + { status: 400 } + ) + } + + // Get posted journal entries within date range + let entriesQuery = supabase + .from('journal_entries') + .select('id, entry_date') + .eq('user_id', user.id) + .eq('status', 'posted') + + if (dateFrom) { + entriesQuery = entriesQuery.gte('entry_date', dateFrom) + } + if (dateTo) { + entriesQuery = entriesQuery.lte('entry_date', dateTo) + } + + const { data: entries, error: entriesError } = await entriesQuery + + if (entriesError) { + return NextResponse.json({ error: entriesError.message }, { status: 500 }) + } + + if (!entries || entries.length === 0) { + return NextResponse.json({ totals: [], monthly: groupBy === 'month' ? [] : undefined }) + } + + const entryIds = entries.map((e) => e.id) + const entryDateMap = new Map(entries.map((e) => [e.id, e.entry_date])) + + // Fetch lines in batches to avoid URL length limits + const batchSize = 200 + const allLines: Array<{ + journal_entry_id: string + account_number: string + debit_amount: number + credit_amount: number + }> = [] + + for (let i = 0; i < entryIds.length; i += batchSize) { + const batch = entryIds.slice(i, i + batchSize) + const { data: lines, error: linesError } = await supabase + .from('journal_entry_lines') + .select('journal_entry_id, account_number, debit_amount, credit_amount') + .in('journal_entry_id', batch) + .gte('account_number', from) + .lte('account_number', to) + + if (linesError) { + return NextResponse.json({ error: linesError.message }, { status: 500 }) + } + if (lines) { + allLines.push(...lines) + } + } + + // Aggregate by account + const accountTotals = new Map() + + for (const line of allLines) { + const existing = accountTotals.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + accountTotals.set(line.account_number, existing) + } + + const totals = Array.from(accountTotals.entries()) + .map(([account_number, bal]) => ({ + account_number, + debit: Math.round(bal.debit * 100) / 100, + credit: Math.round(bal.credit * 100) / 100, + net: Math.round((bal.debit - bal.credit) * 100) / 100, + })) + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + + // Monthly grouping + if (groupBy === 'month') { + const monthlyMap = new Map>() + + for (const line of allLines) { + const entryDate = entryDateMap.get(line.journal_entry_id) + if (!entryDate) continue + const month = entryDate.slice(0, 7) + if (!monthlyMap.has(month)) { + monthlyMap.set(month, new Map()) + } + const monthAccounts = monthlyMap.get(month)! + const existing = monthAccounts.get(line.account_number) || { debit: 0, credit: 0 } + existing.debit += Number(line.debit_amount) || 0 + existing.credit += Number(line.credit_amount) || 0 + monthAccounts.set(line.account_number, existing) + } + + const monthlyFlat = Array.from(monthlyMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([month, accounts]) => + Array.from(accounts.entries()).map(([account_number, bal]) => ({ + month, + account_number, + debit: Math.round(bal.debit * 100) / 100, + credit: Math.round(bal.credit * 100) / 100, + net: Math.round((bal.debit - bal.credit) * 100) / 100, + })) + ) + + return NextResponse.json({ totals, monthly: monthlyFlat }) + } + + return NextResponse.json({ totals }) +} diff --git a/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts new file mode 100644 index 00000000..c6d812b0 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/reverse/__tests__/route.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + makeJournalEntry, +} from '@/tests/helpers' + +// Mock dependencies before imports +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => mockCreateClient(), +})) + +const mockReverseEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + reverseEntry: (...args: unknown[]) => mockReverseEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/bookkeeping/journal-entries/[id]/reverse', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) }, + }) + }) + + it('returns 401 when not authenticated', async () => { + mockCreateClient.mockResolvedValue({ + auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) }, + }) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', { + method: 'POST', + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns reversed entry on success', async () => { + const reversalEntry = makeJournalEntry({ + id: 'reversal-1', + reverses_id: 'entry-1', + source_type: 'storno', + }) + mockReverseEntry.mockResolvedValue(reversalEntry) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', { + method: 'POST', + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(reversalEntry) + expect(mockReverseEntry).toHaveBeenCalledWith('user-1', 'entry-1') + }) + + it('returns 400 when engine throws', async () => { + mockReverseEntry.mockRejectedValue(new Error('Entry already reversed')) + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/reverse', { + method: 'POST', + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Entry already reversed') + }) +}) diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts new file mode 100644 index 00000000..93e4dabb --- /dev/null +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeJournalEntry, +} from '@/tests/helpers' + +// Mock dependencies +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), +})) + +import { GET, POST } from '../route' + +describe('GET /api/bookkeeping/journal-entries', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/bookkeeping/journal-entries') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns entries list', async () => { + const entries = [makeJournalEntry(), makeJournalEntry()] + enqueue({ data: entries, error: null, count: 2 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(entries) + expect(body.count).toBe(2) + }) + + it('passes filters to query', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + searchParams: { + period_id: 'period-1', + status: 'posted', + date_from: '2024-01-01', + date_to: '2024-12-31', + limit: '10', + offset: '5', + }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.from).toHaveBeenCalledWith('journal_entries') + }) + + it('returns 500 on database error', async () => { + enqueue({ data: null, error: { message: 'DB error' } }) + + const request = createMockRequest('/api/bookkeeping/journal-entries') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('DB error') + }) +}) + +describe('POST /api/bookkeeping/journal-entries', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + method: 'POST', + body: {}, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('creates journal entry and returns it', async () => { + const entry = makeJournalEntry() + mockCreateJournalEntry.mockResolvedValue(entry) + + const input = { + fiscal_period_id: 'period-1', + entry_date: '2024-06-15', + description: 'Test entry', + source_type: 'manual', + lines: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 1000 }, + ], + } + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + method: 'POST', + body: input, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(entry) + expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', input) + }) + + it('returns 400 when engine throws', async () => { + mockCreateJournalEntry.mockRejectedValue(new Error('Unbalanced entry')) + + const request = createMockRequest('/api/bookkeeping/journal-entries', { + method: 'POST', + body: { + fiscal_period_id: 'period-1', + entry_date: '2024-06-15', + description: 'Bad entry', + source_type: 'manual', + lines: [{ account_number: '1930', debit_amount: 1000, credit_amount: 0 }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Unbalanced entry') + }) +}) diff --git a/app/api/extensions/[sector]/[slug]/data/route.ts b/app/api/extensions/[sector]/[slug]/data/route.ts new file mode 100644 index 00000000..e0b0cb95 --- /dev/null +++ b/app/api/extensions/[sector]/[slug]/data/route.ts @@ -0,0 +1,119 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET( + request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const extensionId = `${sector}/${slug}` + + const { searchParams } = new URL(request.url) + const key = searchParams.get('key') + + let query = supabase + .from('extension_data') + .select('*') + .eq('user_id', user.id) + .eq('extension_id', extensionId) + + const prefix = searchParams.get('prefix') + + if (key) { + query = query.eq('key', key) + } else if (prefix) { + query = query.ilike('key', `${prefix}%`) + } + + const { data, error } = await query + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { key, value } = body + + if (!key) { + return NextResponse.json({ error: 'key is required' }, { status: 400 }) + } + + const extensionId = `${sector}/${slug}` + + const { data, error } = await supabase + .from('extension_data') + .upsert( + { + user_id: user.id, + extension_id: extensionId, + key, + value, + }, + { onConflict: 'user_id,extension_id,key' } + ) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const key = searchParams.get('key') + + if (!key) { + return NextResponse.json({ error: 'key query parameter is required' }, { status: 400 }) + } + + const extensionId = `${sector}/${slug}` + + const { error } = await supabase + .from('extension_data') + .delete() + .eq('user_id', user.id) + .eq('extension_id', extensionId) + .eq('key', key) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ success: true }) +} diff --git a/app/api/extensions/[sector]/[slug]/settings/route.ts b/app/api/extensions/[sector]/[slug]/settings/route.ts new file mode 100644 index 00000000..5339166e --- /dev/null +++ b/app/api/extensions/[sector]/[slug]/settings/route.ts @@ -0,0 +1,74 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET( + _request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const extensionId = `${sector}/${slug}` + + const { data } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', extensionId) + .eq('key', 'settings') + .single() + + return NextResponse.json({ data: data?.value ?? {} }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const extensionId = `${sector}/${slug}` + + // Get existing settings and merge + const { data: existing } = await supabase + .from('extension_data') + .select('value') + .eq('user_id', user.id) + .eq('extension_id', extensionId) + .eq('key', 'settings') + .single() + + const mergedSettings = { ...(existing?.value ?? {}), ...body } + + const { data, error } = await supabase + .from('extension_data') + .upsert( + { + user_id: user.id, + extension_id: extensionId, + key: 'settings', + value: mergedSettings, + }, + { onConflict: 'user_id,extension_id,key' } + ) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data: data.value }) +} diff --git a/app/api/extensions/ai-categorization/settings/route.ts b/app/api/extensions/ai-categorization/settings/route.ts index 0c393628..9a76b945 100644 --- a/app/api/extensions/ai-categorization/settings/route.ts +++ b/app/api/extensions/ai-categorization/settings/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/ai-categorization' +import { getSettings, saveSettings } from '@/extensions/general/ai-categorization' /** * GET /api/extensions/ai-categorization/settings diff --git a/app/api/extensions/ai-categorization/suggestions/route.ts b/app/api/extensions/ai-categorization/suggestions/route.ts index 2fe4b2c2..c08a91c0 100644 --- a/app/api/extensions/ai-categorization/suggestions/route.ts +++ b/app/api/extensions/ai-categorization/suggestions/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { categorizeTransactions } from '@/extensions/ai-categorization' -import type { CategorizationSuggestion } from '@/extensions/ai-categorization/categorizer' +import { categorizeTransactions } from '@/extensions/general/ai-categorization' +import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer' /** * GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,... diff --git a/app/api/extensions/ai-chat/route.ts b/app/api/extensions/ai-chat/route.ts index 81cf364e..67f26416 100644 --- a/app/api/extensions/ai-chat/route.ts +++ b/app/api/extensions/ai-chat/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { generateChatResponse } from '@/extensions/ai-chat/chatbot/chain' -import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config' +import { generateChatResponse } from '@/extensions/general/ai-chat/chatbot/chain' +import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config' import type { ChatMessage, ChatRequest } from '@/types/chat' // Simple in-memory rate limiting (per user) diff --git a/app/api/extensions/ai-chat/stream/route.ts b/app/api/extensions/ai-chat/stream/route.ts index 0edc89d3..bcf31bb3 100644 --- a/app/api/extensions/ai-chat/stream/route.ts +++ b/app/api/extensions/ai-chat/stream/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' -import { streamChatResponse } from '@/extensions/ai-chat/chatbot/chain' -import { CHATBOT_CONFIG } from '@/extensions/ai-chat/chatbot/config' +import { streamChatResponse } from '@/extensions/general/ai-chat/chatbot/chain' +import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config' import type { ChatMessage, ChatRequest, SourceReference } from '@/types/chat' // Simple in-memory rate limiting (per user) diff --git a/app/api/extensions/enable-banking/callback/route.ts b/app/api/extensions/enable-banking/callback/route.ts index 431b7a92..497ed8be 100644 --- a/app/api/extensions/enable-banking/callback/route.ts +++ b/app/api/extensions/enable-banking/callback/route.ts @@ -1,7 +1,7 @@ import { createServiceClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/enable-banking/lib/api-client' -import type { StoredAccount } from '@/extensions/enable-banking/types' +import { createSession, getAccountBalance, type AccountInfo } from '@/extensions/general/enable-banking/lib/api-client' +import type { StoredAccount } from '@/extensions/general/enable-banking/types' /** * GET /api/extensions/enable-banking/callback diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 0106692e..025b436d 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -1,8 +1,8 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' -import { syncAccountTransactions } from '@/extensions/enable-banking/lib/sync' -import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/enable-banking/lib/api-client' -import type { StoredAccount } from '@/extensions/enable-banking/types' +import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync' +import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client' +import type { StoredAccount } from '@/extensions/general/enable-banking/types' /** * GET /api/extensions/enable-banking/sync/cron diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts index 1c5cc5b6..12cc79ce 100644 --- a/app/api/extensions/push-notifications/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -4,7 +4,7 @@ import { loadExtensions } from '@/lib/extensions/loader' import { sendTaxDeadlineNotifications, sendInvoiceNotifications, -} from '@/extensions/push-notifications/notification-scheduler' +} from '@/extensions/general/push-notifications/notification-scheduler' /** * GET /api/extensions/push-notifications/cron diff --git a/app/api/extensions/push-notifications/settings/route.ts b/app/api/extensions/push-notifications/settings/route.ts index 78891244..b9fecc7a 100644 --- a/app/api/extensions/push-notifications/settings/route.ts +++ b/app/api/extensions/push-notifications/settings/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/push-notifications' +import { getSettings, saveSettings } from '@/extensions/general/push-notifications' /** * GET /api/extensions/push-notifications/settings diff --git a/app/api/extensions/push-notifications/subscribe/route.ts b/app/api/extensions/push-notifications/subscribe/route.ts index 50e226c3..deddb7bf 100644 --- a/app/api/extensions/push-notifications/subscribe/route.ts +++ b/app/api/extensions/push-notifications/subscribe/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getVapidPublicKey } from '@/extensions/push-notifications/notification-sender' +import { getVapidPublicKey } from '@/extensions/general/push-notifications/notification-sender' /** * GET /api/extensions/push-notifications/subscribe diff --git a/app/api/extensions/receipt-ocr/[id]/match/route.ts b/app/api/extensions/receipt-ocr/[id]/match/route.ts index 1eff3cbe..00a1158b 100644 --- a/app/api/extensions/receipt-ocr/[id]/match/route.ts +++ b/app/api/extensions/receipt-ocr/[id]/match/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { findTransactionMatches } from '@/extensions/receipt-ocr/lib/receipt-matcher' +import { findTransactionMatches } from '@/extensions/general/receipt-ocr/lib/receipt-matcher' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import type { Receipt, Transaction } from '@/types' diff --git a/app/api/extensions/receipt-ocr/settings/route.ts b/app/api/extensions/receipt-ocr/settings/route.ts index d633785b..3562786a 100644 --- a/app/api/extensions/receipt-ocr/settings/route.ts +++ b/app/api/extensions/receipt-ocr/settings/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getSettings, saveSettings } from '@/extensions/receipt-ocr' +import { getSettings, saveSettings } from '@/extensions/general/receipt-ocr' /** * GET /api/extensions/receipt-ocr/settings diff --git a/app/api/extensions/receipt-ocr/upload/route.ts b/app/api/extensions/receipt-ocr/upload/route.ts index 4e247045..7f70eaa9 100644 --- a/app/api/extensions/receipt-ocr/upload/route.ts +++ b/app/api/extensions/receipt-ocr/upload/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer' -import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer' +import { analyzeReceipt } from '@/extensions/general/receipt-ocr/lib/receipt-analyzer' +import { processLineItems } from '@/extensions/general/receipt-ocr/lib/receipt-categorizer' import { eventBus } from '@/lib/events/bus' import { ensureInitialized } from '@/lib/init' import { uploadDocument } from '@/lib/core/documents/document-service' diff --git a/app/api/extensions/toggles/[sector]/[slug]/route.ts b/app/api/extensions/toggles/[sector]/[slug]/route.ts new file mode 100644 index 00000000..7b326dc8 --- /dev/null +++ b/app/api/extensions/toggles/[sector]/[slug]/route.ts @@ -0,0 +1,51 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +export async function GET( + _request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { data } = await supabase + .from('extension_toggles') + .select('*') + .eq('user_id', user.id) + .eq('sector_slug', sector) + .eq('extension_slug', slug) + .single() + + return NextResponse.json({ data: data ?? { enabled: false } }) +} + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ sector: string; slug: string }> } +) { + const { sector, slug } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { error } = await supabase + .from('extension_toggles') + .delete() + .eq('user_id', user.id) + .eq('sector_slug', sector) + .eq('extension_slug', slug) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ success: true }) +} diff --git a/app/api/extensions/toggles/__tests__/route.test.ts b/app/api/extensions/toggles/__tests__/route.test.ts new file mode 100644 index 00000000..d49a6f95 --- /dev/null +++ b/app/api/extensions/toggles/__tests__/route.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeExtensionToggle, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +import { GET, POST } from '../route' + +describe('GET /api/extensions/toggles', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const response = await GET() + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns enabled toggles for user', async () => { + const toggles = [ + makeExtensionToggle({ extension_slug: 'receipt-ocr' }), + makeExtensionToggle({ extension_slug: 'ai-chat' }), + ] + enqueue({ data: toggles, error: null }) + + const response = await GET() + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(toggles) + expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles') + }) +}) + +describe('POST /api/extensions/toggles', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/extensions/toggles', { + method: 'POST', + body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when missing fields', async () => { + const request = createMockRequest('/api/extensions/toggles', { + method: 'POST', + body: { sector_slug: 'general' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('sector_slug, extension_slug, and enabled are required') + }) + + it('upserts toggle and returns data', async () => { + const toggle = makeExtensionToggle({ + sector_slug: 'general', + extension_slug: 'receipt-ocr', + enabled: true, + }) + enqueue({ data: toggle, error: null }) + + const request = createMockRequest('/api/extensions/toggles', { + method: 'POST', + body: { sector_slug: 'general', extension_slug: 'receipt-ocr', enabled: true }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(toggle) + expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles') + }) +}) diff --git a/app/api/extensions/toggles/route.ts b/app/api/extensions/toggles/route.ts new file mode 100644 index 00000000..62e4a6a8 --- /dev/null +++ b/app/api/extensions/toggles/route.ts @@ -0,0 +1,62 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' + +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 { data, error } = await supabase + .from('extension_toggles') + .select('*') + .eq('user_id', user.id) + .eq('enabled', true) + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} + +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 body = await request.json() + const { sector_slug, extension_slug, enabled } = body + + if (!sector_slug || !extension_slug || typeof enabled !== 'boolean') { + return NextResponse.json( + { error: 'sector_slug, extension_slug, and enabled are required' }, + { status: 400 } + ) + } + + const { data, error } = await supabase + .from('extension_toggles') + .upsert( + { + user_id: user.id, + sector_slug, + extension_slug, + enabled, + }, + { onConflict: 'user_id,sector_slug,extension_slug' } + ) + .select() + .single() + + if (error) { + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json({ data }) +} diff --git a/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..bb2198d5 --- /dev/null +++ b/app/api/invoices/[id]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeInvoice, + makeCustomer, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateInvoicePaymentJournalEntry = vi.fn() +const mockCreateInvoiceCashEntry = vi.fn() +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoicePaymentJournalEntry: (...args: unknown[]) => + mockCreateInvoicePaymentJournalEntry(...args), + createInvoiceCashEntry: (...args: unknown[]) => + mockCreateInvoiceCashEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/invoices/[id]/mark-paid', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Fakturan hittades inte') + }) + + it('returns 400 when invoice is in draft status', async () => { + const invoice = makeInvoice({ status: 'draft' }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + }) + + it('returns 400 when invoice is already paid', async () => { + const invoice = makeInvoice({ status: 'paid' }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + }) + + it('returns 400 when invoice is credited', async () => { + const invoice = makeInvoice({ status: 'credited' }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + }) + + it('marks sent invoice as paid with accrual method', async () => { + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + total: 12500, + customer, + }) + + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Update invoice status + enqueue({ data: null, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('paid') + expect(body.paid_amount).toBe(12500) + expect(body.journal_entry_id).toBe('je-1') + expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + expect.any(String) + ) + }) + + it('marks overdue invoice as paid with cash method', async () => { + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'overdue', + total: 12500, + customer, + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: { accounting_method: 'cash', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoiceCashEntry.mockResolvedValue({ id: 'je-2' }) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBe('je-2') + expect(mockCreateInvoiceCashEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + expect.any(String), + 'enskild_firma' + ) + }) + + it('returns success with null journal_entry_id when journal entry creation fails', async () => { + const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) + + const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string | null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBeNull() + }) +}) diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts index 34b70c66..29757dba 100644 --- a/app/api/invoices/[id]/pdf/route.ts +++ b/app/api/invoices/[id]/pdf/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { renderToBuffer } from '@react-pdf/renderer' -import { InvoicePDF } from '@/lib/invoice/pdf-template' +import { InvoicePDF } from '@/lib/invoices/pdf-template' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' export async function GET( diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts new file mode 100644 index 00000000..1a8a8657 --- /dev/null +++ b/app/api/invoices/[id]/send/__tests__/route.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeInvoice, + makeCustomer, + makeCompanySettings, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +const mockRenderToBuffer = vi.fn() +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: (...args: unknown[]) => mockRenderToBuffer(...args), + Document: vi.fn(), + Page: vi.fn(), + Text: vi.fn(), + View: vi.fn(), + StyleSheet: { create: (s: unknown) => s }, +})) + +vi.mock('@/lib/invoice/pdf-template', () => ({ + InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'), +})) + +const mockSendEmail = vi.fn() +const mockIsResendConfigured = vi.fn() +vi.mock('@/lib/email/resend', () => ({ + sendEmail: (...args: unknown[]) => mockSendEmail(...args), + isResendConfigured: () => mockIsResendConfigured(), +})) + +vi.mock('@/lib/email/invoice-templates', () => ({ + generateInvoiceEmailHtml: vi.fn().mockReturnValue('Invoice'), + generateInvoiceEmailText: vi.fn().mockReturnValue('Invoice text'), + generateInvoiceEmailSubject: vi.fn().mockReturnValue('Faktura F-2024001'), +})) + +const mockCreateInvoiceJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoiceJournalEntry: (...args: unknown[]) => + mockCreateInvoiceJournalEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/invoices/[id]/send', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const customer = makeCustomer({ id: 'cust-1', email: 'kund@test.se' }) + const company = makeCompanySettings({ accounting_method: 'accrual' }) + const invoice = makeInvoice({ + id: 'inv-1', + status: 'draft', + customer, + items: [ + { + id: 'item-1', + invoice_id: 'inv-1', + sort_order: 0, + description: 'Consulting', + quantity: 10, + unit: 'tim', + unit_price: 1000, + line_total: 10000, + created_at: '2024-06-15T14:30:00Z', + }, + ], + }) + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockIsResendConfigured.mockReturnValue(true) + mockRenderToBuffer.mockResolvedValue(Buffer.from('fake-pdf')) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 503 when email service is not configured', async () => { + mockIsResendConfigured.mockReturnValue(false) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(503) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Fakturan hittades inte') + }) + + it('returns 400 when customer has no email', async () => { + const noEmailInvoice = makeInvoice({ + id: 'inv-1', + customer: makeCustomer({ email: null }), + items: [], + }) + enqueue({ data: noEmailInvoice, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('e-postadress') + }) + + it('returns 404 when company settings not found', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Företagsinställningar saknas') + }) + + it('sends invoice email, updates status, creates journal entry for accrual', async () => { + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Fetch company settings + enqueue({ data: company, error: null }) + + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-1' }) + mockCreateInvoiceJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Update invoice status to 'sent' + enqueue({ data: null, error: null }) + // Update invoice with journal_entry_id + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + messageId: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.messageId).toBe('msg-1') + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'kund@test.se', + subject: 'Faktura F-2024001', + }) + ) + expect(mockCreateInvoiceJournalEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ id: 'inv-1' }), + 'enskild_firma' + ) + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'invoice.sent' }) + ) + }) + + it('skips journal entry for cash method', async () => { + const cashCompany = makeCompanySettings({ accounting_method: 'cash' }) + enqueue({ data: invoice, error: null }) + enqueue({ data: cashCompany, error: null }) + + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-2' }) + + // Update invoice status + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(mockCreateInvoiceJournalEntry).not.toHaveBeenCalled() + }) + + it('does not fail when journal entry creation fails (non-blocking)', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + + mockSendEmail.mockResolvedValue({ success: true, messageId: 'msg-3' }) + mockCreateInvoiceJournalEntry.mockRejectedValue(new Error('Period locked')) + + // Update invoice status + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ success: boolean }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + }) + + it('returns 500 when email sending fails', async () => { + enqueue({ data: invoice, error: null }) + enqueue({ data: company, error: null }) + + mockSendEmail.mockResolvedValue({ success: false, error: 'SMTP error' }) + + const request = createMockRequest('/api/invoices/inv-1/send', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toContain('SMTP error') + }) +}) diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 76924f3c..1ae8249d 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { renderToBuffer } from '@react-pdf/renderer' -import { InvoicePDF } from '@/lib/invoice/pdf-template' +import { InvoicePDF } from '@/lib/invoices/pdf-template' import { sendEmail, isResendConfigured } from '@/lib/email/resend' import { generateInvoiceEmailHtml, diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts new file mode 100644 index 00000000..7a1a1322 --- /dev/null +++ b/app/api/invoices/__tests__/route.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeInvoice, + makeCustomer, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +const mockGetVatRules = vi.fn() +const mockCalculateVat = vi.fn() +vi.mock('@/lib/invoice/vat-rules', () => ({ + getVatRules: (...args: unknown[]) => mockGetVatRules(...args), + calculateVat: (...args: unknown[]) => mockCalculateVat(...args), + calculateTotal: vi.fn(), +})) + +vi.mock('@/lib/currency/riksbanken', () => ({ + fetchExchangeRate: vi.fn().mockResolvedValue(null), + convertToSEK: vi.fn(), +})) + +const mockCreateCreditNoteJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createCreditNoteJournalEntry: (...args: unknown[]) => + mockCreateCreditNoteJournalEntry(...args), +})) + +import { GET, POST } from '../route' + +describe('GET /api/invoices', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/invoices') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns invoices list', async () => { + const invoices = [makeInvoice(), makeInvoice()] + enqueue({ data: invoices, error: null, count: 2 }) + + const request = createMockRequest('/api/invoices') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(invoices) + expect(body.count).toBe(2) + }) + + it('applies status filter', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/invoices', { + searchParams: { status: 'sent' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.from).toHaveBeenCalledWith('invoices') + }) + + it('applies pagination', async () => { + enqueue({ data: [], error: null, count: 0 }) + + const request = createMockRequest('/api/invoices', { + searchParams: { limit: '10', offset: '20' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) + + it('returns 500 on database error', async () => { + enqueue({ data: null, error: { message: 'DB error' } }) + + const request = createMockRequest('/api/invoices') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('DB error') + }) +}) + +describe('POST /api/invoices (create invoice)', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { customer_id: 'cust-1', items: [] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when customer not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { + customer_id: 'cust-999', + invoice_date: '2024-06-15', + due_date: '2024-07-15', + currency: 'SEK', + items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Customer not found') + }) + + it('creates invoice with items and emits event', async () => { + const customer = makeCustomer({ id: 'cust-1' }) + const createdInvoice = makeInvoice({ id: 'inv-1' }) + + mockGetVatRules.mockReturnValue({ + treatment: 'standard_25', + rate: 25, + momsRuta: '10', + reverseChargeText: null, + }) + mockCalculateVat.mockReturnValue(2500) + + // Fetch customer + enqueue({ data: customer, error: null }) + // RPC generate_invoice_number + enqueue({ data: 'F-2024001' }) + // Insert invoice + enqueue({ data: createdInvoice, error: null }) + // Insert items + enqueue({ data: null, error: null }) + // Fetch complete invoice + enqueue({ data: { ...createdInvoice, customer, items: [] }, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { + customer_id: 'cust-1', + invoice_date: '2024-06-15', + due_date: '2024-07-15', + currency: 'SEK', + items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toBeTruthy() + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'invoice.created' }) + ) + }) + + it('rolls back invoice when items insertion fails', async () => { + const customer = makeCustomer({ id: 'cust-1' }) + const createdInvoice = makeInvoice({ id: 'inv-1' }) + + mockGetVatRules.mockReturnValue({ + treatment: 'standard_25', + rate: 25, + momsRuta: '10', + reverseChargeText: null, + }) + mockCalculateVat.mockReturnValue(2500) + + enqueue({ data: customer, error: null }) + enqueue({ data: 'F-2024001' }) + enqueue({ data: createdInvoice, error: null }) + // Items insertion fails + enqueue({ data: null, error: { message: 'Items insert failed' } }) + // Rollback delete + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { + customer_id: 'cust-1', + invoice_date: '2024-06-15', + due_date: '2024-07-15', + currency: 'SEK', + items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('Items insert failed') + }) +}) + +describe('POST /api/invoices (create credit note)', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 404 when original invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-999' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Original invoice not found') + }) + + it('returns 400 when invoice is already credited', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'credited' }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Invoice has already been credited') + }) + + it('returns 400 when invoice is in draft status', async () => { + const original = makeInvoice({ id: 'inv-1', status: 'draft' }) + enqueue({ data: original, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Only sent, paid, or overdue invoices can be credited') + }) + + it('creates credit note with negated amounts and emits event', async () => { + const items = [ + { + id: 'item-1', + invoice_id: 'inv-1', + sort_order: 0, + description: 'Consulting', + quantity: 10, + unit: 'tim', + unit_price: 1000, + line_total: 10000, + created_at: '2024-06-15T14:30:00Z', + }, + ] + const original = makeInvoice({ + id: 'inv-1', + status: 'sent', + subtotal: 10000, + vat_amount: 2500, + total: 12500, + items, + }) + const creditNote = makeInvoice({ + id: 'cn-1', + credited_invoice_id: 'inv-1', + subtotal: -10000, + vat_amount: -2500, + total: -12500, + status: 'sent', + }) + + // Fetch original invoice + enqueue({ data: original, error: null }) + // Insert credit note + enqueue({ data: creditNote, error: null }) + // Insert credit note items + enqueue({ data: null, error: null }) + // Update original status to 'credited' + enqueue({ data: null, error: null }) + // Fetch complete credit note + enqueue({ data: { ...creditNote, items: [] }, error: null }) + // Fetch company settings for entity type + enqueue({ data: { entity_type: 'enskild_firma' }, error: null }) + + mockCreateCreditNoteJournalEntry.mockResolvedValue({ id: 'je-1' }) + // Update credit note with journal_entry_id + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toBeTruthy() + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'credit_note.created' }) + ) + }) + + it('rolls back credit note when items insertion fails', async () => { + const original = makeInvoice({ + id: 'inv-1', + status: 'sent', + items: [ + { + id: 'item-1', + invoice_id: 'inv-1', + sort_order: 0, + description: 'Test', + quantity: 1, + unit: 'st', + unit_price: 1000, + line_total: 1000, + created_at: '2024-06-15T14:30:00Z', + }, + ], + }) + const creditNote = makeInvoice({ id: 'cn-1' }) + + enqueue({ data: original, error: null }) + enqueue({ data: creditNote, error: null }) + // Items fail + enqueue({ data: null, error: { message: 'Items insert failed' } }) + // Rollback delete + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/invoices', { + method: 'POST', + body: { credited_invoice_id: 'inv-1' }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('Items insert failed') + }) +}) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index 55a5d452..d78e86a5 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types' -import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoice/vat-rules' +import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { createCreditNoteJournalEntry, diff --git a/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts new file mode 100644 index 00000000..ce637037 --- /dev/null +++ b/app/api/supplier-invoices/[id]/approve/__tests__/route.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeSupplierInvoice, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +import { POST } from '../route' + +describe('POST /api/supplier-invoices/[id]/approve', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Not found') + }) + + it('returns 400 when invoice is not in registered status', async () => { + enqueue({ data: { status: 'approved' }, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Kan bara godkänna registrerade fakturor') + }) + + it('approves registered invoice', async () => { + const invoice = makeSupplierInvoice({ id: 'inv-1', status: 'registered' }) + const approvedInvoice = { ...invoice, status: 'approved' } + + // First call: fetch invoice status + enqueue({ data: { status: 'registered' }, error: null }) + // Second call: update + select + enqueue({ data: approvedInvoice, error: null }) + + const request = createMockRequest('/api/supplier-invoices/inv-1/approve', { method: 'POST' }) + const response = await POST(request, createMockRouteParams({ id: 'inv-1' })) + const { status, body } = await parseJsonResponse<{ data: unknown }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(approvedInvoice) + }) +}) diff --git a/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts new file mode 100644 index 00000000..e0661b7f --- /dev/null +++ b/app/api/supplier-invoices/[id]/mark-paid/__tests__/route.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeSupplierInvoice, + makeSupplier, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateSupplierInvoicePaymentEntry = vi.fn() +const mockCreateSupplierInvoiceCashEntry = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoicePaymentEntry: (...args: unknown[]) => + mockCreateSupplierInvoicePaymentEntry(...args), + createSupplierInvoiceCashEntry: (...args: unknown[]) => + mockCreateSupplierInvoiceCashEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/supplier-invoices/[id]/mark-paid', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when invoice not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/supplier-invoices/si-999/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-999' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Not found') + }) + + it('returns 400 when invoice is in wrong status', async () => { + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'paid', + supplier: makeSupplier(), + items: [], + }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Fakturan kan inte markeras som betald i nuvarande status') + }) + + it('marks as fully paid with accrual method', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-1' }) + + // Update invoice + enqueue({ data: null, error: null }) + // Record payment + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + remaining_amount: number + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('paid') + expect(body.paid_amount).toBe(10000) + expect(body.remaining_amount).toBe(0) + expect(body.journal_entry_id).toBe('je-1') + expect(mockCreateSupplierInvoicePaymentEntry).toHaveBeenCalled() + }) + + it('marks as partially paid', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockResolvedValue({ id: 'je-2' }) + + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: { amount: 5000 }, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + status: string + paid_amount: number + remaining_amount: number + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.status).toBe('partially_paid') + expect(body.paid_amount).toBe(5000) + expect(body.remaining_amount).toBe(5000) + }) + + it('uses cash method journal entry when configured', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [ + { + id: 'item-1', + supplier_invoice_id: 'si-1', + sort_order: 0, + description: 'Material', + quantity: 10, + unit: 'st', + unit_price: 800, + line_total: 8000, + account_number: '4010', + vat_code: null, + vat_rate: 0.25, + vat_amount: 2000, + created_at: '2024-06-01T00:00:00Z', + }, + ], + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + mockCreateSupplierInvoiceCashEntry.mockResolvedValue({ id: 'je-3' }) + + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.journal_entry_id).toBe('je-3') + expect(mockCreateSupplierInvoiceCashEntry).toHaveBeenCalled() + expect(mockCreateSupplierInvoicePaymentEntry).not.toHaveBeenCalled() + }) + + it('returns success when journal entry creation fails (non-blocking)', async () => { + const supplier = makeSupplier() + const invoice = makeSupplierInvoice({ + id: 'si-1', + status: 'approved', + total: 10000, + remaining_amount: 10000, + paid_amount: 0, + supplier, + items: [], + }) + + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoicePaymentEntry.mockRejectedValue(new Error('Period locked')) + + enqueue({ data: null, error: null }) + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices/si-1/mark-paid', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'si-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: null + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBeNull() + }) +}) diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts new file mode 100644 index 00000000..1c9d3c9a --- /dev/null +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, + makeSupplierInvoice, + makeSupplier, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockFindFiscalPeriod = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), +})) + +const mockCreateSupplierInvoiceRegistrationEntry = vi.fn() +vi.mock('@/lib/bookkeeping/supplier-invoice-entries', () => ({ + createSupplierInvoiceRegistrationEntry: (...args: unknown[]) => + mockCreateSupplierInvoiceRegistrationEntry(...args), +})) + +import { GET, POST } from '../route' + +describe('GET /api/supplier-invoices', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/supplier-invoices') + const response = await GET(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns supplier invoices list', async () => { + const invoices = [makeSupplierInvoice(), makeSupplierInvoice()] + enqueue({ data: invoices, error: null }) + + const request = createMockRequest('/api/supplier-invoices') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ data: unknown[] }>(response) + + expect(status).toBe(200) + expect(body.data).toEqual(invoices) + }) + + it('applies status filter', async () => { + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + searchParams: { status: 'registered' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(mockSupabase.from).toHaveBeenCalledWith('supplier_invoices') + }) + + it('handles to_pay virtual status', async () => { + enqueue({ data: [], error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + searchParams: { status: 'to_pay' }, + }) + const response = await GET(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(200) + }) + + it('returns 500 on database error', async () => { + enqueue({ data: null, error: { message: 'DB error' } }) + + const request = createMockRequest('/api/supplier-invoices') + const response = await GET(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('DB error') + }) +}) + +describe('POST /api/supplier-invoices', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { supplier_id: 'sup-1', items: [] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when supplier not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: 'sup-999', + supplier_invoice_number: 'LF-001', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Material', quantity: 1, unit_price: 8000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Supplier not found') + }) + + it('creates supplier invoice with items and arrival number', async () => { + const supplier = makeSupplier({ id: 'sup-1' }) + const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + + // Fetch supplier + enqueue({ data: supplier, error: null }) + // RPC get_next_arrival_number + enqueue({ data: 5 }) + // Insert invoice + enqueue({ data: createdInvoice, error: null }) + // Insert items + enqueue({ data: null, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual' }, error: null }) + + mockCreateSupplierInvoiceRegistrationEntry.mockResolvedValue({ id: 'je-1' }) + // Update invoice with registration_journal_entry_id + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: 'sup-1', + supplier_invoice_number: 'LF-001', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [ + { + description: 'Material', + quantity: 10, + unit_price: 800, + account_number: '4010', + vat_rate: 0.25, + }, + ], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { registration_journal_entry_id: string } + }>(response) + + expect(status).toBe(200) + expect(body.data).toBeTruthy() + expect(body.data.registration_journal_entry_id).toBe('je-1') + expect(mockCreateSupplierInvoiceRegistrationEntry).toHaveBeenCalled() + }) + + it('skips registration entry for cash method', async () => { + const supplier = makeSupplier({ id: 'sup-1' }) + const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + + enqueue({ data: supplier, error: null }) + enqueue({ data: 6 }) + enqueue({ data: createdInvoice, error: null }) + enqueue({ data: null, error: null }) + enqueue({ data: { accounting_method: 'cash' }, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: 'sup-1', + supplier_invoice_number: 'LF-002', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Service', quantity: 1, unit_price: 5000, account_number: '6200' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { registration_journal_entry_id: null } + }>(response) + + expect(status).toBe(200) + expect(body.data.registration_journal_entry_id).toBeNull() + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + }) + + it('rolls back on items insertion failure', async () => { + const supplier = makeSupplier({ id: 'sup-1' }) + const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) + + enqueue({ data: supplier, error: null }) + enqueue({ data: 7 }) + enqueue({ data: createdInvoice, error: null }) + // Items fail + enqueue({ data: null, error: { message: 'Items insert failed' } }) + // Rollback delete + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: 'sup-1', + supplier_invoice_number: 'LF-003', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [{ description: 'Test', quantity: 1, unit_price: 1000, account_number: '4010' }], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('Items insert failed') + }) +}) diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts new file mode 100644 index 00000000..4246c696 --- /dev/null +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +const mockBuildMappingResultFromCategory = vi.fn() +vi.mock('@/lib/bookkeeping/category-mapping', () => ({ + buildMappingResultFromCategory: (...args: unknown[]) => + mockBuildMappingResultFromCategory(...args), +})) + +const mockCreateTransactionJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/transaction-entries', () => ({ + createTransactionJournalEntry: (...args: unknown[]) => + mockCreateTransactionJournalEntry(...args), +})) + +const mockSaveUserMappingRule = vi.fn() +vi.mock('@/lib/bookkeeping/mapping-engine', () => ({ + saveUserMappingRule: (...args: unknown[]) => mockSaveUserMappingRule(...args), +})) + +import { POST } from '../route' + +describe('POST /api/transactions/[id]/categorize', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const defaultMappingResult = { + rule: null, + debit_account: '6200', + credit_account: '1930', + risk_level: 'NONE', + confidence: 1, + requires_review: false, + default_private: false, + vat_lines: [{ account_number: '2641', debit_amount: 62.5, credit_amount: 0, description: 'Ingående moms' }], + description: 'Test expense', + } + + beforeEach(() => { + vi.clearAllMocks() + reset() + eventBus.clear() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockBuildMappingResultFromCategory.mockReturnValue(defaultMappingResult) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Transaction not found') + }) + + it('updates category only when transaction already has journal entry', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: 'je-existing', + category: 'uncategorized', + }) + // Fetch transaction + enqueue({ data: tx, error: null }) + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + already_had_journal_entry: boolean + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.already_had_journal_entry).toBe(true) + expect(body.journal_entry_id).toBe('je-existing') + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) + + it('creates journal entry for business expense', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'GitHub', + journal_entry_id: null, + }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // Fetch company settings + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + // ensureFiscalPeriod: check existing + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockSaveUserMappingRule.mockResolvedValue(undefined) + + // Update transaction + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + journal_entry_id: string + category: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(true) + expect(body.journal_entry_id).toBe('je-1') + expect(body.category).toBe('expense_software') + expect(mockSaveUserMappingRule).toHaveBeenCalledWith( + 'user-1', + 'GitHub', + '6200', + '1930', + false + ) + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'transaction.categorized' }) + ) + }) + + it('returns success with error when journal entry creation fails (non-blocking)', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + merchant_name: 'Test', + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked')) + + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_created: boolean + journal_entry_error: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_created).toBe(false) + expect(body.journal_entry_error).toBe('Period locked') + }) + + it('returns 500 when transaction update fails', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + merchant_name: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Transaction update fails + enqueue({ data: null, error: { message: 'Update failed' } }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: true, category: 'expense_software' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('Failed to update transaction') + }) + + it('categorizes as private when is_business is false', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: null, + merchant_name: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + enqueue({ data: { id: 'period-1' }, error: null }) + + mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/categorize', { + method: 'POST', + body: { is_business: false }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + category: string + }>(response) + + expect(status).toBe(200) + expect(body.category).toBe('private') + // Should NOT save mapping rule for private transactions + expect(mockSaveUserMappingRule).not.toHaveBeenCalled() + }) +}) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts new file mode 100644 index 00000000..92cee214 --- /dev/null +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, + makeInvoice, + makeCustomer, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) + +const mockCreateJournalEntry = vi.fn() +const mockFindFiscalPeriod = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), + findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), +})) + +vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + getRevenueAccount: vi.fn().mockReturnValue('3001'), + getOutputVatAccount: vi.fn().mockReturnValue('2611'), +})) + +import { POST } from '../route' + +describe('POST /api/transactions/[id]/match-invoice', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + mockFindFiscalPeriod.mockResolvedValue('period-1') + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when invoice_id is missing', async () => { + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: {}, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('invoice_id is required') + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Transaction not found') + }) + + it('returns 400 when transaction is an expense (amount <= 0)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: -500 }) + enqueue({ data: tx, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Only income transactions can be matched to invoices') + }) + + it('returns 400 when transaction is already linked to an invoice', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: 'inv-other' }) + enqueue({ data: tx, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Transaction is already linked to an invoice') + }) + + it('returns 404 when invoice not found', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + enqueue({ data: tx, error: null }) + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-999' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(404) + expect(body.error).toBe('Invoice not found') + }) + + it('returns 400 when invoice is not in unpaid state', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) + const invoice = makeInvoice({ id: 'inv-1', status: 'paid' }) + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toBe('Invoice is not in an unpaid state') + }) + + it('matches transaction to invoice with accrual method', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) + const customer = makeCustomer() + const invoice = makeInvoice({ + id: 'inv-1', + status: 'sent', + total: 12500, + subtotal: 10000, + vat_amount: 2500, + invoice_number: 'F-2024001', + customer, + }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + // Fetch invoice + enqueue({ data: invoice, error: null }) + // Fetch company settings + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) + + // Update invoice to paid + enqueue({ data: null, error: null }) + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + invoice_status: string + paid_amount: number + journal_entry_id: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.invoice_status).toBe('paid') + expect(body.paid_amount).toBe(12500) + expect(body.journal_entry_id).toBe('je-1') + + // Verify accrual journal entry: debit 1930, credit 1510 + expect(mockCreateJournalEntry).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ + source_type: 'invoice_paid', + lines: expect.arrayContaining([ + expect.objectContaining({ account_number: '1930', debit_amount: 12500 }), + expect.objectContaining({ account_number: '1510', credit_amount: 12500 }), + ]), + }) + ) + }) + + it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => { + const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) + const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 }) + + enqueue({ data: tx, error: null }) + enqueue({ data: invoice, error: null }) + enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) + + mockCreateJournalEntry.mockRejectedValue(new Error('Period locked')) + + // Update invoice + enqueue({ data: null, error: null }) + // Update transaction + enqueue({ data: null, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/match-invoice', { + method: 'POST', + body: { invoice_id: 'inv-1' }, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: null + journal_entry_error: string + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBeNull() + expect(body.journal_entry_error).toBe('Period locked') + }) +}) diff --git a/app/api/transactions/batch-match-invoices/route.ts b/app/api/transactions/batch-match-invoices/route.ts index 55ec2a6b..a3d4e9eb 100644 --- a/app/api/transactions/batch-match-invoices/route.ts +++ b/app/api/transactions/batch-match-invoices/route.ts @@ -1,6 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getBestInvoiceMatch } from '@/lib/invoice/invoice-matching' +import { getBestInvoiceMatch } from '@/lib/invoices/invoice-matching' import type { Transaction } from '@/types' /** diff --git a/app/globals.css b/app/globals.css index e9c4f4b1..653ed820 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,36 +1,9 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; - -/* Force light mode */ -html, :root { - color-scheme: light only !important; -} - -@media (prefers-color-scheme: dark) { - :root { - --background: 220 14% 96% !important; - --foreground: 224 12% 13% !important; - --card: 0 0% 100% !important; - --card-foreground: 224 12% 13% !important; - --popover: 0 0% 100% !important; - --popover-foreground: 224 12% 13% !important; - --primary: 222 47% 35% !important; - --primary-foreground: 0 0% 100% !important; - --secondary: 220 13% 93% !important; - --secondary-foreground: 224 12% 13% !important; - --muted: 218 12% 91% !important; - --muted-foreground: 218 8% 46% !important; - --accent: 213 72% 50% !important; - --accent-foreground: 0 0% 100% !important; - --destructive: 0 68% 50% !important; - --destructive-foreground: 0 0% 100% !important; - --border: 218 14% 89% !important; - --input: 218 14% 89% !important; - --ring: 222 47% 35% !important; - } -} +@custom-variant dark (&:where(.dark, .dark *)); :root { + color-scheme: light; /* Clean Slate — Modern ERP Palette */ --background: 220 14% 96%; /* Cool off-white #F2F4F7 */ @@ -95,6 +68,48 @@ html, :root { --duration-slow: 500ms; } +.dark { + color-scheme: dark; + + --background: 222 16% 10%; + --foreground: 216 12% 90%; + + --card: 222 14% 13%; + --card-foreground: 216 12% 90%; + + --popover: 222 14% 13%; + --popover-foreground: 216 12% 90%; + + --primary: 222 50% 55%; + --primary-foreground: 0 0% 100%; + + --secondary: 220 14% 18%; + --secondary-foreground: 216 12% 90%; + + --muted: 220 12% 20%; + --muted-foreground: 218 8% 55%; + + --accent: 213 72% 58%; + --accent-foreground: 0 0% 100%; + + --destructive: 0 62% 55%; + --destructive-foreground: 0 0% 100%; + + --border: 220 12% 22%; + --input: 220 12% 22%; + --ring: 222 50% 55%; + + --success: 152 44% 45%; + --success-foreground: 0 0% 100%; + + --warning: 38 85% 55%; + --warning-foreground: 224 12% 13%; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 12px 40px rgba(0, 0, 0, 0.45); +} + @theme inline { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); @@ -292,3 +307,8 @@ h1, h2, h3 { background: hsl(var(--primary) / 0.15); color: hsl(var(--foreground)); } + +.dark ::selection { + background: hsl(var(--primary) / 0.3); + color: hsl(var(--foreground)); +} diff --git a/app/layout.tsx b/app/layout.tsx index 56d6faa9..535288f0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { Fraunces } from "next/font/google"; import Script from "next/script"; import { Toaster } from "@/components/ui/toaster"; +import { ThemeProvider } from "@/components/theme-provider"; import "./globals.css"; const geistSans = Geist({ @@ -44,16 +45,22 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {children} - + + {children} + +