From acb85edf4a1b9e5185f7fff53f660215da9763ff Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 23 Feb 2026 16:00:38 +0100 Subject: [PATCH 1/4] feat: wire Zod validation into API routes, improve types and components - Add 8 new Zod schemas (UpdateCustomer, UpdateSupplier, UpdateSupplierInvoice, UpdateAccount, BankUnlink, RunReconciliation, CorrectJournalEntry, EvaluateMappingRules) and wire validateBody() into 24 JSON-body API routes - Remove redundant manual validation checks replaced by Zod - Add comprehensive schema tests (222 tests) - Improve type definitions in types/index.ts with expanded interfaces - Refactor extension types (push-notifications, receipt-ocr) for cleaner imports - Update transaction components (BatchCategorySelector, SwipeCategorizationView, QuickReviewDialog, VatTreatmentSelect) and invoice inbox workspace - Add invoice-inbox utilities and type decoupling tests - Fix NE-bilaga, SRU export, and invoice PDF template type usage - Update CLAUDE.md with expanded architecture documentation Co-Authored-By: Claude Opus 4.6 --- .claude/settings.local.json | 4 +- CLAUDE.md | 152 +- app/(dashboard)/invoices/[id]/page.tsx | 2 +- app/(dashboard)/transactions/page.tsx | 65 + .../bookkeeping/accounts/[number]/route.ts | 16 +- app/api/bookkeeping/accounts/route.ts | 6 +- app/api/bookkeeping/fiscal-periods/route.ts | 7 +- .../[id]/correct/__tests__/route.test.ts | 4 +- .../journal-entries/[id]/correct/route.ts | 16 +- .../journal-entries/__tests__/route.test.ts | 11 +- app/api/bookkeeping/journal-entries/route.ts | 7 +- .../mapping-rules/evaluate/route.ts | 10 +- app/api/bookkeeping/mapping-rules/route.ts | 6 +- app/api/customers/[id]/route.ts | 7 +- app/api/customers/route.ts | 8 +- app/api/deadlines/route.ts | 12 +- app/api/invoices/__tests__/route.test.ts | 35 +- app/api/invoices/route.ts | 56 +- app/api/reconciliation/bank/link/route.ts | 14 +- app/api/reconciliation/bank/run/route.ts | 7 +- app/api/reconciliation/bank/unlink/route.ts | 14 +- app/api/settings/route.ts | 6 +- .../supplier-invoices/[id]/mark-paid/route.ts | 13 +- app/api/supplier-invoices/[id]/route.ts | 15 +- .../supplier-invoices/__tests__/route.test.ts | 19 +- app/api/supplier-invoices/route.ts | 8 +- app/api/suppliers/[id]/route.ts | 7 +- app/api/suppliers/route.ts | 7 +- .../[id]/book/__tests__/route.test.ts | 10 +- app/api/transactions/[id]/book/route.ts | 23 +- app/api/transactions/[id]/categorize/route.ts | 17 +- .../match-invoice/__tests__/route.test.ts | 29 +- .../transactions/[id]/match-invoice/route.ts | 17 +- .../[id]/match-supplier-invoice/route.ts | 15 +- .../general/InvoiceInboxWorkspace.tsx | 318 +++ .../invoice-inbox/InboxDetailDialog.tsx | 327 +++ .../general/invoice-inbox/InboxItemCard.tsx | 102 + .../invoice-inbox/InboxSettingsDialog.tsx | 159 ++ .../general/invoice-inbox/InboxUploadZone.tsx | 151 ++ components/invoices/InvoiceReviewContent.tsx | 4 +- .../transactions/BatchCategorySelector.tsx | 22 +- .../transactions/CategoryExpandedDialog.tsx | 21 +- components/transactions/QuickReviewDialog.tsx | 195 ++ .../transactions/SwipeCategorizationView.tsx | 22 +- .../transactions/TransactionInboxCard.tsx | 44 +- .../transactions/VatTreatmentSelect.tsx | 57 + components/transactions/transaction-types.ts | 20 +- .../general/push-notifications/types.ts | 55 +- extensions/general/receipt-ocr/types.ts | 186 +- extensions/ne-bilaga/NEDeclarationView.tsx | 2 +- .../ne-bilaga/__tests__/ne-engine.test.ts | 2 +- extensions/ne-bilaga/ne-engine.ts | 8 +- extensions/sru-export/SRUExportView.tsx | 2 +- extensions/sru-export/lib/sru-generator.ts | 2 +- extensions/sru-export/sru-generator.ts | 2 +- lib/api/__tests__/schemas.test.ts | 1935 +++++++++++++++++ lib/api/__tests__/validate.test.ts | 245 +++ lib/api/schemas.ts | 499 +++++ lib/api/validate.ts | 114 + lib/bookkeeping/invoice-entries.ts | 2 +- lib/email/resend.ts | 5 +- .../__tests__/invoice-inbox-utils.test.ts | 128 ++ .../__tests__/type-decoupling.test.ts | 16 + lib/extensions/invoice-inbox-utils.ts | 54 + lib/extensions/workspace-registry.tsx | 1 + lib/invoices/pdf-template.tsx | 6 +- lib/invoices/vat-rules.ts | 2 +- next.config.ts | 3 +- types/index.ts | 242 ++- 69 files changed, 5042 insertions(+), 556 deletions(-) create mode 100644 components/extensions/general/InvoiceInboxWorkspace.tsx create mode 100644 components/extensions/general/invoice-inbox/InboxDetailDialog.tsx create mode 100644 components/extensions/general/invoice-inbox/InboxItemCard.tsx create mode 100644 components/extensions/general/invoice-inbox/InboxSettingsDialog.tsx create mode 100644 components/extensions/general/invoice-inbox/InboxUploadZone.tsx create mode 100644 components/transactions/QuickReviewDialog.tsx create mode 100644 components/transactions/VatTreatmentSelect.tsx create mode 100644 lib/api/__tests__/schemas.test.ts create mode 100644 lib/api/__tests__/validate.test.ts create mode 100644 lib/api/schemas.ts create mode 100644 lib/api/validate.ts create mode 100644 lib/extensions/__tests__/invoice-inbox-utils.test.ts create mode 100644 lib/extensions/__tests__/type-decoupling.test.ts create mode 100644 lib/extensions/invoice-inbox-utils.ts 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 */}
}> @@ -210,26 +219,33 @@ function OnboardingPageContent() { : `Räkenskapsår ${currentYear}/${currentYear + 1}` } - // Validate period duration (max 18 months) - const startDate = new Date(startStr) - const endDate = new Date(endStr) - const months = (endDate.getFullYear() - startDate.getFullYear()) * 12 + - (endDate.getMonth() - startDate.getMonth()) + 1 - if (months > 18) { - console.error(`Period duration ${months} months exceeds 18-month maximum`) - } else { - await supabase.from('fiscal_periods').upsert({ - user_id: user.id, - name: periodName, - period_start: startStr, - period_end: endStr, - }, { - onConflict: 'user_id,period_start,period_end', + // Validate period duration + const validationError = validatePeriodDuration(startStr, endStr) + if (validationError) { + toast({ + title: 'Ogiltigt räkenskapsår', + description: translatePeriodError(validationError), + variant: 'destructive', }) + setCurrentStep(3) + return } + + await supabase.from('fiscal_periods').upsert({ + user_id: user.id, + name: periodName, + period_start: startStr, + period_end: endStr, + }, { + onConflict: 'user_id,period_start,period_end', + }) } } catch (err) { - console.error('Failed to create fiscal period:', err) + toast({ + title: 'Kunde inte skapa räkenskapsår', + description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.', + variant: 'destructive', + }) } } diff --git a/app/api/extensions/push-notifications/cron/route.ts b/app/api/extensions/push-notifications/cron/route.ts index 12cc79ce..c4b8087c 100644 --- a/app/api/extensions/push-notifications/cron/route.ts +++ b/app/api/extensions/push-notifications/cron/route.ts @@ -4,6 +4,7 @@ import { loadExtensions } from '@/lib/extensions/loader' import { sendTaxDeadlineNotifications, sendInvoiceNotifications, + sendMissingUnderlagNotifications, } from '@/extensions/general/push-notifications/notification-scheduler' /** @@ -40,13 +41,14 @@ export async function GET(request: Request) { try { // Send all notification types in parallel - const [taxResult, invoiceResult] = await Promise.all([ + const [taxResult, invoiceResult, underlagResult] = await Promise.all([ sendTaxDeadlineNotifications(supabase), sendInvoiceNotifications(supabase), + sendMissingUnderlagNotifications(supabase), ]) - const totalSent = taxResult.sent + invoiceResult.sent - const totalSkipped = taxResult.skipped + invoiceResult.skipped + const totalSent = taxResult.sent + invoiceResult.sent + underlagResult.sent + const totalSkipped = taxResult.skipped + invoiceResult.skipped + underlagResult.skipped console.log( `Push notification cron completed: ${totalSent} sent, ${totalSkipped} skipped` @@ -57,6 +59,9 @@ export async function GET(request: Request) { console.log( ` Invoice: ${invoiceResult.sent} sent, ${invoiceResult.skipped} skipped` ) + console.log( + ` Missing underlag: ${underlagResult.sent} sent, ${underlagResult.skipped} skipped` + ) return NextResponse.json({ success: true, @@ -65,6 +70,7 @@ export async function GET(request: Request) { details: { taxDeadlines: taxResult, invoices: invoiceResult, + missingUnderlag: underlagResult, }, }) } catch (error) { diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts index 1ae8249d..bf647d1b 100644 --- a/app/api/invoices/[id]/send/route.ts +++ b/app/api/invoices/[id]/send/route.ts @@ -11,6 +11,7 @@ import { generateInvoiceEmailSubject } from '@/lib/email/invoice-templates' import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries' +import { uploadDocument } from '@/lib/core/documents/document-service' import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types' ensureInitialized() @@ -166,6 +167,7 @@ export async function POST( // Only create journal entries for real invoices (not proformas or delivery notes) const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice' + let createdJournalEntryId: string | undefined if (isRealInvoice && ((company as Record).accounting_method === 'accrual' || !(company as Record).accounting_method)) { try { const journalEntry = await createInvoiceJournalEntry( @@ -174,6 +176,7 @@ export async function POST( (company as CompanySettings).entity_type ) if (journalEntry) { + createdJournalEntryId = journalEntry.id await supabase .from('invoices') .update({ journal_entry_id: journalEntry.id }) @@ -185,6 +188,24 @@ export async function POST( } } + // Auto-store invoice PDF as underlag and link to journal entry + if (isRealInvoice) { + try { + const pdfArrayBuffer = new Uint8Array(pdfBuffer).buffer as ArrayBuffer + await uploadDocument(user.id, { + name: filename, + buffer: pdfArrayBuffer, + type: 'application/pdf', + }, { + upload_source: 'system', + journal_entry_id: createdJournalEntryId, + }) + } catch (err) { + console.error('Failed to store invoice PDF as underlag:', err) + // Non-blocking — don't fail the send + } + } + await eventBus.emit({ type: 'invoice.sent', payload: { invoice: invoice as Invoice, userId: user.id }, diff --git a/app/page.tsx b/app/page.tsx index 9143e22a..80b5e80d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -136,6 +136,36 @@ export default async function RootPage() { .lt('amount', 0) .is('receipt_id', null) + // Count journal entries missing underlag (documents) + const needsDocSourceTypes = [ + 'manual', + 'bank_transaction', + 'supplier_invoice_registered', + 'supplier_invoice_paid', + 'supplier_invoice_cash_payment', + 'import', + ] + + const { count: postedEntriesCount } = await supabase + .from('journal_entries') + .select('*', { count: 'exact', head: true }) + .eq('user_id', user.id) + .eq('status', 'posted') + .in('source_type', needsDocSourceTypes) + + const { data: entriesWithDocs } = await supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('user_id', user.id) + .eq('is_current_version', true) + .not('journal_entry_id', 'is', null) + + const uniqueEntriesWithDocs = new Set( + (entriesWithDocs || []).map((d) => d.journal_entry_id) + ).size + + const missingUnderlagCount = Math.max(0, (postedEntriesCount || 0) - uniqueEntriesWithDocs) + // Calculate receipt streak const { data: recentReceiptActivity } = await supabase .from('receipts') @@ -188,6 +218,7 @@ export default async function RootPage() { bankBalance, deadlines: (deadlines || []) as Deadline[], receiptQueue, + missingUnderlagCount, }} />
diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx index 1384ad0c..ede79797 100644 --- a/components/bookkeeping/JournalEntryForm.tsx +++ b/components/bookkeeping/JournalEntryForm.tsx @@ -6,11 +6,13 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' -import { Plus, Trash2 } from 'lucide-react' +import { Plus, Trash2, AlertTriangle } from 'lucide-react' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent' import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import { getErrorMessage } from '@/lib/errors/get-error-message' +import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes' import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType } from '@/types' @@ -56,11 +58,17 @@ export default function JournalEntryForm({ ) const [isSubmitting, setIsSubmitting] = useState(false) const [showReview, setShowReview] = useState(false) + const [showNoDocWarning, setShowNoDocWarning] = useState(false) const [uploadedFiles, setUploadedFiles] = useState([]) const [accounts, setAccounts] = useState([]) const isUploading = uploadedFiles.some((f) => f.status === 'uploading') + const hasContent = description !== '' || + lines.some(l => l.account_number !== '' || l.debit_amount !== '' || l.credit_amount !== '') || + uploadedFiles.length > 0 + useUnsavedChanges(hasContent) + useEffect(() => { fetchPeriods() fetchAccounts() @@ -118,6 +126,11 @@ export default function JournalEntryForm({ const handleReview = () => { if (!selectedPeriod || !description || !isBalanced) return + const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded') + if (!embedded && !hasDocuments) { + setShowNoDocWarning(true) + return + } setShowReview(true) } @@ -152,8 +165,8 @@ export default function JournalEntryForm({ if (result.error) { toast({ - title: 'Fel', - description: result.error, + title: 'Kunde inte skapa verifikation', + description: getErrorMessage(result, { context: 'journal_entry', statusCode: res.status }), variant: 'destructive', }) } else { @@ -161,6 +174,7 @@ export default function JournalEntryForm({ const journalEntryId = result.data?.id ?? result.journal_entry_id if (journalEntryId && uploadedFiles.length > 0) { const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) + let linkFailCount = 0 for (const file of filesToLink) { try { await fetch(`/api/documents/${file.id}/link`, { @@ -170,8 +184,16 @@ export default function JournalEntryForm({ }) } catch (linkErr) { console.error('[JournalEntryForm] Failed to link document:', linkErr) + linkFailCount++ } } + if (linkFailCount > 0) { + toast({ + title: 'Underlag kunde inte bifogas', + description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`, + variant: 'destructive', + }) + } } toast({ @@ -379,6 +401,31 @@ export default function JournalEntryForm({ attachmentCount={uploadedFiles.filter((f) => f.status === 'uploaded').length} /> + + {/* Warning dialog when no documents attached */} + { + setShowNoDocWarning(false) + setShowReview(true) + }} + isSubmitting={false} + title="Underlag saknas" + warningText="Ingen verifikation har bifogats. Enligt bokforingslagen (BFL) kravs underlag for varje bokforingspost." + confirmLabel="Fortsatt anda" + > +
+ +
+

Inget underlag bifogat

+

+ Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som + underlag. Du kan bifoga underlag nu eller fortsatta utan. +

+
+
+
) diff --git a/components/dashboard/DashboardContent.tsx b/components/dashboard/DashboardContent.tsx index 97a1362d..5d591832 100644 --- a/components/dashboard/DashboardContent.tsx +++ b/components/dashboard/DashboardContent.tsx @@ -28,6 +28,7 @@ import { CheckCircle2, ClipboardList, MessageCircle, + FileWarning, } from 'lucide-react' import type { CompanySettings, EntityType, Deadline, ReceiptQueueSummary, OnboardingProgress } from '@/types' @@ -47,6 +48,7 @@ interface DashboardContentProps { bankBalance: number | null deadlines: Deadline[] receiptQueue: ReceiptQueueSummary | null + missingUnderlagCount: number } onboardingProgress?: OnboardingProgress } @@ -176,6 +178,29 @@ export default function DashboardContent({ firstName, settings, summary, onboard ) } + if (summary.missingUnderlagCount > 0) { + alertItems.push( + + + +
+
+ +
+

Saknade underlag

+

+ {summary.missingUnderlagCount} verifikationer utan underlag +

+
+
+ +
+
+
+ + ) + } + const MAX_VISIBLE_ALERTS = 3 const visibleAlerts = showAllAlerts ? alertItems : alertItems.slice(0, MAX_VISIBLE_ALERTS) const hasMoreAlerts = alertItems.length > MAX_VISIBLE_ALERTS @@ -228,6 +253,9 @@ export default function DashboardContent({ firstName, settings, summary, onboard if (summary.receiptQueue && summary.receiptQueue.pending_review_count > 0) { todoItems.push({ label: 'kvitton att granska', href: '/receipts', count: summary.receiptQueue.pending_review_count, variant: 'default' }) } + if (summary.missingUnderlagCount > 0) { + todoItems.push({ label: 'saknade underlag', href: '/bookkeeping?missingUnderlag=true', count: summary.missingUnderlagCount, variant: 'warning' }) + } if (todoItems.length === 0) return null diff --git a/components/onboarding/Step3TaxRegistration.tsx b/components/onboarding/Step3TaxRegistration.tsx index e1882faf..733733a0 100644 --- a/components/onboarding/Step3TaxRegistration.tsx +++ b/components/onboarding/Step3TaxRegistration.tsx @@ -11,8 +11,9 @@ import { Label } from '@/components/ui/label' import { Checkbox } from '@/components/ui/checkbox' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { InfoTooltip } from '@/components/ui/info-tooltip' -import { Loader2, ArrowRight, ArrowLeft, Check } from 'lucide-react' +import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays, Info } from 'lucide-react' import { cn } from '@/lib/utils' +import { monthsBetween } from '@/lib/bookkeeping/validate-period-duration' import type { MomsPeriod, EntityType } from '@/types' const schema = z.object({ @@ -58,6 +59,14 @@ const monthNames = [ 'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December', ] +function formatSwedishDate(dateStr: string): string { + const d = new Date(dateStr) + const day = d.getDate() + const month = monthNames[d.getMonth()].toLowerCase() + const year = d.getFullYear() + return `${day} ${month} ${year}` +} + /** * Get the last day of a given month (1-indexed). */ @@ -163,6 +172,7 @@ export default function Step3TaxRegistration({ const firstYearStart = watch('first_year_start') const firstYearEnd = watch('first_year_end') const fiscalYearEndMonth = watch('fiscal_year_end_month') + const accountingMethod = watch('accounting_method') // State for AB first-year end month selector const [abEndMonth, setAbEndMonth] = useState( @@ -424,6 +434,21 @@ export default function Step3TaxRegistration({ Ingen giltig slutperiod hittades. Kontrollera startdatumet.

)} + + {firstYearStart && firstYearEnd && ( +
+
+ + Ditt första räkenskapsår +
+

+ {formatSwedishDate(firstYearStart)} – {formatSwedishDate(firstYearEnd)} +

+

+ {monthsBetween(firstYearStart, firstYearEnd)} månader +

+
+ )}
)} @@ -464,6 +489,21 @@ export default function Step3TaxRegistration({

De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.

+ + {fiscalYearEndMonth && ( +
+
+ + Ditt räkenskapsår +
+

+ {fiscalYearEndMonth === 12 + ? `1 januari \u2013 31 december (kalenderår)` + : `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(2025, fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`} +

+

12 månader

+
+ )} )} @@ -594,11 +634,22 @@ export default function Step3TaxRegistration({ )} /> -

- {entityType === 'aktiebolag' - ? 'Aktiebolag med omsättning över 3 MSEK måste använda faktureringsmetoden.' - : 'Som enskild firma med omsättning under 3 MSEK kan du välja kontantmetoden.'} -

+
+
+ + {accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'} +
+

+ {accountingMethod === 'accrual' + ? 'Intäkter och kostnader bokförs när fakturan skickas eller tas emot, oavsett när betalningen sker. Detta ger en mer rättvisande bild av verksamhetens ekonomi.' + : 'Intäkter och kostnader bokförs först när betalningen faktiskt sker. Enklare att hantera men ger en mindre exakt bild av verksamhetens ekonomi vid varje given tidpunkt.'} +

+ {entityType === 'aktiebolag' && ( +

+ Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden. +

+ )} +
diff --git a/components/settings/CalendarFeedSettings.tsx b/components/settings/CalendarFeedSettings.tsx index 200f80b4..eae0696e 100644 --- a/components/settings/CalendarFeedSettings.tsx +++ b/components/settings/CalendarFeedSettings.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input' import { useToast } from '@/components/ui/use-toast' import { Badge } from '@/components/ui/badge' import { Calendar, Copy, RefreshCw, Loader2, ExternalLink, Check } from 'lucide-react' +import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' import type { CalendarFeed } from '@/types' interface CalendarFeedWithUrls extends CalendarFeed { @@ -24,6 +25,7 @@ export function CalendarFeedSettings() { const [isRegenerating, setIsRegenerating] = useState(false) const [feed, setFeed] = useState(null) const [copied, setCopied] = useState(false) + const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm() useEffect(() => { fetchFeed() @@ -99,9 +101,13 @@ export function CalendarFeedSettings() { } const regenerateToken = async () => { - if (!confirm('Är du säker? Den gamla länken kommer sluta fungera.')) { - return - } + const ok = await confirmAction({ + title: 'Skapa ny kalender-länk', + description: 'Den gamla länken slutar fungera omedelbart. Du behöver uppdatera länken i alla kalenderappar som använder den.', + confirmLabel: 'Skapa ny länk', + variant: 'warning', + }) + if (!ok) return setIsRegenerating(true) @@ -334,6 +340,8 @@ export function CalendarFeedSettings() { + + ) } diff --git a/components/transactions/TransactionBookingDialog.tsx b/components/transactions/TransactionBookingDialog.tsx index a47c1357..92cdc724 100644 --- a/components/transactions/TransactionBookingDialog.tsx +++ b/components/transactions/TransactionBookingDialog.tsx @@ -1,9 +1,15 @@ 'use client' +import { useState } from 'react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' -import { ArrowUpRight, ArrowDownRight } from 'lucide-react' +import { ArrowUpRight, ArrowDownRight, ChevronDown, ChevronUp, Paperclip } from 'lucide-react' import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm' +import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' +import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' import type { FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { TransactionWithInvoice } from './transaction-types' @@ -38,17 +44,58 @@ export default function TransactionBookingDialog({ transaction, onBooked, }: TransactionBookingDialogProps) { + const { toast } = useToast() + const [uploadedFiles, setUploadedFiles] = useState([]) + const [showUploadZone, setShowUploadZone] = useState(false) + if (!transaction) return null const isIncome = transaction.amount > 0 + const handleBooked = async (transactionId: string, journalEntryId: string) => { + // Link any uploaded documents to the new journal entry + const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) + if (filesToLink.length > 0) { + let linkFailCount = 0 + for (const file of filesToLink) { + try { + await fetch(`/api/documents/${file.id}/link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: journalEntryId }), + }) + } catch (linkErr) { + console.error('[TransactionBookingDialog] Failed to link document:', linkErr) + linkFailCount++ + } + } + if (linkFailCount > 0) { + toast({ + title: 'Underlag kunde inte bifogas', + description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen. Forsok igen via bokforingssidan.`, + variant: 'destructive', + }) + } + } + + setUploadedFiles([]) + setShowUploadZone(false) + onBooked(transactionId, journalEntryId) + } + return ( - + { + if (!o) { + setUploadedFiles([]) + setShowUploadZone(false) + } + onOpenChange(o) + }}> - Bokför transaktion + Bokfor transaktion - Skapa en verifikation för transaktionen + Skapa en verifikation for transaktionen @@ -77,6 +124,39 @@ export default function TransactionBookingDialog({

+ {/* Document upload section */} +
+ + {showUploadZone && ( +
+ +
+ )} +
+ onBooked(transaction.id, entryId)} + onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)} />
diff --git a/components/ui/destructive-confirm-dialog.tsx b/components/ui/destructive-confirm-dialog.tsx new file mode 100644 index 00000000..136d0803 --- /dev/null +++ b/components/ui/destructive-confirm-dialog.tsx @@ -0,0 +1,179 @@ +'use client' + +import { useState, useCallback, useRef } from 'react' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { AlertTriangle, Loader2 } from 'lucide-react' +import { cn } from '@/lib/utils' + +interface DestructiveConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description: string + confirmLabel?: string + cancelLabel?: string + variant?: 'destructive' | 'warning' + onConfirm: () => void | Promise +} + +export function DestructiveConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Bekräfta', + cancelLabel = 'Avbryt', + variant = 'destructive', + onConfirm, +}: DestructiveConfirmDialogProps) { + const [isLoading, setIsLoading] = useState(false) + + const handleConfirm = async () => { + setIsLoading(true) + try { + await onConfirm() + } finally { + setIsLoading(false) + onOpenChange(false) + } + } + + return ( + { + if (isLoading) return + onOpenChange(v) + }} + > + + +
+
+ +
+
+ {title} + {description} +
+
+
+ + + + +
+
+ ) +} + +interface ConfirmOptions { + title: string + description: string + confirmLabel?: string + cancelLabel?: string + variant?: 'destructive' | 'warning' +} + +interface UseDestructiveConfirmReturn { + dialogProps: DestructiveConfirmDialogProps + confirm: (options: ConfirmOptions) => Promise +} + +/** + * Hook that returns a `confirm()` function as a drop-in replacement for `window.confirm()`. + * Returns `Promise` — true if user confirms, false if they cancel. + * + * Usage: + * ``` + * const { dialogProps, confirm } = useDestructiveConfirm() + * + * async function handleDelete() { + * const ok = await confirm({ title: '...', description: '...' }) + * if (!ok) return + * // proceed with deletion + * } + * + * return <> + * ``` + */ +export function useDestructiveConfirm(): UseDestructiveConfirmReturn { + const [open, setOpen] = useState(false) + const [options, setOptions] = useState({ + title: '', + description: '', + }) + const resolveRef = useRef<((value: boolean) => void) | null>(null) + + const confirm = useCallback((opts: ConfirmOptions): Promise => { + setOptions(opts) + setOpen(true) + return new Promise((resolve) => { + resolveRef.current = resolve + }) + }, []) + + const handleOpenChange = useCallback((v: boolean) => { + setOpen(v) + if (!v && resolveRef.current) { + resolveRef.current(false) + resolveRef.current = null + } + }, []) + + const handleConfirm = useCallback(() => { + if (resolveRef.current) { + resolveRef.current(true) + resolveRef.current = null + } + }, []) + + return { + dialogProps: { + open, + onOpenChange: handleOpenChange, + title: options.title, + description: options.description, + confirmLabel: options.confirmLabel, + cancelLabel: options.cancelLabel, + variant: options.variant, + onConfirm: handleConfirm, + }, + confirm, + } +} diff --git a/extensions/general/push-notifications/index.ts b/extensions/general/push-notifications/index.ts index 7fbd0d98..58d2c938 100644 --- a/extensions/general/push-notifications/index.ts +++ b/extensions/general/push-notifications/index.ts @@ -30,6 +30,7 @@ export interface PushNotificationSettings { invoiceSentEnabled: boolean receiptExtractedEnabled: boolean receiptMatchedEnabled: boolean + missingUnderlagEnabled: boolean } const DEFAULT_SETTINGS: PushNotificationSettings = { @@ -38,6 +39,7 @@ const DEFAULT_SETTINGS: PushNotificationSettings = { invoiceSentEnabled: false, receiptExtractedEnabled: true, receiptMatchedEnabled: true, + missingUnderlagEnabled: true, } /** Get settings via ExtensionContext (preferred in event handlers) */ @@ -54,7 +56,7 @@ export async function getSettings(userId: string): Promise { + let sent = 0 + let skipped = 0 + + // Get all users who have posted entries with source types that need docs + const { data: entries } = await supabase + .from('journal_entries') + .select('id, user_id') + .eq('status', 'posted') + .in('source_type', NEEDS_ATTACHMENT_SOURCE_TYPES) + + if (!entries || entries.length === 0) { + return { sent: 0, skipped: 0 } + } + + // Get all document_attachments linked to journal entries + const { data: attachments } = await supabase + .from('document_attachments') + .select('journal_entry_id') + .eq('is_current_version', true) + .not('journal_entry_id', 'is', null) + + const entriesWithDocs = new Set( + (attachments || []).map((a) => a.journal_entry_id) + ) + + // Group missing counts by user + const userMissingCounts = new Map() + for (const entry of entries) { + if (!entriesWithDocs.has(entry.id)) { + userMissingCounts.set( + entry.user_id, + (userMissingCounts.get(entry.user_id) || 0) + 1 + ) + } + } + + for (const [userId, count] of userMissingCounts) { + // Check user setting + const { data: settings } = await supabase + .from('notification_settings') + .select('missing_underlag_enabled') + .eq('user_id', userId) + .single() + + if (settings && settings.missing_underlag_enabled === false) { + skipped++ + continue + } + + const payload = createMissingUnderlagPayload(count) + + const result = await sendNotificationToUser( + supabase, + userId, + payload, + 'missing_underlag', + 'weekly-check' + ) + + if (result.sent) { + sent++ + } else { + skipped++ + } + } + + return { sent, skipped } +} diff --git a/extensions/general/push-notifications/payload-builders.ts b/extensions/general/push-notifications/payload-builders.ts index 36ca5359..a697ec3b 100644 --- a/extensions/general/push-notifications/payload-builders.ts +++ b/extensions/general/push-notifications/payload-builders.ts @@ -114,6 +114,24 @@ export function createReceiptMatchedPayload( } } +// ============================================================ +// Missing underlag payload +// ============================================================ + +export function createMissingUnderlagPayload(count: number): NotificationPayload { + return { + title: 'Saknade underlag', + body: `${count} verifikation(er) saknar underlag. Bifoga for att uppfylla bokforingslagen.`, + icon: '/icons/icon-192.png', + badge: '/icons/badge-72.png', + tag: 'missing-underlag-weekly', + data: { + url: '/bookkeeping?missingUnderlag=true', + type: 'missing_underlag', + }, + } +} + // ============================================================ // Cron-based payloads (moved from lib/push/web-push.ts) // ============================================================ diff --git a/lib/errors/get-error-message.ts b/lib/errors/get-error-message.ts new file mode 100644 index 00000000..2909d67e --- /dev/null +++ b/lib/errors/get-error-message.ts @@ -0,0 +1,200 @@ +/** + * Maps raw errors to user-friendly Swedish messages. + * + * Priority chain: + * 1. Zod validation field errors + * 2. Postgres error code map + * 3. HTTP status code map + * 4. Context-specific fallback + * 5. Generic fallback + */ + +type ErrorContext = + | 'invoice' + | 'supplier_invoice' + | 'customer' + | 'supplier' + | 'transaction' + | 'journal_entry' + | 'settings' + | 'auth' + +interface GetErrorMessageOptions { + context?: ErrorContext + statusCode?: number +} + +// Postgres error codes -> Swedish messages +const POSTGRES_ERROR_MAP: Record = { + '23505': 'En post med samma uppgifter finns redan.', + '23503': 'Posten kan inte ändras eftersom den refereras av annan data.', + '23502': 'Ett obligatoriskt fält saknas.', + '42501': 'Du har inte behörighet att utföra denna åtgärd.', + '42P01': 'Resursen kunde inte hittas.', + '23514': 'Värdet uppfyller inte de tillåtna kraven.', + '40001': 'En annan ändring pågick samtidigt. Försök igen.', + '40P01': 'En konflikt uppstod. Försök igen.', + '22P02': 'Ogiltigt värde angavs.', + '22003': 'Värdet är utanför tillåtet intervall.', +} + +// HTTP status codes -> Swedish messages +const HTTP_STATUS_MAP: Record = { + 400: 'Förfrågan innehåller ogiltiga uppgifter.', + 401: 'Din session har gått ut. Logga in igen.', + 403: 'Du har inte behörighet att utföra denna åtgärd.', + 404: 'Resursen kunde inte hittas.', + 409: 'En konflikt uppstod. Ladda om sidan och försök igen.', + 422: 'Uppgifterna kunde inte bearbetas. Kontrollera fälten och försök igen.', + 429: 'För många förfrågningar. Vänta en stund och försök igen.', + 500: 'Ett oväntat serverfel uppstod. Försök igen senare.', + 502: 'Servern är tillfälligt otillgänglig. Försök igen om en stund.', + 503: 'Tjänsten är tillfälligt otillgänglig. Försök igen om en stund.', +} + +// Context-specific fallbacks +const CONTEXT_FALLBACKS: Record = { + invoice: 'Kunde inte hantera fakturan. Försök igen.', + supplier_invoice: 'Kunde inte hantera leverantörsfakturan. Försök igen.', + customer: 'Kunde inte hantera kunden. Försök igen.', + supplier: 'Kunde inte hantera leverantören. Försök igen.', + transaction: 'Kunde inte hantera transaktionen. Försök igen.', + journal_entry: 'Kunde inte hantera verifikationen. Försök igen.', + settings: 'Kunde inte spara inställningarna. Försök igen.', + auth: 'Ett fel uppstod vid inloggningen. Försök igen.', +} + +const GENERIC_FALLBACK = 'Något gick fel. Försök igen.' + +/** + * Simple heuristic to detect already-translated Swedish messages. + * If the message contains common Swedish words/patterns, pass it through. + */ +function isSwedishUserMessage(message: string): boolean { + const swedishPatterns = [ + /kunde inte/i, + /försök igen/i, + /ogiltigt?/i, + /saknas/i, + /måste/i, + /redan finns/i, + /gick fel/i, + /behörighet/i, + /session/i, + /förfrågan/i, + /obligatorisk/i, + ] + return swedishPatterns.some((p) => p.test(message)) +} + +/** + * Extract a user-friendly message from a Zod validation error shape. + * Returns null if the error is not a Zod error. + */ +function tryParseZodErrors(error: unknown): string | null { + if (typeof error !== 'object' || error === null) return null + + const obj = error as Record + + // Check for Zod-style field errors: { fieldName: ["message"] } or { issues: [...] } + if (Array.isArray(obj.issues)) { + const issues = obj.issues as Array<{ message?: string; path?: string[] }> + const messages = issues + .slice(0, 3) + .map((issue) => { + const field = issue.path?.join('.') || '' + const msg = issue.message || 'ogiltigt värde' + return field ? `${field}: ${msg}` : msg + }) + if (messages.length > 0) return messages.join('. ') + } + + // Check for { errors: { field: ["msg"] } } shape from validateBody + if (typeof obj.errors === 'object' && obj.errors !== null) { + const fieldErrors = obj.errors as Record + const messages: string[] = [] + for (const [field, msgs] of Object.entries(fieldErrors)) { + if (Array.isArray(msgs) && msgs.length > 0) { + messages.push(`${field}: ${msgs[0]}`) + } + if (messages.length >= 3) break + } + if (messages.length > 0) return messages.join('. ') + } + + return null +} + +/** + * Get a user-friendly Swedish error message from a raw error. + * + * @param error - The raw error. Can be an API response body (object), Error instance, string, or unknown. + * @param options - Optional context and HTTP status code. + */ +export function getErrorMessage( + error: unknown, + options: GetErrorMessageOptions = {} +): string { + const { context, statusCode } = options + + // 1. If it's a string, check if it's already Swedish + if (typeof error === 'string' && error.trim()) { + if (isSwedishUserMessage(error)) return error + } + + // 2. If it's an object, try various parsing strategies + if (typeof error === 'object' && error !== null) { + const obj = error as Record + + // Try Zod validation errors + const zodMessage = tryParseZodErrors(obj) + if (zodMessage) return zodMessage + + // Try Postgres error code + if (typeof obj.code === 'string' && POSTGRES_ERROR_MAP[obj.code]) { + return POSTGRES_ERROR_MAP[obj.code] + } + + // Try error.message if it's already a good Swedish message + if (typeof obj.error === 'string' && obj.error.trim()) { + if (isSwedishUserMessage(obj.error)) return obj.error + } + + if (typeof obj.message === 'string' && obj.message.trim()) { + if (isSwedishUserMessage(obj.message)) return obj.message + } + } + + // 3. Error instance + if (error instanceof Error && error.message.trim()) { + if (isSwedishUserMessage(error.message)) return error.message + } + + // 4. HTTP status code map + if (statusCode && HTTP_STATUS_MAP[statusCode]) { + return HTTP_STATUS_MAP[statusCode] + } + + // 5. Context-specific fallback + if (context && CONTEXT_FALLBACKS[context]) { + return CONTEXT_FALLBACKS[context] + } + + // 6. Generic fallback + return GENERIC_FALLBACK +} + +/** + * Helper that parses a Response body and returns a user-friendly error message. + */ +export async function getResponseErrorMessage( + response: Response, + context?: ErrorContext +): Promise { + try { + const body = await response.json() + return getErrorMessage(body, { context, statusCode: response.status }) + } catch { + return getErrorMessage(null, { context, statusCode: response.status }) + } +} diff --git a/lib/hooks/use-unsaved-changes.ts b/lib/hooks/use-unsaved-changes.ts new file mode 100644 index 00000000..176dcbd9 --- /dev/null +++ b/lib/hooks/use-unsaved-changes.ts @@ -0,0 +1,22 @@ +'use client' + +import { useEffect } from 'react' + +/** + * Attaches a `beforeunload` event listener when the form has unsaved changes. + * This guards against browser close, tab close, and page refresh. + * + * Does NOT guard in-app Next.js navigation (App Router has no supported mechanism). + */ +export function useUnsavedChanges(isDirty: boolean) { + useEffect(() => { + if (!isDirty) return + + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault() + } + + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [isDirty]) +} diff --git a/types/index.ts b/types/index.ts index 1b02e726..0aed19e5 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1220,6 +1220,7 @@ export type NotificationType = | 'receipt_extracted' | 'receipt_matched' | 'invoice_sent' + | 'missing_underlag' // Notification log entry export interface NotificationLog { From bbb82866ee8941f0e041a8068e67b7abfc82b6af Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 23 Feb 2026 20:38:44 +0100 Subject: [PATCH 4/4] feat: add 4 new Swedish bank CSV parsers and improve transaction categorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add auto-detecting CSV parsers for Länsförsäkringar, ICA Banken, Skandia, and Lunar. Refine SEB detection to avoid false matches. Update bank file upload UI with new bank options and export instructions. Include booking templates, improved AI categorization, and transaction review enhancements. Co-Authored-By: Claude Opus 4.6 --- app/(dashboard)/transactions/page.tsx | 13 +- app/api/transactions/[id]/categorize/route.ts | 41 +- .../transactions/suggest-categories/route.ts | 16 +- components/import/BankFileUploadStep.tsx | 32 + .../transactions/BatchCategorySelector.tsx | 9 + components/transactions/QuickReviewDialog.tsx | 80 +- .../transactions/SwipeCategorizationView.tsx | 82 +- components/transactions/transaction-types.ts | 3 +- .../general/ai-categorization/categorizer.ts | 35 +- lib/api/__tests__/schemas.test.ts | 11 +- lib/api/schemas.ts | 13 +- .../__tests__/booking-templates.test.ts | 416 +++ lib/bookkeeping/booking-templates.ts | 2624 +++++++++++++++++ lib/bookkeeping/mapping-engine.ts | 34 +- lib/import/bank-file/__tests__/parser.test.ts | 297 +- lib/import/bank-file/formats/ica-banken.ts | 187 ++ .../bank-file/formats/lansforsakringar.ts | 168 ++ lib/import/bank-file/formats/lunar.ts | 142 + lib/import/bank-file/formats/seb.ts | 6 +- lib/import/bank-file/formats/skandia.ts | 148 + lib/import/bank-file/parser.ts | 9 + lib/import/bank-file/types.ts | 4 + lib/transactions/category-suggestions.ts | 43 +- types/index.ts | 1 + 24 files changed, 4366 insertions(+), 48 deletions(-) create mode 100644 lib/bookkeeping/__tests__/booking-templates.test.ts create mode 100644 lib/bookkeeping/booking-templates.ts create mode 100644 lib/import/bank-file/formats/ica-banken.ts create mode 100644 lib/import/bank-file/formats/lansforsakringar.ts create mode 100644 lib/import/bank-file/formats/lunar.ts create mode 100644 lib/import/bank-file/formats/skandia.ts diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 36c8274f..5bba42f0 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -185,7 +185,7 @@ export default function TransactionsPage() { if (!response.ok) { toast({ title: 'Fel', description: result.error || 'Kunde inte uppdatera transaktion', variant: 'destructive' }) setProcessingId(null) - return false + return null } // Mark as exiting for animation, then update state @@ -217,11 +217,11 @@ export default function TransactionsPage() { setProcessingId(null) }, 350) - return true + return result.journal_entry_id || null } catch { toast({ title: 'Fel', description: 'Något gick fel vid bokföring', variant: 'destructive' }) setProcessingId(null) - return false + return null } } @@ -458,13 +458,14 @@ export default function TransactionsPage() { category: TransactionCategory, vatTreatment: VatTreatment | undefined, accountOverride: string | undefined - ) { - const success = await handleCategorize(id, true, category, vatTreatment, accountOverride) - if (success) { + ): Promise { + const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride) + if (journalEntryId) { setQuickReviewOpen(false) setQuickReviewTransaction(null) setQuickReviewCategory(null) } + return journalEntryId } // Swipe view diff --git a/app/api/transactions/[id]/categorize/route.ts b/app/api/transactions/[id]/categorize/route.ts index db27329e..128151dc 100644 --- a/app/api/transactions/[id]/categorize/route.ts +++ b/app/api/transactions/[id]/categorize/route.ts @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server' import { eventBus } from '@/lib/events' import { ensureInitialized } from '@/lib/init' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' +import { getTemplateById, buildMappingResultFromTemplate } from '@/lib/bookkeeping/booking-templates' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine' import { validateBody } from '@/lib/api/validate' @@ -153,18 +154,36 @@ export async function POST( const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1 // Determine the category to use - const finalCategory: TransactionCategory = is_business - ? (category || 'uncategorized') - : 'private' + let finalCategory: TransactionCategory + if (body.template_id) { + const template = getTemplateById(body.template_id) + if (template) { + finalCategory = is_business ? template.fallback_category : 'private' + } else { + return NextResponse.json({ error: 'Invalid template_id' }, { status: 400 }) + } + } else { + finalCategory = is_business ? (category || 'uncategorized') : 'private' + } - // Build mapping result from category - const mappingResult = buildMappingResultFromCategory( - finalCategory, - transaction as Transaction, - is_business, - entityType, - body.vat_treatment - ) + // Build mapping result from template or category + let mappingResult + if (body.template_id) { + const template = getTemplateById(body.template_id)! + mappingResult = buildMappingResultFromTemplate( + template, + transaction as Transaction, + entityType + ) + } else { + mappingResult = buildMappingResultFromCategory( + finalCategory, + transaction as Transaction, + is_business, + entityType, + body.vat_treatment + ) + } // Apply account override if provided (only for business transactions) if (is_business && body.account_override) { diff --git a/app/api/transactions/suggest-categories/route.ts b/app/api/transactions/suggest-categories/route.ts index a4773834..5f2e219a 100644 --- a/app/api/transactions/suggest-categories/route.ts +++ b/app/api/transactions/suggest-categories/route.ts @@ -1,7 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { NextResponse } from 'next/server' -import { getSuggestedCategories, mergeAiSuggestions, type SuggestedCategory } from '@/lib/transactions/category-suggestions' -import type { Transaction, TransactionCategory } from '@/types' +import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions' +import type { Transaction, TransactionCategory, EntityType } from '@/types' /** * POST /api/transactions/suggest-categories @@ -78,8 +78,17 @@ export async function POST(request: Request) { } } + // Fetch entity type for template matching + const { data: settings } = await supabase + .from('company_settings') + .select('entity_type') + .eq('user_id', user.id) + .single() + const entityType = (settings?.entity_type as EntityType) || undefined + // Generate suggestions for each transaction const suggestions: Record = {} + const template_suggestions: Record = {} for (const tx of transactions) { let result = getSuggestedCategories( @@ -95,7 +104,8 @@ export async function POST(request: Request) { } suggestions[tx.id] = result + template_suggestions[tx.id] = getSuggestedTemplates(tx as Transaction, entityType) } - return NextResponse.json({ suggestions }) + return NextResponse.json({ suggestions, template_suggestions }) } diff --git a/components/import/BankFileUploadStep.tsx b/components/import/BankFileUploadStep.tsx index d81550b2..5cc7b81e 100644 --- a/components/import/BankFileUploadStep.tsx +++ b/components/import/BankFileUploadStep.tsx @@ -27,6 +27,10 @@ const FORMAT_NAMES: Record = { seb: 'SEB', swedbank: 'Swedbank', handelsbanken: 'Handelsbanken', + lansforsakringar: 'Länsförsäkringar', + ica_banken: 'ICA Banken', + skandia: 'Skandia', + lunar: 'Lunar', generic_csv: 'CSV (manuell mappning)', camt053: 'ISO 20022 camt.053', } @@ -122,6 +126,10 @@ export default function BankFileUploadStep({ SEB Swedbank Handelsbanken + Länsförsäkringar + ICA Banken + Skandia + Lunar ISO 20022 camt.053 (XML) Annan CSV (manuell mappning) @@ -229,6 +237,30 @@ export default function BankFileUploadStep({ Logga in → Konton → Transaktioner → Ladda ner (CSV)

+
+

Länsförsäkringar

+

+ Logga in → Konton → Kontoutdrag → Exportera (CSV) +

+
+
+

ICA Banken

+

+ Logga in → Konton → Transaktioner → Exportera till fil (CSV) +

+
+
+

Skandia

+

+ Logga in → Konton → Transaktioner → Exportera (CSV) +

+
+
+

Lunar

+

+ Logga in → Konto → Transaktioner → Exportera (CSV) +

+
diff --git a/components/transactions/BatchCategorySelector.tsx b/components/transactions/BatchCategorySelector.tsx index 9ffa5159..e7c52f30 100644 --- a/components/transactions/BatchCategorySelector.tsx +++ b/components/transactions/BatchCategorySelector.tsx @@ -4,6 +4,7 @@ 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 { Paperclip } from 'lucide-react' import VatTreatmentSelect from './VatTreatmentSelect' import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './transaction-types' import type { TransactionCategory, VatTreatment } from '@/types' @@ -59,6 +60,14 @@ export default function BatchCategorySelector({ ) : (
+ {/* Underlag reminder */} +
+ +

+ Underlag behover bifogas separat for varje transaktion efter bokforing. +

+
+

Momsbehandling

Promise + ) => Promise } export default function QuickReviewDialog({ @@ -38,11 +41,14 @@ export default function QuickReviewDialog({ defaultVat, onConfirm, }: QuickReviewDialogProps) { + const { toast } = useToast() const [accountOverride, setAccountOverride] = useState(defaultAccount) const [vatTreatment, setVatTreatment] = useState(defaultVat) const [accounts, setAccounts] = useState([]) const [isProcessing, setIsProcessing] = useState(false) const [error, setError] = useState(null) + const [uploadedFiles, setUploadedFiles] = useState([]) + const [showUploadZone, setShowUploadZone] = useState(false) // Handle account changes — clear VAT for liability/equity accounts (class 2) const handleAccountChange = useCallback((account: string) => { @@ -85,7 +91,34 @@ export default function QuickReviewDialog({ ? accountOverride : undefined - await onConfirm(transaction.id, category, resolvedVat, override) + const journalEntryId = await onConfirm(transaction.id, category, resolvedVat, override) + + // Link uploaded documents to the journal entry + if (journalEntryId && uploadedFiles.length > 0) { + const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) + let linkFailCount = 0 + for (const file of filesToLink) { + try { + await fetch(`/api/documents/${file.id}/link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: journalEntryId }), + }) + } catch { + linkFailCount++ + } + } + if (linkFailCount > 0) { + toast({ + title: 'Underlag kunde inte bifogas', + description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`, + variant: 'destructive', + }) + } + } + + setUploadedFiles([]) + setShowUploadZone(false) } catch { setError('Ett fel uppstod vid bokföring.') setIsProcessing(false) @@ -93,7 +126,13 @@ export default function QuickReviewDialog({ } return ( - + { + if (!o) { + setUploadedFiles([]) + setShowUploadZone(false) + } + onOpenChange(o) + }}> Granska bokföring @@ -164,6 +203,39 @@ export default function QuickReviewDialog({
+ {/* Document upload */} +
+ + {showUploadZone && ( +
+ +
+ )} +
+ {error && (
{error} diff --git a/components/transactions/SwipeCategorizationView.tsx b/components/transactions/SwipeCategorizationView.tsx index 7bc7508b..9db0c5d8 100644 --- a/components/transactions/SwipeCategorizationView.tsx +++ b/components/transactions/SwipeCategorizationView.tsx @@ -5,12 +5,15 @@ 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 { useToast } from '@/components/ui/use-toast' 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' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' -import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward } from 'lucide-react' +import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone' +import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone' +import { X, ArrowLeft, ArrowRight, Building, AlertTriangle, Check, FileText, Link2, Receipt as ReceiptIcon, SkipForward, Paperclip, ChevronDown, ChevronUp } from 'lucide-react' import type { TransactionCategory, VatTreatment, BASAccount } from '@/types' import type { SuggestedCategory } from '@/lib/transactions/category-suggestions' import type { TransactionWithInvoice, CategorizeHandler, MatchInvoiceHandler } from './transaction-types' @@ -34,6 +37,7 @@ export default function SwipeCategorizationView({ onMatchInvoice, onClose, }: SwipeCategorizationViewProps) { + const { toast } = useToast() const [showAllCategories, setShowAllCategories] = useState(false) const [currentIndex, setCurrentIndex] = useState(0) const [showCategorySelect, setShowCategorySelect] = useState(false) @@ -46,6 +50,8 @@ export default function SwipeCategorizationView({ const [accountOverride, setAccountOverride] = useState('') const [vatTreatment, setVatTreatment] = useState('standard_25') const [accounts, setAccounts] = useState([]) + const [uploadedFiles, setUploadedFiles] = useState([]) + const [showUploadZone, setShowUploadZone] = useState(false) // Clear VAT treatment when switching to a liability/equity account (class 2) useEffect(() => { @@ -143,6 +149,11 @@ export default function SwipeCategorizationView({ [isProcessing, currentTransaction, handleCategorySelect, x, moveToNext] ) + const resetUploadState = useCallback(() => { + setUploadedFiles([]) + setShowUploadZone(false) + }, []) + const handleReviewConfirm = async () => { if (!pendingCategory) return @@ -156,14 +167,39 @@ export default function SwipeCategorizationView({ ? accountOverride : undefined - const success = await onCategorize( + const journalEntryId = await onCategorize( currentTransaction.id, true, pendingCategory, resolvedVat, override ) - if (success) { + if (journalEntryId) { + // Link uploaded documents to the journal entry + if (uploadedFiles.length > 0) { + const filesToLink = uploadedFiles.filter((f) => f.status === 'uploaded' && f.id) + let linkFailCount = 0 + for (const file of filesToLink) { + try { + await fetch(`/api/documents/${file.id}/link`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ journal_entry_id: journalEntryId }), + }) + } catch { + linkFailCount++ + } + } + if (linkFailCount > 0) { + toast({ + title: 'Underlag kunde inte bifogas', + description: `${linkFailCount} fil(er) kunde inte lankas till verifikationen.`, + variant: 'destructive', + }) + } + } + + resetUploadState() setShowReviewStep(false) setPendingCategory(null) moveToNext() @@ -204,8 +240,9 @@ export default function SwipeCategorizationView({ setShowCategorySelect(false) setShowReviewStep(false) setPendingCategory(null) + resetUploadState() moveToNext() - }, [moveToNext]) + }, [moveToNext, resetUploadState]) if (!currentTransaction) { return ( @@ -352,12 +389,45 @@ export default function SwipeCategorizationView({ /> {isLiabilityAccount && (

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

)}
+ {/* Document upload */} +
+ + {showUploadZone && ( +
+ +
+ )} +
+ {error && (
{error} @@ -373,7 +443,7 @@ export default function SwipeCategorizationView({ disabled={isProcessing || !accountOverride} > - {isProcessing ? 'Bokför...' : 'Bokför'} + {isProcessing ? 'Bokfor...' : 'Bokfor'}