diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e0c57d07..435b77ff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -21,7 +21,9 @@ "Bash(npx supabase:*)", "Bash(curl:*)", "Bash(npx tsc:*)", - "Bash(findstr:*)" + "Bash(findstr:*)", + "Bash(git add:*)", + "Bash(git commit:*)" ] } } diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index 17667003..e2c14ec5 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -512,10 +512,33 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st Delsumma {formatCurrency(invoice.subtotal, invoice.currency)} -
- Moms ({invoice.vat_rate}%) - {formatCurrency(invoice.vat_amount, invoice.currency)} -
+ {(() => { + const vatByRate = new Map() + for (const item of invoice.items) { + const rate = item.vat_rate ?? 25 + const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100 + vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) + } + const entries = Array.from(vatByRate.entries()) + .filter(([, vat]) => vat > 0) + .sort(([a], [b]) => b - a) + + if (entries.length === 0) { + return ( +
+ Moms + {formatCurrency(0, invoice.currency)} +
+ ) + } + + return entries.map(([rate, vat]) => ( +
+ Moms {rate}% + {formatCurrency(vat, invoice.currency)} +
+ )) + })()}
Totalt diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx index e5392c0b..f1d45320 100644 --- a/app/(dashboard)/invoices/new/page.tsx +++ b/app/(dashboard)/invoices/new/page.tsx @@ -16,7 +16,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Separator } from '@/components/ui/separator' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' -import { getVatRules, getVatTreatmentLabel, getAvailableVatRates } from '@/lib/invoices/vat-rules' +import { getVatRules, getAvailableVatRates, getVatSummaryFromItems } from '@/lib/invoices/vat-rules' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog' import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' @@ -369,7 +369,7 @@ export default function NewInvoicePage() { {selectedCustomer && vatRules && (

- Momsbehandling: {getVatTreatmentLabel(vatRules.treatment)} + Momsbehandling: {getVatSummaryFromItems(watchItems).label}

