diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 435b77ff..b234b585 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -23,7 +23,9 @@ "Bash(npx tsc:*)", "Bash(findstr:*)", "Bash(git add:*)", - "Bash(git commit:*)" + "Bash(git commit:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:support.fortnox.se)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index ca7f852f..9488e17e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ erp-base is a Swedish-focused accounting SaaS for sole traders (enskild firma) a **Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting. -**Integrations**: Enable Banking (PSD2), Anthropic SDK, OpenAI (embeddings), Resend (email), web-push (VAPID). +**Integrations**: Enable Banking (PSD2), Anthropic SDK, LangChain, OpenAI (embeddings), Resend (email), web-push (VAPID). **Path alias**: `@/*` maps to the project root (tsconfig.json). @@ -34,7 +34,8 @@ app/ (onboarding)/ 6-step setup wizard (dashboard)/ Authenticated routes (invoices, customers, transactions, bookkeeping, reports, suppliers, supplier-invoices, - receipts, settings, calendar, help, import) + receipts, settings, calendar, help, import, + extensions, e/[sector]/[slug]) (public)/ Public invoice action links (no auth) api/ API routes organized by domain @@ -43,20 +44,47 @@ components/ bookkeeping/ Chart of accounts manager, account combobox, add/edit dialogs calendar/ Calendar views, deadline cards, payment summary, todo widgets chat/ ChatWidget, ChatPanel, ChatInput, ChatMessage + customers/ CustomerForm dashboard/ DashboardContent, DashboardNav, FSkattWarningCard + extensions/ Extension marketplace UI (ExtensionCard, SectorCard, + ExtensionToggleButton, workspace components, + per-sector subdirectories) + import/ Bank file import workflow components + invoices/ InvoiceReviewContent + onboarding/ NewUserChecklist, setup step components reports/ Report views (including BankReconciliationView) settings/ CalendarFeedSettings - [feature]/ Feature-organized components (invoices, suppliers, import, etc.) + suppliers/ Supplier-related components + transactions/ Transaction list, categorization, booking components -extensions/ First-party extension implementations - ai-categorization/ AI-powered transaction categorization - ai-chat/ Claude-based chat assistant - enable-banking/ PSD2 bank integration via Enable Banking (JWT auth, sync) - ne-bilaga/ NE tax form attachment generation - push-notifications/ Web push notification system - receipt-ocr/ Receipt image OCR processing - sru-export/ SRU file export - example-logger/ Minimal reference extension (not loaded by default) +extensions/ Sector-based extension hierarchy + general/ General-purpose extensions (all businesses) + ai-categorization/ AI-powered transaction categorization + ai-chat/ Claude-based chat assistant (LangChain RAG) + enable-banking/ PSD2 bank integration (opt-in, commented out in loader) + example-logger/ Minimal reference extension (not loaded) + invoice-inbox/ Supplier invoice intake via email/upload with AI extraction + push-notifications/ Web push notification system + receipt-ocr/ Receipt image OCR processing (includes components/pages) + restaurant/ Restaurant & cafe sector + food-cost/ Food cost percentage calculator + earnings-per-liter/ Revenue per liter of alcohol + pos-import/ POS Z-report import + tip-tracking/ Tip tracking per shift/employee + construction/ Construction & trades sector + rot-calculator/ ROT tax deduction calculator + project-cost/ Project cost tracking + hotel/ Hotel & lodging sector + revpar/ Revenue Per Available Room + occupancy/ Occupancy rate tracking + tech/ IT & consulting sector + billable-hours/ Billable hours & utilization rate + project-billing/ Project billing analysis + ecommerce/ E-commerce sector + shopify-import/ Shopify order import + multichannel-revenue/ Multi-channel revenue analysis + ne-bilaga/ NE tax form attachment generation (top-level) + sru-export/ SRU file export (top-level) lib/ bookkeeping/ Core journal entry engine and all entry generators @@ -70,23 +98,34 @@ lib/ bas-reference.ts BAS account catalog (~180 accounts with metadata, SRU codes) account-descriptions.ts Human-readable account name lookup validate-period-duration.ts Fiscal period duration validation (BFL 3 kap.) + handlers/ Booking handler functions (supplier-invoice-handler.ts) core/ bookkeeping/ Period service, storno reversal, year-end closing documents/ Document archive (upload, versioning, SHA-256 integrity) audit/ Audit trail service tax/ Tax code service - calendar/ Calendar and scheduling utilities + calendar/ Calendar utilities, ICS feed generation currency/ Riksbanken exchange rates - customers/ Customer management helpers - deadlines/ Tax deadline tracking - email/ Email service (Resend integration) + deadlines/ Tax deadline tracking, status engine + email/ Email service (Resend), invoice/reminder templates events/ Event bus (bus.ts, types.ts) - extensions/ Extension registry, loader, types - import/ SIE and bank file parser + extensions/ Extension system + loader.ts FIRST_PARTY_EXTENSIONS array, static imports + registry.ts Runtime extension registry + types.ts Extension, Sector, ExtensionDefinition, toggle types + sectors.ts Sector & extension metadata registry (pure data) + hooks.ts React hooks for extension state + context-factory.ts Extension context builder + toggle-check.ts Extension enable/disable logic + validation.ts Extension data validation + workspace-registry.tsx Extension workspace component registry + icon-resolver.tsx Dynamic icon lookup for extensions + use-account-totals.ts Hook for account balance queries + use-extension-data.ts Hook for extension-specific data + import/ SIE parser, SIE import orchestrator, bank file parser bank-file/ Bank file parser with format modules formats/ camt053, generic-csv, handelsbanken, nordea, seb, swedbank - invoice/ VAT rules, invoice matching (vat-rules.ts, invoice-matching.ts) - invoices/ Invoice business logic (reminder-processor) + invoices/ VAT rules, invoice matching, PDF template, reminder processor reconciliation/ Bank reconciliation engine (4-pass matching algorithm) reports/ Financial reports (trial-balance, income-statement, balance-sheet, vat-declaration, sie-export, @@ -94,18 +133,20 @@ lib/ general-ledger, journal-register, ar-ledger, ar-reconciliation, monthly-breakdown) supabase/ Client setup (client.ts = browser, server.ts = server/admin, - fetch-all.ts = pagination helper for large queries) - tax/ Tax calculations, deadlines, Swedish holidays - transactions/ Transaction processing helpers + fetch-all.ts = pagination helper, middleware.ts) + tax/ Tax calculations, deadlines, Swedish holidays, expense warnings + transactions/ Transaction processing, category suggestions init.ts Extension loader (idempotent, called by API routes) utils.ts Shared utility functions -types/index.ts Canonical type definitions (120+ types, single source of truth) +types/index.ts Canonical type definitions (single source of truth) types/chat.ts Chat-specific type definitions tests/helpers.ts Mock factories and fixture builders supabase/migrations/ SQL migration files -scripts/ Utility scripts (clear-user-data.sql) -dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.) +scripts/ Utility scripts (clear-user-data.sql, copy-extensions.mjs, + move-extensions.js, setup-phase8.js) +dev_docs/ Project documentation (BAS account guides, gap analysis, + Enable Banking docs, Bokio reference screenshots) ``` ### Key Relationships @@ -114,6 +155,7 @@ dev_docs/ Extensive project documentation (PRD, architecture, BA - **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level to load extensions. - **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Core services emit, extensions subscribe. - **Supabase clients**: browser (`lib/supabase/client.ts`), server with user cookies (`createClient()` from `lib/supabase/server.ts`), and service role (`createServiceClient()`). +- **Extension sector system**: Extensions are organized by business sector (`lib/extensions/sectors.ts`). Users can browse/toggle extensions via the marketplace UI (`app/(dashboard)/extensions/`). Sector-specific extension workspaces are rendered at `app/(dashboard)/e/[sector]/[slug]/`. --- @@ -163,7 +205,7 @@ The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system ### Per-Line VAT -Invoice items support individual `vat_rate` values, enabling mixed-rate invoices. The helper `generatePerRateLines()` in `invoice-entries.ts` groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoice/vat-rules.ts`. +Invoice items support individual `vat_rate` values, enabling mixed-rate invoices. The helper `generatePerRateLines()` in `invoice-entries.ts` groups items by VAT rate and creates separate revenue + VAT account lines per rate group. Available rates depend on customer type — use `getAvailableVatRates(customerType, vatNumberValidated)` from `lib/invoices/vat-rules.ts`. ### Bank Reconciliation @@ -199,14 +241,39 @@ These rules exist for legal compliance and are enforced by database triggers. ** ## Extension Development -Extensions are first-party plugins in the `/extensions/` directory, loaded statically at startup. +Extensions are first-party plugins organized by business sector in the `/extensions/` directory, loaded statically at startup. + +### Sector System + +Extensions are grouped into sectors defined in `lib/extensions/sectors.ts`. Each sector targets a specific industry (restaurant, construction, hotel, tech, ecommerce) or serves all businesses (general). The sector registry provides metadata used by the extension marketplace UI. + +Key types (from `lib/extensions/types.ts`): +- `SectorSlug` — `'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce'` +- `ExtensionDefinition` — Marketplace metadata (slug, name, sector, category, icon, dataPattern, description) +- `ExtensionCategory` — `'import' | 'operations' | 'reports' | 'accounting'` +- `ExtensionDataPattern` — `'core' | 'manual' | 'both'` (how extension accesses data) +- `ExtensionToggle` — Per-user enable/disable state for extensions ### Creating a New Extension -1. Create `extensions//index.ts` +1. Create `extensions///index.ts` 2. Export an object implementing the `Extension` interface from `lib/extensions/types.ts` 3. Add a static import to the `FIRST_PARTY_EXTENSIONS` array in `lib/extensions/loader.ts` -4. Extensions **cannot** use dynamic imports (Next.js bundling constraint) +4. Add metadata to the appropriate sector in `lib/extensions/sectors.ts` +5. Extensions **cannot** use dynamic imports (Next.js bundling constraint) + +### Currently Loaded Extensions (FIRST_PARTY_EXTENSIONS) + +``` +receiptOcrExtension @/extensions/general/receipt-ocr +aiCategorizationExtension @/extensions/general/ai-categorization +pushNotificationsExtension @/extensions/general/push-notifications +sruExportExtension @/extensions/sru-export +neBilagaExtension @/extensions/ne-bilaga +aiChatExtension @/extensions/general/ai-chat +invoiceInboxExtension @/extensions/general/invoice-inbox +# enableBankingExtension @/extensions/general/enable-banking (commented out, opt-in) +``` ### Extension Interface @@ -235,7 +302,7 @@ interface Extension { ### Minimal Example -See `extensions/example-logger/index.ts`: +See `extensions/general/example-logger/index.ts`: ```typescript import type { Extension } from '@/lib/extensions/types' @@ -273,6 +340,7 @@ All defined in `lib/events/types.ts`: | `credit_note.created` | `{ creditNote, userId }` | | `transaction.synced` | `{ transactions[], userId }` | | `transaction.categorized` | `{ transaction, account, taxCode, userId }` | +| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` | | `bank.statement_received` | `{ statement, userId }` | | `bank.payment_notification` | `{ notification, userId }` | | `period.locked` | `{ period, userId }` | @@ -282,7 +350,9 @@ All defined in `lib/events/types.ts`: | `receipt.extracted` | `{ receipt, documentId, confidence, userId }` | | `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` | | `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` | -| `transaction.reconciled` | `{ transaction, journalEntryId, method, userId }` | +| `supplier_invoice.received` | `{ inboxItem, userId }` | +| `supplier_invoice.extracted` | `{ inboxItem, confidence, userId }` | +| `supplier_invoice.confirmed` | `{ inboxItem, supplierInvoice, userId }` | | `audit.security_event` | `{ event, userId }` | ### Event Bus Behavior @@ -325,6 +395,13 @@ mockResult({ data: makeTransaction(), error: null }) - `makeJournalEntryLine()` — Line with account number, zero amounts - `makeDocumentAttachment()` — Document with hash, storage path - `makeTaxCode()` — TaxCode with default output VAT 25% +- `makeInvoice()` — Invoice with default customer, amounts, dates +- `makeCustomer()` — Customer with default name, address +- `makeSupplier()` — Supplier with default details +- `makeSupplierInvoice()` — Supplier invoice with default amounts +- `makeCompanySettings()` — Company settings with defaults +- `makeInvoiceInboxItem()` — Invoice inbox item for supplier invoice intake +- `makeExtensionToggle()` — Extension toggle state ### Patterns @@ -363,11 +440,11 @@ mockResult({ data: makeTransaction(), error: null }) ### Location -`supabase/migrations/` — currently 32 files numbered `20240101000001` through `20240101000032`. +`supabase/migrations/` — currently 39 files numbered `20240101000001` through `20240101000038` (note: two files share number `000033`). ### Naming Convention -`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000033_*.sql` +`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000039_*.sql` ### Migration Rules @@ -406,6 +483,13 @@ mockResult({ data: makeTransaction(), error: null }) - **Migration 030 (`bank_reconciliation`)** — Adds `reconciliation_method` column to `transactions` (CHECK constraint for method types), indexes for unmatched transaction lookup, and RPC `get_unlinked_1930_lines()` for finding unreconciled GL lines. - **Migration 031 (`invoice_document_type`)** — Adds `document_type` column to `invoices` (CHECK: invoice/proforma/delivery_note, default 'invoice') and `converted_from_id` FK for tracking proforma-to-invoice conversions. - **Migration 032 (`add_accounting_method`)** — Adds `accounting_method` column to `company_settings` (CHECK: accrual/cash, default 'accrual') to support kontantmetoden vs faktureringsmetoden. +- **Migration 033 (`ai_chat_schema`)** — AI chat conversation and message storage. +- **Migration 033 (`invoice_inbox`)** — Invoice inbox table for supplier invoice intake. (Note: shares number with ai_chat_schema.) +- **Migration 034 (`fix_extension_data_trigger`)** — Fixes extension data trigger. +- **Migration 035 (`fix_push_notifications`)** — Push notifications schema fix. +- **Migration 036 (`fix_enable_banking`)** — Enable Banking schema fix. +- **Migration 037 (`extension_toggles`)** — Extension toggle table for per-user enable/disable. +- **Migration 038 (`fix_match_documents_search_path`)** — Fixes search path for document matching function. --- diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index e2c14ec5..cfbf62ac 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -515,7 +515,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st {(() => { const vatByRate = new Map() for (const item of invoice.items) { - const rate = item.vat_rate ?? 25 + const rate = item.vat_rate ?? 0 const lineVat = Math.round(item.line_total * (rate / 100) * 100) / 100 vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) } diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index c60c0052..36c8274f 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -18,6 +18,9 @@ import TransactionHistoryList from '@/components/transactions/TransactionHistory import InboxZeroState from '@/components/transactions/InboxZeroState' import InvoiceMatchDialog from '@/components/transactions/InvoiceMatchDialog' import TransactionBookingDialog from '@/components/transactions/TransactionBookingDialog' +import QuickReviewDialog from '@/components/transactions/QuickReviewDialog' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions/transaction-types' +import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' 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' @@ -48,6 +51,15 @@ export default function TransactionsPage() { const [bookingDialogOpen, setBookingDialogOpen] = useState(false) const [bookingDialogTransaction, setBookingDialogTransaction] = useState(null) + // Quick review dialog (suggestion review before booking) + const [quickReviewOpen, setQuickReviewOpen] = useState(false) + const [quickReviewTransaction, setQuickReviewTransaction] = useState(null) + const [quickReviewCategory, setQuickReviewCategory] = useState(null) + const [quickReviewLabel, setQuickReviewLabel] = useState('') + + // Entity type for tooltip context + const [entityType, setEntityType] = useState('enskild_firma') + // Set of transaction IDs that are animating out (just categorized) const [exitingIds, setExitingIds] = useState>(new Set()) @@ -128,6 +140,22 @@ export default function TransactionsPage() { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { fetchTransactions() }, []) + // Fetch entity type for tooltip context + useEffect(() => { + async function fetchEntityType() { + try { + const res = await fetch('/api/settings') + const data = await res.json() + if (data?.entity_type) { + setEntityType(data.entity_type) + } + } catch { + // Non-critical, defaults to enskild_firma + } + } + fetchEntityType() + }, []) + // Auto-fetch suggestions when transactions load useEffect(() => { const uncatIds = transactions @@ -416,6 +444,29 @@ export default function TransactionsPage() { setBookingDialogOpen(true) } + function handleOpenQuickReview(transaction: TransactionWithInvoice, suggestion: SuggestedCategory) { + const allCategories = [...EXPENSE_CATEGORIES, ...INCOME_CATEGORIES] + const label = allCategories.find((c) => c.value === suggestion.category)?.label || suggestion.label + setQuickReviewTransaction(transaction) + setQuickReviewCategory(suggestion.category) + setQuickReviewLabel(label) + setQuickReviewOpen(true) + } + + async function handleQuickReviewConfirm( + id: string, + category: TransactionCategory, + vatTreatment: VatTreatment | undefined, + accountOverride: string | undefined + ) { + const success = await handleCategorize(id, true, category, vatTreatment, accountOverride) + if (success) { + setQuickReviewOpen(false) + setQuickReviewTransaction(null) + setQuickReviewCategory(null) + } + } + // Swipe view if (showSwipeView && uncategorizedTransactions.length > 0) { return ( @@ -478,10 +529,12 @@ export default function TransactionsPage() { processingId={processingId} isBatchMode={isBatchMode} isSelected={selectedIds.has(transaction.id)} + entityType={entityType} onCategorize={handleCategorize} onMarkPrivate={handleMarkPrivate} onOpenMatchDialog={openMatchDialog} onOpenCategoryDialog={openCategoryDialog} + onOpenQuickReview={handleOpenQuickReview} onToggleSelect={toggleBatchSelect} /> ))} @@ -537,6 +590,18 @@ export default function TransactionsPage() { onBooked={handleTransactionBooked} /> + + diff --git a/app/api/bookkeeping/accounts/[number]/route.ts b/app/api/bookkeeping/accounts/[number]/route.ts index aab83b06..94847881 100644 --- a/app/api/bookkeeping/accounts/[number]/route.ts +++ b/app/api/bookkeeping/accounts/[number]/route.ts @@ -1,5 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { UpdateAccountSchema } from '@/lib/api/schemas' export async function DELETE( request: Request, @@ -70,19 +72,13 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - // Build update object with only provided fields - const updates: Record = {} - if (body.account_name !== undefined) updates.account_name = body.account_name - if (body.is_active !== undefined) updates.is_active = body.is_active - if (body.description !== undefined) updates.description = body.description - if (body.default_vat_code !== undefined) updates.default_vat_code = body.default_vat_code - if (body.sru_code !== undefined) updates.sru_code = body.sru_code + const validation = await validateBody(request, UpdateAccountSchema) + if (!validation.success) return validation.response + const body = validation.data const { data, error } = await supabase .from('chart_of_accounts') - .update(updates) + .update(body) .eq('user_id', user.id) .eq('account_number', number) .select() diff --git a/app/api/bookkeeping/accounts/route.ts b/app/api/bookkeeping/accounts/route.ts index ba42fdc5..4f00e95f 100644 --- a/app/api/bookkeeping/accounts/route.ts +++ b/app/api/bookkeeping/accounts/route.ts @@ -1,6 +1,8 @@ import { createClient } from '@/lib/supabase/server' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { CreateAccountSchema } from '@/lib/api/schemas' export async function GET(request: Request) { const supabase = await createClient() @@ -47,7 +49,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const validation = await validateBody(request, CreateAccountSchema) + if (!validation.success) return validation.response + const body = validation.data const { data, error } = await supabase .from('chart_of_accounts') diff --git a/app/api/bookkeeping/fiscal-periods/route.ts b/app/api/bookkeeping/fiscal-periods/route.ts index 84d8eb82..b0b8712b 100644 --- a/app/api/bookkeeping/fiscal-periods/route.ts +++ b/app/api/bookkeeping/fiscal-periods/route.ts @@ -1,7 +1,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import type { CreateFiscalPeriodInput } from '@/types' import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration' +import { validateBody } from '@/lib/api/validate' +import { CreateFiscalPeriodSchema } from '@/lib/api/schemas' export async function GET() { const supabase = await createClient() @@ -32,7 +33,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() as CreateFiscalPeriodInput + const validation = await validateBody(request, CreateFiscalPeriodSchema) + if (!validation.success) return validation.response + const body = validation.data // Validate period duration (max 18 months per BFL 3 kap.) const durationError = validatePeriodDuration(body.period_start, body.period_end) 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 index d6fa64a0..8bf9e0b8 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/__tests__/route.test.ts @@ -57,7 +57,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Lines are required') + expect(body.error).toBe('Validation failed') }) it('returns 400 when lines array is empty', async () => { @@ -69,7 +69,7 @@ describe('POST /api/bookkeeping/journal-entries/[id]/correct', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('Lines are required') + expect(body.error).toBe('Validation failed') }) it('returns reversal and corrected entries on success', async () => { diff --git a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts index 4f0e22a4..79a86d67 100644 --- a/app/api/bookkeeping/journal-entries/[id]/correct/route.ts +++ b/app/api/bookkeeping/journal-entries/[id]/correct/route.ts @@ -2,7 +2,8 @@ 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' +import { validateBody } from '@/lib/api/validate' +import { CorrectJournalEntrySchema } from '@/lib/api/schemas' ensureInitialized() @@ -18,16 +19,9 @@ export async function POST( 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 }) - } + const validation = await validateBody(request, CorrectJournalEntrySchema) + if (!validation.success) return validation.response + const body = validation.data try { const result = await correctEntry(user.id, id, body.lines) diff --git a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts index 318f7d77..adb771db 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/route.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/route.test.ts @@ -88,6 +88,8 @@ describe('GET /api/bookkeeping/journal-entries', () => { }) }) +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' + describe('POST /api/bookkeeping/journal-entries', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -116,7 +118,7 @@ describe('POST /api/bookkeeping/journal-entries', () => { mockCreateJournalEntry.mockResolvedValue(entry) const input = { - fiscal_period_id: 'period-1', + fiscal_period_id: VALID_UUID, entry_date: '2024-06-15', description: 'Test entry', source_type: 'manual', @@ -144,11 +146,14 @@ describe('POST /api/bookkeeping/journal-entries', () => { const request = createMockRequest('/api/bookkeeping/journal-entries', { method: 'POST', body: { - fiscal_period_id: 'period-1', + fiscal_period_id: VALID_UUID, entry_date: '2024-06-15', description: 'Bad entry', source_type: 'manual', - lines: [{ account_number: '1930', debit_amount: 1000, credit_amount: 0 }], + lines: [ + { account_number: '1930', debit_amount: 1000, credit_amount: 0 }, + { account_number: '3001', debit_amount: 0, credit_amount: 500 }, + ], }, }) const response = await POST(request) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 9366da98..6e274243 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -2,7 +2,8 @@ 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' +import { validateBody } from '@/lib/api/validate' +import { CreateJournalEntrySchema } from '@/lib/api/schemas' ensureInitialized() @@ -63,7 +64,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() as CreateJournalEntryInput + const validation = await validateBody(request, CreateJournalEntrySchema) + if (!validation.success) return validation.response + const body = validation.data try { const entry = await createJournalEntry(user.id, body) diff --git a/app/api/bookkeeping/mapping-rules/evaluate/route.ts b/app/api/bookkeeping/mapping-rules/evaluate/route.ts index bd29a3ec..d7533f1f 100644 --- a/app/api/bookkeeping/mapping-rules/evaluate/route.ts +++ b/app/api/bookkeeping/mapping-rules/evaluate/route.ts @@ -1,6 +1,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { evaluateMappingRules } from '@/lib/bookkeeping/mapping-engine' +import { validateBody } from '@/lib/api/validate' +import { EvaluateMappingRulesSchema } from '@/lib/api/schemas' import type { Transaction } from '@/types' export async function POST(request: Request) { @@ -11,12 +13,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const validation = await validateBody(request, EvaluateMappingRulesSchema) + if (!validation.success) return validation.response + const body = validation.data // Accept either a transaction ID or raw transaction data let transaction: Transaction - if (body.transaction_id) { + if ('transaction_id' in body) { const { data, error } = await supabase .from('transactions') .select('*') @@ -30,7 +34,7 @@ export async function POST(request: Request) { transaction = data as Transaction } else { - transaction = body as Transaction + transaction = body as unknown as Transaction } try { diff --git a/app/api/bookkeeping/mapping-rules/route.ts b/app/api/bookkeeping/mapping-rules/route.ts index 0c799227..80624a6d 100644 --- a/app/api/bookkeeping/mapping-rules/route.ts +++ b/app/api/bookkeeping/mapping-rules/route.ts @@ -1,5 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { CreateMappingRuleSchema } from '@/lib/api/schemas' export async function GET() { const supabase = await createClient() @@ -31,7 +33,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() + const result = await validateBody(request, CreateMappingRuleSchema) + if (!result.success) return result.response + const body = result.data const { data, error } = await supabase .from('mapping_rules') diff --git a/app/api/customers/[id]/route.ts b/app/api/customers/[id]/route.ts index 6d9edcac..8dfc0977 100644 --- a/app/api/customers/[id]/route.ts +++ b/app/api/customers/[id]/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import type { CreateCustomerInput } from '@/types' +import { validateBody } from '@/lib/api/validate' +import { UpdateCustomerSchema } from '@/lib/api/schemas' export async function GET( request: Request, @@ -62,7 +63,9 @@ export async function PATCH( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: Partial = await request.json() + const result = await validateBody(request, UpdateCustomerSchema) + if (!result.success) return result.response + const body = result.data const updateData: Record = {} diff --git a/app/api/customers/route.ts b/app/api/customers/route.ts index 1978e985..d22153d1 100644 --- a/app/api/customers/route.ts +++ b/app/api/customers/route.ts @@ -2,7 +2,9 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' -import type { CreateCustomerInput, Customer } from '@/types' +import { validateBody } from '@/lib/api/validate' +import { CreateCustomerSchema } from '@/lib/api/schemas' +import type { Customer } from '@/types' ensureInitialized() @@ -37,7 +39,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: CreateCustomerInput = await request.json() + const result = await validateBody(request, CreateCustomerSchema) + if (!result.success) return result.response + const body = result.data const { data, error } = await supabase .from('customers') diff --git a/app/api/deadlines/route.ts b/app/api/deadlines/route.ts index d4ddfe89..d15512ad 100644 --- a/app/api/deadlines/route.ts +++ b/app/api/deadlines/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import type { CreateDeadlineInput } from '@/types' +import { validateBody } from '@/lib/api/validate' +import { CreateDeadlineSchema } from '@/lib/api/schemas' /** * GET /api/deadlines @@ -78,12 +79,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: CreateDeadlineInput = await request.json() - - // Validate required fields - if (!body.title || !body.due_date || !body.deadline_type) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }) - } + const validation = await validateBody(request, CreateDeadlineSchema) + if (!validation.success) return validation.response + const body = validation.data // Insert the deadline const { data, error } = await supabase diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts index 26a197c9..d82bcf18 100644 --- a/app/api/invoices/__tests__/route.test.ts +++ b/app/api/invoices/__tests__/route.test.ts @@ -111,6 +111,9 @@ describe('GET /api/invoices', () => { }) }) +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' +const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001' + describe('POST /api/invoices (create invoice)', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -126,7 +129,7 @@ describe('POST /api/invoices (create invoice)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', - body: { customer_id: 'cust-1', items: [] }, + body: { customer_id: VALID_UUID, items: [] }, }) const response = await POST(request) const { status, body } = await parseJsonResponse(response) @@ -141,7 +144,7 @@ describe('POST /api/invoices (create invoice)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', body: { - customer_id: 'cust-999', + customer_id: VALID_UUID_2, invoice_date: '2024-06-15', due_date: '2024-07-15', currency: 'SEK', @@ -156,7 +159,7 @@ describe('POST /api/invoices (create invoice)', () => { }) it('creates invoice with items and emits event', async () => { - const customer = makeCustomer({ id: 'cust-1' }) + const customer = makeCustomer({ id: VALID_UUID }) const createdInvoice = makeInvoice({ id: 'inv-1' }) mockGetVatRules.mockReturnValue({ @@ -189,7 +192,7 @@ describe('POST /api/invoices (create invoice)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', body: { - customer_id: 'cust-1', + customer_id: VALID_UUID, invoice_date: '2024-06-15', due_date: '2024-07-15', currency: 'SEK', @@ -207,7 +210,7 @@ describe('POST /api/invoices (create invoice)', () => { }) it('rolls back invoice when items insertion fails', async () => { - const customer = makeCustomer({ id: 'cust-1' }) + const customer = makeCustomer({ id: VALID_UUID }) const createdInvoice = makeInvoice({ id: 'inv-1' }) mockGetVatRules.mockReturnValue({ @@ -235,7 +238,7 @@ describe('POST /api/invoices (create invoice)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', body: { - customer_id: 'cust-1', + customer_id: VALID_UUID, invoice_date: '2024-06-15', due_date: '2024-07-15', currency: 'SEK', @@ -265,7 +268,7 @@ describe('POST /api/invoices (create credit note)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', - body: { credited_invoice_id: 'inv-999' }, + body: { credited_invoice_id: VALID_UUID_2 }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -275,12 +278,12 @@ describe('POST /api/invoices (create credit note)', () => { }) it('returns 400 when invoice is already credited', async () => { - const original = makeInvoice({ id: 'inv-1', status: 'credited' }) + const original = makeInvoice({ id: VALID_UUID, status: 'credited' }) enqueue({ data: original, error: null }) const request = createMockRequest('/api/invoices', { method: 'POST', - body: { credited_invoice_id: 'inv-1' }, + body: { credited_invoice_id: VALID_UUID }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -290,12 +293,12 @@ describe('POST /api/invoices (create credit note)', () => { }) it('returns 400 when invoice is in draft status', async () => { - const original = makeInvoice({ id: 'inv-1', status: 'draft' }) + const original = makeInvoice({ id: VALID_UUID, status: 'draft' }) enqueue({ data: original, error: null }) const request = createMockRequest('/api/invoices', { method: 'POST', - body: { credited_invoice_id: 'inv-1' }, + body: { credited_invoice_id: VALID_UUID }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -319,7 +322,7 @@ describe('POST /api/invoices (create credit note)', () => { }, ] const original = makeInvoice({ - id: 'inv-1', + id: VALID_UUID, status: 'sent', subtotal: 10000, vat_amount: 2500, @@ -328,7 +331,7 @@ describe('POST /api/invoices (create credit note)', () => { }) const creditNote = makeInvoice({ id: 'cn-1', - credited_invoice_id: 'inv-1', + credited_invoice_id: VALID_UUID, subtotal: -10000, vat_amount: -2500, total: -12500, @@ -356,7 +359,7 @@ describe('POST /api/invoices (create credit note)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', - body: { credited_invoice_id: 'inv-1' }, + body: { credited_invoice_id: VALID_UUID }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ data: unknown }>(response) @@ -370,7 +373,7 @@ describe('POST /api/invoices (create credit note)', () => { it('rolls back credit note when items insertion fails', async () => { const original = makeInvoice({ - id: 'inv-1', + id: VALID_UUID, status: 'sent', items: [ { @@ -397,7 +400,7 @@ describe('POST /api/invoices (create credit note)', () => { const request = createMockRequest('/api/invoices', { method: 'POST', - body: { credited_invoice_id: 'inv-1' }, + body: { credited_invoice_id: VALID_UUID }, }) const response = await POST(request) const { status, body } = await parseJsonResponse<{ error: string }>(response) diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts index d78e86a5..f44db2c4 100644 --- a/app/api/invoices/route.ts +++ b/app/api/invoices/route.ts @@ -2,7 +2,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' -import type { CreateInvoiceInput, EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types' +import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas' +import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types' import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { @@ -11,11 +12,6 @@ import { ensureInitialized() -interface CreateCreditNoteInput { - credited_invoice_id: string - reason?: string -} - export async function GET(request: Request) { const supabase = await createClient() @@ -59,15 +55,45 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - - // Check if this is a credit note creation request - if (body.credited_invoice_id) { - return createCreditNote(supabase, user.id, body as CreateCreditNoteInput) + let rawBody: unknown + try { + rawBody = await request.json() + } catch { + return NextResponse.json( + { error: 'Invalid JSON in request body', type: 'validation_error' }, + { status: 400 }, + ) } - const invoiceInput = body as CreateInvoiceInput - const documentType: InvoiceDocumentType = body.document_type || 'invoice' + // Check if this is a credit note creation request + if (typeof rawBody === 'object' && rawBody !== null && 'credited_invoice_id' in rawBody) { + const parsed = CreateCreditNoteSchema.safeParse(rawBody) + if (!parsed.success) { + return NextResponse.json( + { + error: 'Validation failed', + type: 'validation_error', + errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })), + }, + { status: 400 }, + ) + } + return createCreditNote(supabase, user.id, parsed.data) + } + + const parsed = CreateInvoiceSchema.safeParse(rawBody) + if (!parsed.success) { + return NextResponse.json( + { + error: 'Validation failed', + type: 'validation_error', + errors: parsed.error.issues.map((i) => ({ field: i.path.join('.'), message: i.message, code: i.code })), + }, + { status: 400 }, + ) + } + const invoiceInput = parsed.data + const documentType: InvoiceDocumentType = invoiceInput.document_type || 'invoice' // Get customer for VAT calculation const { data: customer, error: customerError } = await supabase @@ -225,7 +251,7 @@ export async function POST(request: Request) { async function createCreditNote( supabase: Awaited>, userId: string, - input: CreateCreditNoteInput + input: { credited_invoice_id: string; reason?: string } ) { // Fetch the original invoice with items const { data: originalInvoice, error: originalError } = await supabase @@ -310,7 +336,7 @@ async function createCreditNote( unit: item.unit, unit_price: item.unit_price, line_total: -Math.abs(item.line_total), - vat_rate: item.vat_rate ?? 25, + vat_rate: item.vat_rate ?? 0, vat_amount: -(item.vat_amount ? Math.abs(item.vat_amount) : 0), })) diff --git a/app/api/reconciliation/bank/link/route.ts b/app/api/reconciliation/bank/link/route.ts index 755fd91d..866fda7e 100644 --- a/app/api/reconciliation/bank/link/route.ts +++ b/app/api/reconciliation/bank/link/route.ts @@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { manualLink } from '@/lib/reconciliation/bank-reconciliation' +import { validateBody } from '@/lib/api/validate' +import { BankLinkSchema } from '@/lib/api/schemas' ensureInitialized() @@ -13,15 +15,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { transaction_id, journal_entry_id } = body - - if (!transaction_id || !journal_entry_id) { - return NextResponse.json( - { error: 'transaction_id and journal_entry_id are required' }, - { status: 400 } - ) - } + const validation = await validateBody(request, BankLinkSchema) + if (!validation.success) return validation.response + const { transaction_id, journal_entry_id } = validation.data const result = await manualLink(supabase, user.id, transaction_id, journal_entry_id) diff --git a/app/api/reconciliation/bank/run/route.ts b/app/api/reconciliation/bank/run/route.ts index 2c539a75..5247b783 100644 --- a/app/api/reconciliation/bank/run/route.ts +++ b/app/api/reconciliation/bank/run/route.ts @@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { ensureInitialized } from '@/lib/init' import { runReconciliation } from '@/lib/reconciliation/bank-reconciliation' +import { validateBody } from '@/lib/api/validate' +import { RunReconciliationSchema } from '@/lib/api/schemas' ensureInitialized() @@ -13,8 +15,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { date_from, date_to, dry_run } = body + const validation = await validateBody(request, RunReconciliationSchema) + if (!validation.success) return validation.response + const { date_from, date_to, dry_run } = validation.data const result = await runReconciliation(supabase, user.id, { dateFrom: date_from, diff --git a/app/api/reconciliation/bank/unlink/route.ts b/app/api/reconciliation/bank/unlink/route.ts index ae2fef28..e2e10a9c 100644 --- a/app/api/reconciliation/bank/unlink/route.ts +++ b/app/api/reconciliation/bank/unlink/route.ts @@ -1,6 +1,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { unlinkReconciliation } from '@/lib/reconciliation/bank-reconciliation' +import { validateBody } from '@/lib/api/validate' +import { BankUnlinkSchema } from '@/lib/api/schemas' export async function POST(request: Request) { const supabase = await createClient() @@ -10,15 +12,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json() - const { transaction_id } = body - - if (!transaction_id) { - return NextResponse.json( - { error: 'transaction_id is required' }, - { status: 400 } - ) - } + const validation = await validateBody(request, BankUnlinkSchema) + if (!validation.success) return validation.response + const { transaction_id } = validation.data const result = await unlinkReconciliation(supabase, user.id, transaction_id) diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index e96d23f2..f327fa84 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,6 +1,8 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' import { didTaxFieldsChange, regenerateTaxDeadlinesForUser } from '@/lib/tax/deadline-generator' +import { validateBody } from '@/lib/api/validate' +import { UpdateSettingsSchema } from '@/lib/api/schemas' export async function GET() { const supabase = await createClient() @@ -40,7 +42,9 @@ export async function PUT(request: Request) { .eq('user_id', user.id) .single() - const body = await request.json() + const validation = await validateBody(request, UpdateSettingsSchema) + if (!validation.success) return validation.response + const body = validation.data // Validate: enskild firma must use calendar year (BFL 3 kap.) const effectiveEntityType = body.entity_type || oldSettings?.entity_type diff --git a/app/api/supplier-invoices/[id]/mark-paid/route.ts b/app/api/supplier-invoices/[id]/mark-paid/route.ts index dc67353f..ddf572dd 100644 --- a/app/api/supplier-invoices/[id]/mark-paid/route.ts +++ b/app/api/supplier-invoices/[id]/mark-paid/route.ts @@ -4,15 +4,10 @@ import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' +import { validateBody } from '@/lib/api/validate' +import { MarkSupplierInvoicePaidSchema } from '@/lib/api/schemas' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' -interface MarkPaidRequest { - amount?: number - payment_date?: string - exchange_rate_difference?: number - notes?: string -} - export async function POST( request: Request, { params }: { params: Promise<{ id: string }> } @@ -26,7 +21,9 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: MarkPaidRequest = await request.json() + const validation = await validateBody(request, MarkSupplierInvoicePaidSchema) + if (!validation.success) return validation.response + const body = validation.data // Fetch invoice with supplier and items const { data: invoice, error: fetchError } = await supabase diff --git a/app/api/supplier-invoices/[id]/route.ts b/app/api/supplier-invoices/[id]/route.ts index 0e1e67a4..22e54afd 100644 --- a/app/api/supplier-invoices/[id]/route.ts +++ b/app/api/supplier-invoices/[id]/route.ts @@ -1,5 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' +import { validateBody } from '@/lib/api/validate' +import { UpdateSupplierInvoiceSchema } from '@/lib/api/schemas' export async function GET( _request: Request, @@ -60,18 +62,13 @@ export async function PUT( ) } - const body = await request.json() + const validation = await validateBody(request, UpdateSupplierInvoiceSchema) + if (!validation.success) return validation.response + const body = validation.data const { data, error } = await supabase .from('supplier_invoices') - .update({ - supplier_invoice_number: body.supplier_invoice_number, - invoice_date: body.invoice_date, - due_date: body.due_date, - delivery_date: body.delivery_date, - payment_reference: body.payment_reference, - notes: body.notes, - }) + .update(body) .eq('id', id) .eq('user_id', user.id) .select() diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index d23e8631..d138a037 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -98,6 +98,9 @@ describe('GET /api/supplier-invoices', () => { }) }) +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' +const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001' + describe('POST /api/supplier-invoices', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -112,7 +115,7 @@ describe('POST /api/supplier-invoices', () => { const request = createMockRequest('/api/supplier-invoices', { method: 'POST', - body: { supplier_id: 'sup-1', items: [] }, + body: { supplier_id: VALID_UUID, items: [] }, }) const response = await POST(request) const { status, body } = await parseJsonResponse(response) @@ -127,7 +130,7 @@ describe('POST /api/supplier-invoices', () => { const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: { - supplier_id: 'sup-999', + supplier_id: VALID_UUID_2, supplier_invoice_number: 'LF-001', invoice_date: '2024-06-01', due_date: '2024-07-01', @@ -142,7 +145,7 @@ describe('POST /api/supplier-invoices', () => { }) it('creates supplier invoice with items and arrival number', async () => { - const supplier = makeSupplier({ id: 'sup-1' }) + const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) // Fetch supplier @@ -163,7 +166,7 @@ describe('POST /api/supplier-invoices', () => { const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: { - supplier_id: 'sup-1', + supplier_id: VALID_UUID, supplier_invoice_number: 'LF-001', invoice_date: '2024-06-01', due_date: '2024-07-01', @@ -190,7 +193,7 @@ describe('POST /api/supplier-invoices', () => { }) it('skips registration entry for cash method', async () => { - const supplier = makeSupplier({ id: 'sup-1' }) + const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) enqueue({ data: supplier, error: null }) @@ -202,7 +205,7 @@ describe('POST /api/supplier-invoices', () => { const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: { - supplier_id: 'sup-1', + supplier_id: VALID_UUID, supplier_invoice_number: 'LF-002', invoice_date: '2024-06-01', due_date: '2024-07-01', @@ -220,7 +223,7 @@ describe('POST /api/supplier-invoices', () => { }) it('rolls back on items insertion failure', async () => { - const supplier = makeSupplier({ id: 'sup-1' }) + const supplier = makeSupplier({ id: VALID_UUID }) const createdInvoice = makeSupplierInvoice({ id: 'si-1' }) enqueue({ data: supplier, error: null }) @@ -234,7 +237,7 @@ describe('POST /api/supplier-invoices', () => { const request = createMockRequest('/api/supplier-invoices', { method: 'POST', body: { - supplier_id: 'sup-1', + supplier_id: VALID_UUID, supplier_invoice_number: 'LF-003', invoice_date: '2024-06-01', due_date: '2024-07-01', diff --git a/app/api/supplier-invoices/route.ts b/app/api/supplier-invoices/route.ts index e702d6e3..a3729c1b 100644 --- a/app/api/supplier-invoices/route.ts +++ b/app/api/supplier-invoices/route.ts @@ -2,7 +2,9 @@ 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' +import { validateBody } from '@/lib/api/validate' +import { CreateSupplierInvoiceSchema } from '@/lib/api/schemas' +import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' ensureInitialized() @@ -49,7 +51,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: CreateSupplierInvoiceInput = await request.json() + const validation = await validateBody(request, CreateSupplierInvoiceSchema) + if (!validation.success) return validation.response + const body = validation.data // Validate supplier exists and belongs to user const { data: supplier, error: supplierError } = await supabase diff --git a/app/api/suppliers/[id]/route.ts b/app/api/suppliers/[id]/route.ts index b51422cd..3fe18bc2 100644 --- a/app/api/suppliers/[id]/route.ts +++ b/app/api/suppliers/[id]/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import type { CreateSupplierInput } from '@/types' +import { validateBody } from '@/lib/api/validate' +import { UpdateSupplierSchema } from '@/lib/api/schemas' export async function GET( _request: Request, @@ -66,7 +67,9 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: Partial = await request.json() + const result = await validateBody(request, UpdateSupplierSchema) + if (!result.success) return result.response + const body = result.data const { data, error } = await supabase .from('suppliers') diff --git a/app/api/suppliers/route.ts b/app/api/suppliers/route.ts index a406d9ef..7fe057ea 100644 --- a/app/api/suppliers/route.ts +++ b/app/api/suppliers/route.ts @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import type { CreateSupplierInput, Supplier } from '@/types' +import { validateBody } from '@/lib/api/validate' +import { CreateSupplierSchema } from '@/lib/api/schemas' export async function GET() { const supabase = await createClient() @@ -33,7 +34,9 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: CreateSupplierInput = await request.json() + const result = await validateBody(request, CreateSupplierSchema) + if (!result.success) return result.response + const body = result.data const { data, error } = await supabase .from('suppliers') diff --git a/app/api/transactions/[id]/book/__tests__/route.test.ts b/app/api/transactions/[id]/book/__tests__/route.test.ts index c68ac8cc..0d701a4d 100644 --- a/app/api/transactions/[id]/book/__tests__/route.test.ts +++ b/app/api/transactions/[id]/book/__tests__/route.test.ts @@ -25,10 +25,12 @@ vi.mock('@/lib/bookkeeping/engine', () => ({ import { POST } from '../route' +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' + describe('POST /api/transactions/[id]/book', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } const validBody = { - fiscal_period_id: 'period-1', + fiscal_period_id: VALID_UUID, entry_date: '2025-01-15', description: 'Test booking', lines: [ @@ -61,13 +63,13 @@ describe('POST /api/transactions/[id]/book', () => { it('returns 400 when missing required fields', async () => { const request = createMockRequest('/api/transactions/tx-1/book', { method: 'POST', - body: { fiscal_period_id: 'period-1' }, + body: { fiscal_period_id: VALID_UUID }, }) 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') + expect(body.error).toBe('Validation failed') }) it('returns 404 when transaction not found', async () => { @@ -154,7 +156,7 @@ describe('POST /api/transactions/[id]/book', () => { expect(body.data.id).toBe('je-new') expect(mockCreateJournalEntry).toHaveBeenCalledWith('user-1', { - fiscal_period_id: 'period-1', + fiscal_period_id: VALID_UUID, entry_date: '2025-01-15', description: 'Test booking', source_type: 'bank_transaction', diff --git a/app/api/transactions/[id]/book/route.ts b/app/api/transactions/[id]/book/route.ts index 9b0b33a2..1323d8b5 100644 --- a/app/api/transactions/[id]/book/route.ts +++ b/app/api/transactions/[id]/book/route.ts @@ -3,17 +3,12 @@ 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' +import { validateBody } from '@/lib/api/validate' +import { BookTransactionSchema } from '@/lib/api/schemas' +import type { 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 }> } @@ -27,15 +22,9 @@ export async function POST( 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 } - ) - } + const validation = await validateBody(request, BookTransactionSchema) + if (!validation.success) return validation.response + const { fiscal_period_id, entry_date, description, lines } = validation.data // Fetch transaction (validates ownership) const { data: transaction, error: fetchError } = await supabase diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index b6abf39e..db27329e 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -5,17 +5,12 @@ import { ensureInitialized } from '@/lib/init' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' -import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types' +import { validateBody } from '@/lib/api/validate' +import { CategorizeTransactionSchema } from '@/lib/api/schemas' +import type { Transaction, TransactionCategory, EntityType } from '@/types' ensureInitialized() -interface CategorizeRequest { - is_business: boolean - category?: TransactionCategory - vat_treatment?: VatTreatment - account_override?: string -} - /** * Ensure a fiscal period exists for the given date, create one if needed */ @@ -98,8 +93,10 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Parse request body - const body: CategorizeRequest = await request.json() + // Parse and validate request body + const validation = await validateBody(request, CategorizeTransactionSchema) + if (!validation.success) return validation.response + const body = validation.data const { is_business, category } = body // Fetch the transaction (validates ownership) 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 99271abe..da811939 100644 --- a/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts +++ b/app/api/transactions/[id]/match-invoice/__tests__/route.test.ts @@ -25,6 +25,9 @@ vi.mock('@/lib/bookkeeping/invoice-entries', () => ({ import { POST } from '../route' +const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000' +const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001' + describe('POST /api/transactions/[id]/match-invoice', () => { const mockUser = { id: 'user-1', email: 'test@test.se' } @@ -39,7 +42,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse(response) @@ -57,7 +60,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const { status, body } = await parseJsonResponse<{ error: string }>(response) expect(status).toBe(400) - expect(body.error).toBe('invoice_id is required') + expect(body.error).toBe('Validation failed') }) it('returns 404 when transaction not found', async () => { @@ -65,7 +68,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-999/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-999' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -80,7 +83,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -95,7 +98,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -111,7 +114,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-999' }, + body: { invoice_id: VALID_UUID_2 }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -122,13 +125,13 @@ describe('POST /api/transactions/[id]/match-invoice', () => { it('returns 400 when invoice is not in unpaid state', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null }) - const invoice = makeInvoice({ id: 'inv-1', status: 'paid' }) + const invoice = makeInvoice({ id: VALID_UUID, status: 'paid' }) enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ error: string }>(response) @@ -141,7 +144,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) const customer = makeCustomer() const invoice = makeInvoice({ - id: 'inv-1', + id: VALID_UUID, status: 'sent', total: 12500, subtotal: 10000, @@ -166,7 +169,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ @@ -185,14 +188,14 @@ describe('POST /api/transactions/[id]/match-invoice', () => { // Verify accrual payment entry was called expect(mockCreateInvoicePaymentJournalEntry).toHaveBeenCalledWith( 'user-1', - expect.objectContaining({ id: 'inv-1' }), + expect.objectContaining({ id: VALID_UUID }), '2024-06-15' ) }) it('returns success with journal_entry_error when journal entry fails (non-blocking)', async () => { const tx = makeTransaction({ id: 'tx-1', amount: 12500, invoice_id: null, date: '2024-06-15' }) - const invoice = makeInvoice({ id: 'inv-1', status: 'sent', total: 12500 }) + const invoice = makeInvoice({ id: VALID_UUID, status: 'sent', total: 12500 }) enqueue({ data: tx, error: null }) enqueue({ data: invoice, error: null }) @@ -207,7 +210,7 @@ describe('POST /api/transactions/[id]/match-invoice', () => { const request = createMockRequest('/api/transactions/tx-1/match-invoice', { method: 'POST', - body: { invoice_id: 'inv-1' }, + body: { invoice_id: VALID_UUID }, }) const response = await POST(request, createMockRouteParams({ id: 'tx-1' })) const { status, body } = await parseJsonResponse<{ diff --git a/app/api/transactions/[id]/match-invoice/route.ts b/app/api/transactions/[id]/match-invoice/route.ts index a2a06acb..8a8d40e9 100644 --- a/app/api/transactions/[id]/match-invoice/route.ts +++ b/app/api/transactions/[id]/match-invoice/route.ts @@ -4,12 +4,10 @@ import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, } from '@/lib/bookkeeping/invoice-entries' +import { validateBody } from '@/lib/api/validate' +import { MatchInvoiceSchema } from '@/lib/api/schemas' import type { EntityType, Invoice } from '@/types' -interface MatchInvoiceRequest { - invoice_id: string -} - /** * POST /api/transactions/[id]/match-invoice * @@ -34,13 +32,10 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Parse request body - const body: MatchInvoiceRequest = await request.json() - const { invoice_id } = body - - if (!invoice_id) { - return NextResponse.json({ error: 'invoice_id is required' }, { status: 400 }) - } + // Parse and validate request body + const validation = await validateBody(request, MatchInvoiceSchema) + if (!validation.success) return validation.response + const { invoice_id } = validation.data // Fetch the transaction (validates ownership) const { data: transaction, error: fetchTxError } = await supabase diff --git a/app/api/transactions/[id]/match-supplier-invoice/route.ts b/app/api/transactions/[id]/match-supplier-invoice/route.ts index 19dec4ed..934fb60b 100644 --- a/app/api/transactions/[id]/match-supplier-invoice/route.ts +++ b/app/api/transactions/[id]/match-supplier-invoice/route.ts @@ -5,12 +5,10 @@ import { createSupplierInvoicePaymentEntry, createSupplierInvoiceCashEntry, } from '@/lib/bookkeeping/supplier-invoice-entries' +import { validateBody } from '@/lib/api/validate' +import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas' import type { SupplierInvoice, SupplierInvoiceItem } from '@/types' -interface MatchRequest { - supplier_invoice_id: string -} - /** * Ensure a fiscal period exists for the given date */ @@ -64,12 +62,9 @@ export async function POST( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body: MatchRequest = await request.json() - const { supplier_invoice_id } = body - - if (!supplier_invoice_id) { - return NextResponse.json({ error: 'supplier_invoice_id is required' }, { status: 400 }) - } + const validation = await validateBody(request, MatchSupplierInvoiceSchema) + if (!validation.success) return validation.response + const { supplier_invoice_id } = validation.data // Fetch the transaction const { data: transaction, error: fetchTxError } = await supabase diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx new file mode 100644 index 00000000..b39aa2b1 --- /dev/null +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -0,0 +1,318 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry' +import type { InvoiceInboxItem, Supplier, InboxItemStatus } from '@/types' +import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types' +import { PageHeader } from '@/components/ui/page-header' +import { Card, CardContent } from '@/components/ui/card' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { Settings, Inbox, CheckCircle2, AlertTriangle } from 'lucide-react' +import InboxItemCard from '@/components/extensions/general/invoice-inbox/InboxItemCard' +import InboxUploadZone from '@/components/extensions/general/invoice-inbox/InboxUploadZone' +import InboxDetailDialog from '@/components/extensions/general/invoice-inbox/InboxDetailDialog' +import InboxSettingsDialog from '@/components/extensions/general/invoice-inbox/InboxSettingsDialog' + +type TabValue = 'all' | InboxItemStatus + +const TABS: { value: TabValue; label: string }[] = [ + { value: 'all', label: 'Alla' }, + { value: 'pending', label: 'Ny' }, + { value: 'ready', label: 'Klar' }, + { value: 'confirmed', label: 'Bekräftad' }, + { value: 'rejected', label: 'Avvisad' }, + { value: 'error', label: 'Fel' }, +] + +const DEFAULT_SETTINGS: InvoiceInboxSettings = { + autoProcessEnabled: true, + autoMatchSupplierEnabled: true, + supplierMatchThreshold: 0.7, + inboxEmail: null, +} + +export default function InvoiceInboxWorkspace({ userId }: WorkspaceComponentProps) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState('all') + const [selectedItem, setSelectedItem] = useState(null) + const [isUploading, setIsUploading] = useState(false) + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [settingsOpen, setSettingsOpen] = useState(false) + const [suppliers, setSuppliers] = useState([]) + + const fetchItems = useCallback(async () => { + try { + const res = await fetch('/api/extensions/invoice-inbox/inbox') + if (res.ok) { + const { data } = await res.json() + setItems(data ?? []) + } + } catch { + // Silently fail — user sees empty state + } finally { + setLoading(false) + } + }, []) + + const fetchSettings = useCallback(async () => { + try { + const res = await fetch('/api/extensions/invoice-inbox/settings') + if (res.ok) { + const { data } = await res.json() + if (data) setSettings(data) + } + } catch { + // Use defaults + } + }, []) + + const fetchSuppliers = useCallback(async () => { + try { + const res = await fetch('/api/suppliers') + if (res.ok) { + const { data } = await res.json() + setSuppliers(data ?? []) + } + } catch { + // ok + } + }, []) + + useEffect(() => { + fetchItems() + fetchSettings() + fetchSuppliers() + }, [fetchItems, fetchSettings, fetchSuppliers]) + + function handleUploadComplete(newItem: InvoiceInboxItem) { + setItems((prev) => [newItem, ...prev]) + // Poll for processing completion + pollItem(newItem.id) + } + + async function pollItem(itemId: string) { + for (let i = 0; i < 20; i++) { + await new Promise((r) => setTimeout(r, 3000)) + try { + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`) + if (!res.ok) continue + const { data } = await res.json() + if (data && data.status !== 'processing') { + setItems((prev) => + prev.map((it) => (it.id === itemId ? data : it)) + ) + // Also update the detail dialog if it's open for this item + setSelectedItem((current) => + current?.id === itemId ? data : current + ) + return + } + } catch { + // continue polling + } + } + } + + async function handleConfirm(itemId: string, supplierId?: string) { + const body: Record = {} + if (supplierId) body.supplier_id = supplierId + + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/confirm`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (res.ok) { + setItems((prev) => + prev.map((it) => (it.id === itemId ? { ...it, status: 'confirmed' as const } : it)) + ) + setSelectedItem(null) + fetchSuppliers() // New supplier may have been created + } + } + + async function handleReject(itemId: string) { + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}`, { + method: 'DELETE', + }) + + if (res.ok) { + setItems((prev) => + prev.map((it) => (it.id === itemId ? { ...it, status: 'rejected' as const } : it)) + ) + setSelectedItem(null) + } + } + + async function handleReprocess(itemId: string) { + setItems((prev) => + prev.map((it) => (it.id === itemId ? { ...it, status: 'processing' as const } : it)) + ) + setSelectedItem(null) + + const res = await fetch(`/api/extensions/invoice-inbox/inbox/${itemId}/process`, { + method: 'POST', + }) + + if (res.ok) { + const { data } = await res.json() + if (data) { + setItems((prev) => prev.map((it) => (it.id === itemId ? data : it))) + } + } else { + // Refetch in case of error update + fetchItems() + } + } + + async function handleSaveSettings(updated: InvoiceInboxSettings) { + const res = await fetch('/api/extensions/invoice-inbox/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updated), + }) + + if (res.ok) { + const { data } = await res.json() + if (data) setSettings(data) + } + } + + const filteredItems = + activeTab === 'all' + ? items + : items.filter((it) => it.status === activeTab) + + const totalCount = items.length + const readyCount = items.filter((it) => it.status === 'ready').length + const errorCount = items.filter((it) => it.status === 'error').length + + return ( +
+ setSettingsOpen(true)} + > + + + } + /> + + {/* KPI cards */} +
+ + +
+ +
+
+

