Merge remote-tracking branch 'origin/main' into logger

This commit is contained in:
Emil
2026-02-24 10:02:36 +01:00
108 changed files with 10358 additions and 651 deletions
+3 -1
View File
@@ -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)"
]
}
}
+140 -35
View File
@@ -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,22 +44,52 @@ 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/
api/ Zod validation schemas and utilities for API routes
schemas.ts Zod schemas for all API request bodies and query params
validate.ts validateBody() and validateQuery() helpers
bookkeeping/ Core journal entry engine and all entry generators
engine.ts Draft/commit workflow, balance validation, voucher numbering
invoice-entries.ts Sales invoice journal entries (supports per-line VAT rates)
@@ -70,23 +101,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 +136,22 @@ 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)
extensions.md Extension system design document (architecture, data patterns,
sector model, workspace pattern, migration plan)
```
### Key Relationships
@@ -114,6 +160,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 +210,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 +246,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/<name>/index.ts`
1. Create `extensions/<sector>/<name>/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 +307,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 +345,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 +355,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 +400,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
@@ -344,6 +426,8 @@ mockResult({ data: makeTransaction(), error: null })
### Reference Tests
- `lib/api/__tests__/schemas.test.ts` — Zod schema validation
- `lib/api/__tests__/validate.test.ts` — Body/query validation helpers
- `lib/bookkeeping/__tests__/engine.test.ts` — Balance validation
- `lib/bookkeeping/__tests__/invoice-entries.test.ts` — Per-line VAT, mixed-rate invoices, credit notes
- `lib/core/bookkeeping/__tests__/storno-service.test.ts` — Complex mock queues
@@ -363,11 +447,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 +490,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.
---
@@ -426,6 +517,8 @@ Standard pattern for new API routes:
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { validateBody } from '@/lib/api/validate'
import { CreateInvoiceSchema } from '@/lib/api/schemas'
ensureInitialized() // Module-level — loads extensions for event emission
@@ -437,7 +530,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Business logic...
// Validate request body with Zod schema
const result = await validateBody(request, CreateInvoiceSchema)
if (!result.success) return result.response
const { data } = result
// Business logic using validated `data`...
// Always filter by user_id (defense in depth alongside RLS)
// Wrap journal entry creation in try/catch (non-blocking side effect)
// Emit events after successful operations
@@ -448,6 +546,7 @@ export async function POST(request: Request) {
**Key conventions**:
- Call `ensureInitialized()` at module level in any route that emits events
- **Validate all input** with `validateBody()` / `validateQuery()` from `lib/api/validate.ts` using schemas from `lib/api/schemas.ts`
- Dynamic route params use `{ params }: { params: Promise<{ id: string }> }` (Next.js 16)
- Response shapes: `{ data }` for success, `{ error }` for failures
- Journal entry creation is non-blocking: catch errors and continue
@@ -498,13 +597,19 @@ Hosted on **Vercel** with cron jobs defined in `vercel.json`:
NEXT_PUBLIC_SUPABASE_URL # Supabase project URL
NEXT_PUBLIC_SUPABASE_ANON_KEY # Supabase anonymous key
SUPABASE_SERVICE_ROLE_KEY # Supabase service role key
RESEND_API_KEY # Resend email service API key
RESEND_FROM_EMAIL # Sender email for transactional mail
RESEND_WEBHOOK_SECRET # Webhook auth for Resend
ENABLE_BANKING_APP_ID # Enable Banking app ID
ENABLE_BANKING_PRIVATE_KEY # Enable Banking private key (base64-encoded)
ENABLE_BANKING_SANDBOX # Enable Banking sandbox mode flag
ANTHROPIC_API_KEY # Claude API key (ai-chat)
OPENAI_API_KEY # OpenAI API key (embeddings)
NEXT_PUBLIC_APP_URL # App base URL
CRON_SECRET # Auth secret for Vercel cron jobs
NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key
VAPID_PRIVATE_KEY # Web push private key
VAPID_SUBJECT # VAPID subject (mailto: URI) for web push
```
## Other
+6 -5
View File
@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, Sparkles } from 'lucide-react'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function LoginPage() {
const [email, setEmail] = useState('')
@@ -30,8 +31,8 @@ export default function LoginPage() {
if (error) {
toast({
title: 'Fel',
description: error.message,
title: 'Inloggning misslyckades',
description: getErrorMessage(error, { context: 'auth' }),
variant: 'destructive',
})
return
@@ -42,10 +43,10 @@ export default function LoginPage() {
title: 'E-post skickad!',
description: 'Kolla din inkorg för att logga in.',
})
} catch {
} catch (error) {
toast({
title: 'Fel',
description: 'Något gick fel. Försök igen.',
title: 'Inloggning misslyckades',
description: getErrorMessage(error, { context: 'auth' }),
variant: 'destructive',
})
} finally {
+11 -1
View File
@@ -10,6 +10,7 @@ import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import CustomerForm from '@/components/customers/CustomerForm'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import {
ArrowLeft,
Building,
@@ -66,6 +67,7 @@ export default function CustomerDetailPage({
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isUpdating, setIsUpdating] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchCustomer()
@@ -124,7 +126,13 @@ export default function CustomerDetailPage({
async function handleDelete() {
if (!customer) return
if (!confirm(`Ta bort "${customer.name}"? Detta kan inte angras.`)) return
const ok = await confirmAction({
title: `Ta bort ${customer.name}`,
description: 'Kunden och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
try {
const response = await fetch(`/api/customers/${id}`, {
@@ -352,6 +360,8 @@ export default function CustomerDetailPage({
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Edit dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+1 -1
View File
@@ -515,7 +515,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
{(() => {
const vatByRate = new Map<number, number>()
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)
}
+14 -10
View File
@@ -21,6 +21,8 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Di
import { Loader2, Plus, Trash2, ArrowLeft, Send, Eye } from 'lucide-react'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { Customer, Currency, CreateInvoiceInput, InvoiceDocumentType } from '@/types'
const itemSchema = z.object({
@@ -73,7 +75,7 @@ export default function NewInvoicePage() {
handleSubmit,
watch,
setValue,
formState: { errors },
formState: { errors, isDirty },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
@@ -86,6 +88,8 @@ export default function NewInvoicePage() {
},
})
useUnsavedChanges(isDirty)
// Set date defaults on client only to avoid hydration mismatch
useEffect(() => {
setValue('invoice_date', format(new Date(), 'yyyy-MM-dd'))
@@ -209,7 +213,7 @@ export default function NewInvoicePage() {
const result = await response.json()
if (!response.ok) {
throw new Error(result.error || 'Kunde inte skapa faktura')
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const docLabel = watchDocumentType === 'proforma' ? 'Proformafaktura' : watchDocumentType === 'delivery_note' ? 'Följesedel' : 'Faktura'
@@ -229,8 +233,8 @@ export default function NewInvoicePage() {
}
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Något gick fel',
title: 'Kunde inte skapa faktura',
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
@@ -249,7 +253,7 @@ export default function NewInvoicePage() {
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte skicka faktura')
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
toast({
@@ -258,8 +262,8 @@ export default function NewInvoicePage() {
})
} catch (error) {
toast({
title: 'Fel vid skickning',
description: error instanceof Error ? error.message : 'Något gick fel',
title: 'Kunde inte skicka faktura',
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
@@ -292,7 +296,7 @@ export default function NewInvoicePage() {
if (!response.ok) {
const result = await response.json()
throw new Error(result.error || 'Kunde inte generera förhandsgranskning')
throw new Error(getErrorMessage(result, { context: 'invoice', statusCode: response.status }))
}
const blob = await response.blob()
@@ -300,8 +304,8 @@ export default function NewInvoicePage() {
window.open(url, '_blank')
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte generera PDF',
title: 'Kunde inte generera PDF',
description: getErrorMessage(error, { context: 'invoice' }),
variant: 'destructive',
})
} finally {
+32
View File
@@ -170,6 +170,37 @@ export default async function DashboardPage() {
.lt('amount', 0)
.is('receipt_id', null)
// Count journal entries missing underlag (documents)
// Source types that require supporting 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')
@@ -219,6 +250,7 @@ export default async function DashboardPage() {
bankBalance,
deadlines: (deadlines || []) as Deadline[],
receiptQueue,
missingUnderlagCount,
}}
onboardingProgress={onboardingProgress}
/>
@@ -12,6 +12,7 @@ import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
function formatAmount(amount: number): string {
@@ -47,6 +48,7 @@ export default function SupplierInvoiceDetailPage() {
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
@@ -100,7 +102,13 @@ export default function SupplierInvoiceDetailPage() {
}
async function handleCredit() {
if (!confirm('Vill du registrera en kreditfaktura för denna faktura?')) return
const ok = await confirmAction({
title: 'Registrera kreditfaktura',
description: 'En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.',
confirmLabel: 'Registrera kreditfaktura',
variant: 'warning',
})
if (!ok) return
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
@@ -114,7 +122,13 @@ export default function SupplierInvoiceDetailPage() {
}
async function handleDelete() {
if (!confirm('Vill du ta bort denna faktura?')) return
const ok = await confirmAction({
title: 'Ta bort faktura',
description: 'Fakturan och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
@@ -410,6 +424,8 @@ export default function SupplierInvoiceDetailPage() {
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Pay Dialog */}
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
@@ -16,6 +16,8 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import type { Supplier, BASAccount, VatTreatment } from '@/types'
interface LineItem {
@@ -67,7 +69,7 @@ export default function NewSupplierInvoicePage() {
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const { register, control, handleSubmit, watch, setValue } = useForm<FormData>({
const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm<FormData>({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
@@ -90,6 +92,8 @@ export default function NewSupplierInvoicePage() {
},
})
useUnsavedChanges(isDirty)
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
@@ -208,7 +212,7 @@ export default function NewSupplierInvoicePage() {
const result = await res.json()
if (!res.ok) {
toast({ title: 'Fel', description: result.error || 'Kunde inte registrera faktura', variant: 'destructive' })
toast({ title: 'Kunde inte registrera faktura', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
} else {
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
setShowReview(false)
+11 -1
View File
@@ -10,6 +10,7 @@ import { useToast } from '@/components/ui/use-toast'
import { ArrowLeft, Edit, Trash2, FileText } from 'lucide-react'
import SupplierForm from '@/components/suppliers/SupplierForm'
import Link from 'next/link'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
const supplierTypeLabels: Record<SupplierType, string> = {
@@ -31,6 +32,7 @@ export default function SupplierDetailPage() {
const [isLoading, setIsLoading] = useState(true)
const [isEditOpen, setIsEditOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchSupplier()
@@ -76,7 +78,13 @@ export default function SupplierDetailPage() {
}
async function handleDelete() {
if (!confirm('Är du säker på att du vill ta bort denna leverantör?')) return
const ok = await confirmAction({
title: 'Ta bort leverantör',
description: `"${supplier?.name}" och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.`,
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/suppliers/${params.id}`, { method: 'DELETE' })
const result = await res.json()
@@ -267,6 +275,8 @@ export default function SupplierDetailPage() {
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+69 -3
View File
@@ -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<TransactionWithInvoice | null>(null)
// Quick review dialog (suggestion review before booking)
const [quickReviewOpen, setQuickReviewOpen] = useState(false)
const [quickReviewTransaction, setQuickReviewTransaction] = useState<TransactionWithInvoice | null>(null)
const [quickReviewCategory, setQuickReviewCategory] = useState<TransactionCategory | null>(null)
const [quickReviewLabel, setQuickReviewLabel] = useState('')
// Entity type for tooltip context
const [entityType, setEntityType] = useState<string>('enskild_firma')
// Set of transaction IDs that are animating out (just categorized)
const [exitingIds, setExitingIds] = useState<Set<string>>(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
@@ -157,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
@@ -189,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
}
}
@@ -416,6 +444,30 @@ 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
): Promise<string | null> {
const journalEntryId = await handleCategorize(id, true, category, vatTreatment, accountOverride)
if (journalEntryId) {
setQuickReviewOpen(false)
setQuickReviewTransaction(null)
setQuickReviewCategory(null)
}
return journalEntryId
}
// Swipe view
if (showSwipeView && uncategorizedTransactions.length > 0) {
return (
@@ -478,10 +530,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 +591,18 @@ export default function TransactionsPage() {
onBooked={handleTransactionBooked}
/>
<QuickReviewDialog
key={quickReviewTransaction?.id ?? '' + String(quickReviewCategory)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReviewTransaction}
category={quickReviewCategory}
categoryLabel={quickReviewLabel}
defaultAccount={quickReviewCategory ? getDefaultAccountForCategory(quickReviewCategory) : ''}
defaultVat={quickReviewCategory ? (getDefaultVatTreatmentForCategory(quickReviewCategory) ?? 'none') : 'none'}
onConfirm={handleQuickReviewConfirm}
/>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
+32 -16
View File
@@ -6,6 +6,7 @@ import { createClient } from '@/lib/supabase/client'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
import Step1EntityType from '@/components/onboarding/Step1EntityType'
@@ -26,6 +27,14 @@ const STEP_TITLES = [
'Tillägg',
]
function translatePeriodError(msg: string): string {
if (msg.includes('end must be after')) return 'Slutdatumet måste vara efter startdatumet.'
if (msg.includes('start must be the 1st')) return 'Startdatumet måste vara den 1:a i en månad.'
if (msg.includes('end must be the last day')) return 'Slutdatumet måste vara sista dagen i en månad.'
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får inte överstiga 18 månader (BFL 3 kap.).'
return 'Ogiltigt räkenskapsår. Kontrollera datumen och försök igen.'
}
export default function OnboardingPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
@@ -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',
})
}
}
+6 -10
View File
@@ -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<string, unknown> = {}
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()
+5 -1
View File
@@ -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')
+5 -2
View File
@@ -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)
@@ -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 () => {
@@ -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)
@@ -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)
+5 -2
View File
@@ -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)
@@ -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 {
+5 -1
View File
@@ -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')
+5 -2
View File
@@ -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<CreateCustomerInput> = await request.json()
const result = await validateBody(request, UpdateCustomerSchema)
if (!result.success) return result.response
const body = result.data
const updateData: Record<string, unknown> = {}
+6 -2
View File
@@ -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')
+5 -7
View File
@@ -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
@@ -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) {
+21
View File
@@ -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<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).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 },
+19 -16
View File
@@ -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)
+41 -15
View File
@@ -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<ReturnType<typeof createClient>>,
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),
}))
+5 -9
View File
@@ -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)
+5 -2
View File
@@ -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,
+5 -9
View File
@@ -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)
+5 -1
View File
@@ -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
@@ -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
+6 -9
View File
@@ -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()
@@ -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',
+6 -2
View File
@@ -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
+5 -2
View File
@@ -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<CreateSupplierInput> = 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')
+5 -2
View File
@@ -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')
@@ -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',
+6 -17
View File
@@ -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
+37 -21
View File
@@ -3,19 +3,15 @@ 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 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 +94,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)
@@ -156,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) {
@@ -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<{
@@ -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
@@ -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
@@ -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<string, SuggestedCategory[]> = {}
const template_suggestions: Record<string, SuggestedTemplate[]> = {}
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 })
}
+31
View File
@@ -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,
}}
/>
</div>
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
@@ -41,6 +41,9 @@ export default function JournalEntryAttachments({
const [showUpload, setShowUpload] = useState(false)
const [uploadFiles, setUploadFiles] = useState<UploadedFile[]>([])
const onCountChangeRef = useRef(onCountChange)
onCountChangeRef.current = onCountChange
const fetchDocuments = useCallback(async () => {
try {
const res = await fetch(
@@ -48,13 +51,13 @@ export default function JournalEntryAttachments({
)
const { data } = await res.json()
setDocuments(data || [])
onCountChange?.(data?.length || 0)
onCountChangeRef.current?.(data?.length || 0)
} catch {
console.error('Failed to fetch documents')
} finally {
setLoading(false)
}
}, [journalEntryId, onCountChange])
}, [journalEntryId])
useEffect(() => {
fetchDocuments()
+50 -3
View File
@@ -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<UploadedFile[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
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}
/>
</ConfirmationDialog>
{/* Warning dialog when no documents attached */}
<ConfirmationDialog
open={showNoDocWarning}
onOpenChange={setShowNoDocWarning}
onConfirm={() => {
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"
>
<div className="flex items-start gap-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-4">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-300">
<p className="font-medium mb-1">Inget underlag bifogat</p>
<p>
Enligt bokforingslagen (BFL 5 kap. 6-7 §§) ska varje bokforingspost ha en verifikation som
underlag. Du kan bifoga underlag nu eller fortsatta utan.
</p>
</div>
</div>
</ConfirmationDialog>
</div>
)
+28
View File
@@ -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(
<Link key="missing-underlag" href="/bookkeeping?missingUnderlag=true" className="group">
<Card className="h-full border-l-2 border-l-warning hover:bg-muted/20 transition-colors">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<FileWarning className="h-4 w-4 text-warning-foreground flex-shrink-0" />
<div>
<p className="font-medium text-sm">Saknade underlag</p>
<p className="text-xs text-muted-foreground mt-0.5">
{summary.missingUnderlagCount} verifikationer utan underlag
</p>
</div>
</div>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground/50 group-hover:text-muted-foreground group-hover:translate-x-0.5 transition-all" />
</div>
</CardContent>
</Card>
</Link>
)
}
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
@@ -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<InvoiceInboxItem[]>([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<TabValue>('all')
const [selectedItem, setSelectedItem] = useState<InvoiceInboxItem | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [settings, setSettings] = useState<InvoiceInboxSettings>(DEFAULT_SETTINGS)
const [settingsOpen, setSettingsOpen] = useState(false)
const [suppliers, setSuppliers] = useState<Supplier[]>([])
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<string, string> = {}
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 (
<div className="space-y-6">
<PageHeader
title="Fakturainkorgen"
description="Ladda upp leverantörsfakturor och låt AI extrahera data automatiskt"
action={
<Button
variant="outline"
size="icon"
onClick={() => setSettingsOpen(true)}
>
<Settings className="h-4 w-4" />
</Button>
}
/>
{/* KPI cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Inbox className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-2xl font-medium">{totalCount}</p>
<p className="text-xs text-muted-foreground">Totalt</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning/15">
<CheckCircle2 className="h-5 w-5 text-warning-foreground" />
</div>
<div>
<p className="text-2xl font-medium">{readyCount}</p>
<p className="text-xs text-muted-foreground">Att granska</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertTriangle className="h-5 w-5 text-destructive" />
</div>
<div>
<p className="text-2xl font-medium">{errorCount}</p>
<p className="text-xs text-muted-foreground">Fel</p>
</div>
</CardContent>
</Card>
</div>
{/* Upload zone */}
<InboxUploadZone
onUploadComplete={handleUploadComplete}
isUploading={isUploading}
setIsUploading={setIsUploading}
/>
{/* Tabs + item list */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as TabValue)}>
<TabsList>
{TABS.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{TABS.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full rounded-xl" />
))}
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/40 mb-3" />
<p className="text-sm text-muted-foreground">
{activeTab === 'all'
? 'Inga fakturor ännu. Ladda upp en faktura ovan.'
: 'Inga fakturor med denna status.'}
</p>
</div>
) : (
<div className="space-y-2">
{filteredItems.map((item) => (
<InboxItemCard
key={item.id}
item={item}
onClick={() => setSelectedItem(item)}
/>
))}
</div>
)}
</TabsContent>
))}
</Tabs>
{/* Detail dialog */}
<InboxDetailDialog
item={selectedItem}
open={selectedItem != null}
onOpenChange={(open) => {
if (!open) setSelectedItem(null)
}}
onConfirm={handleConfirm}
onReject={handleReject}
onReprocess={handleReprocess}
suppliers={suppliers}
/>
{/* Settings dialog */}
<InboxSettingsDialog
open={settingsOpen}
onOpenChange={setSettingsOpen}
settings={settings}
onSave={handleSaveSettings}
/>
</div>
)
}
@@ -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<void>
onReject: (itemId: string) => Promise<void>
onReprocess: (itemId: string) => Promise<void>
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<string | undefined>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<DialogTitle>Granska faktura</DialogTitle>
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
</DialogHeader>
{/* Confidence */}
{item.confidence != null && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">AI-konfidens</span>
<Badge variant={confidenceVariant}>{confidence.label} ({Math.round(item.confidence * 100)}%)</Badge>
</div>
<Progress value={item.confidence * 100} className="h-1.5" />
</div>
)}
{item.error_message && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{item.error_message}
</div>
)}
{extraction && (
<>
<Separator />
{/* Supplier info */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Leverantör</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-muted-foreground">Namn</span>
<p className="font-medium">{extraction.supplier.name ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Org.nr</span>
<p className="font-medium">{extraction.supplier.orgNumber ?? '-'}</p>
</div>
{extraction.supplier.bankgiro && (
<div>
<span className="text-muted-foreground">Bankgiro</span>
<p className="font-medium">{extraction.supplier.bankgiro}</p>
</div>
)}
{extraction.supplier.plusgiro && (
<div>
<span className="text-muted-foreground">Plusgiro</span>
<p className="font-medium">{extraction.supplier.plusgiro}</p>
</div>
)}
</div>
{/* Supplier match override */}
<div className="space-y-1.5">
<label className="text-sm text-muted-foreground">
Matchad leverantör
{matchedSupplierName && (
<span className="ml-1 text-xs">
(auto: {matchedSupplierName})
</span>
)}
</label>
<Select
value={supplierId ?? '__new__'}
onValueChange={(v) => setSelectedSupplierId(v === '__new__' ? undefined : v)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Skapa ny leverantör" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__new__">Skapa ny leverantör</SelectItem>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
{s.org_number ? ` (${s.org_number})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* Invoice details */}
<div className="space-y-3">
<h4 className="text-sm font-medium">Fakturadetaljer</h4>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div>
<span className="text-muted-foreground">Fakturanummer</span>
<p className="font-medium">{extraction.invoice.invoiceNumber ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Valuta</span>
<p className="font-medium">{extraction.invoice.currency}</p>
</div>
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{extraction.invoice.invoiceDate ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{extraction.invoice.dueDate ?? '-'}</p>
</div>
{extraction.invoice.paymentReference && (
<div className="col-span-2">
<span className="text-muted-foreground">Betalningsreferens</span>
<p className="font-medium font-mono">{extraction.invoice.paymentReference}</p>
</div>
)}
</div>
</div>
{/* Line items */}
{extraction.lineItems.length > 0 && (
<>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">Rader</h4>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right w-16">Antal</TableHead>
<TableHead className="text-right w-24">À-pris</TableHead>
<TableHead className="text-right w-24">Belopp</TableHead>
<TableHead className="text-right w-16">Moms</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{extraction.lineItems.map((line, i) => (
<TableRow key={i}>
<TableCell className="text-sm">{line.description}</TableCell>
<TableCell className="text-right text-sm">{line.quantity}</TableCell>
<TableCell className="text-right text-sm">
{line.unitPrice != null ? formatSEK(line.unitPrice) : '-'}
</TableCell>
<TableCell className="text-right text-sm font-medium">
{formatSEK(line.lineTotal)}
</TableCell>
<TableCell className="text-right text-sm">
{line.vatRate != null ? `${line.vatRate}%` : '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</>
)}
<Separator />
{/* Totals */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto</span>
<span>{formatSEK(extraction.totals.subtotal)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span>{formatSEK(extraction.totals.vatAmount)}</span>
</div>
<Separator />
<div className="flex justify-between font-medium text-base">
<span>Totalt</span>
<span>{formatSEK(extraction.totals.total)}</span>
</div>
</div>
</>
)}
<DialogFooter className="gap-2 sm:gap-0">
{canReject && (
<Button
variant="outline"
onClick={() => handleAction('reject')}
disabled={loading !== null}
>
{loading === 'reject' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<X className="h-4 w-4 mr-2" />
)}
Avvisa
</Button>
)}
{canReprocess && (
<Button
variant="outline"
onClick={() => handleAction('reprocess')}
disabled={loading !== null}
>
{loading === 'reprocess' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Bearbeta igen
</Button>
)}
{canConfirm && (
<Button
onClick={() => handleAction('confirm')}
disabled={loading !== null}
>
{loading === 'confirm' ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Check className="h-4 w-4 mr-2" />
)}
Bekräfta
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<Card
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={onClick}
>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{item.source === 'email' ? (
<Mail className="h-5 w-5 text-muted-foreground" />
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">{fileName}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
{supplierName ? (
<span className="text-sm text-muted-foreground truncate">{supplierName}</span>
) : (
<span className="text-sm text-muted-foreground/60 italic">Okänd leverantör</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
{summary.total > 0 && (
<span className="text-sm font-medium">{formatSEK(summary.total)}</span>
)}
<div className="flex items-center gap-1.5">
{item.confidence != null && (
<Badge variant={confidenceVariant} className="text-[10px] px-1.5 py-0">
{confidence.label}
</Badge>
)}
<Badge variant={statusVariant}>
{getStatusLabel(item.status)}
</Badge>
</div>
<span className="text-[11px] text-muted-foreground">
{formatRelativeTime(item.created_at)}
</span>
</div>
</CardContent>
</Card>
)
}
@@ -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<void>
}
export default function InboxSettingsDialog({
open,
onOpenChange,
settings,
onSave,
}: InboxSettingsDialogProps) {
const [local, setLocal] = useState<InvoiceInboxSettings>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Inställningar</DialogTitle>
</DialogHeader>
<div className="space-y-6 py-2">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Automatisk bearbetning</Label>
<p className="text-xs text-muted-foreground">
Analysera fakturor automatiskt vid uppladdning
</p>
</div>
<Switch
checked={local.autoProcessEnabled}
onCheckedChange={(checked) =>
setLocal((prev) => ({ ...prev, autoProcessEnabled: checked }))
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Automatisk leverantörsmatchning</Label>
<p className="text-xs text-muted-foreground">
Matcha extraherade uppgifter mot befintliga leverantörer
</p>
</div>
<Switch
checked={local.autoMatchSupplierEnabled}
onCheckedChange={(checked) =>
setLocal((prev) => ({ ...prev, autoMatchSupplierEnabled: checked }))
}
/>
</div>
<div className="space-y-2">
<Label>Matchningströskel</Label>
<p className="text-xs text-muted-foreground">
Lägsta konfidens för automatisk leverantörsmatchning (0-1)
</p>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={local.supplierMatchThreshold}
onChange={(e) =>
setLocal((prev) => ({
...prev,
supplierMatchThreshold: Math.min(1, Math.max(0, parseFloat(e.target.value) || 0)),
}))
}
/>
</div>
{local.inboxEmail && (
<div className="space-y-2">
<Label>Inkorg-e-post</Label>
<p className="text-xs text-muted-foreground">
Vidarebefodra fakturor till denna adress
</p>
<div className="flex gap-2">
<Input
value={local.inboxEmail}
readOnly
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={handleCopyEmail}
>
{copied ? (
<Check className="h-4 w-4 text-success" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Avbryt
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Spara
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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<string | null>(null)
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) uploadFile(file)
// Reset so same file can be re-selected
e.target.value = ''
},
[uploadFile]
)
return (
<div>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => !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'
)}
>
<input
ref={inputRef}
type="file"
accept=".pdf,.jpg,.jpeg,.png,.webp"
className="hidden"
onChange={handleFileSelect}
disabled={isUploading}
/>
{isUploading ? (
<>
<Loader2 className="h-8 w-8 text-primary animate-spin mb-2" />
<p className="text-sm text-muted-foreground">Laddar upp och analyserar...</p>
</>
) : isDragOver ? (
<>
<FileUp className="h-8 w-8 text-primary mb-2" />
<p className="text-sm font-medium text-primary">Släpp filen här</p>
</>
) : (
<>
<Upload className="h-8 w-8 text-muted-foreground/60 mb-2" />
<p className="text-sm text-muted-foreground">
Dra och släpp en faktura, eller{' '}
<span className="font-medium text-primary">välj fil</span>
</p>
<p className="text-xs text-muted-foreground/60 mt-1">
PDF, JPEG, PNG eller WebP (max 10 MB)
</p>
</>
)}
</div>
{error && (
<p className="text-sm text-destructive mt-2">{error}</p>
)}
</div>
)
}
+32
View File
@@ -27,6 +27,10 @@ const FORMAT_NAMES: Record<string, string> = {
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({
<SelectItem value="seb">SEB</SelectItem>
<SelectItem value="swedbank">Swedbank</SelectItem>
<SelectItem value="handelsbanken">Handelsbanken</SelectItem>
<SelectItem value="lansforsakringar">Länsförsäkringar</SelectItem>
<SelectItem value="ica_banken">ICA Banken</SelectItem>
<SelectItem value="skandia">Skandia</SelectItem>
<SelectItem value="lunar">Lunar</SelectItem>
<SelectItem value="camt053">ISO 20022 camt.053 (XML)</SelectItem>
<SelectItem value="generic_csv">Annan CSV (manuell mappning)</SelectItem>
</SelectContent>
@@ -229,6 +237,30 @@ export default function BankFileUploadStep({
Logga in → Konton → Transaktioner → Ladda ner (CSV)
</p>
</div>
<div>
<p className="font-medium">Länsförsäkringar</p>
<p className="text-muted-foreground">
Logga in → Konton → Kontoutdrag → Exportera (CSV)
</p>
</div>
<div>
<p className="font-medium">ICA Banken</p>
<p className="text-muted-foreground">
Logga in → Konton → Transaktioner → Exportera till fil (CSV)
</p>
</div>
<div>
<p className="font-medium">Skandia</p>
<p className="text-muted-foreground">
Logga in → Konton → Transaktioner → Exportera (CSV)
</p>
</div>
<div>
<p className="font-medium">Lunar</p>
<p className="text-muted-foreground">
Logga in → Konto → Transaktioner → Exportera (CSV)
</p>
</div>
</CardContent>
</Card>
</div>
+2 -2
View File
@@ -54,7 +54,7 @@ export function InvoiceReviewContent({
// Calculate per-rate VAT breakdown
const vatByRate = new Map<number, number>()
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({
<td className="py-2 text-center">{item.unit}</td>
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
{showVatColumn && (
<td className="py-2 text-right">{item.vat_rate ?? 25}%</td>
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
)}
<td className="py-2 text-right">
{formatCurrency(item.quantity * item.unit_price, currency)}
+57 -6
View File
@@ -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<number>(
@@ -424,6 +434,21 @@ export default function Step3TaxRegistration({
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
</p>
)}
{firstYearStart && firstYearEnd && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt första räkenskapsår
</div>
<p className="text-sm text-muted-foreground">
{formatSwedishDate(firstYearStart)} &ndash; {formatSwedishDate(firstYearEnd)}
</p>
<p className="text-xs text-muted-foreground">
{monthsBetween(firstYearStart, firstYearEnd)} månader
</p>
</div>
)}
</div>
)}
@@ -464,6 +489,21 @@ export default function Step3TaxRegistration({
<p className="text-sm text-muted-foreground">
De flesta har kalenderår (december). Brutet räkenskapsår slutar annan månad.
</p>
{fiscalYearEndMonth && (
<div className="mt-3 rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt räkenskapsår
</div>
<p className="text-sm text-muted-foreground">
{fiscalYearEndMonth === 12
? `1 januari \u2013 31 december (kalenderår)`
: `1 ${monthNames[fiscalYearEndMonth].toLowerCase()} \u2013 ${lastDayOfMonth(2025, fiscalYearEndMonth)} ${monthNames[fiscalYearEndMonth - 1].toLowerCase()}`}
</p>
<p className="text-xs text-muted-foreground">12 månader</p>
</div>
)}
</div>
)}
</div>
@@ -594,11 +634,22 @@ export default function Step3TaxRegistration({
</Select>
)}
/>
<p className="text-xs text-muted-foreground">
{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.'}
</p>
<div className="rounded-lg border bg-muted/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Info className="h-4 w-4 text-muted-foreground" />
{accountingMethod === 'accrual' ? 'Faktureringsmetoden' : 'Kontantmetoden'}
</div>
<p className="text-sm text-muted-foreground">
{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.'}
</p>
{entityType === 'aktiebolag' && (
<p className="text-xs text-amber-700 bg-amber-50 rounded px-2 py-1">
Aktiebolag med omsättning över 3 MSEK per år måste använda faktureringsmetoden.
</p>
)}
</div>
</div>
</div>
+11 -3
View File
@@ -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<CalendarFeedWithUrls | null>(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() {
</CardContent>
</Card>
<DestructiveConfirmDialog {...confirmDialogProps} />
</div>
)
}
@@ -4,13 +4,13 @@ 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 { Paperclip } from 'lucide-react'
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
@@ -60,23 +60,20 @@ export default function BatchCategorySelector({
</div>
) : (
<div className="space-y-4 py-2">
{/* Underlag reminder */}
<div className="flex items-start gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 p-3">
<Paperclip className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 shrink-0" />
<p className="text-xs text-amber-800 dark:text-amber-300">
Underlag behover bifogas separat for varje transaktion efter bokforing.
</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
<Select
<VatTreatmentSelect
value={vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{vatTreatmentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
onValueChange={setVatTreatment}
/>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Kostnader</h4>
@@ -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 */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-2">Momsbehandling</h4>
<Select
<VatTreatmentSelect
value={vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TREATMENT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
onValueChange={setVatTreatment}
/>
</div>
{/* Category grid */}
@@ -0,0 +1,267 @@
'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 { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { ArrowUpRight, ArrowDownRight, Check, Paperclip, ChevronDown, ChevronUp } from 'lucide-react'
import { getDefaultAccountForCategory } from '@/lib/bookkeeping/category-mapping'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
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<string | null>
}
export default function QuickReviewDialog({
open,
onOpenChange,
transaction,
category,
categoryLabel,
defaultAccount,
defaultVat,
onConfirm,
}: QuickReviewDialogProps) {
const { toast } = useToast()
const [accountOverride, setAccountOverride] = useState(defaultAccount)
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>(defaultVat)
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
// 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
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)
}
}
return (
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
if (!o) {
setUploadedFiles([])
setShowUploadZone(false)
}
onOpenChange(o)
}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Granska bokföring</DialogTitle>
<DialogDescription>
Kontrollera konto och moms innan du bokför
</DialogDescription>
</DialogHeader>
{/* Transaction summary */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div
className={`h-9 w-9 rounded-full flex items-center justify-center flex-shrink-0 ${
isIncome
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{isIncome ? (
<ArrowUpRight className="h-4 w-4" />
) : (
<ArrowDownRight className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{transaction.description}</p>
<p className="text-xs text-muted-foreground">{formatDate(transaction.date)}</p>
</div>
<p className={`font-medium text-sm flex-shrink-0 ${isIncome ? 'text-success' : ''}`}>
{isIncome ? '+' : ''}
{formatCurrency(transaction.amount, transaction.currency)}
</p>
</div>
{/* Category (read-only) */}
<div>
<label className="text-sm font-medium text-muted-foreground">Kategori</label>
<div className="mt-1">
<Badge variant="outline" className="text-sm py-1 px-3">{categoryLabel}</Badge>
</div>
</div>
{/* Account */}
<div>
<label className="text-sm font-medium text-muted-foreground">Konto</label>
<div className="mt-1">
<AccountCombobox
value={accountOverride}
accounts={accounts}
onChange={handleAccountChange}
/>
</div>
</div>
{/* VAT treatment */}
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms för skuld-/eget kapital-konton
</p>
)}
</div>
</div>
{/* Document upload */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
className="flex-1"
onClick={() => onOpenChange(false)}
disabled={isProcessing}
>
Avbryt
</Button>
<Button
className="flex-1"
onClick={handleConfirm}
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -5,16 +5,19 @@ 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 { 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'
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 +27,6 @@ interface SwipeCategorizationViewProps {
onClose: () => void
}
const vatTreatmentOptions = VAT_TREATMENT_OPTIONS
const expenseCategories = EXPENSE_CATEGORIES
const incomeCategories = INCOME_CATEGORIES
@@ -35,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)
@@ -47,6 +50,8 @@ export default function SwipeCategorizationView({
const [accountOverride, setAccountOverride] = useState('')
const [vatTreatment, setVatTreatment] = useState<VatTreatment | 'none'>('standard_25')
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [showUploadZone, setShowUploadZone] = useState(false)
// Clear VAT treatment when switching to a liability/equity account (class 2)
useEffect(() => {
@@ -144,6 +149,11 @@ export default function SwipeCategorizationView({
[isProcessing, currentTransaction, handleCategorySelect, x, moveToNext]
)
const resetUploadState = useCallback(() => {
setUploadedFiles([])
setShowUploadZone(false)
}, [])
const handleReviewConfirm = async () => {
if (!pendingCategory) return
@@ -157,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()
@@ -205,8 +240,9 @@ export default function SwipeCategorizationView({
setShowCategorySelect(false)
setShowReviewStep(false)
setPendingCategory(null)
resetUploadState()
moveToNext()
}, [moveToNext])
}, [moveToNext, resetUploadState])
if (!currentTransaction) {
return (
@@ -346,30 +382,52 @@ export default function SwipeCategorizationView({
<div>
<label className="text-sm font-medium text-muted-foreground">Momsbehandling</label>
<div className="mt-1">
<Select
<VatTreatmentSelect
value={isLiabilityAccount ? 'none' : vatTreatment}
onValueChange={(v) => setVatTreatment(v as VatTreatment | 'none')}
onValueChange={setVatTreatment}
disabled={isLiabilityAccount}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{vatTreatmentOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
/>
{isLiabilityAccount && (
<p className="text-xs text-muted-foreground mt-1">
Ingen moms för skuld-/eget kapital-konton
Ingen moms for skuld-/eget kapital-konton
</p>
)}
</div>
</div>
{/* Document upload */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm">
{error}
@@ -385,7 +443,7 @@ export default function SwipeCategorizationView({
disabled={isProcessing || !accountOverride}
>
<Check className="mr-2 h-4 w-4" />
{isProcessing ? 'Bokför...' : 'Bokför'}
{isProcessing ? 'Bokfor...' : 'Bokfor'}
</Button>
<Button
variant="ghost"
@@ -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<UploadedFile[]>([])
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={(o) => {
if (!o) {
setUploadedFiles([])
setShowUploadZone(false)
}
onOpenChange(o)
}}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bokför transaktion</DialogTitle>
<DialogTitle>Bokfor transaktion</DialogTitle>
<DialogDescription>
Skapa en verifikation för transaktionen
Skapa en verifikation for transaktionen
</DialogDescription>
</DialogHeader>
@@ -77,6 +124,39 @@ export default function TransactionBookingDialog({
</p>
</div>
{/* Document upload section */}
<div className="rounded-lg border">
<button
type="button"
onClick={() => setShowUploadZone(!showUploadZone)}
className="flex items-center justify-between w-full px-3 py-2.5 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Underlag (valfritt)</span>
{uploadedFiles.filter((f) => f.status === 'uploaded').length > 0 && (
<span className="text-xs text-muted-foreground">
{uploadedFiles.filter((f) => f.status === 'uploaded').length} bifogade
</span>
)}
</div>
{showUploadZone ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
{showUploadZone && (
<div className="px-3 pb-3">
<DocumentUploadZone
files={uploadedFiles}
onFilesChange={setUploadedFiles}
compact
/>
</div>
)}
</div>
<JournalEntryForm
key={transaction.id}
embedded
@@ -86,7 +166,7 @@ export default function TransactionBookingDialog({
submitUrl={`/api/transactions/${transaction.id}/book`}
sourceType="bank_transaction"
sourceId={transaction.id}
onEntryCreated={(entryId) => onBooked(transaction.id, entryId)}
onEntryCreated={(entryId) => handleBooked(transaction.id, entryId)}
/>
</DialogContent>
</Dialog>
@@ -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 */}
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-muted-foreground"
onClick={() => onMarkPrivate(transaction.id)}
disabled={isProcessing || isDisabled}
>
Privat
</Button>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-muted-foreground"
onClick={() => onMarkPrivate(transaction.id)}
disabled={isProcessing || isDisabled}
>
Privat
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[260px]">
<p className="text-sm leading-relaxed">
{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)'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Open category dialog */}
<Button
@@ -0,0 +1,57 @@
'use client'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check } from 'lucide-react'
import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { VAT_TREATMENT_OPTIONS } from './transaction-types'
import type { VatTreatment } from '@/types'
interface VatTreatmentSelectProps {
value: VatTreatment | 'none'
onValueChange: (value: VatTreatment | 'none') => void
disabled?: boolean
}
export default function VatTreatmentSelect({
value,
onValueChange,
disabled,
}: VatTreatmentSelectProps) {
return (
<Select
value={value}
onValueChange={(v) => onValueChange(v as VatTreatment | 'none')}
disabled={disabled}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VAT_TREATMENT_OPTIONS.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className={cn(
'relative flex w-full cursor-default select-none items-start rounded-md py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary focus:text-secondary-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50'
)}
>
<span className="absolute left-2 top-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4 text-primary" />
</SelectPrimitive.ItemIndicator>
</span>
<div>
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
{opt.description && (
<p className="text-xs text-muted-foreground mt-0.5 font-normal">
{opt.description}
</p>
)}
</div>
</SelectPrimitive.Item>
))}
</SelectContent>
</Select>
)
}
+15 -8
View File
@@ -10,13 +10,14 @@ export type ViewMode = 'inbox' | 'history'
export type HistoryFilter = 'all' | 'business' | 'private'
// Handler types
// Returns the journal_entry_id on success, null on failure
export type CategorizeHandler = (
id: string,
isBusiness: boolean,
category?: TransactionCategory,
vatTreatment?: VatTreatment,
accountOverride?: string
) => Promise<boolean>
) => Promise<string | null>
export type MatchInvoiceHandler = (
transactionId: string,
@@ -50,12 +51,18 @@ export const INCOME_CATEGORIES: CategoryOption[] = [
{ value: 'income_other', label: 'Övrigt' },
]
export const VAT_TREATMENT_OPTIONS: { value: VatTreatment | 'none'; label: string }[] = [
export interface VatTreatmentOption {
value: VatTreatment | 'none'
label: string
description?: string
}
export const VAT_TREATMENT_OPTIONS: VatTreatmentOption[] = [
{ value: 'standard_25', label: 'Moms 25%' },
{ value: 'reduced_12', label: 'Moms 12%' },
{ value: 'reduced_6', label: 'Moms 6%' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet' },
{ value: 'export', label: 'Export' },
{ value: 'exempt', label: 'Momsfri' },
{ value: 'none', label: 'Ingen moms' },
{ value: 'reduced_12', label: 'Moms 12%', description: 'Livsmedel, hotell, camping' },
{ value: 'reduced_6', label: 'Moms 6%', description: 'Böcker, tidningar, kollektivtrafik' },
{ value: 'reverse_charge', label: 'Omvänd skattskyldighet', description: 'Köparen redovisar momsen (EU-tjänster m.m.)' },
{ value: 'export', label: 'Export', description: 'Försäljning utanför EU (behåller avdragsrätt)' },
{ value: 'exempt', label: 'Momsfri', description: 'Undantaget enligt ML (vård, utbildning, finans)' },
{ value: 'none', label: 'Ingen moms', description: 'Ej momspliktigt (t.ex. lön, privata uttag)' },
]
@@ -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<void>
}
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 (
<Dialog
open={open}
onOpenChange={(v) => {
if (isLoading) return
onOpenChange(v)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-start gap-4">
<div
className={cn(
'flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full',
variant === 'destructive'
? 'bg-red-100 text-red-600'
: 'bg-amber-100 text-amber-600'
)}
>
<AlertTriangle className="h-5 w-5" />
</div>
<div className="space-y-1">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{cancelLabel}
</Button>
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={isLoading}
className={
variant === 'warning'
? 'bg-amber-600 hover:bg-amber-700 text-white'
: undefined
}
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface ConfirmOptions {
title: string
description: string
confirmLabel?: string
cancelLabel?: string
variant?: 'destructive' | 'warning'
}
interface UseDestructiveConfirmReturn {
dialogProps: DestructiveConfirmDialogProps
confirm: (options: ConfirmOptions) => Promise<boolean>
}
/**
* Hook that returns a `confirm()` function as a drop-in replacement for `window.confirm()`.
* Returns `Promise<boolean>` — 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 <><DestructiveConfirmDialog {...dialogProps} /></>
* ```
*/
export function useDestructiveConfirm(): UseDestructiveConfirmReturn {
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
description: '',
})
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setOpen(true)
return new Promise<boolean>((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,
}
}
@@ -10,6 +10,7 @@
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import { BOOKING_TEMPLATES, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import type { TransactionCategory, EntityType } from '@/types'
// ============================================================
@@ -39,6 +40,7 @@ export interface CategorizationSuggestion {
confidence: number
reasoning: string
isPrivate: boolean
templateId?: string
}
export interface CategorizationProvider {
@@ -73,6 +75,17 @@ function getCategoryAccountMap(entityType: EntityType): Record<string, { account
}
}
/**
* Build an abbreviated template reference for the AI prompt.
* Filters by direction (expense/income) to keep prompt concise.
*/
function getTemplateReference(direction: 'expense' | 'income'): string {
return BOOKING_TEMPLATES
.filter((t) => t.direction === direction || t.direction === 'transfer')
.map((t) => `${t.id}: ${t.name_sv} → ${t.debit_account}/${t.credit_account}`)
.join('\n')
}
const NON_DEDUCTIBLE_RULES = `
ICKE-AVDRAGSGILLA KOSTNADER (svensk skatterätt):
- Kläder: Normalt inte avdragsgilla (RÅ 1988 ref. 35)
@@ -112,10 +125,21 @@ export class AnthropicCategorizationProvider implements CategorizationProvider {
const privateAccount = context.entityType === 'aktiebolag' ? '2893' : '2013'
const categoryAccountMap = getCategoryAccountMap(context.entityType)
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt kategori och BAS-konto.
// Build template references based on batch direction (most transactions will be same direction)
const hasExpenses = batch.some((t) => t.amount < 0)
const hasIncome = batch.some((t) => t.amount > 0)
const templateRef = [
hasExpenses ? `UTGIFTSMALLAR:\n${getTemplateReference('expense')}` : '',
hasIncome ? `INTÄKTSMALLAR:\n${getTemplateReference('income')}` : '',
].filter(Boolean).join('\n\n')
KATEGORIER OCH BAS-KONTON:
const systemPrompt = `Du är expert på svensk bokföring och kategorisering av banktransaktioner enligt BAS-kontoplanen.
Din uppgift är att kategorisera varje transaktion till rätt mall-ID (templateId) och BAS-konto.
BOKFÖRINGSMALLAR (id: namn → debitkonto/kreditkonto):
${templateRef}
KATEGORIER (fallback om ingen mall matchar):
${Object.entries(categoryAccountMap)
.map(([cat, info]) => `- ${cat}: ${info.account} (${info.label})`)
.join('\n')}
@@ -136,7 +160,8 @@ REGLER:
3. Ange confidence 0.0-1.0 baserat på hur säker du är
4. Ange kort reasoning på svenska
5. Om en transaktion liknar privat konsumtion (kläder, gym, etc.), sätt category: "private"
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata`
6. taxCode: "MPI" för avdragsgilla affärskostnader med moms, "MP1" för intäkter med moms, null för momsfria/privata
7. Ange templateId om en bokföringsmall matchar (föredra mallar framför generiska kategorier)`
const historyContext =
context.recentHistory.length > 0
@@ -168,6 +193,7 @@ Returnera ett JSON-objekt med följande struktur:
{
"transactionId": "id",
"category": "expense_software",
"templateId": "it_saas_subscription",
"basAccount": "5420",
"taxCode": "MPI",
"confidence": 0.9,
@@ -265,6 +291,7 @@ Returnera ENDAST JSON-objektet, ingen annan text.`
confidence: Math.max(0, Math.min(1, Number(s.confidence) || 0.5)),
reasoning: (s.reasoning as string) || '',
isPrivate: category === 'private' || Boolean(s.isPrivate),
templateId: (s.templateId as string) || undefined,
}
})
}
@@ -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<PushNotificationSetti
const { data } = await supabase
.from('notification_settings')
.select(
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled'
'period_locked_enabled, period_year_closed_enabled, invoice_sent_enabled, receipt_extracted_enabled, receipt_matched_enabled, missing_underlag_enabled'
)
.eq('user_id', userId)
.single()
@@ -66,6 +68,7 @@ export async function getSettings(userId: string): Promise<PushNotificationSetti
invoiceSentEnabled: data.invoice_sent_enabled ?? DEFAULT_SETTINGS.invoiceSentEnabled,
receiptExtractedEnabled: data.receipt_extracted_enabled ?? DEFAULT_SETTINGS.receiptExtractedEnabled,
receiptMatchedEnabled: data.receipt_matched_enabled ?? DEFAULT_SETTINGS.receiptMatchedEnabled,
missingUnderlagEnabled: data.missing_underlag_enabled ?? DEFAULT_SETTINGS.missingUnderlagEnabled,
}
}
@@ -89,6 +92,7 @@ export async function saveSettings(
invoice_sent_enabled: merged.invoiceSentEnabled,
receipt_extracted_enabled: merged.receiptExtractedEnabled,
receipt_matched_enabled: merged.receiptMatchedEnabled,
missing_underlag_enabled: merged.missingUnderlagEnabled,
},
{ onConflict: 'user_id' }
)
@@ -16,6 +16,7 @@ import {
createTaxDeadlinePayload,
createInvoiceOverduePayload,
createInvoiceDuePayload,
createMissingUnderlagPayload,
} from './payload-builders'
/**
@@ -208,3 +209,92 @@ export async function sendInvoiceNotifications(
return { sent, skipped }
}
/**
* Source types that require supporting documents (underlag).
*/
const NEEDS_ATTACHMENT_SOURCE_TYPES = [
'manual',
'bank_transaction',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'import',
]
/**
* Send missing underlag notifications.
* Checks all users for posted journal entries without attached documents.
* Deduplicates via the 'missing-underlag-weekly' tag on the notification payload.
*/
export async function sendMissingUnderlagNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
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<string, number>()
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 }
}
@@ -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)
// ============================================================
+2 -53
View File
@@ -1,53 +1,2 @@
// Push subscription for Web Push API
export interface PushSubscription {
id: string
user_id: string
endpoint: string
p256dh: string
auth: string
user_agent: string | null
is_active: boolean
last_used_at: string | null
created_at: string
}
// Notification settings per user
export interface NotificationSettings {
id: string
user_id: string
tax_deadlines_enabled: boolean
invoice_reminders_enabled: boolean
quiet_start: string // time format "HH:MM"
quiet_end: string // time format "HH:MM"
email_enabled: boolean
push_enabled: boolean
period_locked_enabled: boolean
period_year_closed_enabled: boolean
invoice_sent_enabled: boolean
receipt_extracted_enabled: boolean
receipt_matched_enabled: boolean
created_at: string
updated_at: string
}
// Notification type for logging
export type NotificationType =
| 'tax_deadline'
| 'invoice_due'
| 'invoice_overdue'
| 'period_locked'
| 'period_year_closed'
| 'receipt_extracted'
| 'receipt_matched'
| 'invoice_sent'
// Notification log entry
export interface NotificationLog {
id: string
user_id: string
notification_type: NotificationType
reference_id: string
days_before: number
sent_at: string
delivery_status: 'sent' | 'delivered' | 'failed'
}
// Re-export from canonical source — kept for backwards compatibility with extension-internal imports
export type { PushSubscription, NotificationSettings, NotificationType, NotificationLog } from '@/types'
+15 -171
View File
@@ -1,171 +1,15 @@
import type { TransactionCategory, Transaction } from '@/types'
// Receipt extraction status
export type ReceiptStatus = 'pending' | 'processing' | 'extracted' | 'confirmed' | 'error'
// Receipt record
export interface Receipt {
id: string
user_id: string
// Image storage
image_url: string
image_thumbnail_url: string | null
// Extraction status
status: ReceiptStatus
extraction_confidence: number | null
// Extracted header data
merchant_name: string | null
merchant_org_number: string | null
merchant_vat_number: string | null
receipt_date: string | null
receipt_time: string | null
total_amount: number | null
currency: string
vat_amount: number | null
// Special flags
is_restaurant: boolean
is_systembolaget: boolean
is_foreign_merchant: boolean
// Restaurant representation data
representation_persons: number | null
representation_purpose: string | null
// Transaction matching
matched_transaction_id: string | null
match_confidence: number | null
// Raw extraction data
raw_extraction: ReceiptExtractionResult | null
created_at: string
updated_at: string
// Relations (populated when fetched)
line_items?: ReceiptLineItem[]
matched_transaction?: Transaction
}
// Receipt line item record
export interface ReceiptLineItem {
id: string
receipt_id: string
// Extracted data
description: string
quantity: number
unit_price: number | null
line_total: number
vat_rate: number | null
vat_amount: number | null
// Classification
is_business: boolean | null
category: TransactionCategory | null
bas_account: string | null
// Confidence
extraction_confidence: number | null
suggested_category: string | null
sort_order: number
created_at: string
}
// AI extraction result from Claude Vision
export interface ReceiptExtractionResult {
merchant: {
name: string | null
orgNumber: string | null
vatNumber: string | null
isForeign: boolean
}
receipt: {
date: string | null
time: string | null
currency: string
}
lineItems: ExtractedLineItem[]
totals: {
subtotal: number | null
vatAmount: number | null
total: number | null
}
flags: {
isRestaurant: boolean
isSystembolaget: boolean
isForeignMerchant: boolean
}
confidence: number
}
// Extracted line item from AI
export interface ExtractedLineItem {
description: string
quantity: number
unitPrice: number | null
lineTotal: number
vatRate: number | null
suggestedCategory: string | null
confidence?: number
}
// Match candidate for receipt-to-transaction matching
export interface ReceiptMatchCandidate {
transaction: Transaction
confidence: number
matchReasons: string[]
dateVariance: number
amountVariance: number
}
// Input for creating a receipt
export interface CreateReceiptInput {
image_url: string
image_thumbnail_url?: string
}
// Input for confirming receipt line items
export interface ConfirmReceiptInput {
line_items: ConfirmLineItemInput[]
matched_transaction_id?: string
representation_persons?: number
representation_purpose?: string
}
export interface ConfirmLineItemInput {
id: string
is_business: boolean
category?: TransactionCategory
bas_account?: string
}
// Receipt queue summary
export interface ReceiptQueueSummary {
unmatched_receipts_count: number
unmatched_transactions_count: number
pending_review_count: number
streak_count: number
}
// Camera quality feedback
export interface CameraQualityFeedback {
lightingOk: boolean
distanceOk: boolean
focusOk: boolean
readyToCapture: boolean
message?: string
}
// Swedish labels for receipt status
export const RECEIPT_STATUS_LABELS: Record<ReceiptStatus, string> = {
pending: 'Väntar',
processing: 'Analyserar',
extracted: 'Extraherat',
confirmed: 'Bekräftat',
error: 'Fel'
}
// Re-export from canonical source — kept for backwards compatibility with extension-internal imports
export type {
ReceiptStatus,
Receipt,
ReceiptLineItem,
ReceiptExtractionResult,
ExtractedLineItem,
ReceiptMatchCandidate,
CreateReceiptInput,
ConfirmReceiptInput,
ConfirmLineItemInput,
ReceiptQueueSummary,
CameraQualityFeedback,
} from '@/types'
export { RECEIPT_STATUS_LABELS } from '@/types'
+1 -1
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { NEDeclaration } from '@/types'
import type { NEDeclaration } from '@/extensions/ne-bilaga/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
import type { NEAccountMapping } from '@/types'
import type { NEAccountMapping } from '@/extensions/ne-bilaga/types'
/**
* Helper to check if an account falls into a specific ruta
+5 -3
View File
@@ -1,13 +1,15 @@
import { createClient } from '@/lib/supabase/server'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
NEDeclaration,
NEDeclarationRutor,
NEAccountMapping,
FiscalPeriod,
JournalEntry,
JournalEntryLine,
} from '@/types'
import type {
NEDeclaration,
NEDeclarationRutor,
NEAccountMapping,
} from './types'
/**
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
+1 -1
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Download, AlertCircle } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import type { SRUExportResult, SRUCoverageStats } from '@/types'
import type { SRUExportResult, SRUCoverageStats } from '@/extensions/sru-export/types'
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+1 -1
View File
@@ -1,4 +1,4 @@
import type { NEDeclaration, SRUFile, SRURecord } from '@/types'
import type { NEDeclaration, SRUFile, SRURecord } from '@/extensions/ne-bilaga/types'
/**
* SRU File Generator
+1 -1
View File
@@ -1,4 +1,4 @@
import type { SRUFile, SRURecord } from '@/types'
import type { SRUFile, SRURecord } from '@/extensions/ne-bilaga/types'
import { sruFileToString, validateSRUFile } from './lib/sru-generator'
import type { SRUBalance } from './sru-engine'
File diff suppressed because it is too large Load Diff
+245
View File
@@ -0,0 +1,245 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { validateBody, validateQuery } from '../validate'
// ============================================================
// Helpers
// ============================================================
function createJsonRequest(body: unknown): Request {
return new Request('http://localhost/api/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
function createMalformedRequest(): Request {
return new Request('http://localhost/api/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not valid json {{{',
})
}
function createRequestWithQuery(params: Record<string, string>): Request {
const url = new URL('http://localhost/api/test')
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return new Request(url.toString())
}
const TestSchema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
email: z.string().email().optional(),
})
const QuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
})
// ============================================================
// validateBody
// ============================================================
describe('validateBody', () => {
it('returns success with parsed data on valid input', async () => {
const request = createJsonRequest({ name: 'Alice', age: 30 })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data).toEqual({ name: 'Alice', age: 30 })
}
})
it('returns success with optional fields', async () => {
const request = createJsonRequest({ name: 'Bob', age: 25, email: 'bob@test.com' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.email).toBe('bob@test.com')
}
})
it('returns failure response on invalid body', async () => {
const request = createJsonRequest({ name: '', age: -5 })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(result.response.status).toBe(400)
expect(body.error).toBe('Validation failed')
expect(body.type).toBe('validation_error')
expect(body.errors).toBeInstanceOf(Array)
expect(body.errors.length).toBeGreaterThan(0)
}
})
it('returns field paths in error details', async () => {
const request = createJsonRequest({ name: 'Alice', age: 'not-a-number' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
const ageError = body.errors.find((e: { field: string }) => e.field === 'age')
expect(ageError).toBeDefined()
expect(ageError.code).toBeDefined()
}
})
it('returns error for malformed JSON', async () => {
const request = createMalformedRequest()
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(result.response.status).toBe(400)
expect(body.error).toBe('Invalid JSON in request body')
expect(body.type).toBe('validation_error')
}
})
it('reports all validation errors, not just the first', async () => {
// Missing name and age
const request = createJsonRequest({})
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
expect(body.errors.length).toBeGreaterThanOrEqual(2)
}
})
it('strips unknown fields (Zod default behavior)', async () => {
const request = createJsonRequest({ name: 'Alice', age: 30, secret: 'hidden' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(true)
if (result.success) {
expect((result.data as Record<string, unknown>).secret).toBeUndefined()
}
})
it('rejects invalid email format', async () => {
const request = createJsonRequest({ name: 'Alice', age: 30, email: 'not-email' })
const result = await validateBody(request, TestSchema)
expect(result.success).toBe(false)
})
})
// ============================================================
// validateQuery
// ============================================================
describe('validateQuery', () => {
it('returns success with parsed query params', () => {
const request = createRequestWithQuery({ page: '3', limit: '25' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.page).toBe(3)
expect(result.data.limit).toBe(25)
}
})
it('applies defaults for missing params', () => {
const request = createRequestWithQuery({})
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.page).toBe(1)
expect(result.data.limit).toBe(50)
}
})
it('coerces string values to numbers', () => {
const request = createRequestWithQuery({ page: '10' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(true)
if (result.success) {
expect(typeof result.data.page).toBe('number')
}
})
it('returns failure for invalid query params', () => {
const request = createRequestWithQuery({ page: '0', limit: '200' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = result.response as unknown as { status: number }
expect(result.response.status).toBe(400)
}
})
it('includes error details in response', () => {
const request = createRequestWithQuery({ limit: 'abc' })
const result = validateQuery(request, QuerySchema)
expect(result.success).toBe(false)
if (!result.success) {
// The response is a NextResponse — we verify it's a 400
expect(result.response.status).toBe(400)
}
})
})
// ============================================================
// Integration: validateBody with domain schemas
// ============================================================
describe('validateBody with domain schemas', () => {
// Demonstrates using validateBody with the actual schemas from schemas.ts
// This pattern is what API routes should use
const InvoiceSchema = z.object({
customer_id: z.string().uuid(),
invoice_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
items: z.array(z.object({
description: z.string().min(1),
amount: z.number().positive(),
})).min(1),
})
it('validates a well-formed invoice request', async () => {
const request = createJsonRequest({
customer_id: '550e8400-e29b-41d4-a716-446655440000',
invoice_date: '2025-03-15',
items: [{ description: 'Service', amount: 1000 }],
})
const result = await validateBody(request, InvoiceSchema)
expect(result.success).toBe(true)
})
it('catches nested array validation errors', async () => {
const request = createJsonRequest({
customer_id: '550e8400-e29b-41d4-a716-446655440000',
invoice_date: '2025-03-15',
items: [{ description: '', amount: -1 }],
})
const result = await validateBody(request, InvoiceSchema)
expect(result.success).toBe(false)
if (!result.success) {
const body = await result.response.json()
// Should catch both description and amount errors
expect(body.errors.length).toBeGreaterThanOrEqual(2)
const fields = body.errors.map((e: { field: string }) => e.field)
expect(fields.some((f: string) => f.includes('items'))).toBe(true)
}
})
})
+500
View File
@@ -0,0 +1,500 @@
import { z } from 'zod'
// ============================================================
// Shared primitives
// ============================================================
/** UUID v4 string */
const uuid = z.string().uuid()
/** ISO date string (YYYY-MM-DD) */
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD date format')
/** BAS account number — always a string of 4 digits */
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
/** Positive monetary amount (> 0) */
const positiveAmount = z.number().positive()
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
/** Time string (HH:MM or HH:MM:SS) */
const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format')
// ============================================================
// Enum schemas (matching types/index.ts)
// ============================================================
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
export const CustomerTypeSchema = z.enum([
'individual',
'swedish_business',
'eu_business',
'non_eu_business',
])
export const SupplierTypeSchema = z.enum([
'swedish_business',
'eu_business',
'non_eu_business',
])
export const InvoiceStatusSchema = z.enum([
'draft', 'sent', 'paid', 'overdue', 'cancelled', 'credited',
])
export const InvoiceDocumentTypeSchema = z.enum([
'invoice', 'proforma', 'delivery_note',
])
export const SupplierInvoiceStatusSchema = z.enum([
'registered', 'approved', 'paid', 'partially_paid', 'overdue', 'disputed', 'credited',
])
export const VatTreatmentSchema = z.enum([
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
])
export const AccountingMethodSchema = z.enum(['accrual', 'cash'])
export const CurrencySchema = z.enum(['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK'])
export const TransactionCategorySchema = z.enum([
'income_services',
'income_products',
'income_other',
'expense_equipment',
'expense_software',
'expense_travel',
'expense_office',
'expense_marketing',
'expense_professional_services',
'expense_education',
'expense_bank_fees',
'expense_card_fees',
'expense_currency_exchange',
'expense_other',
'private',
'uncategorized',
])
export const JournalEntrySourceTypeSchema = z.enum([
'manual',
'bank_transaction',
'invoice_created',
'invoice_paid',
'invoice_cash_payment',
'credit_note',
'salary_payment',
'opening_balance',
'year_end',
'storno',
'correction',
'import',
'system',
'supplier_invoice_registered',
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'supplier_credit_note',
])
export const AccountTypeSchema = z.enum([
'asset', 'equity', 'liability', 'revenue', 'expense',
])
export const NormalBalanceSchema = z.enum(['debit', 'credit'])
export const MappingRuleTypeSchema = z.enum([
'mcc_code', 'merchant_name', 'description_pattern', 'amount_threshold', 'combined',
])
export const RiskLevelSchema = z.enum(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH'])
export const DeadlineTypeSchema = z.enum([
'delivery', 'invoicing', 'report', 'tax', 'other',
])
export const DeadlinePrioritySchema = z.enum(['critical', 'important', 'normal'])
export const TaxDeadlineTypeSchema = z.enum([
'moms_monthly',
'moms_quarterly',
'moms_yearly',
'f_skatt',
'arbetsgivardeklaration',
'inkomstdeklaration_ef',
'inkomstdeklaration_ab',
'arsredovisning',
'periodisk_sammanstallning',
'bokslut',
])
export const DeadlineSourceSchema = z.enum(['system', 'user'])
export const MomsPeriodSchema = z.enum(['monthly', 'quarterly', 'yearly'])
export const DocumentUploadSourceSchema = z.enum([
'camera', 'file_upload', 'email', 'e_invoice', 'scan', 'api', 'system',
])
// ============================================================
// Invoice schemas
// ============================================================
export const CreateInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
quantity: z.number().positive('Quantity must be positive'),
unit: z.string().min(1, 'Unit is required'),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
})
export const CreateInvoiceSchema = z.object({
customer_id: uuid,
invoice_date: isoDate,
due_date: isoDate,
currency: CurrencySchema,
document_type: InvoiceDocumentTypeSchema.optional(),
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const CreateCreditNoteSchema = z.object({
credited_invoice_id: uuid,
reason: z.string().optional(),
})
// ============================================================
// Customer schemas
// ============================================================
export const CreateCustomerSchema = z.object({
name: z.string().min(1, 'Customer name is required'),
customer_type: CustomerTypeSchema,
email: z.string().email('Invalid email address').optional(),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
default_payment_terms: z.number().int().positive().optional(),
notes: z.string().optional(),
})
export const UpdateCustomerSchema = CreateCustomerSchema.partial()
// ============================================================
// Supplier schemas
// ============================================================
export const CreateSupplierSchema = z.object({
name: z.string().min(1, 'Supplier name is required'),
supplier_type: SupplierTypeSchema,
email: z.string().email('Invalid email address').optional(),
phone: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
org_number: z.string().optional(),
vat_number: z.string().optional(),
bankgiro: z.string().optional(),
plusgiro: z.string().optional(),
bank_account: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
default_expense_account: accountNumber.optional(),
default_payment_terms: z.number().int().positive().optional(),
default_currency: CurrencySchema.nullable().optional(),
notes: z.string().optional(),
})
export const UpdateSupplierSchema = CreateSupplierSchema.partial()
// ============================================================
// Supplier invoice schemas
// ============================================================
export const CreateSupplierInvoiceItemSchema = z.object({
description: z.string().min(1, 'Item description is required'),
amount: z.number().optional(),
account_number: accountNumber,
vat_rate: z.number().min(0).max(100).optional(),
vat_code: z.string().optional(),
quantity: z.number().optional(),
unit: z.string().optional(),
unit_price: z.number().optional(),
})
export const CreateSupplierInvoiceSchema = z.object({
supplier_id: uuid,
supplier_invoice_number: z.string().min(1, 'Supplier invoice number is required'),
invoice_date: isoDate,
due_date: isoDate,
delivery_date: isoDate.optional(),
currency: CurrencySchema.optional(),
exchange_rate: z.number().positive().optional(),
vat_treatment: VatTreatmentSchema.optional(),
reverse_charge: z.boolean().optional(),
payment_reference: z.string().optional(),
notes: z.string().optional(),
items: z.array(CreateSupplierInvoiceItemSchema).min(1, 'At least one item is required'),
})
export const MarkSupplierInvoicePaidSchema = z.object({
amount: z.number().positive().optional(),
payment_date: isoDate.optional(),
exchange_rate_difference: z.number().optional(),
notes: z.string().optional(),
})
export const UpdateSupplierInvoiceSchema = z.object({
supplier_invoice_number: z.string().min(1).optional(),
invoice_date: isoDate.optional(),
due_date: isoDate.optional(),
delivery_date: isoDate.optional(),
payment_reference: z.string().optional(),
notes: z.string().optional(),
})
// ============================================================
// Journal entry schemas
// ============================================================
export const CreateJournalEntryLineSchema = z.object({
account_number: accountNumber,
debit_amount: nonNegativeAmount.default(0),
credit_amount: nonNegativeAmount.default(0),
line_description: z.string().optional(),
currency: z.string().optional(),
amount_in_currency: z.number().optional(),
exchange_rate: z.number().positive().optional(),
tax_code: z.string().optional(),
cost_center: z.string().optional(),
project: z.string().optional(),
})
export const CreateJournalEntrySchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1, 'Description is required'),
source_type: JournalEntrySourceTypeSchema.default('manual'),
source_id: z.string().optional(),
voucher_series: z.string().optional(),
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
export const CorrectJournalEntrySchema = z.object({
lines: z.array(CreateJournalEntryLineSchema).min(2, 'At least two lines are required for double-entry'),
})
// ============================================================
// Transaction schemas
// ============================================================
export const CategorizeTransactionSchema = z.object({
is_business: z.boolean(),
category: TransactionCategorySchema.optional(),
template_id: z.string().optional(),
vat_treatment: VatTreatmentSchema.optional(),
account_override: accountNumber.optional(),
})
export const BookTransactionSchema = z.object({
fiscal_period_id: uuid,
entry_date: isoDate,
description: z.string().min(1, 'Description is required'),
lines: z.array(CreateJournalEntryLineSchema).min(1, 'At least one line is required'),
})
export const MatchInvoiceSchema = z.object({
invoice_id: uuid,
})
export const MatchSupplierInvoiceSchema = z.object({
supplier_invoice_id: uuid,
})
// ============================================================
// Settings schemas
// ============================================================
export const UpdateSettingsSchema = z.object({
entity_type: EntityTypeSchema.optional(),
company_name: z.string().optional(),
org_number: z.string().optional(),
address_line1: z.string().optional(),
address_line2: z.string().optional(),
postal_code: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
f_skatt: z.boolean().optional(),
vat_registered: z.boolean().optional(),
vat_number: z.string().optional(),
moms_period: MomsPeriodSchema.nullable().optional(),
fiscal_year_start_month: z.number().int().min(1).max(12).optional(),
preliminary_tax_monthly: z.number().nullable().optional(),
bank_name: z.string().optional(),
clearing_number: z.string().optional(),
account_number: z.string().optional(),
iban: z.string().optional(),
bic: z.string().optional(),
accounting_method: AccountingMethodSchema.optional(),
invoice_prefix: z.string().nullable().optional(),
next_invoice_number: z.number().int().positive().optional(),
invoice_default_days: z.number().int().positive().optional(),
invoice_default_notes: z.string().nullable().optional(),
email: z.string().email().optional(),
pays_salaries: z.boolean().optional(),
sector_slug: z.string().nullable().optional(),
}).refine(
(data) => {
// BFL 3 kap.: Enskild firma must have fiscal year starting January
if (data.entity_type === 'enskild_firma' && data.fiscal_year_start_month !== undefined) {
return data.fiscal_year_start_month === 1
}
return true
},
{
message: 'Enskild firma must have fiscal year starting in January (BFL 3 kap.)',
path: ['fiscal_year_start_month'],
}
)
// ============================================================
// Fiscal period schemas
// ============================================================
export const CreateFiscalPeriodSchema = z.object({
name: z.string().min(1, 'Period name is required'),
period_start: isoDate,
period_end: isoDate,
}).refine(
(data) => data.period_start < data.period_end,
{
message: 'Period start must be before period end',
path: ['period_end'],
}
)
// ============================================================
// Mapping rule schemas
// ============================================================
export const CreateMappingRuleSchema = z.object({
rule_name: z.string().min(1, 'Rule name is required'),
rule_type: MappingRuleTypeSchema,
priority: z.number().int().min(0).optional(),
mcc_codes: z.array(z.string()).optional(),
merchant_pattern: z.string().optional(),
description_pattern: z.string().optional(),
amount_min: z.number().optional(),
amount_max: z.number().optional(),
debit_account: accountNumber,
credit_account: accountNumber,
vat_treatment: z.string().optional(),
risk_level: RiskLevelSchema.optional(),
default_private: z.boolean().optional(),
requires_review: z.boolean().optional(),
confidence_score: z.number().min(0).max(1).optional(),
})
export const EvaluateMappingRulesSchema = z.union([
z.object({ transaction_id: uuid }),
z.object({
description: z.string().optional(),
amount: z.number(),
}).passthrough(),
])
// ============================================================
// Deadline schemas
// ============================================================
export const CreateDeadlineSchema = z.object({
title: z.string().min(1, 'Title is required'),
due_date: isoDate,
due_time: timeString.optional(),
deadline_type: DeadlineTypeSchema,
priority: DeadlinePrioritySchema.optional(),
customer_id: uuid.optional(),
notes: z.string().optional(),
tax_deadline_type: TaxDeadlineTypeSchema.optional(),
tax_period: z.string().optional(),
source: DeadlineSourceSchema.optional(),
linked_report_type: z.string().optional(),
linked_report_period: z.record(z.string(), z.unknown()).optional(),
})
// ============================================================
// Account schemas
// ============================================================
export const CreateAccountSchema = z.object({
account_number: accountNumber,
account_name: z.string().min(1, 'Account name is required'),
account_type: AccountTypeSchema,
normal_balance: NormalBalanceSchema,
plan_type: z.enum(['k1', 'full_bas']).optional(),
description: z.string().optional(),
})
export const UpdateAccountSchema = z.object({
account_name: z.string().min(1).optional(),
is_active: z.boolean().optional(),
description: z.string().nullable().optional(),
default_vat_code: z.string().nullable().optional(),
sru_code: z.string().nullable().optional(),
})
// ============================================================
// Bank reconciliation schemas
// ============================================================
export const BankLinkSchema = z.object({
transaction_id: uuid,
journal_entry_id: uuid,
})
export const BankUnlinkSchema = z.object({
transaction_id: uuid,
})
export const RunReconciliationSchema = z.object({
date_from: isoDate.optional(),
date_to: isoDate.optional(),
dry_run: z.boolean().optional(),
})
// ============================================================
// Report query schemas
// ============================================================
export const VatDeclarationQuerySchema = z.object({
periodType: z.enum(['monthly', 'quarterly', 'yearly']),
year: z.coerce.number().int().min(2000).max(2100),
period: z.coerce.number().int().min(1).max(12),
})
export const ReportPeriodQuerySchema = z.object({
fiscal_period_id: uuid.optional(),
year: z.coerce.number().int().min(2000).max(2100).optional(),
month: z.coerce.number().int().min(1).max(12).optional(),
})
// ============================================================
// Pagination schemas
// ============================================================
export const PaginationQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().nonnegative().default(0),
})
+114
View File
@@ -0,0 +1,114 @@
import { z } from 'zod'
import { NextResponse } from 'next/server'
export interface ValidationSuccess<T> {
success: true
data: T
}
export interface ValidationFailure {
success: false
response: NextResponse
}
export type ValidationResult<T> = ValidationSuccess<T> | ValidationFailure
/**
* Validate a request body against a Zod schema.
*
* Returns `{ success: true, data }` on valid input, or
* `{ success: false, response }` with a 400 NextResponse on failure.
*
* Usage in an API route:
* ```ts
* const result = await validateBody(request, CreateInvoiceSchema)
* if (!result.success) return result.response
* const { data } = result
* ```
*/
export async function validateBody<T>(
request: Request,
schema: z.ZodType<T>,
): Promise<ValidationResult<T>> {
let body: unknown
try {
body = await request.json()
} catch {
return {
success: false,
response: NextResponse.json(
{
error: 'Invalid JSON in request body',
type: 'validation_error',
},
{ status: 400 },
),
}
}
const result = schema.safeParse(body)
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
return {
success: false,
response: NextResponse.json(
{
error: 'Validation failed',
type: 'validation_error',
errors,
},
{ status: 400 },
),
}
}
return { success: true, data: result.data }
}
/**
* Validate query parameters (from URL searchParams) against a Zod schema.
*
* Usage:
* ```ts
* const params = validateQuery(request, VatDeclarationQuerySchema)
* if (!params.success) return params.response
* const { data } = params
* ```
*/
export function validateQuery<T>(
request: Request,
schema: z.ZodType<T>,
): ValidationResult<T> {
const url = new URL(request.url)
const raw = Object.fromEntries(url.searchParams.entries())
const result = schema.safeParse(raw)
if (!result.success) {
const errors = result.error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}))
return {
success: false,
response: NextResponse.json(
{
error: 'Invalid query parameters',
type: 'validation_error',
errors,
},
{ status: 400 },
),
}
}
return { success: true, data: result.data }
}
@@ -0,0 +1,416 @@
import { describe, it, expect } from 'vitest'
import { makeTransaction } from '@/tests/helpers'
import {
BOOKING_TEMPLATES,
getTemplateById,
getTemplatesByGroup,
getTemplatesByMcc,
getTemplateGroups,
searchTemplates,
findMatchingTemplates,
buildMappingResultFromTemplate,
type BookingTemplate,
} from '../booking-templates'
// ============================================================
// Template Data Integrity
// ============================================================
describe('BOOKING_TEMPLATES data integrity', () => {
it('has exactly 100 templates', () => {
expect(BOOKING_TEMPLATES).toHaveLength(100)
})
it('all template IDs are unique', () => {
const ids = BOOKING_TEMPLATES.map((t) => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('all templates have valid required fields', () => {
for (const t of BOOKING_TEMPLATES) {
expect(t.id).toBeTruthy()
expect(t.name_sv).toBeTruthy()
expect(t.name_en).toBeTruthy()
expect(t.group).toBeTruthy()
expect(['expense', 'income', 'transfer']).toContain(t.direction)
expect(['all', 'enskild_firma', 'aktiebolag']).toContain(t.entity_applicability)
expect(t.debit_account).toMatch(/^\d{4}$/)
expect(t.credit_account).toMatch(/^\d{4}$/)
expect(['full', 'non_deductible', 'conditional']).toContain(t.deductibility)
expect(['NONE', 'LOW', 'MEDIUM', 'HIGH', 'VERY_HIGH']).toContain(t.risk_level)
expect(typeof t.requires_review).toBe('boolean')
expect(t.impact_score).toBeGreaterThanOrEqual(1)
expect(t.impact_score).toBeLessThanOrEqual(10)
expect(t.auto_match_confidence).toBeGreaterThanOrEqual(0.5)
expect(t.auto_match_confidence).toBeLessThanOrEqual(1.0)
expect(typeof t.default_private).toBe('boolean')
expect(t.fallback_category).toBeTruthy()
expect(t.description_sv).toBeTruthy()
expect(Array.isArray(t.mcc_codes)).toBe(true)
expect(Array.isArray(t.keywords)).toBe(true)
expect(t.keywords.length).toBeGreaterThan(0)
}
})
it('all AB-specific override accounts are valid 4-digit strings', () => {
for (const t of BOOKING_TEMPLATES) {
if (t.debit_account_ab) {
expect(t.debit_account_ab).toMatch(/^\d{4}$/)
}
if (t.credit_account_ab) {
expect(t.credit_account_ab).toMatch(/^\d{4}$/)
}
}
})
it('vat_rate is consistent with vat_treatment', () => {
for (const t of BOOKING_TEMPLATES) {
if (t.vat_treatment === 'standard_25') {
expect(t.vat_rate).toBe(0.25)
} else if (t.vat_treatment === 'reduced_12') {
expect(t.vat_rate).toBe(0.12)
} else if (t.vat_treatment === 'reduced_6') {
expect(t.vat_rate).toBe(0.06)
} else if (t.vat_treatment === 'reverse_charge' || t.vat_treatment === 'export' || t.vat_treatment === 'exempt' || t.vat_treatment === null) {
expect(t.vat_rate).toBe(0)
}
}
})
})
// ============================================================
// Lookup Functions
// ============================================================
describe('getTemplateById', () => {
it('returns correct template for known ID', () => {
const t = getTemplateById('it_saas_subscription')
expect(t).toBeDefined()
expect(t!.name_sv).toBe('Programvara / SaaS-prenumeration')
expect(t!.debit_account).toBe('5420')
})
it('returns undefined for unknown ID', () => {
expect(getTemplateById('nonexistent')).toBeUndefined()
})
})
describe('getTemplatesByGroup', () => {
it('returns templates for the premises group', () => {
const templates = getTemplatesByGroup('premises')
expect(templates.length).toBeGreaterThan(0)
for (const t of templates) {
expect(t.group).toBe('premises')
}
})
it('returns empty array for non-existent group', () => {
expect(getTemplatesByGroup('nonexistent' as never)).toEqual([])
})
})
describe('getTemplatesByMcc', () => {
it('returns templates for MCC 5541 (fuel)', () => {
const templates = getTemplatesByMcc(5541)
expect(templates.length).toBeGreaterThan(0)
expect(templates.some((t) => t.id === 'vehicle_fuel')).toBe(true)
})
it('returns empty array for unknown MCC', () => {
expect(getTemplatesByMcc(9999)).toEqual([])
})
})
describe('getTemplateGroups', () => {
it('returns all 18 groups', () => {
const groups = getTemplateGroups()
expect(groups).toHaveLength(18)
for (const g of groups) {
expect(g.group).toBeTruthy()
expect(g.label_sv).toBeTruthy()
expect(g.label_en).toBeTruthy()
expect(Array.isArray(g.templates)).toBe(true)
}
})
it('every template is in exactly one group', () => {
const groups = getTemplateGroups()
const allTemplates = groups.flatMap((g) => g.templates)
expect(allTemplates).toHaveLength(100)
})
})
// ============================================================
// Search
// ============================================================
describe('searchTemplates', () => {
it('finds templates by Swedish name', () => {
const results = searchTemplates('lokalhyra')
expect(results.length).toBeGreaterThan(0)
expect(results.some((t) => t.id === 'premises_rent')).toBe(true)
})
it('finds templates by English name', () => {
const results = searchTemplates('software')
expect(results.length).toBeGreaterThan(0)
expect(results.some((t) => t.id === 'it_saas_subscription')).toBe(true)
})
it('finds templates by keywords', () => {
const results = searchTemplates('spotify')
expect(results.length).toBeGreaterThan(0)
})
it('returns empty for empty query', () => {
expect(searchTemplates('')).toEqual([])
})
it('filters by entity type', () => {
const results = searchTemplates('pension', 'enskild_firma')
// Should include EF-specific and 'all', but not AB-only
for (const t of results) {
expect(t.entity_applicability).not.toBe('aktiebolag')
}
})
it('supports multi-token search', () => {
const results = searchTemplates('digital annons')
expect(results.some((t) => t.id === 'marketing_online_ads')).toBe(true)
})
})
// ============================================================
// findMatchingTemplates
// ============================================================
describe('findMatchingTemplates', () => {
it('matches by MCC code with high confidence', () => {
const tx = makeTransaction({
amount: -500,
mcc_code: 5541,
description: 'Gas station',
merchant_name: 'OKQ8',
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeGreaterThan(0)
expect(matches[0].template.id).toBe('vehicle_fuel')
expect(matches[0].confidence).toBeGreaterThan(0.3)
})
it('matches by keywords in description', () => {
const tx = makeTransaction({
amount: -299,
description: 'Google Ads campaign',
merchant_name: 'Google',
})
const matches = findMatchingTemplates(tx)
expect(matches.some((m) => m.template.id === 'marketing_online_ads')).toBe(true)
})
it('returns empty for a transaction with no signals', () => {
const tx = makeTransaction({
amount: -100,
description: 'XYZ123ABC',
mcc_code: null,
merchant_name: null,
})
const matches = findMatchingTemplates(tx)
expect(matches).toEqual([])
})
it('filters by entity type', () => {
const tx = makeTransaction({
amount: -5000,
description: 'Löneutbetalning',
})
const matches = findMatchingTemplates(tx, 'enskild_firma')
// Personnel salary is AB-only, should not appear
for (const m of matches) {
expect(m.template.entity_applicability).not.toBe('aktiebolag')
}
})
it('does not match expense templates for positive amounts', () => {
const tx = makeTransaction({
amount: 1000,
description: 'Bensin okq8',
mcc_code: 5541,
})
const matches = findMatchingTemplates(tx)
// vehicle_fuel is an expense template, should not match positive amount
expect(matches.every((m) => m.template.direction !== 'expense')).toBe(true)
})
it('returns max 5 results', () => {
const tx = makeTransaction({
amount: -100,
description: 'software subscription cloud hosting domain',
mcc_code: 5817,
})
const matches = findMatchingTemplates(tx)
expect(matches.length).toBeLessThanOrEqual(5)
})
it('results are sorted by confidence descending', () => {
const tx = makeTransaction({
amount: -999,
description: 'Google cloud hosting',
mcc_code: 4816,
})
const matches = findMatchingTemplates(tx)
for (let i = 1; i < matches.length; i++) {
expect(matches[i - 1].confidence).toBeGreaterThanOrEqual(matches[i].confidence)
}
})
})
// ============================================================
// buildMappingResultFromTemplate
// ============================================================
describe('buildMappingResultFromTemplate', () => {
const getTemplate = (id: string): BookingTemplate => {
const t = getTemplateById(id)
if (!t) throw new Error(`Template not found: ${id}`)
return t
}
it('produces valid MappingResult for expense with 25% VAT', () => {
const template = getTemplate('it_saas_subscription')
const tx = makeTransaction({ amount: -1250 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('5420')
expect(result.credit_account).toBe('1930')
expect(result.template_id).toBe('it_saas_subscription')
expect(result.rule).toBeNull()
expect(result.confidence).toBe(1.0)
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(250) // 1250 * 0.25 / 1.25 = 250
})
it('produces valid MappingResult for expense with 12% VAT', () => {
const template = getTemplate('travel_hotel')
const tx = makeTransaction({ amount: -1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('5820')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(120) // 1120 * 0.12 / 1.12 = 120
})
it('produces valid MappingResult for expense with 6% VAT', () => {
const template = getTemplate('travel_train')
const tx = makeTransaction({ amount: -530 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2641')
expect(result.vat_lines[0].debit_amount).toBe(30) // 530 * 0.06 / 1.06 = 30
})
it('produces reverse charge lines for EU purchases', () => {
const template = getTemplate('it_saas_eu')
const tx = makeTransaction({ amount: -1000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(2)
// Fiktiv ingående moms
expect(result.vat_lines[0].account_number).toBe('2645')
expect(result.vat_lines[0].debit_amount).toBe(250)
// Fiktiv utgående moms
expect(result.vat_lines[1].account_number).toBe('2614')
expect(result.vat_lines[1].credit_amount).toBe(250)
})
it('produces no VAT lines for exempt expenses', () => {
const template = getTemplate('premises_rent')
const tx = makeTransaction({ amount: -10000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
})
it('produces no VAT lines for non-deductible templates', () => {
const template = getTemplate('private_withdrawal_ef')
const tx = makeTransaction({ amount: -5000 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(0)
expect(result.default_private).toBe(true)
})
it('produces output VAT lines for income with 25% VAT', () => {
const template = getTemplate('revenue_services_25')
const tx = makeTransaction({ amount: 12500 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.debit_account).toBe('1930')
expect(result.credit_account).toBe('3001')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2611')
expect(result.vat_lines[0].credit_amount).toBe(2500) // 12500 * 0.25 / 1.25 = 2500
})
it('produces output VAT lines for income with 12% VAT', () => {
const template = getTemplate('revenue_products_12')
const tx = makeTransaction({ amount: 1120 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2621')
expect(result.vat_lines[0].credit_amount).toBe(120)
})
it('produces output VAT lines for income with 6% VAT', () => {
const template = getTemplate('revenue_products_6')
const tx = makeTransaction({ amount: 1060 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2631')
expect(result.vat_lines[0].credit_amount).toBe(60)
})
it('resolves AB-specific accounts for aktiebolag', () => {
const template = getTemplate('education_course')
const tx = makeTransaction({ amount: -5000 })
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(efResult.debit_account).toBe('6991')
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
expect(abResult.debit_account).toBe('7610')
})
it('resolves AB-specific private account', () => {
const template = getTemplate('private_expense')
const tx = makeTransaction({ amount: -300 })
const efResult = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(efResult.debit_account).toBe('2013')
const abResult = buildMappingResultFromTemplate(template, tx, 'aktiebolag')
expect(abResult.debit_account).toBe('2893')
})
it('includes template_id in the MappingResult', () => {
const template = getTemplate('bank_fees')
const tx = makeTransaction({ amount: -49 })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.template_id).toBe('bank_fees')
expect(result.rule).toBeNull()
})
it('sets description with template name and transaction description', () => {
const template = getTemplate('vehicle_fuel')
const tx = makeTransaction({ amount: -800, description: 'OKQ8 tankstation' })
const result = buildMappingResultFromTemplate(template, tx, 'enskild_firma')
expect(result.description).toBe('Drivmedel: OKQ8 tankstation')
})
})
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -55,7 +55,7 @@ function generatePerRateLines(
// Group items by vat_rate
const rateGroups = new Map<number, { subtotal: number; vatAmount: number }>()
for (const item of items) {
const rate = item.vat_rate ?? 25
const rate = item.vat_rate ?? 0
const group = rateGroups.get(rate) || { subtotal: 0, vatAmount: 0 }
group.subtotal += item.line_total
group.vatAmount += item.vat_amount || 0
+33 -1
View File
@@ -5,11 +5,13 @@ import {
extractNetAmount,
extractVatAmount,
} from './vat-entries'
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
import type {
MappingRule,
MappingResult,
RiskLevel,
Transaction,
EntityType,
VatJournalLine,
} from '@/types'
@@ -28,7 +30,8 @@ const CAPITALIZATION_THRESHOLD = 29400
*/
export async function evaluateMappingRules(
userId: string,
transaction: Transaction
transaction: Transaction,
entityType?: EntityType
): Promise<MappingResult> {
const supabase = await createClient()
@@ -41,6 +44,9 @@ export async function evaluateMappingRules(
.order('priority', { ascending: true })
if (error || !rules || rules.length === 0) {
// Try template-based matching before default fallback
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return templateResult
return getDefaultResult(transaction)
}
@@ -51,9 +57,35 @@ export async function evaluateMappingRules(
}
}
// Try template-based matching before default fallback
const templateResult = evaluateTemplateRules(transaction, entityType)
if (templateResult) return templateResult
return getDefaultResult(transaction)
}
/**
* Evaluate booking templates as a fallback when no DB mapping rule matches.
* Returns the best template match if confidence >= 0.3, otherwise null.
*/
function evaluateTemplateRules(
transaction: Transaction,
entityType?: EntityType
): MappingResult | null {
const matches = findMatchingTemplates(transaction, entityType)
if (matches.length === 0 || matches[0].confidence < 0.3) return null
const best = matches[0]
const result = buildMappingResultFromTemplate(
best.template,
transaction,
entityType || 'enskild_firma'
)
// Override the confidence with the auto-match confidence (not 1.0)
result.confidence = best.confidence
return result
}
/**
* Check if a transaction matches a mapping rule
*/
+3 -2
View File
@@ -5,9 +5,10 @@ const log = createLogger('email')
// Default sender configuration
// Using a fixed From address with dynamic Reply-To
// From: "Företagsnamn via ERP Base" <fakturor@erp-base.se>
// From: "Företagsnamn via ERP Base" <your-from@example.com>
// Reply-To: user's company email from company_settings
const DEFAULT_FROM_EMAIL = 'fakturor@arcim.io'
// Set RESEND_FROM_EMAIL in .env.local to your verified Resend domain
const DEFAULT_FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'noreply@localhost'
// Lazy initialization to avoid errors at build time
let resendClient: Resend | null = null
+200
View File
@@ -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<string, string> = {
'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<number, string> = {
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<ErrorContext, string> = {
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<string, unknown>
// 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<string, string[]>
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<string, unknown>
// 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<string> {
try {
const body = await response.json()
return getErrorMessage(body, { context, statusCode: response.status })
} catch {
return getErrorMessage(null, { context, statusCode: response.status })
}
}
@@ -0,0 +1,128 @@
import { describe, it, expect } from 'vitest'
import {
getStatusLabel,
getStatusVariant,
getConfidenceLabel,
formatExtractionSummary,
} from '../invoice-inbox-utils'
import type { InboxItemStatus } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
describe('getStatusLabel', () => {
const cases: [InboxItemStatus, string][] = [
['pending', 'Väntar'],
['processing', 'Bearbetar'],
['ready', 'Klar'],
['confirmed', 'Bekräftad'],
['rejected', 'Avvisad'],
['error', 'Fel'],
]
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
expect(getStatusLabel(status)).toBe(expected)
})
})
describe('getStatusVariant', () => {
const cases: [InboxItemStatus, string][] = [
['pending', 'secondary'],
['processing', 'default'],
['ready', 'warning'],
['confirmed', 'success'],
['rejected', 'destructive'],
['error', 'destructive'],
]
it.each(cases)('returns "%s" → "%s"', (status, expected) => {
expect(getStatusVariant(status)).toBe(expected)
})
})
describe('getConfidenceLabel', () => {
it('returns unknown for null', () => {
expect(getConfidenceLabel(null)).toEqual({ label: 'Okänd', variant: 'outline' })
})
it('returns high for >= 0.9', () => {
expect(getConfidenceLabel(0.95)).toEqual({ label: 'Hög', variant: 'success' })
expect(getConfidenceLabel(0.9)).toEqual({ label: 'Hög', variant: 'success' })
})
it('returns medium for 0.7-0.89', () => {
expect(getConfidenceLabel(0.85)).toEqual({ label: 'Medium', variant: 'warning' })
expect(getConfidenceLabel(0.7)).toEqual({ label: 'Medium', variant: 'warning' })
})
it('returns low for < 0.7', () => {
expect(getConfidenceLabel(0.5)).toEqual({ label: 'Låg', variant: 'destructive' })
expect(getConfidenceLabel(0.0)).toEqual({ label: 'Låg', variant: 'destructive' })
})
})
describe('formatExtractionSummary', () => {
it('handles null data', () => {
expect(formatExtractionSummary(null)).toEqual({
supplierName: '',
total: 0,
lineCount: 0,
})
})
it('handles undefined data', () => {
expect(formatExtractionSummary(undefined)).toEqual({
supplierName: '',
total: 0,
lineCount: 0,
})
})
it('extracts summary from complete data', () => {
const data: InvoiceExtractionResult = {
supplier: {
name: 'Acme AB',
orgNumber: '556123-4567',
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
},
invoice: {
invoiceNumber: 'INV-001',
invoiceDate: '2025-01-15',
dueDate: '2025-02-15',
paymentReference: null,
currency: 'SEK',
},
lineItems: [
{ description: 'Item 1', quantity: 1, unitPrice: 100, lineTotal: 100, vatRate: 25, accountSuggestion: null },
{ description: 'Item 2', quantity: 2, unitPrice: 50, lineTotal: 100, vatRate: 25, accountSuggestion: null },
],
totals: { subtotal: 200, vatAmount: 50, total: 250 },
vatBreakdown: [{ rate: 25, base: 200, amount: 50 }],
confidence: 0.92,
}
expect(formatExtractionSummary(data)).toEqual({
supplierName: 'Acme AB',
total: 250,
lineCount: 2,
})
})
it('handles null supplier name', () => {
const data: InvoiceExtractionResult = {
supplier: { name: null, orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: null, invoiceDate: null, dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
vatBreakdown: [],
confidence: 0.5,
}
expect(formatExtractionSummary(data)).toEqual({
supplierName: '',
total: 0,
lineCount: 0,
})
})
})
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'fs'
import { resolve } from 'path'
describe('type decoupling', () => {
it('types/index.ts must not import from @/extensions/', () => {
const typesPath = resolve(__dirname, '../../../types/index.ts')
const content = readFileSync(typesPath, 'utf-8')
const extensionImports = content
.split('\n')
.filter((line) => /from\s+['"]@\/extensions\//.test(line))
expect(extensionImports).toEqual([])
})
})
+54
View File
@@ -0,0 +1,54 @@
import type { InboxItemStatus } from '@/types'
import type { InvoiceExtractionResult } from '@/extensions/general/invoice-inbox/types'
const STATUS_LABELS: Record<InboxItemStatus, string> = {
pending: 'Väntar',
processing: 'Bearbetar',
ready: 'Klar',
confirmed: 'Bekräftad',
rejected: 'Avvisad',
error: 'Fel',
}
export function getStatusLabel(status: InboxItemStatus): string {
return STATUS_LABELS[status] ?? status
}
const STATUS_VARIANTS: Record<InboxItemStatus, string> = {
pending: 'secondary',
processing: 'default',
ready: 'warning',
confirmed: 'success',
rejected: 'destructive',
error: 'destructive',
}
export function getStatusVariant(status: InboxItemStatus): string {
return STATUS_VARIANTS[status] ?? 'outline'
}
export function getConfidenceLabel(confidence: number | null): { label: string; variant: string } {
if (confidence == null) {
return { label: 'Okänd', variant: 'outline' }
}
if (confidence >= 0.9) {
return { label: 'Hög', variant: 'success' }
}
if (confidence >= 0.7) {
return { label: 'Medium', variant: 'warning' }
}
return { label: 'Låg', variant: 'destructive' }
}
export function formatExtractionSummary(
data: InvoiceExtractionResult | null | undefined
): { supplierName: string; total: number; lineCount: number } {
if (!data) {
return { supplierName: '', total: 0, lineCount: 0 }
}
return {
supplierName: data.supplier?.name ?? '',
total: data.totals?.total ?? 0,
lineCount: data.lineItems?.length ?? 0,
}
}
+1
View File
@@ -13,6 +13,7 @@ const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> =
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
'general/invoice-inbox': dynamic(() => import('@/components/extensions/general/InvoiceInboxWorkspace')),
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
// Restaurant
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
+22
View File
@@ -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])
}
+296 -1
View File
@@ -116,6 +116,51 @@ const CAMT053_XML_WITH_STRUCTURED_REF = `<?xml version="1.0" encoding="UTF-8"?>
</BkToCstmrStmt>
</Document>`
const LANSFORSAKRINGAR_CSV = [
'"Datum";"Bokföringsdag";"Typ";"Text";"Belopp";"Saldo"',
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
'"2024-01-13";"2024-01-13";"Insättning";"LÖNEUTBETALNING";"25 000,00";"12 877,17"',
].join('\n')
const LANSFORSAKRINGAR_CSV_NO_HEADER = [
'"2024-01-15";"2024-01-15";"Kortköp";"SPOTIFY AB";"-99,00";"12 345,67"',
'"2024-01-14";"2024-01-14";"Kortköp";"ICA MAXI";"-432,50";"12 444,67"',
].join('\n')
const ICA_BANKEN_CSV = [
'Kontonamn: Lönekonto',
'Kontonummer: 1234 567 890',
'Saldo: 12 877,17',
'Tillgängligt belopp: 12 877,17',
'Period: 2024-01-01 - 2024-01-31',
'Exporterad: 2024-02-01',
'Datum;Text;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;ICA MAXI LINDHAGEN;-432,50;12444,67',
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SKANDIA_CSV = [
'Datum;Beskrivning;Belopp;Saldo',
'2024-01-15;SPOTIFY AB;-99,00;12345,67',
'2024-01-14;HEMKÖP FRIDHEMSPLAN;-432,50;12444,67',
'2024-01-13;LÖNEUTBETALNING;25000,00;12877,17',
].join('\n')
const SKANDIA_CSV_WITH_BANKKATEGORI = [
'Datum;Beskrivning;Belopp;Saldo;Bankkategori',
'2024-01-15;SPOTIFY AB;-99,00;12345,67;Underhållning',
'2024-01-14;ICA MAXI;-432,50;12444,67;Livsmedel',
].join('\n')
const LUNAR_CSV = [
'Date,Text,Amount,Balance',
'2024-01-15,SPOTIFY AB,"-99,00","12.345,67"',
'2024-01-14,ICA MAXI LINDHAGEN,"-432,50","12.444,67"',
'2024-01-13,LÖNEUTBETALNING,"25.000,00","12.877,17"',
].join('\n')
const UNKNOWN_CSV = [
'id,name,value,timestamp',
'1,Widget A,100,2024-01-15T10:00:00',
@@ -185,6 +230,42 @@ describe('detectFileFormat', () => {
}
})
it('detects Länsförsäkringar CSV from header with "typ" keyword', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV, 'lansforsakringar.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects Länsförsäkringar CSV from two adjacent date fields (no header)', () => {
const format = detectFileFormat(LANSFORSAKRINGAR_CSV_NO_HEADER, 'export.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lansforsakringar')
})
it('detects ICA Banken CSV from metadata rows before header', () => {
const format = detectFileFormat(ICA_BANKEN_CSV, 'ica.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('ica_banken')
})
it('detects Skandia CSV from "beskrivning" header keyword', () => {
const format = detectFileFormat(SKANDIA_CSV, 'skandia.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('skandia')
})
it('detects Skandia CSV from "bankkategori" header keyword', () => {
const format = detectFileFormat(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('skandia')
})
it('detects Lunar CSV from English headers (date, text, amount, balance)', () => {
const format = detectFileFormat(LUNAR_CSV, 'lunar.csv')
expect(format).not.toBeNull()
expect(format!.id).toBe('lunar')
})
it('returns null for unrecognized CSV content', () => {
const format = detectFileFormat(UNKNOWN_CSV, 'data.csv')
expect(format).toBeNull()
@@ -426,6 +507,216 @@ describe('parseBankFile — Handelsbanken format', () => {
})
})
describe('parseBankFile — Länsförsäkringar format', () => {
it('parses semicolon-delimited CSV with quoted fields and comma decimal separator', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.format_name).toBe('Länsförsäkringar')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts with comma decimal and space thousands', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('handles files without a header row (data-only)', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV_NO_HEADER, 'lf.csv')
expect(result.format).toBe('lansforsakringar')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
it('calculates stats correctly', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(LANSFORSAKRINGAR_CSV, 'lf.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — ICA Banken format', () => {
it('parses semicolon-delimited CSV with metadata rows before header', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.format).toBe('ica_banken')
expect(result.format_name).toBe('ICA Banken')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('skips metadata rows and finds the correct header', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
// No transaction should contain metadata text
const descriptions = result.transactions.map((t) => t.description)
expect(descriptions).not.toContain(expect.stringContaining('Kontonamn'))
expect(descriptions).not.toContain(expect.stringContaining('Exporterad'))
})
it('correctly parses amounts with comma decimal separator', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('calculates stats correctly', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(ICA_BANKEN_CSV, 'ica.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — Skandia format', () => {
it('parses semicolon-delimited CSV with comma decimal separator', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.format).toBe('skandia')
expect(result.format_name).toBe('Skandia')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts and descriptions', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[1].description).toBe('HEMKÖP FRIDHEMSPLAN')
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.transactions[0].balance).toBe(12345.67)
})
it('handles files with bankkategori column', () => {
const result = parseBankFile(SKANDIA_CSV_WITH_BANKKATEGORI, 'skandia.csv')
expect(result.format).toBe('skandia')
expect(result.transactions).toHaveLength(2)
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[1].amount).toBe(-432.5)
})
it('calculates stats correctly', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(SKANDIA_CSV, 'skandia.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
})
describe('parseBankFile — Lunar format', () => {
it('parses comma-delimited CSV with English headers', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.format).toBe('lunar')
expect(result.format_name).toBe('Lunar')
expect(result.transactions).toHaveLength(3)
expect(result.issues).toHaveLength(0)
})
it('correctly parses amounts with comma decimal and period thousand separator', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.transactions[0].amount).toBe(-99)
expect(result.transactions[0].description).toBe('SPOTIFY AB')
expect(result.transactions[0].date).toBe('2024-01-15')
expect(result.transactions[1].amount).toBe(-432.5)
expect(result.transactions[2].amount).toBe(25000)
})
it('parses balance field with period thousand separator', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.transactions[0].balance).toBe(12345.67)
expect(result.transactions[2].balance).toBe(12877.17)
})
it('calculates stats correctly', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.stats.total_income).toBe(25000)
expect(result.stats.total_expenses).toBe(-531.5)
expect(result.stats.parsed_rows).toBe(3)
})
it('extracts correct date range', () => {
const result = parseBankFile(LUNAR_CSV, 'lunar.csv')
expect(result.date_from).toBe('2024-01-13')
expect(result.date_to).toBe('2024-01-15')
})
it('does not confuse Lunar (English) with Nordea (Swedish) headers', () => {
// Nordea has Swedish headers, Lunar has English
const nordeaResult = detectFileFormat(NORDEA_CSV, 'test.csv')
const lunarResult = detectFileFormat(LUNAR_CSV, 'test.csv')
expect(nordeaResult!.id).toBe('nordea')
expect(lunarResult!.id).toBe('lunar')
})
})
describe('parseBankFile — camt.053 XML format', () => {
it('parses XML with credit and debit entries', () => {
const result = parseBankFile(CAMT053_XML, 'statement.xml')
@@ -850,13 +1141,17 @@ describe('getFormat and getAllFormats', () => {
it('getAllFormats returns all registered formats', () => {
const formats = getAllFormats()
expect(formats.length).toBeGreaterThanOrEqual(6)
expect(formats.length).toBeGreaterThanOrEqual(10)
const ids = formats.map((f) => f.id)
expect(ids).toContain('nordea')
expect(ids).toContain('seb')
expect(ids).toContain('swedbank')
expect(ids).toContain('handelsbanken')
expect(ids).toContain('lansforsakringar')
expect(ids).toContain('ica_banken')
expect(ids).toContain('skandia')
expect(ids).toContain('lunar')
expect(ids).toContain('camt053')
expect(ids).toContain('generic_csv')
})
+187
View File
@@ -0,0 +1,187 @@
/**
* ICA Banken CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Datum, Text, Belopp, Saldo
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - ~6 metadata rows before the actual data (account info, period, etc.)
* - Header row contains "datum" and "belopp"
* - Must skip metadata lines to find the real header
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
/**
* Check if a line looks like the ICA Banken data header.
* ICA Banken header: semicolon-delimited with "datum", "text", "belopp", "saldo"
*/
function isICAHeader(line: string): boolean {
const lower = line.toLowerCase().replace(/"/g, '')
if (!lower.includes(';')) return false
const fields = lower.split(';').map((f) => f.trim())
return (
fields.some((f) => f === 'datum') &&
fields.some((f) => f === 'belopp') &&
fields.some((f) => f === 'text')
)
}
/**
* Detect ICA Banken format: semicolon-delimited file with metadata lines
* before a header containing "datum" and "belopp".
*/
function detectICABanken(lines: string[]): boolean {
// Look for a header row within the first ~10 lines (skipping metadata)
let metadataCount = 0
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (isICAHeader(lines[i])) {
// Must have at least 2 metadata rows before header to distinguish from
// other semicolon-delimited formats (SEB, Handelsbanken, LF)
return metadataCount >= 2
}
metadataCount++
}
return false
}
export const icaBankenFormat: BankFileFormat = {
id: 'ica_banken',
name: 'ICA Banken',
description: 'ICA Banken CSV (semicolon-delimited, metadata rows before header)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
return detectICABanken(lines)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Find the header row
let headerLineIdx = -1
for (let i = 0; i < Math.min(lines.length, 10); i++) {
if (isICAHeader(lines[i])) {
headerLineIdx = i
break
}
}
if (headerLineIdx === -1) {
issues.push({
row: 1,
message: 'Could not find ICA Banken header row',
severity: 'error',
})
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
// Parse header columns
const headers = lines[headerLineIdx].split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'datum')
const descIdx = headers.findIndex((h) => h === 'text')
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: headerLineIdx + 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = headerLineIdx + 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'ica_banken',
format_name: 'ICA Banken',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - headerLineIdx - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
@@ -0,0 +1,168 @@
/**
* Länsförsäkringar CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator, double-quoted fields
* Columns: Datum, Bokföringsdag, Typ, Text, Belopp, (Saldo optional)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Fields are double-quoted
* - Two adjacent date columns (Datum + Bokföringsdag) is unique to Länsförsäkringar
* - No guaranteed header row — detect by structure
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
/**
* Check if a line has the Länsförsäkringar structure:
* two adjacent YYYY-MM-DD date fields in a semicolon-delimited, quoted row.
*/
function isLFRow(line: string): boolean {
if (!line.includes(';')) return false
const fields = parseCSVLine(line, ';').map((f) => f.trim())
return fields.length >= 5 && DATE_RE.test(fields[0]) && DATE_RE.test(fields[1])
}
/**
* Check if a line looks like a Länsförsäkringar header row.
*/
function isLFHeader(line: string): boolean {
const lower = line.toLowerCase().replace(/"/g, '')
return (
lower.includes(';') &&
lower.includes('datum') &&
lower.includes('typ') &&
lower.includes('belopp')
)
}
export const lansforsakringarFormat: BankFileFormat = {
id: 'lansforsakringar',
name: 'Länsförsäkringar',
description: 'Länsförsäkringar CSV (semicolon-delimited, quoted fields)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((l) => l.trim() !== '')
if (lines.length < 1) return false
// Check for header with "typ" keyword (unique to LF among semicolon formats)
if (isLFHeader(lines[0])) return true
// Alternatively: detect data rows with two adjacent date fields
// Check first few non-empty lines for the two-date pattern
for (let i = 0; i < Math.min(lines.length, 3); i++) {
if (isLFRow(lines[i])) return true
}
return false
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Determine if first row is a header or data
let startIdx = 0
let dateIdx = 0
let descIdx = 3
let amountIdx = 4
let balanceIdx = 5
if (isLFHeader(lines[0])) {
// Parse header to find column indices
const headers = parseCSVLine(lines[0], ';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
dateIdx = headers.findIndex((h) => h === 'datum')
if (dateIdx === -1) dateIdx = 0
descIdx = headers.findIndex((h) => h === 'text' || h === 'beskrivning')
if (descIdx === -1) descIdx = 3
amountIdx = headers.findIndex((h) => h === 'belopp')
if (amountIdx === -1) amountIdx = 4
balanceIdx = headers.findIndex((h) => h === 'saldo')
startIdx = 1
}
for (let i = startIdx; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ';').map((f) => f.trim().replace(/^"|"$/g, ''))
if (fields.length < 5) {
issues.push({ row: i + 1, message: 'Too few columns', severity: 'warning' })
skippedRows++
continue
}
const date = fields[dateIdx]
const description = fields[descIdx] || 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 && balanceIdx < fields.length ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!DATE_RE.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: description.trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
const totalDataRows = lines.length - startIdx
return {
format: 'lansforsakringar',
format_name: 'Länsförsäkringar',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: totalDataRows,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+142
View File
@@ -0,0 +1,142 @@
/**
* Lunar CSV format parser
*
* Format: Comma-delimited, comma decimal separator (amounts are quoted)
* Columns: Date, Text, Amount, Balance (English headers)
* Date format: YYYY-MM-DD
* Encoding: UTF-8
*
* Notes:
* - English headers distinguish Lunar from Nordea (Swedish headers)
* - Amounts use comma as decimal separator but are quoted since the file
* delimiter is also comma
* - Thousand separator is period (e.g. "1.234,56")
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
import { parseCSVLine } from './nordea'
function parseLunarAmount(value: string): number {
// Lunar format: "1.234,56" or "-1.234,56"
// Remove period (thousand separator), replace comma (decimal separator) with period
const cleaned = value.replace(/\./g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const lunarFormat: BankFileFormat = {
id: 'lunar',
name: 'Lunar',
description: 'Lunar CSV (comma-delimited, English headers)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// Lunar: comma-delimited with English headers
// Must NOT contain semicolons, must have "date", "text", "amount", "balance"
return (
!firstLine.includes(';') &&
firstLine.includes('date') &&
firstLine.includes('text') &&
firstLine.includes('amount') &&
firstLine.includes('balance')
)
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = parseCSVLine(headerLine, ',').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'date')
const descIdx = headers.findIndex((h) => h === 'text')
const amountIdx = headers.findIndex((h) => h === 'amount')
const balanceIdx = headers.findIndex((h) => h === 'balance')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (date, amount)',
severity: 'error',
})
return {
format: 'lunar',
format_name: 'Lunar',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = parseCSVLine(line, ',').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseLunarAmount(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseLunarAmount(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'lunar',
format_name: 'Lunar',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}
+4 -2
View File
@@ -25,12 +25,14 @@ export const sebFormat: BankFileFormat = {
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase() || ''
// SEB headers contain "bokföringsdag" or "bokforingsdatum" and use semicolons
// SEB headers contain "bokföringsdag"/"bokforingsdatum" AND "valutadag"/"verifikationsnummer"
// The secondary check distinguishes SEB from Länsförsäkringar (which also has "bokföringsdag")
return (
firstLine.includes(';') &&
(firstLine.includes('bokföringsdag') ||
firstLine.includes('bokforingsdatum') ||
firstLine.includes('bokföringsdag'))
firstLine.includes('bokföringsdag')) &&
(firstLine.includes('valutadag') || firstLine.includes('verifikationsnummer'))
)
},
+148
View File
@@ -0,0 +1,148 @@
/**
* Skandia CSV format parser
*
* Format: Semicolon-delimited, comma decimal separator
* Columns: Datum, Beskrivning/Text, Belopp, Saldo (possibly Bankkategori)
* Date format: YYYY-MM-DD
* Encoding: UTF-8 or Windows-1252
*
* Notes:
* - Header contains "beskrivning" (unique keyword not used by SEB/Handelsbanken)
* or "bankkategori" (Skandia-specific column)
*/
import type { BankFileFormat, BankFileParseResult, ParsedBankTransaction, BankFileParseIssue } from '../types'
import { prepareContent } from '../encoding'
function parseCommaDecimal(value: string): number {
const cleaned = value.replace(/\s/g, '').replace(',', '.')
return parseFloat(cleaned)
}
export const skandiaFormat: BankFileFormat = {
id: 'skandia',
name: 'Skandia',
description: 'Skandia CSV (semicolon-delimited)',
fileExtensions: ['.csv', '.txt'],
detect(content: string, _filename: string): boolean {
const prepared = prepareContent(content)
const firstLine = prepared.split('\n')[0]?.toLowerCase().replace(/"/g, '') || ''
if (!firstLine.includes(';')) return false
const fields = firstLine.split(';').map((f) => f.trim())
// "bankkategori" is unique to Skandia
if (fields.some((f) => f.includes('bankkategori'))) return true
// "beskrivning" as a standalone column header with semicolon delimiter
// Must also have "datum" and "belopp" to confirm it's a bank export
// Note: Handelsbanken uses "beskrivning" only as a fallback in descIdx logic,
// but its header detection is "reskontradatum"/"transaktionsdatum" which is checked first
if (
fields.some((f) => f === 'beskrivning') &&
fields.some((f) => f === 'datum') &&
fields.some((f) => f === 'belopp')
) {
return true
}
return false
},
parse(content: string): BankFileParseResult {
const prepared = prepareContent(content)
const lines = prepared.split('\n').filter((line) => line.trim() !== '')
const transactions: ParsedBankTransaction[] = []
const issues: BankFileParseIssue[] = []
let skippedRows = 0
// Parse header
const headerLine = lines[0] || ''
const headers = headerLine.split(';').map((h) => h.trim().toLowerCase().replace(/"/g, ''))
const dateIdx = headers.findIndex((h) => h === 'datum')
const descIdx = headers.findIndex((h) => h === 'beskrivning' || h === 'text')
const amountIdx = headers.findIndex((h) => h === 'belopp')
const balanceIdx = headers.findIndex((h) => h === 'saldo')
if (dateIdx === -1 || amountIdx === -1) {
issues.push({
row: 1,
message: 'Could not identify required columns (datum, belopp)',
severity: 'error',
})
return {
format: 'skandia',
format_name: 'Skandia',
transactions: [],
date_from: null,
date_to: null,
issues,
stats: { total_rows: 0, parsed_rows: 0, skipped_rows: 0, total_income: 0, total_expenses: 0 },
}
}
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const fields = line.split(';').map((f) => f.trim().replace(/^"|"$/g, ''))
const date = fields[dateIdx]
const description = descIdx >= 0 ? fields[descIdx] : 'Unknown'
const amountStr = fields[amountIdx]
const balanceStr = balanceIdx >= 0 ? fields[balanceIdx] : undefined
if (!date || !amountStr) {
skippedRows++
continue
}
const amount = parseCommaDecimal(amountStr)
if (isNaN(amount)) {
issues.push({ row: i + 1, message: `Invalid amount: ${amountStr}`, severity: 'warning' })
skippedRows++
continue
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
issues.push({ row: i + 1, message: `Invalid date: ${date}`, severity: 'warning' })
skippedRows++
continue
}
const balance = balanceStr ? parseCommaDecimal(balanceStr) : null
transactions.push({
date,
description: (description || 'Unknown').trim(),
amount,
currency: 'SEK',
balance: isNaN(balance as number) ? null : balance,
reference: null,
counterparty: null,
raw_line: line,
})
}
const dates = transactions.map((t) => t.date).sort()
return {
format: 'skandia',
format_name: 'Skandia',
transactions,
date_from: dates[0] || null,
date_to: dates[dates.length - 1] || null,
issues,
stats: {
total_rows: lines.length - 1,
parsed_rows: transactions.length,
skipped_rows: skippedRows,
total_income: Math.round(transactions.filter((t) => t.amount > 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
total_expenses: Math.round(transactions.filter((t) => t.amount < 0).reduce((s, t) => s + t.amount, 0) * 100) / 100,
},
}
},
}

Some files were not shown because too many files have changed in this diff Show More