{vatRules.reverseChargeText && (

@@ -664,10 +664,8 @@ export default function NewInvoicePage() { vat_rate: item.vat_rate ?? (vatRules?.rate || 25), }))} subtotal={subtotal} - vatRate={vatRules.rate} vatAmount={vatAmount} total={total} - vatTreatment={vatRules.treatment} yourReference={pendingData?.your_reference} ourReference={pendingData?.our_reference} notes={pendingData?.notes} diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index 825f0d8e..2fafeac1 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -103,9 +103,8 @@ export default function ReportsPage() { {selectedPeriod ? ( -

- - + + Saldobalans @@ -151,9 +150,7 @@ export default function ReportsPage() { Bankavstämning - -
-
+ diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 231a3302..c60c0052 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -17,7 +17,7 @@ import TransactionInboxCard from '@/components/transactions/TransactionInboxCard import TransactionHistoryList from '@/components/transactions/TransactionHistoryList' import InboxZeroState from '@/components/transactions/InboxZeroState' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' -import CategoryExpandedDialog from '@/components/transactions/CategoryExpandedDialog' +import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types' import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' @@ -44,10 +44,9 @@ export default function TransactionsPage() { const [selectedTransaction, setSelectedTransaction] = useState(null) const [isConfirmingMatch, setIsConfirmingMatch] = useState(false) - // Category expanded dialog - const [categoryDialogOpen, setCategoryDialogOpen] = useState(false) - const [categoryDialogTransaction, setCategoryDialogTransaction] = useState(null) - const [categoryDialogProcessing, setCategoryDialogProcessing] = useState(false) + // Booking dialog (journal entry form) + const [bookingDialogOpen, setBookingDialogOpen] = useState(false) + const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) // Set of transaction IDs that are animating out (just categorized) const [exitingIds, setExitingIds] = useState>(new Set()) @@ -164,7 +163,16 @@ export default function TransactionsPage() { // Mark as exiting for animation, then update state setExitingIds((prev) => new Set(prev).add(id)) + if (result.journal_entry_created) { + toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) + } else if (result.journal_entry_error) { + toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' }) + } else { + toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' }) + } + // Update transaction in state after a brief delay for animation + setExitingIds((prev) => new Set(prev).add(id)) setTimeout(() => { setTransactions((prev) => prev.map((t) => @@ -178,17 +186,9 @@ export default function TransactionsPage() { next.delete(id) return next }) + setProcessingId(null) }, 350) - if (result.journal_entry_created) { - toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) - } else if (result.journal_entry_error) { - toast({ title: 'Delvis bokförd', description: `Verifikation kunde inte skapas: ${result.journal_entry_error}`, variant: 'destructive' }) - } else { - toast({ title: 'Delvis bokförd', description: 'Transaktion uppdaterad men verifikation kunde inte skapas' }) - } - - setProcessingId(null) return true } catch { toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' }) @@ -218,6 +218,12 @@ export default function TransactionsPage() { return } + toast({ + title: 'Faktura matchad', + description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`, + }) + setMatchDialogOpen(false) + // Mark as exiting for animation setExitingIds((prev) => new Set(prev).add(selectedTransaction.id)) setTimeout(() => { @@ -241,18 +247,13 @@ export default function TransactionsPage() { next.delete(selectedTransaction.id) return next }) + setSelectedTransaction(null) + setIsConfirmingMatch(false) }, 350) - - toast({ - title: 'Faktura matchad', - description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`, - }) - setMatchDialogOpen(false) - setSelectedTransaction(null) } catch { toast({ title: 'Fel', description: 'Något gick fel vid matchning', variant: 'destructive' }) + setIsConfirmingMatch(false) } - setIsConfirmingMatch(false) } async function handleMatchInvoice(transactionId: string, invoiceId: string): Promise { @@ -329,15 +330,25 @@ export default function TransactionsPage() { setIsCreating(false) } - async function handleCategoryDialogSelect(category: TransactionCategory) { - if (!categoryDialogTransaction) return - setCategoryDialogProcessing(true) - const success = await handleCategorize(categoryDialogTransaction.id, true, category) - setCategoryDialogProcessing(false) - if (success) { - setCategoryDialogOpen(false) - setCategoryDialogTransaction(null) - } + function handleTransactionBooked(transactionId: string, journalEntryId: string) { + setExitingIds((prev) => new Set(prev).add(transactionId)) + setTimeout(() => { + setTransactions((prev) => + prev.map((t) => + t.id === transactionId + ? { ...t, is_business: true, journal_entry_id: journalEntryId } + : t + ) + ) + setExitingIds((prev) => { + const next = new Set(prev) + next.delete(transactionId) + return next + }) + }, 350) + setBookingDialogOpen(false) + setBookingDialogTransaction(null) + toast({ title: 'Bokförd', description: 'Transaktion bokförd och verifikation skapad' }) } // Batch mode handlers @@ -401,8 +412,8 @@ export default function TransactionsPage() { } function openCategoryDialog(transaction: TransactionWithInvoice) { - setCategoryDialogTransaction(transaction) - setCategoryDialogOpen(true) + setBookingDialogTransaction(transaction) + setBookingDialogOpen(true) } // Swipe view @@ -481,6 +492,7 @@ export default function TransactionsPage() { )} @@ -518,12 +530,11 @@ export default function TransactionsPage() { onConfirm={handleConfirmInvoiceMatch} /> - diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts new file mode 100644 index 00000000..d6fa64a0 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + makeJournalEntry, +} from '@/tests/helpers' + +const mockCreateClient = vi.fn() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => mockCreateClient(), +})) + +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + +const mockCorrectEntry = vi.fn() +vi.mock('@/lib/core/bookkeeping/storno-service', () => ({ + correctEntry: (...args: unknown[]) => mockCorrectEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { + 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/correct', { + method: 'POST', + body: { lines: [] }, + }) + 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 400 when lines are missing', async () => { + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: {}, + }) + 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('Lines are required') + }) + + it('returns 400 when lines array is empty', async () => { + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines: [] }, + }) + 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('Lines are required') + }) + + it('returns reversal and corrected entries on success', async () => { + const reversal = makeJournalEntry({ + id: 'reversal-1', + reverses_id: 'entry-1', + source_type: 'storno', + }) + const corrected = makeJournalEntry({ + id: 'corrected-1', + correction_of_id: 'entry-1', + source_type: 'correction', + }) + mockCorrectEntry.mockResolvedValue({ reversal, corrected }) + + const 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/entry-1/correct', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ data: { reversal: unknown; corrected: unknown } }>(response) + + expect(status).toBe(200) + expect(body.data.reversal).toEqual(reversal) + expect(body.data.corrected).toEqual(corrected) + expect(mockCorrectEntry).toHaveBeenCalledWith('user-1', 'entry-1', lines) + }) + + it('returns 400 when correctEntry throws for unbalanced lines', async () => { + mockCorrectEntry.mockRejectedValue( + new Error('Corrected entry is not balanced: debits (1000) != credits (500)') + ) + + const lines = [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 500 }, + ] + + const request = createMockRequest('/api/bookkeeping/journal-entries/entry-1/correct', { + method: 'POST', + body: { lines }, + }) + const response = await POST(request, createMockRouteParams({ id: 'entry-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(400) + expect(body.error).toContain('not balanced') + }) + + it('returns 400 when entry is not found or not posted', async () => { + mockCorrectEntry.mockRejectedValue(new Error('Can only correct posted entries')) + + const 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/entry-1/correct', { + method: 'POST', + body: { lines }, + }) + 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('Can only correct posted entries') + }) +}) diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts new file mode 100644 index 00000000..4f0e22a4 --- /dev/null +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -0,0 +1,41 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { correctEntry } from '@/lib/core/bookkeeping/storno-service' +import { ensureInitialized } from '@/lib/init' +import type { CreateJournalEntryLineInput } from '@/types' + +ensureInitialized() + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: { lines: CreateJournalEntryLineInput[] } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) + } + + if (!body.lines || !Array.isArray(body.lines) || body.lines.length === 0) { + return NextResponse.json({ error: 'Lines are required' }, { status: 400 }) + } + + try { + const result = await correctEntry(user.id, id, body.lines) + return NextResponse.json({ data: result }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to correct entry' }, + { status: 400 } + ) + } +} diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index 93e4dabb..318f7d77 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + const mockCreateJournalEntry = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 9c447af6..9366da98 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -1,8 +1,11 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createJournalEntry } from '@/lib/bookkeeping/engine' +import { ensureInitialized } from '@/lib/init' import type { CreateJournalEntryInput } from '@/types' +ensureInitialized() + export async function GET(request: Request) { const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() diff --git a/app/api/extensions/ai-categorization/settings/route.ts b/app/api/extensions/ai-categorization/settings/route.ts index 9a76b945..38ec4e63 100644 --- a/app/api/extensions/ai-categorization/settings/route.ts +++ b/app/api/extensions/ai-categorization/settings/route.ts @@ -1,6 +1,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { getSettings, saveSettings } from '@/extensions/general/ai-categorization' +import { ensureInitialized } from '@/lib/init' + +ensureInitialized() /** * 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 c08a91c0..1b7a624f 100644 --- a/app/api/extensions/ai-categorization/suggestions/route.ts +++ b/app/api/extensions/ai-categorization/suggestions/route.ts @@ -1,8 +1,11 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { categorizeTransactions } from '@/extensions/general/ai-categorization' +import { ensureInitialized } from '@/lib/init' import type { CategorizationSuggestion } from '@/extensions/general/ai-categorization/categorizer' +ensureInitialized() + /** * GET /api/extensions/ai-categorization/suggestions?transaction_ids=id1,id2,... * Fetch pre-computed AI suggestions for given transaction IDs diff --git a/app/api/extensions/enable-banking/sync/cron/route.ts b/app/api/extensions/enable-banking/sync/cron/route.ts index 025b436d..017beb38 100644 --- a/app/api/extensions/enable-banking/sync/cron/route.ts +++ b/app/api/extensions/enable-banking/sync/cron/route.ts @@ -2,8 +2,11 @@ import { createClient } from '@supabase/supabase-js' import { NextResponse } from 'next/server' import { syncAccountTransactions } from '@/extensions/general/enable-banking/lib/sync' import { isConsentExpiringSoon, getDaysUntilExpiry } from '@/extensions/general/enable-banking/lib/api-client' +import { ensureInitialized } from '@/lib/init' import type { StoredAccount } from '@/extensions/general/enable-banking/types' +ensureInitialized() + /** * GET /api/extensions/enable-banking/sync/cron * Automatic daily bank transaction sync diff --git a/app/api/extensions/receipt-ocr/upload/route.ts b/app/api/extensions/receipt-ocr/upload/route.ts index 7f70eaa9..215792dd 100644 --- a/app/api/extensions/receipt-ocr/upload/route.ts +++ b/app/api/extensions/receipt-ocr/upload/route.ts @@ -143,6 +143,8 @@ export async function POST(request: Request) { vat_amount: item.vatRate && item.lineTotal ? (item.lineTotal * item.vatRate) / (100 + item.vatRate) : null, extraction_confidence: item.confidence, suggested_category: item.suggestedCategory, + category: item.category, + bas_account: item.basAccount, sort_order: index, })) diff --git a/app/api/extensions/toggles/[sector]/[slug]/route.ts b/app/api/extensions/toggles/[sector]/[slug]/route.ts index 7b326dc8..3c8df51d 100644 --- a/app/api/extensions/toggles/[sector]/[slug]/route.ts +++ b/app/api/extensions/toggles/[sector]/[slug]/route.ts @@ -1,6 +1,15 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +// Legacy general extensions default to enabled when no toggle row exists +const LEGACY_GENERAL_EXTENSIONS = [ + 'receipt-ocr', + 'ai-categorization', + 'ai-chat', + 'push-notifications', + 'enable-banking', +] + export async function GET( _request: Request, { params }: { params: Promise<{ sector: string; slug: string }> } @@ -21,7 +30,14 @@ export async function GET( .eq('extension_slug', slug) .single() - return NextResponse.json({ data: data ?? { enabled: false } }) + if (data) { + return NextResponse.json({ data }) + } + + // No toggle row: legacy general extensions default to enabled + const defaultEnabled = + sector === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(slug) + return NextResponse.json({ data: { enabled: defaultEnabled } }) } export async function DELETE( diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts index 0dd8b28a..8f7d77df 100644 --- a/app/api/invoices/preview-pdf/route.ts +++ b/app/api/invoices/preview-pdf/route.ts @@ -55,25 +55,33 @@ export async function POST(request: Request) { const docType: InvoiceDocumentType = document_type || 'invoice' const isDeliveryNote = docType === 'delivery_note' - // Build items with line totals - const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => ({ - id: `preview-${index}`, - invoice_id: 'preview', - sort_order: index, - description: item.description, - quantity: item.quantity, - unit: item.unit, - unit_price: item.unit_price, - line_total: Math.round(item.quantity * item.unit_price * 100) / 100, - vat_rate: item.vat_rate ?? vatRules.rate, - vat_amount: 0, - created_at: new Date().toISOString(), - })) + // Build items with line totals and per-item VAT + const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => { + const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100 + const rate = item.vat_rate ?? vatRules.rate + return { + id: `preview-${index}`, + invoice_id: 'preview', + sort_order: index, + description: item.description, + quantity: item.quantity, + unit: item.unit, + unit_price: item.unit_price, + line_total: lineTotal, + vat_rate: rate, + vat_amount: isDeliveryNote ? 0 : Math.round(lineTotal * (rate / 100) * 100) / 100, + created_at: new Date().toISOString(), + } + }) const subtotal = invoiceItems.reduce((sum, item) => sum + item.line_total, 0) - const vatAmount = isDeliveryNote ? 0 : Math.round(subtotal * (vatRules.rate / 100) * 100) / 100 + const vatAmount = isDeliveryNote ? 0 : invoiceItems.reduce((sum, item) => sum + item.vat_amount, 0) const total = isDeliveryNote ? 0 : subtotal + vatAmount + // Derive vat_rate from items: single rate → that rate, mixed → null + const itemRates = new Set(invoiceItems.map((item) => item.vat_rate)) + const effectiveVatRate = isDeliveryNote ? 0 : (itemRates.size === 1 ? itemRates.values().next().value! : null) + // Construct a temporary Invoice-like object const previewInvoice = { id: 'preview', @@ -93,7 +101,7 @@ export async function POST(request: Request) { total, total_sek: null, vat_treatment: vatRules.treatment, - vat_rate: isDeliveryNote ? 0 : vatRules.rate, + vat_rate: effectiveVatRate, moms_ruta: vatRules.momsRuta, your_reference: your_reference || null, our_reference: our_reference || null, diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index 1c9d3c9a..d23e8631 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -12,6 +12,10 @@ vi.mock('@/lib/supabase/server', () => ({ createClient: () => Promise.resolve(mockSupabase), })) +vi.mock('@/lib/init', () => ({ + ensureInitialized: vi.fn(), +})) + const mockFindFiscalPeriod = vi.fn() vi.mock('@/lib/bookkeeping/engine', () => ({ findFiscalPeriod: (...args: unknown[]) => mockFindFiscalPeriod(...args), diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index 564c4428..e702d6e3 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -1,8 +1,11 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries' +import { ensureInitialized } from '@/lib/init' import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types' +ensureInitialized() + export async function GET(request: Request) { const supabase = await createClient() diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts new file mode 100644 index 00000000..c68ac8cc --- /dev/null +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createMockRouteParams, + createQueuedMockSupabase, + makeTransaction, + makeJournalEntry, +} 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 mockCreateJournalEntry = vi.fn() +vi.mock('@/lib/bookkeeping/engine', () => ({ + createJournalEntry: (...args: unknown[]) => mockCreateJournalEntry(...args), +})) + +import { POST } from '../route' + +describe('POST /api/transactions/[id]/book', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + const validBody = { + fiscal_period_id: 'period-1', + entry_date: '2025-01-15', + description: 'Test booking', + lines: [ + { account_number: '6200', debit_amount: 500, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 500 }, + ], + } + + 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/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + 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 missing required fields', async () => { + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: { fiscal_period_id: 'period-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).toContain('Missing required fields') + }) + + it('returns 404 when transaction not found', async () => { + enqueue({ data: null, error: { message: 'Not found' } }) + + const request = createMockRequest('/api/transactions/tx-999/book', { + method: 'POST', + body: validBody, + }) + 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 409 when transaction already has a journal entry', async () => { + const tx = makeTransaction({ + id: 'tx-1', + journal_entry_id: 'je-existing', + }) + enqueue({ data: tx, error: null }) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(409) + expect(body.error).toBe('Transaction already has a journal entry') + }) + + it('returns 400 when journal entry creation fails (engine error)', async () => { + const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null }) + enqueue({ data: tx, error: null }) + + mockCreateJournalEntry.mockRejectedValue(new Error('Entry is not balanced')) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + 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('Entry is not balanced') + }) + + it('creates journal entry and links to transaction (happy path)', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + journal_entry_id: null, + }) + const je = makeJournalEntry({ id: 'je-new' }) + + // Fetch transaction + enqueue({ data: tx, error: null }) + + mockCreateJournalEntry.mockResolvedValue(je) + + // Update transaction + enqueue({ data: null, error: null }) + + const emitSpy = vi.spyOn(eventBus, 'emit') + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) + const { status, body } = await parseJsonResponse<{ + success: boolean + journal_entry_id: string + data: { id: string } + }>(response) + + expect(status).toBe(200) + expect(body.success).toBe(true) + expect(body.journal_entry_id).toBe('je-new') + expect(body.data.id).toBe('je-new') + + expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', { + fiscal_period_id: 'period-1', + entry_date: '2025-01-15', + description: 'Test booking', + source_type: 'bank_transaction', + source_id: 'tx-1', + lines: validBody.lines, + }) + + expect(emitSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: 'transaction.categorized' }) + ) + }) + + it('returns 500 when transaction update fails', async () => { + const tx = makeTransaction({ id: 'tx-1', journal_entry_id: null }) + const je = makeJournalEntry({ id: 'je-new' }) + + enqueue({ data: tx, error: null }) + mockCreateJournalEntry.mockResolvedValue(je) + // Update fails + enqueue({ data: null, error: { message: 'Update failed' } }) + + const request = createMockRequest('/api/transactions/tx-1/book', { + method: 'POST', + body: validBody, + }) + 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') + }) +}) diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts new file mode 100644 index 00000000..9b0b33a2 --- /dev/null +++ b/app/api/transactions/[id]/book/route.ts @@ -0,0 +1,115 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { eventBus } from '@/lib/events' +import { ensureInitialized } from '@/lib/init' +import { createJournalEntry } from '@/lib/bookkeeping/engine' +import type { CreateJournalEntryLineInput, Transaction } from '@/types' + +ensureInitialized() + +interface BookRequest { + fiscal_period_id: string + entry_date: string + description: string + lines: CreateJournalEntryLineInput[] +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const supabase = await createClient() + const { id } = await params + + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body: BookRequest = await request.json() + const { fiscal_period_id, entry_date, description, lines } = body + + if (!fiscal_period_id || !entry_date || !description || !lines?.length) { + return NextResponse.json( + { error: 'Missing required fields: fiscal_period_id, entry_date, description, lines' }, + { status: 400 } + ) + } + + // Fetch transaction (validates ownership) + const { data: transaction, error: fetchError } = await supabase + .from('transactions') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single() + + if (fetchError || !transaction) { + return NextResponse.json({ error: 'Transaction not found' }, { status: 404 }) + } + + // Reject if already booked + if (transaction.journal_entry_id) { + return NextResponse.json( + { error: 'Transaction already has a journal entry' }, + { status: 409 } + ) + } + + // Create journal entry via the engine + let journalEntry + try { + journalEntry = await createJournalEntry(user.id, { + fiscal_period_id, + entry_date, + description, + source_type: 'bank_transaction', + source_id: id, + lines, + }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to create journal entry' }, + { status: 400 } + ) + } + + // Link transaction to the journal entry + const { error: updateError } = await supabase + .from('transactions') + .update({ + journal_entry_id: journalEntry.id, + is_business: true, + category: 'uncategorized', + }) + .eq('id', id) + + if (updateError) { + return NextResponse.json( + { error: 'Failed to update transaction' }, + { status: 500 } + ) + } + + // Emit event (non-blocking) + try { + await eventBus.emit({ + type: 'transaction.categorized', + payload: { + transaction: transaction as Transaction, + account: lines[0]?.account_number || '', + taxCode: '', + userId: user.id, + }, + }) + } catch { + // Non-critical + } + + return NextResponse.json({ + data: journalEntry, + journal_entry_id: journalEntry.id, + success: true, + }) +} diff --git a/app/api/transactions/[id]/categorize/__tests__/route.test.ts b/app/api/transactions/[id]/categorize/__tests__/route.test.ts index 4246c696..a4eff82b 100644 --- a/app/api/transactions/[id]/categorize/__tests__/route.test.ts +++ b/app/api/transactions/[id]/categorize/__tests__/route.test.ts @@ -128,7 +128,7 @@ describe('POST /api/transactions/[id]/categorize', () => { // 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 }) + enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) mockSaveUserMappingRule.mockResolvedValue(undefined) @@ -177,7 +177,7 @@ describe('POST /api/transactions/[id]/categorize', () => { 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 }) + enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockRejectedValue(new Error('Period locked')) @@ -210,7 +210,7 @@ describe('POST /api/transactions/[id]/categorize', () => { 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 }) + enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) @@ -228,6 +228,33 @@ describe('POST /api/transactions/[id]/categorize', () => { expect(body.error).toBe('Failed to update transaction') }) + it('returns 400 when mapping result has empty debit_account', async () => { + const tx = makeTransaction({ + id: 'tx-1', + amount: -500, + journal_entry_id: null, + }) + + enqueue({ data: tx, error: null }) + enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }) + + mockBuildMappingResultFromCategory.mockReturnValue({ + ...defaultMappingResult, + debit_account: '', + }) + + 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(400) + expect(body.error).toBe('Invalid account mapping: debit and credit accounts are required') + expect(mockCreateTransactionJournalEntry).not.toHaveBeenCalled() + }) + it('categorizes as private when is_business is false', async () => { const tx = makeTransaction({ id: 'tx-1', @@ -237,7 +264,7 @@ describe('POST /api/transactions/[id]/categorize', () => { 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 }) + enqueue({ data: [{ id: 'period-1' }], error: null }) mockCreateTransactionJournalEntry.mockResolvedValue({ id: 'je-1' }) diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index 3ce1dec4..b6abf39e 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -199,6 +199,14 @@ export async function POST( } } + // Validate that both accounts are present before proceeding + if (!mappingResult.debit_account || !mappingResult.credit_account) { + return NextResponse.json( + { error: 'Invalid account mapping: debit and credit accounts are required' }, + { status: 400 } + ) + } + // Ensure fiscal period exists for the transaction date await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth) diff --git a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts index 92cee214..99271abe 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -14,14 +14,11 @@ 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), -})) - +const mockCreateInvoicePaymentJournalEntry = vi.fn() +const mockCreateInvoiceCashEntry = vi.fn() vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ + createInvoicePaymentJournalEntry: (...args: unknown[]) => mockCreateInvoicePaymentJournalEntry(...args), + createInvoiceCashEntry: (...args: unknown[]) => mockCreateInvoiceCashEntry(...args), getRevenueAccount: vi.fn().mockReturnValue('3001'), getOutputVatAccount: vi.fn().mockReturnValue('2611'), })) @@ -35,7 +32,6 @@ describe('POST /api/transactions/[id]/match-invoice', () => { vi.clearAllMocks() reset() mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) - mockFindFiscalPeriod.mockResolvedValue('period-1') }) it('returns 401 when not authenticated', async () => { @@ -161,7 +157,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Fetch company settings enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateJournalEntry.mockResolvedValue({ id: 'je-1' }) + mockCreateInvoicePaymentJournalEntry.mockResolvedValue({ id: 'je-1' }) // Update invoice to paid enqueue({ data: null, error: null }) @@ -186,16 +182,11 @@ describe('POST /api/transactions/[id]/match-invoice', () => { 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( + // Verify accrual payment entry was called + expect(mockCreateInvoicePaymentJournalEntry).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 }), - ]), - }) + expect.objectContaining({ id: 'inv-1' }), + '2024-06-15' ) }) @@ -207,7 +198,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { enqueue({ data: invoice, error: null }) enqueue({ data: { accounting_method: 'accrual', entity_type: 'enskild_firma' }, error: null }) - mockCreateJournalEntry.mockRejectedValue(new Error('Period locked')) + mockCreateInvoicePaymentJournalEntry.mockRejectedValue(new Error('Period locked')) // Update invoice enqueue({ data: null, error: null }) diff --git a/app/page.tsx b/app/page.tsx index 39b0883f..9143e22a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import { redirect } from 'next/navigation' import DashboardNav from '@/components/dashboard/DashboardNav' import DashboardContent from '@/components/dashboard/DashboardContent' import type { Deadline, ReceiptQueueSummary } from '@/types' +import { ChatWidget } from '@/components/chat' export default async function RootPage() { const supabase = await createClient() @@ -190,6 +191,7 @@ export default async function RootPage() { }} />
+
) diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index eaca2ede..08e62428 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -139,7 +139,10 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value setSearch(newValue) - onChange(newValue) + // Only emit valid account numbers to parent + if (/^\d{4}$/.test(newValue) && accounts.some(a => a.account_number === newValue)) { + onChange(newValue) + } if (!isOpen) { setIsOpen(true) } @@ -149,6 +152,15 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo setIsOpen(true) } + const handleBlur = () => { + // Small delay to allow dropdown click to fire first + setTimeout(() => { + if (!accounts.some(a => a.account_number === search)) { + setSearch(value) + } + }, 150) + } + // Find matching account for helper text const matchedAccount = useMemo(() => { if (!value || value.length !== 4) return null @@ -162,16 +174,16 @@ export default function AccountCombobox({ value, accounts, onChange }: AccountCo value={search} onChange={handleInputChange} onFocus={handleFocus} + onBlur={handleBlur} onKeyDown={handleKeyDown} placeholder="1930" className="font-mono h-8" - maxLength={4} autoComplete="off" /> - {/* Account name helper text (md+ screens only) */} + {/* Account name helper text */} {matchedAccount && ( -

+

{matchedAccount.account_name}

)} diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx new file mode 100644 index 00000000..20aae05e --- /dev/null +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -0,0 +1,266 @@ +'use client' + +import { useState, useEffect } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { AccountNumber } from '@/components/ui/account-number' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { useToast } from '@/components/ui/use-toast' +import { Plus, Trash2 } from 'lucide-react' +import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types' + +interface CorrectionLine { + account_number: string + debit_amount: string + credit_amount: string + line_description: string +} + +interface Props { + entry: JournalEntry + open: boolean + onOpenChange: (open: boolean) => void + onCorrected: () => void +} + +export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCorrected }: Props) { + const { toast } = useToast() + const [accounts, setAccounts] = useState([]) + const [lines, setLines] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + const originalLines = ((entry.lines || []) as JournalEntryLine[]) + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + + useEffect(() => { + if (open) { + // Pre-fill with original entry's lines + setLines( + originalLines.map((l) => ({ + account_number: l.account_number, + debit_amount: Number(l.debit_amount) > 0 ? String(Number(l.debit_amount)) : '', + credit_amount: Number(l.credit_amount) > 0 ? String(Number(l.credit_amount)) : '', + line_description: l.line_description || '', + })) + ) + fetchAccounts() + } + }, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps + + async function fetchAccounts() { + try { + const res = await fetch('/api/bookkeeping/accounts') + const { data } = await res.json() + setAccounts(data || []) + } catch { + // Accounts will be empty — user can still type account numbers manually + } + } + + const updateLine = (index: number, field: keyof CorrectionLine, value: string) => { + setLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l))) + } + + const addLine = () => { + setLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }]) + } + + const removeLine = (index: number) => { + setLines((prev) => prev.filter((_, i) => i !== index)) + } + + const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) + const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) + const roundedDebit = Math.round(totalDebit * 100) / 100 + const roundedCredit = Math.round(totalCredit * 100) / 100 + const isBalanced = roundedDebit === roundedCredit && roundedDebit > 0 + + const hasValidLines = lines.length >= 2 && lines.every((l) => l.account_number.length === 4) + + async function handleSubmit() { + if (!isBalanced || !hasValidLines) return + + setIsSubmitting(true) + try { + const apiLines = lines.map((l) => ({ + account_number: l.account_number, + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + line_description: l.line_description || undefined, + })) + + const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ lines: apiLines }), + }) + + if (!res.ok) { + const { error } = await res.json() + throw new Error(error || 'Failed to create correction') + } + + toast({ title: 'Ändringsverifikation skapad', description: 'Storno och rättelse har bokförts.' }) + onOpenChange(false) + onCorrected() + } catch (err) { + toast({ + title: 'Fel', + description: err instanceof Error ? err.message : 'Kunde inte skapa ändringsverifikation', + variant: 'destructive', + }) + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Skapa ändringsverifikation + + + {/* Original entry (read-only) */} +
+
+ {entry.voucher_series}{entry.voucher_number} + {entry.entry_date} + Original +
+

{entry.description}

+ + + + + + + + + + + + {originalLines.map((line) => ( + + + + + + + ))} + +
KontoBeskrivningDebetKredit
{line.line_description || ''} + {Number(line.debit_amount) > 0 + ? Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : ''} + + {Number(line.credit_amount) > 0 + ? Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 }) + : ''} +
+
+ + {/* Divider */} +
+ + {/* Corrected lines (editable) */} +
+