{totalCount}

+

Totalt

+
+
+
+ + +
+ +
+
+

{readyCount}

+

Att granska

+
+
+
+ + +
+ +
+
+

{errorCount}

+

Fel

+
+
+
+
+ + {/* Upload zone */} + + + {/* Tabs + item list */} + setActiveTab(v as TabValue)}> + + {TABS.map((tab) => ( + + {tab.label} + + ))} + + + {TABS.map((tab) => ( + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : filteredItems.length === 0 ? ( +
+ +

+ {activeTab === 'all' + ? 'Inga fakturor ännu. Ladda upp en faktura ovan.' + : 'Inga fakturor med denna status.'} +

+
+ ) : ( +
+ {filteredItems.map((item) => ( + setSelectedItem(item)} + /> + ))} +
+ )} +
+ ))} +
+ + {/* Detail dialog */} + { + if (!open) setSelectedItem(null) + }} + onConfirm={handleConfirm} + onReject={handleReject} + onReprocess={handleReprocess} + suppliers={suppliers} + /> + + {/* Settings dialog */} + +
+ ) +} diff --git a/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx b/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx new file mode 100644 index 00000000..8a62fe9a --- /dev/null +++ b/components/extensions/general/invoice-inbox/InboxDetailDialog.tsx @@ -0,0 +1,327 @@ +'use client' + +import { useState } from 'react' +import type { InvoiceInboxItem, Supplier } from '@/types' +import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { Progress } from '@/components/ui/progress' +import { + getStatusLabel, + getStatusVariant, + getConfidenceLabel, +} from '@/lib/extensions/invoice-inbox-utils' +import { Loader2, RefreshCw, Check, X } from 'lucide-react' + +interface InboxDetailDialogProps { + item: InvoiceInboxItem | null + open: boolean + onOpenChange: (open: boolean) => void + onConfirm: (itemId: string, supplierId?: string) => Promise + onReject: (itemId: string) => Promise + onReprocess: (itemId: string) => Promise + suppliers: Supplier[] +} + +function formatSEK(amount: number | null): string { + if (amount == null) return '-' + return new Intl.NumberFormat('sv-SE', { + style: 'currency', + currency: 'SEK', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} + +export default function InboxDetailDialog({ + item, + open, + onOpenChange, + onConfirm, + onReject, + onReprocess, + suppliers, +}: InboxDetailDialogProps) { + const [loading, setLoading] = useState<'confirm' | 'reject' | 'reprocess' | null>(null) + const [selectedSupplierId, setSelectedSupplierId] = useState(undefined) + + if (!item) return null + + const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null + const confidence = getConfidenceLabel(item.confidence) + const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + + const matchedSupplierName = (item.supplier as { name?: string } | undefined)?.name + const supplierId = selectedSupplierId ?? item.matched_supplier_id ?? undefined + + const canConfirm = item.status === 'ready' && extraction != null + const canReprocess = item.status !== 'confirmed' + const canReject = item.status !== 'confirmed' && item.status !== 'rejected' + + async function handleAction(action: 'confirm' | 'reject' | 'reprocess') { + setLoading(action) + try { + if (action === 'confirm') { + await onConfirm(item!.id, supplierId) + } else if (action === 'reject') { + await onReject(item!.id) + } else { + await onReprocess(item!.id) + } + } finally { + setLoading(null) + } + } + + return ( + + + +
+ Granska faktura + + {getStatusLabel(item.status)} + +
+
+ + {/* Confidence */} + {item.confidence != null && ( +
+
+ AI-konfidens + {confidence.label} ({Math.round(item.confidence * 100)}%) +
+ +
+ )} + + {item.error_message && ( +
+ {item.error_message} +
+ )} + + {extraction && ( + <> + + + {/* Supplier info */} +
+

Leverantör

+
+
+ Namn +

{extraction.supplier.name ?? '-'}

+
+
+ Org.nr +

{extraction.supplier.orgNumber ?? '-'}

+
+ {extraction.supplier.bankgiro && ( +
+ Bankgiro +

{extraction.supplier.bankgiro}

+
+ )} + {extraction.supplier.plusgiro && ( +
+ Plusgiro +

{extraction.supplier.plusgiro}

+
+ )} +
+ + {/* Supplier match override */} +
+ + +
+
+ + + + {/* Invoice details */} +
+

Fakturadetaljer

+
+
+ Fakturanummer +

{extraction.invoice.invoiceNumber ?? '-'}

+
+
+ Valuta +

{extraction.invoice.currency}

+
+
+ Fakturadatum +

{extraction.invoice.invoiceDate ?? '-'}

+
+
+ Förfallodatum +

{extraction.invoice.dueDate ?? '-'}

+
+ {extraction.invoice.paymentReference && ( +
+ Betalningsreferens +

{extraction.invoice.paymentReference}

+
+ )} +
+
+ + {/* Line items */} + {extraction.lineItems.length > 0 && ( + <> + +
+

Rader

+
+ + + + Beskrivning + Antal + À-pris + Belopp + Moms + + + + {extraction.lineItems.map((line, i) => ( + + {line.description} + {line.quantity} + + {line.unitPrice != null ? formatSEK(line.unitPrice) : '-'} + + + {formatSEK(line.lineTotal)} + + + {line.vatRate != null ? `${line.vatRate}%` : '-'} + + + ))} + +
+
+
+ + )} + + + + {/* Totals */} +
+
+ Netto + {formatSEK(extraction.totals.subtotal)} +
+
+ Moms + {formatSEK(extraction.totals.vatAmount)} +
+ +
+ Totalt + {formatSEK(extraction.totals.total)} +
+
+ + )} + + + {canReject && ( + + )} + {canReprocess && ( + + )} + {canConfirm && ( + + )} + +
+
+ ) +} diff --git a/components/extensions/general/invoice-inbox/InboxItemCard.tsx b/components/extensions/general/invoice-inbox/InboxItemCard.tsx new file mode 100644 index 00000000..89a40480 --- /dev/null +++ b/components/extensions/general/invoice-inbox/InboxItemCard.tsx @@ -0,0 +1,102 @@ +'use client' + +import type { InvoiceInboxItem } from '@/types' +import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { + getStatusLabel, + getStatusVariant, + getConfidenceLabel, + formatExtractionSummary, +} from '@/lib/extensions/invoice-inbox-utils' +import { Mail, Upload, FileText } from 'lucide-react' + +interface InboxItemCardProps { + item: InvoiceInboxItem + onClick: () => void +} + +function formatRelativeTime(dateStr: string): string { + const now = new Date() + const date = new Date(dateStr) + const diffMs = now.getTime() - date.getTime() + const diffMin = Math.floor(diffMs / 60000) + if (diffMin < 1) return 'Just nu' + if (diffMin < 60) return `${diffMin} min sedan` + const diffH = Math.floor(diffMin / 60) + if (diffH < 24) return `${diffH} tim sedan` + const diffD = Math.floor(diffH / 24) + if (diffD === 1) return 'Igår' + return `${diffD} dagar sedan` +} + +function formatSEK(amount: number): string { + return new Intl.NumberFormat('sv-SE', { + style: 'currency', + currency: 'SEK', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount) +} + +export default function InboxItemCard({ item, onClick }: InboxItemCardProps) { + const extraction = item.extracted_data as unknown as InvoiceExtractionResult | null + const summary = formatExtractionSummary(extraction) + const confidence = getConfidenceLabel(item.confidence) + const statusVariant = getStatusVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + const confidenceVariant = confidence.variant as 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' + + const fileName = (item.document as { file_name?: string } | undefined)?.file_name ?? 'Okänd fil' + const supplierName = (item.supplier as { name?: string } | undefined)?.name ?? summary.supplierName + + return ( + + +
+ {item.source === 'email' ? ( + + ) : ( + + )} +
+ +
+
+ + {fileName} +
+
+ {supplierName ? ( + {supplierName} + ) : ( + Okänd leverantör + )} +
+
+ +
+ {summary.total > 0 && ( + {formatSEK(summary.total)} + )} +
+ {item.confidence != null && ( + + {confidence.label} + + )} + + {getStatusLabel(item.status)} + +
+ + {formatRelativeTime(item.created_at)} + +
+
+
+ ) +} diff --git a/components/extensions/general/invoice-inbox/InboxSettingsDialog.tsx b/components/extensions/general/invoice-inbox/InboxSettingsDialog.tsx new file mode 100644 index 00000000..6387c9ac --- /dev/null +++ b/components/extensions/general/invoice-inbox/InboxSettingsDialog.tsx @@ -0,0 +1,159 @@ +'use client' + +import { useState } from 'react' +import type { InvoiceInboxSettings } from '@/extensions/general/invoice-inbox/types' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Loader2, Copy, Check } from 'lucide-react' + +interface InboxSettingsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + settings: InvoiceInboxSettings + onSave: (settings: InvoiceInboxSettings) => Promise +} + +export default function InboxSettingsDialog({ + open, + onOpenChange, + settings, + onSave, +}: InboxSettingsDialogProps) { + const [local, setLocal] = useState(settings) + const [saving, setSaving] = useState(false) + const [copied, setCopied] = useState(false) + + // Reset local state when dialog opens with new settings + function handleOpenChange(isOpen: boolean) { + if (isOpen) { + setLocal(settings) + } + onOpenChange(isOpen) + } + + async function handleSave() { + setSaving(true) + try { + await onSave(local) + onOpenChange(false) + } finally { + setSaving(false) + } + } + + function handleCopyEmail() { + if (local.inboxEmail) { + navigator.clipboard.writeText(local.inboxEmail) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( + + + + Inställningar + + +
+
+
+ +

+ Analysera fakturor automatiskt vid uppladdning +

+
+ + setLocal((prev) => ({ ...prev, autoProcessEnabled: checked })) + } + /> +
+ +
+
+ +