Rättade rader

+ +
+ {lines.map((line, index) => ( +
+ updateLine(index, 'account_number', v)} + /> + updateLine(index, 'line_description', e.target.value)} + placeholder="Beskrivning" + className="h-8" + /> + updateLine(index, 'debit_amount', e.target.value)} + placeholder="Debet" + className="h-8 text-right" + min={0} + step="0.01" + /> + updateLine(index, 'credit_amount', e.target.value)} + placeholder="Kredit" + className="h-8 text-right" + min={0} + step="0.01" + /> + +
+ ))} +
+ + + + {/* Balance summary */} +
+
+ Debet: + + {roundedDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + +
+
+ Kredit: + + {roundedCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + +
+
+ + {!isBalanced && roundedDebit + roundedCredit > 0 && ( +

+ Debet och kredit måste vara lika och större än 0. +

+ )} +
+ + + + + + +
+ ) +} diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index d8ac5e08..1384ad0c 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -12,29 +12,48 @@ import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntry import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' -import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount } from '@/types' +import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types' -interface Props { - onCreated?: () => void -} - -interface FormLine { +export interface FormLine { account_number: string debit_amount: string credit_amount: string line_description: string } -export default function JournalEntryForm({ onCreated }: Props) { +interface Props { + onCreated?: () => void + onEntryCreated?: (entryId: string) => void + initialLines?: FormLine[] + initialDate?: string + initialDescription?: string + sourceType?: JournalEntrySourceType + sourceId?: string + submitUrl?: string + embedded?: boolean +} + +const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' } + +export default function JournalEntryForm({ + onCreated, + onEntryCreated, + initialLines, + initialDate, + initialDescription, + sourceType, + sourceId, + submitUrl, + embedded, +}: Props) { const { toast } = useToast() const [periods, setPeriods] = useState([]) const [selectedPeriod, setSelectedPeriod] = useState('') - const [entryDate, setEntryDate] = useState(new Date().toISOString().split('T')[0]) - const [description, setDescription] = useState('') - const [lines, setLines] = useState([ - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - ]) + const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0]) + const [description, setDescription] = useState(initialDescription ?? '') + const [lines, setLines] = useState( + initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }] + ) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) @@ -63,10 +82,7 @@ export default function JournalEntryForm({ onCreated }: Props) { } const addLine = () => { - setLines([ - ...lines, - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - ]) + setLines([...lines, { ...BLANK_LINE }]) } const removeLine = (index: number) => { @@ -85,12 +101,20 @@ export default function JournalEntryForm({ onCreated }: Props) { updated[index].debit_amount = '' } + // Auto-fill line description from account name when selecting an account + if (field === 'account_number' && value && !updated[index].line_description) { + const account = accounts.find((a) => a.account_number === value) + if (account) { + updated[index].line_description = account.account_name + } + } + setLines(updated) } const totalDebit = lines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0) const totalCredit = lines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0) - const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0 + const isBalanced = Math.round((totalDebit - totalCredit) * 100) === 0 && totalDebit > 0 const handleReview = () => { if (!selectedPeriod || !description || !isBalanced) return @@ -109,14 +133,17 @@ export default function JournalEntryForm({ onCreated }: Props) { line_description: l.line_description || undefined, })) - const res = await fetch('/api/bookkeeping/journal-entries', { + const url = submitUrl ?? '/api/bookkeeping/journal-entries' + + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fiscal_period_id: selectedPeriod, entry_date: entryDate, description, - source_type: 'manual', + source_type: sourceType ?? 'manual', + source_id: sourceId, lines: entryLines, }), }) @@ -131,8 +158,8 @@ export default function JournalEntryForm({ onCreated }: Props) { }) } else { // Link uploaded documents to the new journal entry (non-blocking) - const journalEntryId = result.data?.id - if (journalEntryId) { + const journalEntryId = result.data?.id ?? result.journal_entry_id + if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) for (const file of filesToLink) { try { @@ -149,163 +176,160 @@ export default function JournalEntryForm({ onCreated }: Props) { toast({ title: 'Verifikation skapad', - description: `Verifikation ${result.data?.voucher_series}${result.data?.voucher_number} har skapats.`, + description: `Verifikation ${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''} har skapats.`, }) setShowReview(false) // Reset form setDescription('') setUploadedFiles([]) - setLines([ - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }, - ]) + setLines([{ ...BLANK_LINE }, { ...BLANK_LINE }]) onCreated?.() + if (journalEntryId) { + onEntryCreated?.(journalEntryId) + } } setIsSubmitting(false) } - return ( - - - Ny verifikation - - -
-
- - -
-
- - setEntryDate(e.target.value)} - /> -
-
- - setDescription(e.target.value)} - placeholder="Verifikationstext..." - /> -
-
- - {/* Entry lines */} + const formContent = ( +
+
- - - - - - - - - - - - {lines.map((line, index) => ( - - - - - - - - ))} - - - - - - - - - -
KontoBeskrivningDebetKredit
- updateLine(index, 'account_number', num)} - /> - - updateLine(index, 'line_description', e.target.value)} - placeholder="Radtext..." - className="h-8" - /> - - updateLine(index, 'debit_amount', e.target.value)} - placeholder="0,00" - className="text-right h-8" - min="0" - step="0.01" - /> - - updateLine(index, 'credit_amount', e.target.value)} - placeholder="0,00" - className="text-right h-8" - min="0" - step="0.01" - /> - - -
- Summa - - {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} - - {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} -
- -
+
+ + setEntryDate(e.target.value)} + /> +
+
+ + setDescription(e.target.value)} + placeholder="Verifikationstext..." + /> +
+
- {/* Document attachments */} + {/* Entry lines */} +
+ + + + + + + + + + + + {lines.map((line, index) => ( + + + + + + + + ))} + + + + + + + + + +
KontoBeskrivningDebetKredit
+ updateLine(index, 'account_number', num)} + /> + + updateLine(index, 'line_description', e.target.value)} + placeholder="Radtext..." + className="h-8" + /> + + updateLine(index, 'debit_amount', e.target.value)} + placeholder="0,00" + className="text-right h-8" + min="0" + step="0.01" + /> + + updateLine(index, 'credit_amount', e.target.value)} + placeholder="0,00" + className="text-right h-8" + min="0" + step="0.01" + /> + + +
+ Summa + + {totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + + {totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })} +
+ + +
+ + {/* Document attachments */} + {!embedded && (
+ )} - {!isBalanced && totalDebit > 0 && ( -

- Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr -

- )} + {!isBalanced && totalDebit > 0 && ( +

+ Differens: {Math.abs(totalDebit - totalCredit).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} kr +

+ )} -
- - {(!description || !selectedPeriod || isUploading) && ( -
- {!description &&

Ange en beskrivning

} - {!selectedPeriod &&

Välj en räkenskapsperiod

} - {isUploading &&

Vänta tills filerna laddats upp

} -
- )} -
- - + + {(!description || !selectedPeriod || isUploading) && ( +
+ {!description &&

Ange en beskrivning

} + {!selectedPeriod &&

Välj en räkenskapsperiod

} + {isUploading &&

Vänta tills filerna laddats upp

} +
+ )} +
+ + + p.id === selectedPeriod)?.name || ''} + entryDate={entryDate} + description={description} + lines={lines} + totalDebit={totalDebit} + totalCredit={totalCredit} + attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length} + /> + +
+ ) + + if (embedded) { + return formContent + } + + return ( + + + Ny verifikation + + + {formContent} ) diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 98475cd7..de63de7d 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -9,6 +9,7 @@ import { Switch } from '@/components/ui/switch' import { ChevronDown, ChevronRight, Paperclip, AlertTriangle } from 'lucide-react' import { AccountNumber } from '@/components/ui/account-number' import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments' +import CorrectionEntryDialog from '@/components/bookkeeping/CorrectionEntryDialog' import type { JournalEntry, JournalEntryLine } from '@/types' const NEEDS_ATTACHMENT = new Set([ @@ -32,6 +33,7 @@ export default function JournalEntryList({ periodId }: Props) { const [page, setPage] = useState(0) const [attachmentCounts, setAttachmentCounts] = useState>({}) const [showMissingOnly, setShowMissingOnly] = useState(false) + const [correctionEntry, setCorrectionEntry] = useState(null) const pageSize = 20 const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => { @@ -209,7 +211,7 @@ export default function JournalEntryList({ periodId }: Props) { - + @@ -220,7 +222,7 @@ export default function JournalEntryList({ periodId }: Props) { .sort((a, b) => a.sort_order - b.sort_order) .map((line) => ( - + @@ -264,6 +266,18 @@ export default function JournalEntryList({ periodId }: Props) { journalEntryId={entry.id} onCountChange={(c) => handleAttachmentCountChange(entry.id, c)} /> + + {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( +
+ +
+ )} )} @@ -271,6 +285,16 @@ export default function JournalEntryList({ periodId }: Props) { })} + {/* Correction dialog */} + {correctionEntry && ( + { if (!open) setCorrectionEntry(null) }} + onCorrected={() => { setCorrectionEntry(null); fetchEntries() }} + /> + )} + {/* Pagination */} {count > pageSize && (
diff --git a/components/chat/ChatWidget.tsx b/components/chat/ChatWidget.tsx index 7b44d80a..c74ea0c0 100644 --- a/components/chat/ChatWidget.tsx +++ b/components/chat/ChatWidget.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { ChatPanel } from './ChatPanel' import { MessageCircle, X } from 'lucide-react' @@ -8,6 +8,47 @@ import { cn } from '@/lib/utils' export function ChatWidget() { const [isOpen, setIsOpen] = useState(false) + // Default to true for legacy compatibility (ai-chat defaults to enabled) + const [enabled, setEnabled] = useState(true) + + // Fetch initial toggle state + useEffect(() => { + const check = async () => { + try { + const res = await fetch('/api/extensions/toggles/general/ai-chat') + if (res.ok) { + const { data } = await res.json() + // Legacy: enabled by default when no toggle row exists + setEnabled(data?.enabled ?? true) + } + } catch { + // Keep default (enabled) on fetch failure + } + } + check() + }, []) + + // Listen for real-time toggle changes + useEffect(() => { + const handler = (e: Event) => { + const { sectorSlug, extensionSlug, enabled: newValue } = (e as CustomEvent).detail + if (sectorSlug === 'general' && extensionSlug === 'ai-chat') { + setEnabled(newValue) + if (!newValue) setIsOpen(false) + } + } + window.addEventListener('extension-toggle-changed', handler) + return () => window.removeEventListener('extension-toggle-changed', handler) + }, []) + + // Allow other components to open the chat via custom event + useEffect(() => { + const handler = () => setIsOpen(true) + window.addEventListener('open-ai-chat', handler) + return () => window.removeEventListener('open-ai-chat', handler) + }, []) + + if (!enabled) return null return ( <> diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index ce280b6c..97a1362d 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -27,6 +27,7 @@ import { Landmark, CheckCircle2, ClipboardList, + MessageCircle, } from 'lucide-react' import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' @@ -179,6 +180,8 @@ export default function DashboardContent({ firstName, settings, summary, onboard const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS) const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS + const openAiChat = () => window.dispatchEvent(new Event('open-ai-chat')) + // Quick action items const quickActions = [ { href: '/invoices/new', icon: Receipt, label: 'Ny faktura', desc: 'Skapa och skicka', accent: true }, @@ -400,6 +403,18 @@ export default function DashboardContent({ firstName, settings, summary, onboard ) })} + {/* AI assistant quick action */} +
diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index e25733f9..243f2b59 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -2,9 +2,9 @@ import { Badge } from '@/components/ui/badge' import { Separator } from '@/components/ui/separator' -import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules' +import { getVatSummaryFromItems } from '@/lib/invoices/vat-rules' import { formatCurrency } from '@/lib/utils' -import type { Customer, Currency, VatTreatment } from '@/types' +import type { Customer, Currency } from '@/types' interface ReviewItem { description: string @@ -21,10 +21,8 @@ interface InvoiceReviewContentProps { currency: Currency items: ReviewItem[] subtotal: number - vatRate: number vatAmount: number total: number - vatTreatment: VatTreatment yourReference?: string ourReference?: string notes?: string @@ -37,10 +35,8 @@ export function InvoiceReviewContent({ currency, items, subtotal, - vatRate, vatAmount, total, - vatTreatment, yourReference, ourReference, notes, @@ -52,24 +48,20 @@ export function InvoiceReviewContent({ non_eu_business: 'Utanför EU', } - // Check if items have mixed VAT rates - const hasPerLineVat = items.some((item) => item.vat_rate !== undefined) - const uniqueRates = hasPerLineVat - ? new Set(items.map((item) => item.vat_rate ?? vatRate)) - : new Set([vatRate]) - const showVatColumn = hasPerLineVat && uniqueRates.size > 1 + // Derive VAT summary from items + const vatSummary = getVatSummaryFromItems(items) // Calculate per-rate VAT breakdown const vatByRate = new Map() - if (hasPerLineVat) { - for (const item of items) { - const rate = item.vat_rate ?? vatRate - const lineTotal = item.quantity * item.unit_price - const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 - vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) - } + for (const item of items) { + const rate = item.vat_rate ?? 25 + const lineTotal = item.quantity * item.unit_price + const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 + vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) } + const showVatColumn = vatByRate.size > 1 + return (
{/* Customer info */} @@ -85,7 +77,7 @@ export function InvoiceReviewContent({ {/* VAT treatment */} - {getVatTreatmentLabel(vatTreatment)} + {vatSummary.label} {/* Dates */} @@ -120,7 +112,7 @@ export function InvoiceReviewContent({
{showVatColumn && ( - + )}
KontoKonto Beskrivning Debet Kredit
{line.line_description || ''} {item.unit} {formatCurrency(item.unit_price, currency)}{item.vat_rate ?? vatRate}%{item.vat_rate ?? 25}% {formatCurrency(item.quantity * item.unit_price, currency)} @@ -136,21 +128,19 @@ export function InvoiceReviewContent({ Delsumma {formatCurrency(subtotal, currency)} - {vatByRate.size > 1 ? ( - // Per-rate breakdown - Array.from(vatByRate.entries()) - .filter(([, vat]) => vat > 0) - .sort(([a], [b]) => b - a) - .map(([rate, vat]) => ( -
- Moms {rate}% - {formatCurrency(vat, currency)} -
- )) - ) : ( + {Array.from(vatByRate.entries()) + .filter(([, vat]) => vat > 0) + .sort(([a], [b]) => b - a) + .map(([rate, vat]) => ( +
+ Moms {rate}% + {formatCurrency(vat, currency)} +
+ ))} + {Array.from(vatByRate.values()).every((vat) => vat === 0) && (
- Moms ({vatRate}%) - {formatCurrency(vatAmount, currency)} + Moms + {formatCurrency(0, currency)}
)} diff --git a/components/transactions/CategoryExpandedDialog.tsx b/components/transactions/CategoryExpandedDialog.tsx index 089ef01d..6890c74f 100644 --- a/components/transactions/CategoryExpandedDialog.tsx +++ b/components/transactions/CategoryExpandedDialog.tsx @@ -1,18 +1,20 @@ 'use client' +import { useState, useEffect } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight } from 'lucide-react' -import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types' -import type { TransactionCategory } from '@/types' +import type { TransactionCategory, VatTreatment } from '@/types' interface CategoryExpandedDialogProps { open: boolean onOpenChange: (open: boolean) => void transaction: TransactionWithInvoice | null - onSelectCategory: (category: TransactionCategory) => void + onSelectCategory: (category: TransactionCategory, vatTreatment?: VatTreatment) => void isProcessing: boolean } @@ -23,10 +25,23 @@ export default function CategoryExpandedDialog({ onSelectCategory, isProcessing, }: CategoryExpandedDialogProps) { + const [vatTreatment, setVatTreatment] = useState('standard_25') + + useEffect(() => { + if (open) { + setVatTreatment('standard_25') + } + }, [open, transaction?.id]) + if (!transaction) return null const isIncome = transaction.amount > 0 + const handleSelectCategory = (category: TransactionCategory) => { + const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment + onSelectCategory(category, resolvedVat) + } + return ( @@ -62,6 +77,26 @@ export default function CategoryExpandedDialog({

+ {/* VAT treatment selector */} +
+

Momsbehandling

+ +
+ {/* Category grid */}
@@ -73,7 +108,7 @@ export default function CategoryExpandedDialog({ variant="outline" size="sm" className="justify-start text-xs" - onClick={() => onSelectCategory(cat.value)} + onClick={() => handleSelectCategory(cat.value)} disabled={isProcessing} > {cat.label} @@ -90,7 +125,7 @@ export default function CategoryExpandedDialog({ variant="outline" size="sm" className="justify-start text-xs" - onClick={() => onSelectCategory(cat.value)} + onClick={() => handleSelectCategory(cat.value)} disabled={isProcessing} > {cat.label} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index 8d9f634a..494521f7 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -48,6 +48,13 @@ export default function SwipeCategorizationView({ const [vatTreatment, setVatTreatment] = useState('standard_25') const [accounts, setAccounts] = useState([]) + // Clear VAT treatment when switching to a liability/equity account (class 2) + useEffect(() => { + if (accountOverride.startsWith('2') && vatTreatment !== 'none') { + setVatTreatment('none') + } + }, [accountOverride]) // eslint-disable-line react-hooks/exhaustive-deps + // Fetch accounts on mount useEffect(() => { async function fetchAccounts() { @@ -375,7 +382,7 @@ export default function SwipeCategorizationView({
-
-

0 ? 'text-success' : '' - }`} - > - {transaction.amount > 0 ? '+' : ''} - {formatCurrency(transaction.amount, transaction.currency)} -

- {transaction.currency !== 'SEK' && transaction.amount_sek && ( -

- {formatCurrency(transaction.amount_sek)} -

+
+ {transaction.is_business === null && !transaction.journal_entry_id && ( + )} +
+

0 ? 'text-success' : '' + }`} + > + {transaction.amount > 0 ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+ {transaction.currency !== 'SEK' && transaction.amount_sek && ( +

+ {formatCurrency(transaction.amount_sek)} +

+ )} +
diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index ad013efa..beb17335 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { formatCurrency, formatDate } from '@/lib/utils' -import { ArrowUpRight, ArrowDownRight, FileText, MoreHorizontal, Loader2 } from 'lucide-react' +import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' @@ -178,15 +178,15 @@ export default function TransactionInboxCard({ Privat - {/* More options */} + {/* Open category dialog */} )} diff --git a/extensions/general/push-notifications/index.ts b/extensions/general/push-notifications/index.ts index 1dfbba81..7fbd0d98 100644 --- a/extensions/general/push-notifications/index.ts +++ b/extensions/general/push-notifications/index.ts @@ -52,15 +52,21 @@ export async function getSettings(userId: string): Promise) } + if (!data) return { ...DEFAULT_SETTINGS } + return { + periodLockedEnabled: data.period_locked_enabled ?? DEFAULT_SETTINGS.periodLockedEnabled, + periodYearClosedEnabled: data.period_year_closed_enabled ?? DEFAULT_SETTINGS.periodYearClosedEnabled, + invoiceSentEnabled: data.invoice_sent_enabled ?? DEFAULT_SETTINGS.invoiceSentEnabled, + receiptExtractedEnabled: data.receipt_extracted_enabled ?? DEFAULT_SETTINGS.receiptExtractedEnabled, + receiptMatchedEnabled: data.receipt_matched_enabled ?? DEFAULT_SETTINGS.receiptMatchedEnabled, + } } export async function saveSettings( @@ -74,15 +80,17 @@ export async function saveSettings( const supabase = await createClient() await supabase - .from('extension_data') + .from('notification_settings') .upsert( { user_id: userId, - extension_id: 'push-notifications', - key: 'settings', - value: merged, + period_locked_enabled: merged.periodLockedEnabled, + period_year_closed_enabled: merged.periodYearClosedEnabled, + invoice_sent_enabled: merged.invoiceSentEnabled, + receipt_extracted_enabled: merged.receiptExtractedEnabled, + receipt_matched_enabled: merged.receiptMatchedEnabled, }, - { onConflict: 'user_id,extension_id,key' } + { onConflict: 'user_id' } ) return merged diff --git a/extensions/general/push-notifications/types.ts b/extensions/general/push-notifications/types.ts index 40faeafb..5018f8bf 100644 --- a/extensions/general/push-notifications/types.ts +++ b/extensions/general/push-notifications/types.ts @@ -21,6 +21,11 @@ export interface NotificationSettings { quiet_end: string // time format "HH:MM" email_enabled: boolean push_enabled: boolean + period_locked_enabled: boolean + period_year_closed_enabled: boolean + invoice_sent_enabled: boolean + receipt_extracted_enabled: boolean + receipt_matched_enabled: boolean created_at: string updated_at: string } diff --git a/extensions/general/receipt-ocr/index.ts b/extensions/general/receipt-ocr/index.ts index 830a9830..b9596dcd 100644 --- a/extensions/general/receipt-ocr/index.ts +++ b/extensions/general/receipt-ocr/index.ts @@ -183,6 +183,8 @@ async function handleDocumentUploaded( : null, extraction_confidence: item.confidence, suggested_category: item.suggestedCategory, + category: item.category, + bas_account: item.basAccount, sort_order: index, })) diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts index 2ffa158f..3e05b61d 100644 --- a/lib/bookkeeping/__tests__/category-mapping.test.ts +++ b/lib/bookkeeping/__tests__/category-mapping.test.ts @@ -4,7 +4,10 @@ import { getExpenseAccountForCategory, getDefaultAccountForCategory, getDefaultVatTreatmentForCategory, + buildMappingResultFromCategory, } from '../category-mapping' +import { makeTransaction } from '@/tests/helpers' +import type { TransactionCategory } from '@/types' describe('getCategoryAccountMapping', () => { describe('income_products uses correct account', () => { @@ -81,6 +84,79 @@ describe('getDefaultAccountForCategory', () => { }) }) +describe('buildMappingResultFromCategory', () => { + describe('reverse charge handling', () => { + it('generates fiktiv moms lines for reverse charge expense', () => { + const tx = makeTransaction({ amount: -1000 }) + const result = buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge') + + expect(result.vat_lines).toHaveLength(2) + + const debitLine = result.vat_lines.find((l) => l.account_number === '2645') + expect(debitLine).toBeDefined() + expect(debitLine!.debit_amount).toBe(250) + expect(debitLine!.credit_amount).toBe(0) + + const creditLine = result.vat_lines.find((l) => l.account_number === '2614') + expect(creditLine).toBeDefined() + expect(creditLine!.debit_amount).toBe(0) + expect(creditLine!.credit_amount).toBe(250) + }) + + it('does not generate regular input VAT (2641) for reverse charge', () => { + const tx = makeTransaction({ amount: -1000 }) + const result = buildMappingResultFromCategory('expense_equipment', tx, true, 'enskild_firma', 'reverse_charge') + + const hasRegularVat = result.vat_lines.some((l) => l.account_number === '2641') + expect(hasRegularVat).toBe(false) + }) + + it('does not generate VAT lines for reverse charge on income', () => { + const tx = makeTransaction({ amount: 1000 }) + const result = buildMappingResultFromCategory('income_services', tx, true, 'enskild_firma', 'reverse_charge') + + expect(result.vat_lines).toHaveLength(0) + }) + + it('does not generate VAT lines for reverse charge on private transactions', () => { + const tx = makeTransaction({ amount: -1000 }) + const result = buildMappingResultFromCategory('expense_software', tx, false, 'enskild_firma', 'reverse_charge') + + expect(result.vat_lines).toHaveLength(0) + }) + }) +}) + +describe('buildMappingResultFromCategory returns non-empty accounts', () => { + const allCategories: TransactionCategory[] = [ + 'income_services', + 'income_products', + 'income_other', + 'expense_equipment', + 'expense_software', + 'expense_travel', + 'expense_office', + 'expense_marketing', + 'expense_professional_services', + 'expense_education', + 'expense_bank_fees', + 'expense_card_fees', + 'expense_currency_exchange', + 'expense_other', + 'private', + 'uncategorized', + ] + + it.each(allCategories)('returns non-empty debit_account and credit_account for "%s"', (category) => { + const tx = makeTransaction({ amount: category.startsWith('income') ? 1000 : -1000 }) + const isBusiness = category !== 'private' + const result = buildMappingResultFromCategory(category, tx, isBusiness) + + expect(result.debit_account).toBeTruthy() + expect(result.credit_account).toBeTruthy() + }) +}) + describe('getDefaultVatTreatmentForCategory', () => { it('returns standard_25 for regular expense categories', () => { expect(getDefaultVatTreatmentForCategory('expense_equipment')).toBe('standard_25') diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts index f40c89c9..cbbed14d 100644 --- a/lib/bookkeeping/category-mapping.ts +++ b/lib/bookkeeping/category-mapping.ts @@ -1,5 +1,5 @@ import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types' -import { getVatRate } from './vat-entries' +import { getVatRate, generateReverseChargeLines } from './vat-entries' /** * Maps TransactionCategory to BAS accounts for journal entry creation @@ -176,7 +176,19 @@ export function buildMappingResultFromCategory( const treatment = mapping.vatTreatment as VatTreatment | null if (isBusiness && treatment) { const vatRate = getVatRate(treatment) - if (vatRate > 0) { + if (treatment === 'reverse_charge' && transaction.amount < 0) { + // EU reverse charge: fiktiv moms (offsetting entries) + const absAmount = Math.abs(transaction.amount) + const rcLines = generateReverseChargeLines(absAmount) + for (const rcl of rcLines) { + vatLines.push({ + account_number: rcl.account_number, + debit_amount: rcl.debit_amount, + credit_amount: rcl.credit_amount, + description: rcl.line_description || '', + }) + } + } else if (vatRate > 0) { const grossAmount = Math.abs(transaction.amount) const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100 diff --git a/lib/bookkeeping/mapping-engine.ts b/lib/bookkeeping/mapping-engine.ts index 733e2c2b..37fed2e0 100644 --- a/lib/bookkeeping/mapping-engine.ts +++ b/lib/bookkeeping/mapping-engine.ts @@ -114,7 +114,7 @@ function buildResult(rule: MappingRule, transaction: Transaction): MappingResult const absAmount = Math.abs(transaction.amount) const isExpense = transaction.amount < 0 - let debitAccount = rule.debit_account || (isExpense ? '6900' : '1930') + let debitAccount = rule.debit_account || (isExpense ? '6991' : '1930') let creditAccount = rule.credit_account || (isExpense ? '1930' : '3001') // Check capitalization threshold for equipment @@ -183,7 +183,7 @@ function getDefaultResult(transaction: Transaction): MappingResult { return { rule: null, - debit_account: isExpense ? '6900' : '1930', + debit_account: isExpense ? '6991' : '1930', credit_account: isExpense ? '1930' : '3001', risk_level: 'MEDIUM', confidence: 0.1, diff --git a/lib/bookkeeping/transaction-entries.ts b/lib/bookkeeping/transaction-entries.ts index 18d0c008..411601ad 100644 --- a/lib/bookkeeping/transaction-entries.ts +++ b/lib/bookkeeping/transaction-entries.ts @@ -39,6 +39,12 @@ export async function createTransactionJournalEntry( transaction: Transaction, mappingResult: MappingResult ): Promise { + if (!mappingResult.debit_account || !mappingResult.credit_account) { + throw new Error( + `Invalid mapping result: debit_account="${mappingResult.debit_account}", credit_account="${mappingResult.credit_account}". Both must be non-empty.` + ) + } + const fiscalPeriodId = await findFiscalPeriod(userId, transaction.date) if (!fiscalPeriodId) { console.warn('No open fiscal period found for transaction date:', transaction.date) diff --git a/lib/core/bookkeeping/storno-service.ts b/lib/core/bookkeeping/storno-service.ts index 8ef4da27..15b20046 100644 --- a/lib/core/bookkeeping/storno-service.ts +++ b/lib/core/bookkeeping/storno-service.ts @@ -128,81 +128,105 @@ export async function correctEntry( .eq('id', originalEntryId) // ===== Step 2: Create corrected entry ===== - const correctedVoucherNumber = await getNextVoucherNumber( - userId, - original.fiscal_period_id, - original.voucher_series || 'A' - ) - - // Resolve account IDs for corrected lines - const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] - const { data: accounts } = await supabase - .from('chart_of_accounts') - .select('id, account_number') - .eq('user_id', userId) - .in('account_number', accountNumbers) - - const accountIdMap = new Map() - for (const account of accounts || []) { - accountIdMap.set(account.account_number, account.id) + // If anything in this step fails, we must roll back the reversal from step 1 + // to avoid leaving the ledger in an inconsistent state. + async function rollbackReversal() { + // Restore original entry to 'posted' status + await supabase + .from('journal_entries') + .update({ status: 'posted', reversed_by_id: null }) + .eq('id', originalEntryId) + // Delete the reversal entry (it was just created, safe to remove since + // the DB trigger allows deleting draft entries and we need to clean up) + await supabase.from('journal_entry_lines').delete().eq('journal_entry_id', reversalEntry.id) + await supabase.from('journal_entries').delete().eq('id', reversalEntry.id) } - const { data: correctedEntry, error: correctedError } = await supabase - .from('journal_entries') - .insert({ - user_id: userId, - fiscal_period_id: original.fiscal_period_id, - voucher_number: correctedVoucherNumber, - voucher_series: original.voucher_series || 'A', - entry_date: new Date().toISOString().split('T')[0], - description: `Rättelse: ${original.description}`, - source_type: 'correction', - correction_of_id: originalEntryId, - status: 'draft', - }) - .select() - .single() + let correctedEntry: typeof reversalEntry - if (correctedError || !correctedEntry) { - throw new Error(`Failed to create corrected entry: ${correctedError?.message}`) - } + try { + const correctedVoucherNumber = await getNextVoucherNumber( + userId, + original.fiscal_period_id, + original.voucher_series || 'A' + ) - // Insert corrected lines - const correctedLineInserts = correctedLines.map((line, index) => ({ - journal_entry_id: correctedEntry.id, - account_number: line.account_number, - account_id: accountIdMap.get(line.account_number) || null, - debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, - credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, - currency: line.currency || 'SEK', - amount_in_currency: line.amount_in_currency - ? Math.round(line.amount_in_currency * 100) / 100 - : null, - exchange_rate: line.exchange_rate || null, - line_description: line.line_description || null, - tax_code: line.tax_code || null, - cost_center: line.cost_center || null, - project: line.project || null, - sort_order: index, - })) + // Resolve account IDs for corrected lines + const accountNumbers = [...new Set(correctedLines.map((l) => l.account_number))] + const { data: accounts } = await supabase + .from('chart_of_accounts') + .select('id, account_number') + .eq('user_id', userId) + .in('account_number', accountNumbers) - const { error: correctedLinesError } = await supabase - .from('journal_entry_lines') - .insert(correctedLineInserts) + const accountIdMap = new Map() + for (const account of accounts || []) { + accountIdMap.set(account.account_number, account.id) + } - if (correctedLinesError) { - await supabase.from('journal_entries').delete().eq('id', correctedEntry.id) - throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`) - } + const { data: newEntry, error: correctedError } = await supabase + .from('journal_entries') + .insert({ + user_id: userId, + fiscal_period_id: original.fiscal_period_id, + voucher_number: correctedVoucherNumber, + voucher_series: original.voucher_series || 'A', + entry_date: new Date().toISOString().split('T')[0], + description: `Rättelse: ${original.description}`, + source_type: 'correction', + correction_of_id: originalEntryId, + status: 'draft', + }) + .select() + .single() - // Post the corrected entry - const { error: postCorrectedError } = await supabase - .from('journal_entries') - .update({ status: 'posted' }) - .eq('id', correctedEntry.id) + if (correctedError || !newEntry) { + throw new Error(`Failed to create corrected entry: ${correctedError?.message}`) + } - if (postCorrectedError) { - throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`) + correctedEntry = newEntry + + // Insert corrected lines + const correctedLineInserts = correctedLines.map((line, index) => ({ + journal_entry_id: correctedEntry.id, + account_number: line.account_number, + account_id: accountIdMap.get(line.account_number) || null, + debit_amount: Math.round((line.debit_amount || 0) * 100) / 100, + credit_amount: Math.round((line.credit_amount || 0) * 100) / 100, + currency: line.currency || 'SEK', + amount_in_currency: line.amount_in_currency + ? Math.round(line.amount_in_currency * 100) / 100 + : null, + exchange_rate: line.exchange_rate || null, + line_description: line.line_description || null, + tax_code: line.tax_code || null, + cost_center: line.cost_center || null, + project: line.project || null, + sort_order: index, + })) + + const { error: correctedLinesError } = await supabase + .from('journal_entry_lines') + .insert(correctedLineInserts) + + if (correctedLinesError) { + await supabase.from('journal_entries').delete().eq('id', correctedEntry.id) + throw new Error(`Failed to create corrected lines: ${correctedLinesError.message}`) + } + + // Post the corrected entry + const { error: postCorrectedError } = await supabase + .from('journal_entries') + .update({ status: 'posted' }) + .eq('id', correctedEntry.id) + + if (postCorrectedError) { + throw new Error(`Failed to post corrected entry: ${postCorrectedError.message}`) + } + } catch (err) { + // Roll back the reversal to restore ledger consistency + await rollbackReversal() + throw err } // ===== Step 3: Fetch complete entries ===== diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts index 3168f4d6..916b36cc 100644 --- a/lib/core/documents/__tests__/document-service.test.ts +++ b/lib/core/documents/__tests__/document-service.test.ts @@ -25,6 +25,8 @@ function makeClient(storageOverrides: Record = {}) { from: vi.fn().mockImplementation(() => makeBuilder()), rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }), storage: { + getBucket: vi.fn().mockResolvedValue({ data: { id: 'documents' }, error: null }), + createBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' }, error: null }), from: vi.fn().mockReturnValue({ upload: vi.fn().mockResolvedValue({ data: {}, error: null }), download: vi.fn().mockResolvedValue({ @@ -43,14 +45,16 @@ function makeClient(storageOverrides: Record = {}) { vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(async () => makeClient()), + createServiceClient: vi.fn(async () => makeClient()), })) -import { uploadDocument, createNewVersion, verifyIntegrity } from '../document-service' +import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service' import { createClient } from '@/lib/supabase/server' beforeEach(() => { vi.clearAllMocks() eventBus.clear() + _resetBucketVerified() resultIdx = 0 results = [] // Reset the mock to use default makeClient diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts index 01a3afc8..1a307b90 100644 --- a/lib/core/documents/document-service.ts +++ b/lib/core/documents/document-service.ts @@ -1,4 +1,4 @@ -import { createClient } from '@/lib/supabase/server' +import { createClient, createServiceClient } from '@/lib/supabase/server' import { eventBus } from '@/lib/events' import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types' @@ -10,6 +10,33 @@ import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadS * for documents linked to committed entries. */ +let bucketVerified = false + +/** @internal Reset bucket verification flag — for testing only */ +export function _resetBucketVerified() { + bucketVerified = false +} + +/** + * Ensure the 'documents' storage bucket exists, creating it if missing. + * Runs once per process lifetime (same pattern as ensureInitialized). + */ +async function ensureDocumentsBucket(): Promise { + if (bucketVerified) return + + const supabase = await createServiceClient() + const { data: bucket } = await supabase.storage.getBucket('documents') + + if (!bucket) { + await supabase.storage.createBucket('documents', { + public: false, + fileSizeLimit: 52428800, // 50 MB + }) + } + + bucketVerified = true +} + /** * Compute SHA-256 hash of a file buffer */ @@ -31,6 +58,7 @@ export async function uploadDocument( journal_entry_line_id?: string } = {} ): Promise { + await ensureDocumentsBucket() const supabase = await createClient() // Compute SHA-256 hash @@ -102,6 +130,7 @@ export async function createNewVersion( originalId: string, file: { name: string; buffer: ArrayBuffer; type?: string } ): Promise { + await ensureDocumentsBucket() const supabase = await createClient() // Compute SHA-256 hash diff --git a/lib/extensions/hooks.ts b/lib/extensions/hooks.ts index 267cb15d..ba41a3b6 100644 --- a/lib/extensions/hooks.ts +++ b/lib/extensions/hooks.ts @@ -62,6 +62,11 @@ export function useExtensionToggle(sectorSlug: string, extensionSlug: string) { }) if (!res.ok) { setEnabled(!newValue) // Revert on error + } else { + // Notify other components about the toggle change + window.dispatchEvent(new CustomEvent('extension-toggle-changed', { + detail: { sectorSlug, extensionSlug, enabled: newValue }, + })) } } catch { setEnabled(!newValue) // Revert on error diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx index cc8f28ee..5ae6ecbc 100644 --- a/lib/invoices/pdf-template.tsx +++ b/lib/invoices/pdf-template.tsx @@ -440,7 +440,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN )) ) : ( - Moms ({invoice.vat_rate ?? 25}%): + Moms ({invoice.vat_rate ?? (vatByRate.size === 1 ? vatByRate.keys().next().value : 25)}%): {formatCurrency(invoice.vat_amount, invoice.currency)} )} diff --git a/lib/invoices/vat-rules.ts b/lib/invoices/vat-rules.ts index 10af9069..1957649f 100644 --- a/lib/invoices/vat-rules.ts +++ b/lib/invoices/vat-rules.ts @@ -118,14 +118,14 @@ export function getVatRules( * Calculate VAT amount */ export function calculateVat(subtotal: number, vatRate: number): number { - return subtotal * (vatRate / 100) + return Math.round(subtotal * vatRate) / 100 } /** * Calculate total including VAT */ export function calculateTotal(subtotal: number, vatRate: number): number { - return subtotal + calculateVat(subtotal, vatRate) + return Math.round((subtotal + calculateVat(subtotal, vatRate)) * 100) / 100 } /** @@ -153,6 +153,36 @@ export function getVatTreatmentLabel(treatment: VatTreatment): string { return labels[treatment] } +/** + * Derive a display-friendly VAT summary from invoice line items. + * + * - If all items share a single rate → returns that rate's label and treatment + * - If items have mixed rates → returns "Blandade momssatser" with null rate/treatment + */ +export function getVatSummaryFromItems( + items: { vat_rate?: number | null }[] +): { label: string; treatment: VatTreatment | null; rate: number | null; isMixed: boolean } { + const rates = new Set(items.map((item) => item.vat_rate ?? 25)) + + if (rates.size === 1) { + const rate = rates.values().next().value! + const treatment = getVatTreatmentForRate(rate) + return { + label: getVatTreatmentLabel(treatment), + treatment, + rate, + isMixed: false, + } + } + + return { + label: 'Blandade momssatser', + treatment: null, + rate: null, + isMixed: true, + } +} + /** * Get moms ruta description */ diff --git a/lib/reports/__tests__/balance-sheet.test.ts b/lib/reports/__tests__/balance-sheet.test.ts index 31888595..69a8bcf0 100644 --- a/lib/reports/__tests__/balance-sheet.test.ts +++ b/lib/reports/__tests__/balance-sheet.test.ts @@ -159,9 +159,14 @@ describe('generateBalanceSheet', () => { const report = await generateBalanceSheet('user-1', 'period-1') expect(report.asset_sections).toHaveLength(1) // Only 1930 - expect(report.equity_liability_sections).toEqual([]) + // Class 3-8 accounts are not included as balance sheet rows, but their + // net result (credit - debit = 40000 + 500 - 8000 = 32500) appears as + // "Årets resultat" in equity so the balance sheet can balance. + expect(report.equity_liability_sections).toHaveLength(1) + expect(report.equity_liability_sections[0].title).toBe('Årets resultat') + expect(report.equity_liability_sections[0].subtotal).toBe(32500) expect(report.total_assets).toBe(50000) - expect(report.total_equity_liabilities).toBe(0) + expect(report.total_equity_liabilities).toBe(32500) }) it('uses Math.round for monetary precision on subtotals', async () => { diff --git a/lib/reports/__tests__/monthly-breakdown.test.ts b/lib/reports/__tests__/monthly-breakdown.test.ts index 5101ff44..70015bcc 100644 --- a/lib/reports/__tests__/monthly-breakdown.test.ts +++ b/lib/reports/__tests__/monthly-breakdown.test.ts @@ -105,26 +105,26 @@ describe('generateMonthlyBreakdown', () => { data: [ { account_number: '3001', - debit: 0, - credit: 10000, + debit_amount: 0, + credit_amount: 10000, journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, { account_number: '5010', - debit: 3000, - credit: 0, + debit_amount: 3000, + credit_amount: 0, journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, { account_number: '3001', - debit: 0, - credit: 5000, + debit_amount: 0, + credit_amount: 5000, journal_entry: { entry_date: '2024-02-10', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, { account_number: '6200', - debit: 1500, - credit: 0, + debit_amount: 1500, + credit_amount: 0, journal_entry: { entry_date: '2024-02-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, ], @@ -156,7 +156,7 @@ describe('generateMonthlyBreakdown', () => { expect(mar.expenses).toBe(0) }) - it('ignores non-revenue/expense accounts (class 1, 2, 8)', async () => { + it('ignores balance sheet accounts (class 1, 2) but includes class 8 financial items', async () => { let callCount = 0 supabase.from.mockImplementation(() => { callCount++ @@ -184,22 +184,28 @@ describe('generateMonthlyBreakdown', () => { data: [ { account_number: '1930', - debit: 10000, - credit: 0, + debit_amount: 10000, + credit_amount: 0, journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, { account_number: '2611', - debit: 0, - credit: 2500, + debit_amount: 0, + credit_amount: 2500, journal_entry: { entry_date: '2024-01-15', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, { - account_number: '8999', - debit: 500, - credit: 0, + account_number: '8400', + debit_amount: 500, + credit_amount: 0, journal_entry: { entry_date: '2024-01-20', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, }, + { + account_number: '8300', + debit_amount: 0, + credit_amount: 200, + journal_entry: { entry_date: '2024-01-25', status: 'posted', user_id: 'user-1', fiscal_period_id: 'period-1' }, + }, ], error: null, }), @@ -211,7 +217,10 @@ describe('generateMonthlyBreakdown', () => { const result = await generateMonthlyBreakdown('user-1', 'period-1') const jan = result.months.find((m) => m.label === 'Jan')! - expect(jan.income).toBe(0) - expect(jan.expenses).toBe(0) + // Class 1 and 2 are ignored + // Class 8 debit (8400 interest expense) → expense + expect(jan.expenses).toBe(500) + // Class 8 credit (8300 interest income) → income + expect(jan.income).toBe(200) }) }) diff --git a/lib/reports/ar-reconciliation.ts b/lib/reports/ar-reconciliation.ts index b01ca329..358408ef 100644 --- a/lib/reports/ar-reconciliation.ts +++ b/lib/reports/ar-reconciliation.ts @@ -13,7 +13,7 @@ export interface ARReconciliationResult { */ export async function generateARReconciliation( userId: string, - _periodId: string + periodId: string ): Promise { const supabase = await createClient() @@ -25,20 +25,31 @@ export async function generateARReconciliation( .in('status', ['sent', 'overdue']) const arLedgerTotal = (invoices || []) - .reduce((sum, inv) => sum + ((Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)), 0) + .reduce((sum, inv) => Math.round((sum + (Number(inv.total) || 0) - (Number(inv.paid_amount) || 0)) * 100) / 100, 0) - // Get account 1510 balance from journal entry lines + // Get account 1510 balance from posted journal entry lines in this period const { data: journalLines } = await supabase .from('journal_entry_lines') - .select('debit_amount, credit_amount, journal_entry_id') + .select(` + debit_amount, + credit_amount, + journal_entry:journal_entries!inner( + status, + user_id, + fiscal_period_id + ) + `) .eq('account_number', '1510') + .eq('journal_entries.user_id', userId) + .eq('journal_entries.fiscal_period_id', periodId) + .eq('journal_entries.status', 'posted') // Account 1510 is an asset: debit normal balance // Balance = debits - credits let account1510Balance = 0 if (journalLines) { for (const line of journalLines) { - account1510Balance += (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0) + account1510Balance = Math.round((account1510Balance + (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)) * 100) / 100 } } diff --git a/lib/reports/general-ledger.ts b/lib/reports/general-ledger.ts index fdd0f062..0fc9e1ab 100644 --- a/lib/reports/general-ledger.ts +++ b/lib/reports/general-ledger.ts @@ -57,7 +57,7 @@ export async function generateGeneralLedger( .select('id, entry_date, voucher_number, voucher_series, description, source_type') .eq('user_id', userId) .eq('fiscal_period_id', periodId) - .in('status', ['posted', 'reversed']) + .eq('status', 'posted') if (!entries || entries.length === 0) { return { accounts: [], period: { start: period.period_start, end: period.period_end } } @@ -95,7 +95,7 @@ export async function generateGeneralLedger( .from('journal_entries') .select('id') .eq('user_id', userId) - .in('status', ['posted', 'reversed']) + .eq('status', 'posted') .lt('entry_date', period.period_start) const openingBalances = new Map() diff --git a/lib/reports/monthly-breakdown.ts b/lib/reports/monthly-breakdown.ts index a76c1818..76d96d21 100644 --- a/lib/reports/monthly-breakdown.ts +++ b/lib/reports/monthly-breakdown.ts @@ -46,8 +46,8 @@ export async function generateMonthlyBreakdown( .from('journal_entry_lines') .select(` account_number, - debit, - credit, + debit_amount, + credit_amount, journal_entry:journal_entries!inner( entry_date, status, @@ -63,17 +63,21 @@ export async function generateMonthlyBreakdown( return { months: [] } } - // Build monthly aggregates - const monthMap = new Map() + // Build monthly aggregates using year-aware keys ("2024-03", "2024-04", etc.) + // to avoid data corruption for non-calendar fiscal years (e.g., Apr-Mar) + const monthMap = new Map() // Initialize all months in the period range const startDate = new Date(period.period_start) const endDate = new Date(period.period_end) - const startMonth = startDate.getMonth() - const endMonth = endDate.getMonth() + (endDate.getFullYear() - startDate.getFullYear()) * 12 - for (let m = startMonth; m <= endMonth; m++) { - monthMap.set(m % 12, { income: 0, expenses: 0 }) + for ( + let y = startDate.getFullYear(), m = startDate.getMonth(); + y < endDate.getFullYear() || (y === endDate.getFullYear() && m <= endDate.getMonth()); + m === 11 ? (y++, m = 0) : m++ + ) { + const key = `${y}-${String(m).padStart(2, '0')}` + monthMap.set(key, { year: y, month: m, income: 0, expenses: 0 }) } for (const line of lines) { @@ -85,35 +89,39 @@ export async function generateMonthlyBreakdown( } const accountClass = parseInt(line.account_number.charAt(0)) const entryDate = new Date(entry.entry_date) - const month = entryDate.getMonth() + const key = `${entryDate.getFullYear()}-${String(entryDate.getMonth()).padStart(2, '0')}` - if (!monthMap.has(month)) { - monthMap.set(month, { income: 0, expenses: 0 }) + if (!monthMap.has(key)) { + monthMap.set(key, { year: entryDate.getFullYear(), month: entryDate.getMonth(), income: 0, expenses: 0 }) } - const bucket = monthMap.get(month)! + const bucket = monthMap.get(key)! if (accountClass === 3) { // Revenue accounts: credit side represents revenue - bucket.income = Math.round((bucket.income + line.credit - line.debit) * 100) / 100 + bucket.income = Math.round((bucket.income + line.credit_amount - line.debit_amount) * 100) / 100 } else if (accountClass >= 4 && accountClass <= 7) { // Expense accounts: debit side represents expenses - bucket.expenses = Math.round((bucket.expenses + line.debit - line.credit) * 100) / 100 + bucket.expenses = Math.round((bucket.expenses + line.debit_amount - line.credit_amount) * 100) / 100 + } else if (accountClass === 8) { + // Financial items (class 8): interest, exchange gains/losses, etc. + const amount = line.credit_amount - line.debit_amount + if (amount >= 0) { + bucket.income = Math.round((bucket.income + amount) * 100) / 100 + } else { + bucket.expenses = Math.round((bucket.expenses + Math.abs(amount)) * 100) / 100 + } } } - // Convert to sorted array + // Convert to sorted array (keys sort naturally as "YYYY-MM") const months: MonthlyBreakdownMonth[] = [] - const sortedMonths = Array.from(monthMap.entries()).sort((a, b) => { - // Handle year boundaries (e.g., Nov-Dec-Jan for broken fiscal year) - const aAdj = a[0] < startMonth ? a[0] + 12 : a[0] - const bAdj = b[0] < startMonth ? b[0] + 12 : b[0] - return aAdj - bAdj - }) + const sortedKeys = Array.from(monthMap.keys()).sort() - for (const [month, data] of sortedMonths) { + for (const key of sortedKeys) { + const data = monthMap.get(key)! months.push({ - label: MONTH_LABELS[month], + label: MONTH_LABELS[data.month], income: data.income, expenses: data.expenses, net: Math.round((data.income - data.expenses) * 100) / 100, diff --git a/lib/reports/sie-export.ts b/lib/reports/sie-export.ts index 4b34263f..2c694423 100644 --- a/lib/reports/sie-export.ts +++ b/lib/reports/sie-export.ts @@ -195,7 +195,8 @@ function dateStringToSIE(dateStr: string): string { * Format amount for SIE (no thousands separator, . as decimal) */ function formatAmount(amount: number): string { - return amount.toFixed(2) + const rounded = Math.round(amount * 100) / 100 + return rounded.toFixed(2) } /** @@ -218,7 +219,7 @@ function calculateBalances( for (const line of lines) { const current = balances.get(line.account_number) || 0 const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0) - balances.set(line.account_number, current + netAmount) + balances.set(line.account_number, Math.round((current + netAmount) * 100) / 100) } } diff --git a/lib/reports/supplier-reconciliation.ts b/lib/reports/supplier-reconciliation.ts index 51f56743..9f7a2aba 100644 --- a/lib/reports/supplier-reconciliation.ts +++ b/lib/reports/supplier-reconciliation.ts @@ -24,21 +24,31 @@ export async function generateReconciliation( .in('status', ['registered', 'approved', 'partially_paid', 'overdue']) const supplierLedgerTotal = (invoices || []) - .reduce((sum, inv) => sum + (inv.remaining_amount || 0), 0) + .reduce((sum, inv) => Math.round((sum + (inv.remaining_amount || 0)) * 100) / 100, 0) - // Get account 2440 balance from journal entry lines + // Get account 2440 balance from posted journal entry lines in this period const { data: journalLines } = await supabase .from('journal_entry_lines') - .select('debit_amount, credit_amount, journal_entry_id') + .select(` + debit_amount, + credit_amount, + journal_entry:journal_entries!inner( + status, + user_id, + fiscal_period_id + ) + `) .eq('account_number', '2440') + .eq('journal_entries.user_id', userId) + .eq('journal_entries.fiscal_period_id', periodId) + .eq('journal_entries.status', 'posted') - // Filter to posted entries in the period + // Account 2440 is a liability: credit normal balance + // Balance = credits - debits let account2440Balance = 0 if (journalLines) { - // Account 2440 is a liability: credit normal balance - // Balance = credits - debits for (const line of journalLines) { - account2440Balance += (line.credit_amount || 0) - (line.debit_amount || 0) + account2440Balance = Math.round((account2440Balance + (line.credit_amount || 0) - (line.debit_amount || 0)) * 100) / 100 } } diff --git a/package.json b/package.json index b92ad082..305ec57d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "test": "vitest" + "test": "vitest run" }, "dependencies": { "@anthropic-ai/sdk": "^0.72.1", diff --git a/supabase/migrations/20240101000033_ai_chat_schema.sql b/supabase/migrations/20240101000033_ai_chat_schema.sql new file mode 100644 index 00000000..c21dc34f --- /dev/null +++ b/supabase/migrations/20240101000033_ai_chat_schema.sql @@ -0,0 +1,127 @@ +-- Migration 033: AI Chat Schema +-- Creates tables for the AI chat assistant extension: +-- chat_sessions, chat_messages, knowledge_documents, and match_documents RPC + +-- Enable pgvector for embedding storage +create extension if not exists vector with schema extensions; + +-- ============================================================ +-- chat_sessions +-- ============================================================ + +create table public.chat_sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users on delete cascade not null, + title text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table public.chat_sessions enable row level security; + +create policy "chat_sessions_select" on public.chat_sessions + for select using (auth.uid() = user_id); +create policy "chat_sessions_insert" on public.chat_sessions + for insert with check (auth.uid() = user_id); +create policy "chat_sessions_update" on public.chat_sessions + for update using (auth.uid() = user_id); +create policy "chat_sessions_delete" on public.chat_sessions + for delete using (auth.uid() = user_id); + +create index idx_chat_sessions_user_created on public.chat_sessions (user_id, created_at desc); + +create trigger chat_sessions_updated_at + before update on public.chat_sessions + for each row execute function public.update_updated_at_column(); + +-- ============================================================ +-- chat_messages +-- ============================================================ + +create table public.chat_messages ( + id uuid primary key default gen_random_uuid(), + session_id uuid references public.chat_sessions on delete cascade not null, + user_id uuid references auth.users on delete cascade not null, + role text not null check (role in ('user', 'assistant')), + content text not null, + sources jsonb, + created_at timestamptz not null default now() +); + +alter table public.chat_messages enable row level security; + +create policy "chat_messages_select" on public.chat_messages + for select using (auth.uid() = user_id); +create policy "chat_messages_insert" on public.chat_messages + for insert with check (auth.uid() = user_id); +create policy "chat_messages_update" on public.chat_messages + for update using (auth.uid() = user_id); +create policy "chat_messages_delete" on public.chat_messages + for delete using (auth.uid() = user_id); + +create index idx_chat_messages_session on public.chat_messages (session_id, created_at); + +-- ============================================================ +-- knowledge_documents +-- ============================================================ + +create table public.knowledge_documents ( + id uuid primary key default gen_random_uuid(), + source_file text not null, + title text not null, + section_title text, + content text not null, + content_hash text unique not null, + embedding extensions.vector(1536), + metadata jsonb default '{}', + created_at timestamptz not null default now() +); + +alter table public.knowledge_documents enable row level security; + +-- Knowledge documents are shared — any authenticated user can read +create policy "knowledge_documents_select" on public.knowledge_documents + for select using (true); + +create index idx_knowledge_documents_hash on public.knowledge_documents (content_hash); + +-- ============================================================ +-- match_documents RPC (vector similarity search) +-- ============================================================ + +create or replace function public.match_documents( + query_embedding extensions.vector, + match_count int default 5, + match_threshold float default 0.7 +) +returns table ( + id uuid, + source_file text, + title text, + section_title text, + content text, + metadata jsonb, + similarity float +) +language plpgsql +security definer +set search_path = public, extensions +as $$ +begin + return query + select + kd.id, + kd.source_file, + kd.title, + kd.section_title, + kd.content, + kd.metadata, + 1 - (kd.embedding <=> query_embedding)::float as similarity + from public.knowledge_documents kd + where 1 - (kd.embedding <=> query_embedding) >= match_threshold + order by kd.embedding <=> query_embedding + limit match_count; +end; +$$; + +grant execute on function public.match_documents(extensions.vector, int, float) to authenticated; diff --git a/supabase/migrations/20240101000034_fix_extension_data_trigger.sql b/supabase/migrations/20240101000034_fix_extension_data_trigger.sql new file mode 100644 index 00000000..6bfd0b85 --- /dev/null +++ b/supabase/migrations/20240101000034_fix_extension_data_trigger.sql @@ -0,0 +1,19 @@ +-- Migration 034: Fix extension_data updated_at trigger +-- The original trigger references update_updated_at() which does not exist. +-- The correct function is public.update_updated_at_column(). +-- Wrapped in DO block in case extension_data table does not yet exist. + +do $$ +begin + if exists ( + select 1 from information_schema.tables + where table_schema = 'public' and table_name = 'extension_data' + ) then + drop trigger if exists extension_data_updated_at on public.extension_data; + + create trigger extension_data_updated_at + before update on public.extension_data + for each row execute function public.update_updated_at_column(); + end if; +end; +$$; diff --git a/supabase/migrations/20240101000035_fix_push_notifications.sql b/supabase/migrations/20240101000035_fix_push_notifications.sql new file mode 100644 index 00000000..a12388db --- /dev/null +++ b/supabase/migrations/20240101000035_fix_push_notifications.sql @@ -0,0 +1,28 @@ +-- Migration 035: Fix push notifications +-- 1. Expand notification_log notification_type CHECK constraint to include new event types +-- 2. Add per-event enabled columns to notification_settings + +-- Drop and recreate the CHECK constraint with new types +alter table public.notification_log + drop constraint if exists notification_log_notification_type_check; + +alter table public.notification_log + add constraint notification_log_notification_type_check + check (notification_type in ( + 'tax_deadline', + 'invoice_due', + 'invoice_overdue', + 'period_locked', + 'period_year_closed', + 'invoice_sent', + 'receipt_extracted', + 'receipt_matched' + )); + +-- Add new per-event enabled columns to notification_settings +alter table public.notification_settings + add column if not exists period_locked_enabled boolean default true, + add column if not exists period_year_closed_enabled boolean default true, + add column if not exists invoice_sent_enabled boolean default false, + add column if not exists receipt_extracted_enabled boolean default true, + add column if not exists receipt_matched_enabled boolean default true; diff --git a/supabase/migrations/20240101000036_fix_enable_banking.sql b/supabase/migrations/20240101000036_fix_enable_banking.sql new file mode 100644 index 00000000..74d5269e --- /dev/null +++ b/supabase/migrations/20240101000036_fix_enable_banking.sql @@ -0,0 +1,5 @@ +-- Migration 036: Fix Enable Banking +-- Add authorization_id column to bank_connections for PSD2 authorization tracking + +alter table public.bank_connections + add column if not exists authorization_id text; diff --git a/supabase/migrations/20240101000029_extension_toggles.sql b/supabase/migrations/20240101000037_extension_toggles.sql similarity index 100% rename from supabase/migrations/20240101000029_extension_toggles.sql rename to supabase/migrations/20240101000037_extension_toggles.sql diff --git a/supabase/migrations/20240101000038_fix_match_documents_search_path.sql b/supabase/migrations/20240101000038_fix_match_documents_search_path.sql new file mode 100644 index 00000000..26cb1bf3 --- /dev/null +++ b/supabase/migrations/20240101000038_fix_match_documents_search_path.sql @@ -0,0 +1,37 @@ +-- Migration 038: Fix match_documents search_path +-- The function needs the extensions schema in search_path to use pgvector operators. + +create or replace function public.match_documents( + query_embedding extensions.vector, + match_count int default 5, + match_threshold float default 0.7 +) +returns table ( + id uuid, + source_file text, + title text, + section_title text, + content text, + metadata jsonb, + similarity float +) +language plpgsql +security definer +set search_path = public, extensions +as $$ +begin + return query + select + kd.id, + kd.source_file, + kd.title, + kd.section_title, + kd.content, + kd.metadata, + 1 - (kd.embedding <=> query_embedding)::float as similarity + from public.knowledge_documents kd + where 1 - (kd.embedding <=> query_embedding) >= match_threshold + order by kd.embedding <=> query_embedding + limit match_count; +end; +$$;