+ Matcha extraherade uppgifter mot befintliga leverantörer +

+
+ + setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked })) + } + /> +
+ +
+ +

+ Lägsta konfidens för automatisk leverantörsmatchning (0-1) +

+ + setLocal((prev) => ({ + ...prev, + supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)), + })) + } + /> +
+ + {local.inboxEmail && ( +
+ +

+ Vidarebefodra fakturor till denna adress +

+
+ + +
+
+ )} +
+ + + + + +
+
+ ) +} diff --git a/components/extensions/general/invoice-inbox/InboxUploadZone.tsx b/components/extensions/general/invoice-inbox/InboxUploadZone.tsx new file mode 100644 index 00000000..dafd37af --- /dev/null +++ b/components/extensions/general/invoice-inbox/InboxUploadZone.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import type { InvoiceInboxItem } from '@/types' +import { Upload, Loader2, FileUp } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface InboxUploadZoneProps { + onUploadComplete: (item: InvoiceInboxItem) => void + isUploading: boolean + setIsUploading: (v: boolean) => void +} + +const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp'] +const MAX_SIZE = 10 * 1024 * 1024 // 10 MB + +export default function InboxUploadZone({ + onUploadComplete, + isUploading, + setIsUploading, +}: InboxUploadZoneProps) { + const [isDragOver, setIsDragOver] = useState(false) + const [error, setError] = useState(null) + const inputRef = useRef(null) + + const uploadFile = useCallback( + async (file: File) => { + setError(null) + + if (!ACCEPTED_TYPES.includes(file.type)) { + setError('Filtypen stöds inte. Välj PDF, JPEG, PNG eller WebP.') + return + } + + if (file.size > MAX_SIZE) { + setError('Filen är för stor. Max 10 MB.') + return + } + + setIsUploading(true) + + try { + const formData = new FormData() + formData.append('file', file) + + const res = await fetch('/api/extensions/invoice-inbox/inbox', { + method: 'POST', + body: formData, + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({ error: 'Uppladdning misslyckades' })) + setError(body.error ?? 'Uppladdning misslyckades') + return + } + + const { data } = await res.json() + onUploadComplete(data) + } catch { + setError('Nätverksfel vid uppladdning') + } finally { + setIsUploading(false) + } + }, + [onUploadComplete, setIsUploading] + ) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + const file = e.dataTransfer.files[0] + if (file) uploadFile(file) + }, + [uploadFile] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragOver(false) + }, []) + + const handleFileSelect = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) uploadFile(file) + // Reset so same file can be re-selected + e.target.value = '' + }, + [uploadFile] + ) + + return ( +
+
!isUploading && inputRef.current?.click()} + className={cn( + 'relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors cursor-pointer', + isDragOver + ? 'border-primary bg-primary/5' + : 'border-border hover:border-primary/50 hover:bg-accent/30', + isUploading && 'pointer-events-none opacity-60' + )} + > + + + {isUploading ? ( + <> + +

Laddar upp och analyserar...

+ + ) : isDragOver ? ( + <> + +

Släpp filen här

+ + ) : ( + <> + +

+ Dra och släpp en faktura, eller{' '} + välj fil +

+

+ PDF, JPEG, PNG eller WebP (max 10 MB) +

+ + )} +
+ + {error && ( +

{error}

+ )} +
+ ) +} diff --git a/components/invoices/InvoiceReviewContent.tsx b/components/invoices/InvoiceReviewContent.tsx index 243f2b59..24a5844e 100644 --- a/components/invoices/InvoiceReviewContent.tsx +++ b/components/invoices/InvoiceReviewContent.tsx @@ -54,7 +54,7 @@ export function InvoiceReviewContent({ // Calculate per-rate VAT breakdown const vatByRate = new Map() for (const item of items) { - const rate = item.vat_rate ?? 25 + const rate = item.vat_rate ?? 0 const lineTotal = item.quantity * item.unit_price const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100 vatByRate.set(rate, (vatByRate.get(rate) || 0) + lineVat) @@ -112,7 +112,7 @@ export function InvoiceReviewContent({ {item.unit} {formatCurrency(item.unit_price, currency)} {showVatColumn && ( - {item.vat_rate ?? 25}% + {item.vat_rate ?? 0}% )} {formatCurrency(item.quantity * item.unit_price, currency)} diff --git a/components/transactions/BatchCategorySelector.tsx b/components/transactions/BatchCategorySelector.tsx index 0a0d8aee..9ffa5159 100644 --- a/components/transactions/BatchCategorySelector.tsx +++ b/components/transactions/BatchCategorySelector.tsx @@ -4,13 +4,12 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Progress } from '@/components/ui/progress' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' +import VatTreatmentSelect from './VatTreatmentSelect' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' import type { TransactionCategory, VatTreatment } from '@/types' const expenseCategories = EXPENSE_CATEGORIES const incomeCategories = INCOME_CATEGORIES -const vatTreatmentOptions = VAT_TREATMENT_OPTIONS interface BatchCategorySelectorProps { open: boolean @@ -62,21 +61,10 @@ export default function BatchCategorySelector({

Momsbehandling

- + onValueChange={setVatTreatment} + />

Kostnader

diff --git a/components/transactions/CategoryExpandedDialog.tsx b/components/transactions/CategoryExpandedDialog.tsx index 6890c74f..70ce1312 100644 --- a/components/transactions/CategoryExpandedDialog.tsx +++ b/components/transactions/CategoryExpandedDialog.tsx @@ -3,10 +3,10 @@ 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, VAT_TREATMENT_OPTIONS } from './transaction-types' +import VatTreatmentSelect from './VatTreatmentSelect' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' import type { TransactionWithInvoice } from './transaction-types' import type { TransactionCategory, VatTreatment } from '@/types' @@ -80,21 +80,10 @@ export default function CategoryExpandedDialog({ {/* VAT treatment selector */}

Momsbehandling

- + onValueChange={setVatTreatment} + />
{/* Category grid */} diff --git a/components/transactions/QuickReviewDialog.tsx b/components/transactions/QuickReviewDialog.tsx new file mode 100644 index 00000000..a33ce900 --- /dev/null +++ b/components/transactions/QuickReviewDialog.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { formatCurrency, formatDate } from '@/lib/utils' +import { ArrowUpRight, ArrowDownRight, Check } from 'lucide-react' +import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping' +import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import VatTreatmentSelect from './VatTreatmentSelect' +import type { TransactionWithInvoice } from './transaction-types' +import type { TransactionCategory, VatTreatment, BASAccount } from '@/types' + +interface QuickReviewDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + transaction: TransactionWithInvoice | null + category: TransactionCategory | null + categoryLabel: string + defaultAccount: string + defaultVat: VatTreatment | 'none' + onConfirm: ( + id: string, + category: TransactionCategory, + vatTreatment: VatTreatment | undefined, + accountOverride: string | undefined + ) => Promise +} + +export default function QuickReviewDialog({ + open, + onOpenChange, + transaction, + category, + categoryLabel, + defaultAccount, + defaultVat, + onConfirm, +}: QuickReviewDialogProps) { + const [accountOverride, setAccountOverride] = useState(defaultAccount) + const [vatTreatment, setVatTreatment] = useState(defaultVat) + const [accounts, setAccounts] = useState([]) + const [isProcessing, setIsProcessing] = useState(false) + const [error, setError] = useState(null) + + // Handle account changes — clear VAT for liability/equity accounts (class 2) + const handleAccountChange = useCallback((account: string) => { + setAccountOverride(account) + if (account.startsWith('2')) { + setVatTreatment('none') + } + }, []) + + // Fetch accounts on mount + useEffect(() => { + async function fetchAccounts() { + try { + const res = await fetch('/api/bookkeeping/accounts') + const data = await res.json() + if (data.accounts) { + setAccounts(data.accounts) + } + } catch { + // Non-critical + } + } + fetchAccounts() + }, []) + + if (!transaction || !category) return null + + const isIncome = transaction.amount > 0 + const isLiabilityAccount = accountOverride.startsWith('2') + + async function handleConfirm() { + if (!category || !transaction) return + + setIsProcessing(true) + setError(null) + try { + const resolvedVat = vatTreatment === 'none' ? undefined : vatTreatment + const catDefault = getDefaultAccountForCategory(category) + const override = accountOverride && accountOverride !== catDefault + ? accountOverride + : undefined + + await onConfirm(transaction.id, category, resolvedVat, override) + } catch { + setError('Ett fel uppstod vid bokföring.') + setIsProcessing(false) + } + } + + return ( + + + + Granska bokföring + + Kontrollera konto och moms innan du bokför + + + + {/* Transaction summary */} +
+
+ {isIncome ? ( + + ) : ( + + )} +
+
+

{transaction.description}

+

{formatDate(transaction.date)}

+
+

+ {isIncome ? '+' : ''} + {formatCurrency(transaction.amount, transaction.currency)} +

+
+ + {/* Category (read-only) */} +
+ +
+ {categoryLabel} +
+
+ + {/* Account */} +
+ +
+ +
+
+ + {/* VAT treatment */} +
+ +
+ + {isLiabilityAccount && ( +

+ Ingen moms för skuld-/eget kapital-konton +

+ )} +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Actions */} +
+ + +
+
+
+ ) +} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index 494521f7..7bc7508b 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -5,7 +5,7 @@ import { motion, useMotionValue, useTransform, AnimatePresence, type PanInfo } f import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import VatTreatmentSelect from './VatTreatmentSelect' import { formatCurrency, formatDate } from '@/lib/utils' import { checkExpenseWarnings } from '@/lib/tax/expense-warnings' import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping' @@ -14,7 +14,7 @@ import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Lin import type { TransactionCategory, VatTreatment, BASAccount } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types' -import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, VAT_TREATMENT_OPTIONS } from './transaction-types' +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' interface SwipeCategorizationViewProps { transactions: TransactionWithInvoice[] @@ -24,7 +24,6 @@ interface SwipeCategorizationViewProps { onClose: () => void } -const vatTreatmentOptions = VAT_TREATMENT_OPTIONS const expenseCategories = EXPENSE_CATEGORIES const incomeCategories = INCOME_CATEGORIES @@ -346,22 +345,11 @@ export default function SwipeCategorizationView({
- + /> {isLiabilityAccount && (

Ingen moms för skuld-/eget kapital-konton diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index beb17335..1a62e3d7 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' import { formatCurrency, formatDate } from '@/lib/utils' import { ArrowUpRight, ArrowDownRight, FileText, Loader2 } from 'lucide-react' +import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/info-tooltip' import type { TransactionWithInvoice, CategorizeHandler } from './transaction-types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' @@ -16,10 +17,12 @@ interface TransactionInboxCardProps { processingId: string | null isBatchMode: boolean isSelected: boolean + entityType?: string onCategorize: CategorizeHandler onMarkPrivate: (id: string) => void onOpenMatchDialog: (transaction: TransactionWithInvoice) => void onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void + onOpenQuickReview?: (transaction: TransactionWithInvoice, suggestion: SuggestedCategory) => void onToggleSelect: (id: string) => void onAnimationComplete?: (id: string) => void } @@ -30,10 +33,12 @@ export default function TransactionInboxCard({ processingId, isBatchMode, isSelected, + entityType = 'enskild_firma', onCategorize, onMarkPrivate, onOpenMatchDialog, onOpenCategoryDialog, + onOpenQuickReview, onToggleSelect, onAnimationComplete, }: TransactionInboxCardProps) { @@ -45,8 +50,12 @@ export default function TransactionInboxCard({ const isUncategorized = transaction.is_business === null && !transaction.journal_entry_id const showCheckbox = isBatchMode && isUncategorized - async function handleSuggestionClick(suggestion: SuggestedCategory) { - await onCategorize(transaction.id, true, suggestion.category) + function handleSuggestionClick(suggestion: SuggestedCategory) { + if (onOpenQuickReview) { + onOpenQuickReview(transaction, suggestion) + } else { + onCategorize(transaction.id, true, suggestion.category) + } } return ( @@ -168,15 +177,28 @@ export default function TransactionInboxCard({ )} {/* Private button */} - + + + + + + +

+ {entityType === 'aktiebolag' + ? 'Privat utgift med företagets kort \u2014 bokförs som skuld till ägaren (konto 2893)' + : 'Privat uttag \u2014 bokförs mot konto 2013 (Övriga egna uttag)'} +

+ + + {/* Open category dialog */}