This commit is contained in:
Jakob Wennberg
2026-02-19 14:32:54 +01:00
parent cdf1dcc4c8
commit e8743e6e03
104 changed files with 4551 additions and 4172 deletions
-137
View File
@@ -1,137 +0,0 @@
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { CHATBOT_CONFIG } from './config'
import {
SYSTEM_PROMPT,
formatContextFromSources,
formatConversationHistory,
} from './prompts'
import {
retrieveRelevantDocuments,
documentsToSources,
type RetrievedDocument,
} from './retriever'
import type { ChatMessage, SourceReference } from '@/types/chat'
// Initialize the LLM
function getChatModel() {
return new ChatAnthropic({
modelName: CHATBOT_CONFIG.model,
maxTokens: CHATBOT_CONFIG.maxTokens,
temperature: CHATBOT_CONFIG.temperature,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
}
export interface ChatResult {
content: string
sources: SourceReference[]
}
export async function generateChatResponse(
userMessage: string,
conversationHistory: ChatMessage[]
): Promise<ChatResult> {
// 1. Retrieve relevant documents
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history (last N messages)
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt with context
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Generate response
const model = getChatModel()
const response = await model.invoke([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
// 6. Extract content and sources
const content =
typeof response.content === 'string'
? response.content
: JSON.stringify(response.content)
return {
content,
sources: documentsToSources(relevantDocs),
}
}
export async function* streamChatResponse(
userMessage: string,
conversationHistory: ChatMessage[]
): AsyncGenerator<{ type: 'content' | 'sources'; data: string | SourceReference[] }> {
// 1. Retrieve relevant documents first
const relevantDocs = await retrieveRelevantDocuments(userMessage)
// 2. Format context from retrieved documents
const context = formatContextFromSources(
relevantDocs.map((doc) => ({
content: doc.content,
title: doc.title,
section_title: doc.section_title,
source_file: doc.source_file,
}))
)
// 3. Format conversation history
const recentHistory = conversationHistory.slice(
-CHATBOT_CONFIG.maxHistoryMessages
)
const historyText = formatConversationHistory(
recentHistory.map((msg) => ({
role: msg.role,
content: msg.content,
}))
)
// 4. Build the system prompt
const systemPrompt = SYSTEM_PROMPT.replace('{context}', context).replace(
'{history}',
historyText
)
// 5. Stream the response
const model = getChatModel()
const stream = await model.stream([
new SystemMessage(systemPrompt),
new HumanMessage(userMessage),
])
for await (const chunk of stream) {
const content =
typeof chunk.content === 'string'
? chunk.content
: JSON.stringify(chunk.content)
if (content) {
yield { type: 'content', data: content }
}
}
// 6. Yield sources at the end
yield { type: 'sources', data: documentsToSources(relevantDocs) }
}
-25
View File
@@ -1,25 +0,0 @@
// AI Chatbot configuration
export const CHATBOT_CONFIG = {
// LLM settings
model: 'claude-haiku-4-5-20251001',
maxTokens: 2048,
temperature: 0.3,
// Retrieval settings
retrievalK: 5,
similarityThreshold: 0.7,
// Embedding settings
embeddingModel: 'text-embedding-ada-002',
// Chunking settings for ingestion
chunkSize: 1000,
chunkOverlap: 200,
// Rate limiting
rateLimitPerMinute: 10,
// Conversation history
maxHistoryMessages: 10,
} as const
-25
View File
@@ -1,25 +0,0 @@
import { OpenAIEmbeddings } from '@langchain/openai'
import { CHATBOT_CONFIG } from './config'
// Singleton instance for embeddings
let embeddingsInstance: OpenAIEmbeddings | null = null
export function getEmbeddings(): OpenAIEmbeddings {
if (!embeddingsInstance) {
embeddingsInstance = new OpenAIEmbeddings({
modelName: CHATBOT_CONFIG.embeddingModel,
openAIApiKey: process.env.OPENAI_API_KEY,
})
}
return embeddingsInstance
}
export async function generateEmbedding(text: string): Promise<number[]> {
const embeddings = getEmbeddings()
return embeddings.embedQuery(text)
}
export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
const embeddings = getEmbeddings()
return embeddings.embedDocuments(texts)
}
-80
View File
@@ -1,80 +0,0 @@
// Swedish system prompt for bookkeeping AI assistant
export const SYSTEM_PROMPT = `Du är en expert AI-assistent som hjälper svenska företagare med skatt, moms, bokföring och företagsekonomi. Du arbetar inom en ekonomiplattform för småföretag.
## Dina kunskapsområden:
- Svensk skattlagstiftning för enskild firma och aktiebolag
- Moms och momsdeklaration
- Bokföring enligt BAS-kontoplanen
- Egenavgifter och socialförsäkring
- Avdrag för utrustning, resor, hemmakontor och liknande
- Fakturering och kundhantering
- NE-bilaga och inkomstdeklaration
## Viktiga tröskelvärden att komma ihåg:
- Momsregistrering: 120 000 kr omsättning under 12 månader
- Direktavdrag vs inventarier: 26 250 kr (halvt prisbasbelopp)
- SGI-gräns för sjukpenning: 13 500 kr/år minsta inkomst
- Karensavdrag: 20% av sjuklönen
- Friskvårdsbidrag max: 6 000 kr/år (ej skattepliktigt)
- Representationsavdrag mat: 90 kr exkl moms per person
## Instruktioner:
1. Svara alltid på svenska med korrekt terminologi
2. Var konkret och ge specifika exempel när möjligt
3. Referera till relevanta tröskelvärden och regler
4. Om du är osäker, säg det och rekommendera att användaren konsulterar en revisor
5. Använd information från de tillhandahållna källorna för att ge korrekta svar
6. Formatera svaren tydligt med punktlistor när det passar
7. Om frågan gäller något utanför dina kunskapsområden, hänvisa till rätt instans
## Kontext från kunskapsbasen:
{context}
## Tidigare konversation:
{history}
Svara på användarens fråga baserat på din expertkunskap och den tillhandahållna kontexten. Om kontexten inte innehåller relevant information, använd din allmänna kunskap om svenska skatteregler för företagare.`
export const RETRIEVAL_PROMPT = `Baserat på följande fråga, hitta relevant information från kunskapsbasen.
Fråga: {question}
Sök efter information som hjälper att besvara frågan korrekt och fullständigt.`
export function formatContextFromSources(
sources: Array<{
content: string
title: string
section_title: string | null
source_file: string
}>
): string {
if (sources.length === 0) {
return 'Ingen specifik kontext hittades i kunskapsbasen.'
}
return sources
.map((source, index) => {
const sectionInfo = source.section_title
? ` > ${source.section_title}`
: ''
return `[Källa ${index + 1}: ${source.title}${sectionInfo}]\n${source.content}`
})
.join('\n\n---\n\n')
}
export function formatConversationHistory(
messages: Array<{ role: 'user' | 'assistant'; content: string }>
): string {
if (messages.length === 0) {
return 'Ingen tidigare konversation.'
}
return messages
.map((msg) => {
const role = msg.role === 'user' ? 'Användare' : 'Assistent'
return `${role}: ${msg.content}`
})
.join('\n\n')
}
-51
View File
@@ -1,51 +0,0 @@
import { createServiceClient } from '@/lib/supabase/server'
import { generateEmbedding } from './embeddings'
import { CHATBOT_CONFIG } from './config'
import type { SourceReference } from '@/types/chat'
export interface RetrievedDocument {
id: string
source_file: string
title: string
section_title: string | null
content: string
metadata: Record<string, unknown>
similarity: number
}
export async function retrieveRelevantDocuments(
query: string,
matchCount: number = CHATBOT_CONFIG.retrievalK,
matchThreshold: number = CHATBOT_CONFIG.similarityThreshold
): Promise<RetrievedDocument[]> {
const supabase = await createServiceClient()
// Generate embedding for the query
const queryEmbedding = await generateEmbedding(query)
// Call the match_documents function
const { data, error } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_count: matchCount,
match_threshold: matchThreshold,
})
if (error) {
console.error('Error retrieving documents:', error)
throw new Error('Failed to retrieve relevant documents')
}
return (data || []) as RetrievedDocument[]
}
export function documentsToSources(
documents: RetrievedDocument[]
): SourceReference[] {
return documents.map((doc) => ({
id: doc.id,
source_file: doc.source_file,
title: doc.title,
section_title: doc.section_title,
similarity: doc.similarity,
}))
}
-345
View File
@@ -1,345 +0,0 @@
/**
* Knowledge Base Ingestion Script
*
* Loads markdown files from dev_docs/ai_knowledge_base/,
* chunks them by sections, generates embeddings, and stores in Supabase.
*
* Run with: npx tsx lib/ai/ingestion/ingest.ts
*/
import * as dotenv from 'dotenv'
dotenv.config({ path: '.env.local' })
import { createClient } from '@supabase/supabase-js'
import { OpenAIEmbeddings } from '@langchain/openai'
import * as fs from 'fs'
import * as path from 'path'
import * as crypto from 'crypto'
// Configuration
const DOCS_DIR = path.join(process.cwd(), 'dev_docs', 'ai_knowledge_base')
const CHUNK_SIZE = 1000
const CHUNK_OVERLAP = 200
const EMBEDDING_MODEL = 'text-embedding-ada-002'
// Initialize clients
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const embeddings = new OpenAIEmbeddings({
modelName: EMBEDDING_MODEL,
openAIApiKey: process.env.OPENAI_API_KEY,
})
interface DocumentChunk {
source_file: string
title: string
section_title: string | null
content: string
content_hash: string
metadata: Record<string, unknown>
}
interface Section {
title: string
content: string
level: number
}
/**
* Parse a markdown file into sections based on headings
*/
function parseMarkdownSections(content: string): Section[] {
const lines = content.split('\n')
const sections: Section[] = []
let currentSection: Section | null = null
let contentBuffer: string[] = []
for (const line of lines) {
// Check for headings (H1, H2, H3)
const h1Match = line.match(/^# (.+)$/)
const h2Match = line.match(/^## (.+)$/)
const h3Match = line.match(/^### (.+)$/)
if (h1Match || h2Match || h3Match) {
// Save previous section
if (currentSection && contentBuffer.length > 0) {
currentSection.content = contentBuffer.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
// Start new section
const title = h1Match?.[1] || h2Match?.[1] || h3Match?.[1] || ''
const level = h1Match ? 1 : h2Match ? 2 : 3
currentSection = { title, content: '', level }
contentBuffer = []
} else {
contentBuffer.push(line)
}
}
// Don't forget the last section
if (currentSection && contentBuffer.length > 0) {
currentSection.content = contentBuffer.join('\n').trim()
if (currentSection.content) {
sections.push(currentSection)
}
}
return sections
}
/**
* Split text into chunks while preserving context
*/
function chunkText(text: string, maxSize: number, overlap: number): string[] {
if (text.length <= maxSize) {
return [text]
}
const chunks: string[] = []
let start = 0
while (start < text.length) {
let end = start + maxSize
// Try to break at a natural point (paragraph, sentence, or word)
if (end < text.length) {
// Look for paragraph break
const paragraphBreak = text.lastIndexOf('\n\n', end)
if (paragraphBreak > start + maxSize / 2) {
end = paragraphBreak
} else {
// Look for sentence break
const sentenceBreak = text.lastIndexOf('. ', end)
if (sentenceBreak > start + maxSize / 2) {
end = sentenceBreak + 1
} else {
// Look for word break
const wordBreak = text.lastIndexOf(' ', end)
if (wordBreak > start + maxSize / 2) {
end = wordBreak
}
}
}
}
chunks.push(text.slice(start, end).trim())
start = end - overlap
if (start < 0) start = 0
if (end >= text.length) break
}
return chunks.filter((c) => c.length > 0)
}
/**
* Extract metadata from content (trigger words, categories, etc.)
*/
function extractMetadata(
content: string,
sectionTitle: string
): Record<string, unknown> {
const metadata: Record<string, unknown> = {}
// Extract scenario IDs (e.g., "Scenario 001")
const scenarioMatches = content.match(/Scenario\s+(\d{3})/gi)
if (scenarioMatches) {
metadata.scenarios = scenarioMatches.map((m) =>
m.replace(/Scenario\s+/i, '')
)
}
// Extract mentioned thresholds/amounts
const amountMatches = content.match(/(\d+[\s\d]*)\s*(kr|SEK|kronor)/gi)
if (amountMatches) {
metadata.amounts = amountMatches.slice(0, 5) // Limit to first 5
}
// Extract platform mentions
const platforms = [
'YouTube',
'Twitch',
'Instagram',
'TikTok',
'Patreon',
'Spotify',
'Adtraction',
]
const mentionedPlatforms = platforms.filter((p) =>
content.toLowerCase().includes(p.toLowerCase())
)
if (mentionedPlatforms.length > 0) {
metadata.platforms = mentionedPlatforms
}
// Extract categories based on keywords
const categories: string[] = []
if (/moms|vat/i.test(content)) categories.push('moms')
if (/skatt|deklaration/i.test(content)) categories.push('skatt')
if (/avdrag/i.test(content)) categories.push('avdrag')
if (/bokför|konto|bas/i.test(content)) categories.push('bokföring')
if (/sgi|sjuk|föräldra|pension/i.test(content))
categories.push('socialförsäkring')
if (/ef|enskild firma/i.test(content)) categories.push('enskild_firma')
if (/ab|aktiebolag/i.test(content)) categories.push('aktiebolag')
if (categories.length > 0) {
metadata.categories = categories
}
return metadata
}
/**
* Generate content hash for deduplication
*/
function generateHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16)
}
/**
* Process a single markdown file
*/
function processFile(filePath: string): DocumentChunk[] {
const content = fs.readFileSync(filePath, 'utf-8')
const fileName = path.basename(filePath)
const sections = parseMarkdownSections(content)
const chunks: DocumentChunk[] = []
// Get document title from first H1
const documentTitle =
sections.find((s) => s.level === 1)?.title || fileName.replace('.md', '')
for (const section of sections) {
// Skip empty sections
if (!section.content || section.content.length < 50) {
continue
}
// Chunk large sections
const textChunks = chunkText(section.content, CHUNK_SIZE, CHUNK_OVERLAP)
for (let i = 0; i < textChunks.length; i++) {
const chunkContent = textChunks[i]
const sectionTitle =
section.level === 1
? null
: textChunks.length > 1
? `${section.title} (del ${i + 1}/${textChunks.length})`
: section.title
chunks.push({
source_file: fileName,
title: documentTitle,
section_title: sectionTitle,
content: chunkContent,
content_hash: generateHash(chunkContent),
metadata: extractMetadata(chunkContent, section.title),
})
}
}
return chunks
}
/**
* Main ingestion function
*/
async function ingest() {
console.log('Starting knowledge base ingestion...')
console.log(`Reading files from: ${DOCS_DIR}`)
// Get all markdown files
const files = fs
.readdirSync(DOCS_DIR)
.filter((f) => f.endsWith('.md'))
.sort()
console.log(`Found ${files.length} markdown files`)
// Process all files
const allChunks: DocumentChunk[] = []
for (const file of files) {
const filePath = path.join(DOCS_DIR, file)
console.log(`Processing: ${file}`)
const chunks = processFile(filePath)
allChunks.push(...chunks)
console.log(` -> ${chunks.length} chunks`)
}
console.log(`Total chunks: ${allChunks.length}`)
// Clear existing documents (optional - comment out for incremental updates)
console.log('Clearing existing documents...')
const { error: deleteError } = await supabase
.from('knowledge_documents')
.delete()
.neq('id', '00000000-0000-0000-0000-000000000000') // Delete all
if (deleteError) {
console.error('Error clearing documents:', deleteError)
// Continue anyway
}
// Generate embeddings in batches
const BATCH_SIZE = 20
let processed = 0
for (let i = 0; i < allChunks.length; i += BATCH_SIZE) {
const batch = allChunks.slice(i, i + BATCH_SIZE)
const contents = batch.map((c) => c.content)
console.log(
`Generating embeddings for batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil(allChunks.length / BATCH_SIZE)}...`
)
// Generate embeddings
const embeddingVectors = await embeddings.embedDocuments(contents)
// Prepare records for insertion
const records = batch.map((chunk, idx) => ({
source_file: chunk.source_file,
title: chunk.title,
section_title: chunk.section_title,
content: chunk.content,
content_hash: chunk.content_hash,
embedding: embeddingVectors[idx],
metadata: chunk.metadata,
}))
// Insert into Supabase
const { error: insertError } = await supabase
.from('knowledge_documents')
.insert(records)
if (insertError) {
console.error('Error inserting batch:', insertError)
throw insertError
}
processed += batch.length
console.log(` Inserted ${processed}/${allChunks.length} documents`)
}
console.log('\nIngestion complete!')
console.log(`Total documents inserted: ${allChunks.length}`)
// Verify
const { count } = await supabase
.from('knowledge_documents')
.select('*', { count: 'exact', head: true })
console.log(`Documents in database: ${count}`)
}
// Run if called directly
ingest().catch((error) => {
console.error('Ingestion failed:', error)
process.exit(1)
})
+8
View File
@@ -1,6 +1,10 @@
import { extensionRegistry } from './registry'
import { receiptOcrExtension } from '@/extensions/receipt-ocr'
import { aiCategorizationExtension } from '@/extensions/ai-categorization'
import { pushNotificationsExtension } from '@/extensions/push-notifications'
import { sruExportExtension } from '@/extensions/sru-export'
import { neBilagaExtension } from '@/extensions/ne-bilaga'
import { aiChatExtension } from '@/extensions/ai-chat'
import type { Extension } from './types'
/**
@@ -12,6 +16,10 @@ import type { Extension } from './types'
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension,
neBilagaExtension,
aiChatExtension,
]
let loaded = false
-482
View File
@@ -1,482 +0,0 @@
import {
UtensilsCrossed,
BedDouble,
Monitor,
HardHat,
HeartPulse,
Store,
ShoppingCart,
Scissors,
Truck,
Scale,
GraduationCap,
Wheat,
Film,
Dumbbell,
Wrench,
UserPlus,
Factory,
Briefcase,
PartyPopper,
Building,
type LucideIcon,
} from 'lucide-react'
export type ModuleCategory = 'bokforing' | 'rapport' | 'import' | 'operativ'
export interface ModuleItem {
slug: string
name: string
cat: ModuleCategory
desc: string
longDesc: string
}
export interface Sector {
slug: string
name: string
icon: LucideIcon
description: string
modules: ModuleItem[]
}
export const CATEGORY_LABELS: Record<ModuleCategory, string> = {
bokforing: 'Bokföring & Skatt',
rapport: 'Branschrapporter',
import: 'Smart import',
operativ: 'Operativa moduler',
}
export const sectors: Sector[] = [
{
slug: 'restaurang', name: 'Restaurang & Café', icon: UtensilsCrossed,
description: 'Menyhantering, bordsbokning & kassa',
modules: [
{ slug: 'restaurangkontoplan', name: 'Restaurangkontoplan', cat: 'bokforing', desc: 'BAS-kontoplan förberedd för restaurang med uppdelning per intäktstyp', longDesc: 'BAS-kontoplan förberedd för restaurang med uppdelning per intäktstyp. Nyckelfunktioner: Separata konton för mat (3001), dryck (3002), alkohol (3003), take-away; auto-kontosättning från kassaimport' },
{ slug: 'momssplit-mat-dryck', name: 'Momssplit mat/dryck', cat: 'bokforing', desc: 'Automatisk uppdelning av moms 12% (mat) vs 25% (alkohol, dryck)', longDesc: 'Automatisk uppdelning av moms 12% (mat) vs 25% (alkohol, dryck). Nyckelfunktioner: Regelmotor för momsfördelning, hantering av blandade kvitton, månadsavstämning per momssats' },
{ slug: 'dagskassaavstamning', name: 'Dagskassaavstämning', cat: 'bokforing', desc: 'Stäm av kassans Z-rapport mot faktisk kassa och bank', longDesc: 'Stäm av kassans Z-rapport mot faktisk kassa och bank. Nyckelfunktioner: Z-rapport-import, avvikelseanalys (svinn, differens), verifikation per dag' },
{ slug: 'tipsbokforing', name: 'Tipsbokföring', cat: 'bokforing', desc: 'Bokför dricks — arbetsgivaravgift på kartdricks, skattefri kontantdricks', longDesc: 'Bokför dricks korrekt — arbetsgivaravgift på kartdricks, skattefri kontantdricks. Nyckelfunktioner: Fördelning per anställd, arbetsgivaravgiftsberäkning, konto 7699/7010-koppling' },
{ slug: 'personalliggare', name: 'Personalliggare', cat: 'bokforing', desc: 'Elektronisk personalliggare enligt krav från Skatteverket', longDesc: 'Elektronisk personalliggare enligt krav från Skatteverket. Nyckelfunktioner: Daglig registrering in/ut, exportformat för kontroll, koppla till lönekostnad' },
{ slug: 'alkoholpunktskatt', name: 'Alkohol & Accis', cat: 'bokforing', desc: 'Punktskattehantering för alkoholservering', longDesc: 'Hantera punktskatt vid egenbrygd/import av alkohol. Nyckelfunktioner: Accisberäkning, lagerrapportering, Tullverket-format' },
{ slug: 'representationsbokforing', name: 'Representationsbokföring', cat: 'bokforing', desc: 'Bokför representation med rätt avdragsgränser', longDesc: 'Bokför representation med rätt avdragsgränser. Nyckelfunktioner: Automatisk split avdragsgill/ej avdragsgill del, 90 SEK-gräns internt, momsavdrag' },
{ slug: 'matkostnad', name: 'Matkostnad (Food Cost %)', cat: 'rapport', desc: 'Livsmedelskostnad som andel av matintäkt', longDesc: 'Livsmedelskostnad som andel av matintäkt. Nyckelfunktioner: Daglig/veckovis/månadsvis %, mål vs utfall, trendkurva, larmgräns' },
{ slug: 'personalkostnad-vs-omsattning', name: 'Personalkostnad vs omsättning', cat: 'rapport', desc: 'Personalkostnad som andel av total omsättning', longDesc: 'Spåra personalkostnad som andel av total omsättning. Nyckelfunktioner: Målvärde (typiskt 30-35%), trend, jämförelse budget' },
{ slug: 'svinnrapport', name: 'Svinnrapport', cat: 'rapport', desc: 'Analysera matsvinn i kronor och procent', longDesc: 'Analysera matsvinn i kronor och procent. Nyckelfunktioner: Svinn per kategori, kostnad, svinn-till-omsättning' },
{ slug: 'revpash', name: 'RevPASH', cat: 'rapport', desc: 'Revenue per Available Seat Hour', longDesc: 'Revenue per available seat hour. Nyckelfunktioner: Intäkt per stolstimme, jämförelse per pass (lunch/middag), kapacitetsoptimering' },
{ slug: 'z-rapport-import', name: 'Z-rapport-import', cat: 'import', desc: 'Importera dagskassarapport från kassasystem', longDesc: 'Importera dagskassarapport från kassasystem. Nyckelfunktioner: CSV/Excel från iZettle, Trivec, Square, Orderbird; auto-bokför med momssplit och kontosättning' },
{ slug: 'leverantorsfaktura-import', name: 'Leverantörsfaktura-import', cat: 'import', desc: 'Importera leverantörsfakturor för matinköp', longDesc: 'Importera leverantörsfakturor för matinköp. Nyckelfunktioner: Parsning av leverantörsformat (Martin & Servera, Menigo), kostnadsfördelning per kategori' },
{ slug: 'menyhantering', name: 'Menyhantering', cat: 'operativ', desc: 'Bygg och publicera menyer digitalt', longDesc: 'Bygg och publicera menyer digitalt. Nyckelfunktioner: Menybyggare, säsongsbyte, allergenmarkering, prissättning per kanal' },
{ slug: 'bordsbokning', name: 'Bordsbokning', cat: 'operativ', desc: 'Online och manuell bordsreservation', longDesc: 'Online och manuell bordsreservation. Nyckelfunktioner: Bokningswidget, no-show-hantering, sittningsplanering, kapacitetsvy' },
{ slug: 'receptkalkyl', name: 'Receptkalkyl', cat: 'operativ', desc: 'Beräkna kostnad per rätt utifrån inköpspriser', longDesc: 'Beräkna kostnad per rätt utifrån inköpspriser. Nyckelfunktioner: Receptbyggare, portionskostnad, marginalkalkyl, prisändringsanalys' },
{ slug: 'personalschema', name: 'Personalschema', cat: 'operativ', desc: 'Schemalägg personal i skift', longDesc: 'Schemalägg personal i skift. Nyckelfunktioner: Skiftplanering, bytesförfrågningar, övertidsvarning, tillgänglighetsvy' },
{ slug: 'leverantorsbestallning', name: 'Leverantörsbeställning', cat: 'operativ', desc: 'Beställ varor direkt till leverantörer', longDesc: 'Beställ varor direkt till leverantörer. Nyckelfunktioner: Beställningsmallar, leveransbevakning, prishistorik' },
],
},
{
slug: 'hotell', name: 'Hotell & Boende', icon: BedDouble,
description: 'Rumsbokning, gäster & städschema',
modules: [
{ slug: 'hotellkontoplan', name: 'Hotellkontoplan', cat: 'bokforing', desc: 'BAS-konton uppdelade per intäktstyp: rum, konferens, F&B', longDesc: 'BAS-konton uppdelade per intäktstyp: rum, konferens, F&B, tilläggstjänster. Nyckelfunktioner: Konton 3010 (rumsintäkter), 3020 (konferens), 3030 (F&B); auto-kontosättning' },
{ slug: 'momssplit-boende-tjanst', name: 'Momssplit boende/tjänst', cat: 'bokforing', desc: 'Moms 12% på boende, 25% på frukost, minibar, spa', longDesc: 'Moms 12% på boende, 25% på frukost, minibar, spa, parkering. Nyckelfunktioner: Automatisk split vid kombinationspaket (halvpension, konferenspaket), momsavstämning' },
{ slug: 'forskottsbetalning', name: 'Förskottsbetalning', cat: 'bokforing', desc: 'Bokför depositioner som förutbetald intäkt', longDesc: 'Bokför förskott/depositioner korrekt som förutbetald intäkt. Nyckelfunktioner: Konto 2420, automatisk intäktsföring vid incheckning, avbokningsbokföring' },
{ slug: 'provisionshantering', name: 'Provisionshantering', cat: 'bokforing', desc: 'Bokför provision till Booking.com, Expedia, Airbnb', longDesc: 'Bokför provision till Booking.com, Expedia, Airbnb som kostnad. Nyckelfunktioner: Provisionsberäkning per kanal, nettointäkt per bokning, konto 6090' },
{ slug: 'nattrevision', name: 'Nattrevision', cat: 'bokforing', desc: 'Automatiserad nattlig avstämning av dagens intäkter', longDesc: 'Automatiserad nattlig avstämning av dagens intäkter. Nyckelfunktioner: Dagsbokslut, kassaavstämning, no-show-debitering, verifikation per dygn' },
{ slug: 'revpar', name: 'RevPAR', cat: 'rapport', desc: 'Revenue Per Available Room', longDesc: 'Revenue per available room — branschens viktigaste KPI. Nyckelfunktioner: Beläggningsgrad × ADR, jämförelse per månad/säsong, budget vs utfall' },
{ slug: 'adr', name: 'ADR (Average Daily Rate)', cat: 'rapport', desc: 'Genomsnittlig rumspris', longDesc: 'Genomsnittlig rumspris. Nyckelfunktioner: Pris per rumstyp, kanalfördelning, säsongsanalys' },
{ slug: 'belaggningsgrad', name: 'Beläggningsgrad', cat: 'rapport', desc: 'Procent uthyrda rum', longDesc: 'Procent uthyrda rum. Nyckelfunktioner: Daglig/veckovis/månadsvis, prognos baserad på bokningsläge' },
{ slug: 'kanallonsamhet', name: 'Kanallönsamhet', cat: 'rapport', desc: 'Jämför nettointäkt per kanal efter provision', longDesc: 'Jämför nettointäkt per kanal efter provision. Nyckelfunktioner: Direktbokning vs OTA, provision %, rekommendation' },
{ slug: 'pms-import', name: 'PMS-import', cat: 'import', desc: 'Importera data från PMS (Mews, Protel, Hotellinx)', longDesc: 'Importera boknings- och intäktsdata från PMS (Mews, Protel, Hotellinx). Nyckelfunktioner: CSV/Excel, nattrevisionsrapport, auto-bokföring med momssplit' },
{ slug: 'channel-manager-rapport', name: 'Channel Manager-rapport', cat: 'import', desc: 'Importera provisionssammanställning per kanal', longDesc: 'Importera provisionssammanställning per kanal. Nyckelfunktioner: Booking/Expedia/Airbnb-rapporter, provisionsbokföring' },
{ slug: 'rumsbokning', name: 'Rumsbokning', cat: 'operativ', desc: 'Central bokningsmotor för rum och stugor', longDesc: 'Central bokningsmotor. Nyckelfunktioner: Kalendervy, tillgänglighet, säsongspriser, min/max-nätter' },
{ slug: 'gastregister', name: 'Gästregister', cat: 'operativ', desc: 'Gästprofiler och historik', longDesc: 'Gästprofiler och historik. Nyckelfunktioner: Kontaktuppgifter, besökshistorik, preferenser, GDPR' },
{ slug: 'stadschema', name: 'Städschema', cat: 'operativ', desc: 'Planera och följ upp städning per rum', longDesc: 'Planera städning per rum. Nyckelfunktioner: Status per rum, checklista, personalfördelning' },
{ slug: 'sasongsplanering', name: 'Säsongsplanering', cat: 'operativ', desc: 'Planera priser och kapacitet per säsong', longDesc: 'Priser och kapacitet per säsong. Nyckelfunktioner: Säsongsdefinition, dynamisk prissättning, beläggningsprognos' },
{ slug: 'gastkommunikation', name: 'Gästkommunikation', cat: 'operativ', desc: 'Automatiserad kommunikation med gäster', longDesc: 'Automatiska meddelanden. Nyckelfunktioner: Bekräftelse, incheckning, utcheckning, recensionsförfrågan' },
],
},
{
slug: 'tech', name: 'Tech & IT', icon: Monitor,
description: 'Projekthantering, tid & SLA',
modules: [
{ slug: 'it-kontoplan', name: 'IT-kontoplan', cat: 'bokforing', desc: 'BAS-konton för konsult/SaaS-bolag', longDesc: 'BAS-konton anpassade för konsult/SaaS-bolag. Nyckelfunktioner: Konton för konsultintäkter, licensintäkter, SaaS MRR; auto-kontosättning' },
{ slug: 'projektredovisning', name: 'Projektredovisning', cat: 'bokforing', desc: 'Successiv vinstavräkning per kundprojekt', longDesc: 'Intäkter och kostnader per kundprojekt. Nyckelfunktioner: Successiv vinstavräkning, WIP-beräkning (konto 1470), projektbokslut' },
{ slug: 'fou-avdrag', name: 'FoU-avdrag', cat: 'bokforing', desc: 'Avdrag för forskning och utveckling', longDesc: 'Beräkna avdrag för forskning och utveckling. Nyckelfunktioner: Nedsättning av arbetsgivaravgifter, underlag per anställd, Skatteverket-krav' },
{ slug: 'licensavskrivning', name: 'Licensavskrivning', cat: 'bokforing', desc: 'Avskrivning av mjukvarulicenser och IP', longDesc: 'Hantera avskrivning av mjukvarulicenser och IP. Nyckelfunktioner: Avskrivningsplan, konto 1010/1020, linjär vs degressiv avskrivning' },
{ slug: 'eu-tjanstmoms', name: 'EU-tjänstemoms', cat: 'bokforing', desc: 'Omvänd skattskyldighet vid EU-försäljning', longDesc: 'Omvänd skattskyldighet vid försäljning till EU-kunder. Nyckelfunktioner: Automatisk B2B-kontroll (VAT-nummer), konto 2614, EU-försäljningsrapport' },
{ slug: 'debiteringsgrad', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Andel debiterbara timmar av total arbetstid', longDesc: 'Andel debiterbara timmar av total arbetstid. Nyckelfunktioner: Per konsult/team, mål vs utfall, trend' },
{ slug: 'projektlonsamhet', name: 'Projektlönsamhet', cat: 'rapport', desc: 'Intäkt minus kostnad per projekt', longDesc: 'Intäkt minus kostnad per projekt. Nyckelfunktioner: Timkostnad vs debiterat, överdrag-varning, jämförelse budget' },
{ slug: 'mrr-arr', name: 'MRR/ARR', cat: 'rapport', desc: 'Monthly/Annual Recurring Revenue', longDesc: 'Monthly/Annual Recurring Revenue för SaaS. Nyckelfunktioner: Tillväxttakt, churn, expansion revenue' },
{ slug: 'tidrapport-import', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Harvest, Toggl, Clockify', longDesc: 'Importera tidrapporter från Harvest, Toggl, Clockify. Nyckelfunktioner: CSV/Excel, auto-koppling till projekt, fakturaunderlag' },
{ slug: 'projekthantering', name: 'Projekthantering', cat: 'operativ', desc: 'Överblick och styrning av kundprojekt', longDesc: 'Överblick och styrning av kundprojekt. Nyckelfunktioner: Projekttavla, milstolpar, tidslinje, budgetuppföljning' },
{ slug: 'tidrapportering', name: 'Tidrapportering', cat: 'operativ', desc: 'Logga tid per projekt och kund', longDesc: 'Logga tid per projekt och kund. Nyckelfunktioner: Timer, manuell inmatning, godkännandeflöde, debiterbara timmar' },
{ slug: 'arendehantering', name: 'Ärendehantering', cat: 'operativ', desc: 'Supportärenden och buggspårning', longDesc: 'Supportärenden och buggspårning. Nyckelfunktioner: Ärendekö, SLA-prioritet, statusflöde, kundportal' },
{ slug: 'resursplanering', name: 'Resursplanering', cat: 'operativ', desc: 'Allokera personal till projekt', longDesc: 'Allokera personal till projekt. Nyckelfunktioner: Beläggningsvy, kompetensfilter, överlappningsvarning' },
],
},
{
slug: 'bygg', name: 'Bygg & Entreprenad', icon: HardHat,
description: 'Projektkalkyler, underentreprenörer',
modules: [
{ slug: 'byggkontoplan', name: 'Byggkontoplan', cat: 'bokforing', desc: 'BAS-konton med projektkontosättning och UE-konton', longDesc: 'BAS-konton med projektkontosättning och UE-konton. Nyckelfunktioner: Separata konton per projekt, UE-kostnader (konto 4400), materialkostnader' },
{ slug: 'omvand-skattskyldighet-bygg', name: 'Omvänd skattskyldighet bygg', cat: 'bokforing', desc: 'Omvänd moms vid UE-tjänster (6 kap. ML)', longDesc: 'Automatisk hantering av omvänd moms (6 kap. ML) vid UE-tjänster. Nyckelfunktioner: Automatisk kontroll köpare/säljare, fakturamärkning, momsdeklaration ruta 24' },
{ slug: 'rot-avdrag', name: 'ROT-avdrag', cat: 'bokforing', desc: 'ROT-avdrag per kund — 30% max 50 000 SEK', longDesc: 'Beräkna och rapportera ROT-avdrag per kund. Nyckelfunktioner: Automatisk 30%-beräkning, maxbelopp 50 000 SEK, personnummervalidering, Skatteverket-format' },
{ slug: 'successiv-vinstavrakning', name: 'Successiv vinstavräkning', cat: 'bokforing', desc: 'Periodisera intäkter efter färdigställandegrad', longDesc: 'Periodisera projektintäkter efter färdigställandegrad. Nyckelfunktioner: Beräkning av färdigställandegrad, intäktsperiodisering, konto 1470 WIP' },
{ slug: 'ue-attestering', name: 'UE-attestering', cat: 'bokforing', desc: 'Attestflöde för UE-fakturor med F-skatt-kontroll', longDesc: 'Attestflöde för underentreprenörsfakturor. Nyckelfunktioner: Godkännandeflöde, delavstämning, F-skatt-kontroll, försäkringsverifiering' },
{ slug: 'personalliggare-bygg', name: 'Personalliggare bygg', cat: 'bokforing', desc: 'Elektronisk personalliggare inkl. UE-personal', longDesc: 'Elektronisk personalliggare enligt Skatteverkets krav. Nyckelfunktioner: In/ut-registrering, export vid kontroll, UE-personal inkluderad' },
{ slug: 'ata-bokforing', name: 'ÄTA-bokföring', cat: 'bokforing', desc: 'Koppla ÄTA-arbeten till projekt och faktura', longDesc: 'Koppla ÄTA-arbeten till rätt projekt och faktura. Nyckelfunktioner: ÄTA-registrering, kostnadseffekt på projektkalkyl, intäktsökning vid godkänd ÄTA' },
{ slug: 'projektmarginal', name: 'Projektmarginal', cat: 'rapport', desc: 'Kalkylerad vs verklig marginal per projekt', longDesc: 'Kalkylerad vs verklig marginal per projekt. Nyckelfunktioner: Budget vs utfall, ÄTA-påverkan, UE-kostnad vs plan' },
{ slug: 'ata-analys', name: 'ÄTA-analys', cat: 'rapport', desc: 'ÄTA som andel av ursprunglig kalkyl', longDesc: 'ÄTA som andel av ursprunglig kalkyl. Nyckelfunktioner: ÄTA-andel per projekt, typ av ÄTA, kundgodkännandestatus' },
{ slug: 'likviditet-per-projekt', name: 'Likviditet per projekt', cat: 'rapport', desc: 'Kassaflöde per projekt', longDesc: 'Kassaflöde per projekt — fakturerat vs betalt vs kostnad. Nyckelfunktioner: Faktureringsrytm, kundfordringsålder, UE-skulder' },
{ slug: 'ue-fakturaimport', name: 'UE-fakturaimport', cat: 'import', desc: 'Importera underentreprenörsfakturor', longDesc: 'Importera underentreprenörsfakturor. Nyckelfunktioner: Parsning, projektkoppling, F-skatt-kontroll, auto-kontosättning' },
{ slug: 'materialkostnadsimport', name: 'Materialkostnadsimport', cat: 'import', desc: 'Importera materialinköp per projekt', longDesc: 'Importera materialinköp per projekt. Nyckelfunktioner: Leverantörsformat (Beijer, Ahlsell), projektfördelning' },
{ slug: 'projektkalkyl', name: 'Projektkalkyl', cat: 'operativ', desc: 'Detaljerade kalkyler inför anbud', longDesc: 'Detaljerade kalkyler inför anbud. Nyckelfunktioner: Arbete, material, maskiner, UE-kostnader, marginalpåslag' },
{ slug: 'ata-hantering', name: 'ÄTA-hantering', cat: 'operativ', desc: 'Spåra ändringar, tillägg och avgående', longDesc: 'Spåra ändringar, tillägg och avgående. Nyckelfunktioner: Registrering, kundgodkännande, koppling till faktura' },
{ slug: 'byggdagbok', name: 'Byggdagbok', cat: 'operativ', desc: 'Digital byggdagbok per projekt', longDesc: 'Digital dagbok per projekt. Nyckelfunktioner: Dagliga noteringar, foto, väderpåverkan, personallog' },
{ slug: 'ritningshantering', name: 'Ritningshantering', cat: 'operativ', desc: 'Versionshantering av projektritningar', longDesc: 'Versionshantering av projektritningar. Nyckelfunktioner: Uppladdning, versionsjämförelse, distribution till UE' },
{ slug: 'materialbestallning', name: 'Materialbeställning', cat: 'operativ', desc: 'Beställ och spåra material till projekt', longDesc: 'Beställ och spåra material. Nyckelfunktioner: Materiallistor, leveransbevakning, kostnadsuppföljning' },
],
},
{
slug: 'halsa', name: 'Hälsa & Sjukvård', icon: HeartPulse,
description: 'Patientbokning, journal & recept',
modules: [
{ slug: 'vardkontoplan', name: 'Vårdkontoplan', cat: 'bokforing', desc: 'BAS-konton: region, privat, försäkring', longDesc: 'BAS-konton uppdelade per intäktstyp: regionavtal, privatpatienter, försäkring. Nyckelfunktioner: Konton 3010 (regionersättning), 3020 (patientavgift), 3030 (försäkring)' },
{ slug: 'momsfrihet-sjukvard', name: 'Momsfrihet sjukvård', cat: 'bokforing', desc: 'Momsfria vårdtjänster vs momspliktiga sidotjänster', longDesc: 'Hantera momsfria vårdtjänster vs momspliktiga sidotjänster. Nyckelfunktioner: Automatisk momsbedömning per tjänsttyp, korrekt moms vid blandad verksamhet' },
{ slug: 'forsakringsersattning', name: 'Försäkringsersättning', cat: 'bokforing', desc: 'Bokför försäkringsersättningar vid godkännande', longDesc: 'Bokför ersättningar från försäkringsbolag korrekt. Nyckelfunktioner: Intäktsföring vid godkännande, kundfordran per försäkringsbolag, avskrivning av nekade ärenden' },
{ slug: 'frikort-hogkostnadsskydd', name: 'Frikort & Högkostnadsskydd', cat: 'bokforing', desc: 'Patienters frikort vid avgiftsberäkning', longDesc: 'Hantera patienters frikort vid avgiftsberäkning. Nyckelfunktioner: Automatisk kontroll mot högkostnadsgräns, rapportering till region' },
{ slug: 'intakt-per-behandlare', name: 'Intäkt per behandlare', cat: 'rapport', desc: 'Omsättning fördelat per vårdgivare', longDesc: 'Omsättning fördelat per vårdgivare. Nyckelfunktioner: Per dag/vecka/månad, privatpatienter vs region, besökssnitt' },
{ slug: 'patientmix', name: 'Patientmix', cat: 'rapport', desc: 'Fördelning region/privat/försäkring', longDesc: 'Fördelning region/privat/försäkring. Nyckelfunktioner: Intäktsfördelning per betalarkategori, trend, lönsamhet per typ' },
{ slug: 'regionersattningsimport', name: 'Regionersättningsimport', cat: 'import', desc: 'Importera utbetalningsbesked från region', longDesc: 'Importera utbetalningsbesked från region. Nyckelfunktioner: Periodisering, kontosättning, avstämning mot avtal' },
{ slug: 'forsakringsrapport-import', name: 'Försäkringsrapport-import', cat: 'import', desc: 'Importera beslut från försäkringsbolag', longDesc: 'Importera beslut och utbetalningar från försäkringsbolag. Nyckelfunktioner: Auto-bokföring godkända/nekade ärenden' },
{ slug: 'patientbokning', name: 'Patientbokning', cat: 'operativ', desc: 'Bokning av besök och behandlingar', longDesc: 'Bokning av besök och behandlingar. Nyckelfunktioner: Onlinebokning, kalendervy, SMS-påminnelse, väntelista' },
{ slug: 'journalhantering', name: 'Journalhantering', cat: 'operativ', desc: 'Strukturerade patientjournaler', longDesc: 'Strukturerade patientjournaler. Nyckelfunktioner: Anteckningar, diagnoser, vårdplan, GDPR-efterlevnad' },
{ slug: 'remisshantering', name: 'Remisshantering', cat: 'operativ', desc: 'Skicka och ta emot remisser', longDesc: 'Skicka och ta emot remisser. Nyckelfunktioner: Mallar, statusspårning, svarsbevakning' },
{ slug: 'kassasystem-patientavgifter', name: 'Kassasystem & Patientavgifter', cat: 'operativ', desc: 'Debitera patientbetalningar', longDesc: 'Debitera patientbetalningar. Nyckelfunktioner: Avgiftskategorier, frikort-koll, kortbetalning' },
],
},
{
slug: 'detaljhandel', name: 'Detaljhandel', icon: Store,
description: 'Lager, kassa & leverantörer',
modules: [
{ slug: 'detaljhandelskontoplan', name: 'Detaljhandelskontoplan', cat: 'bokforing', desc: 'BAS-konton per produktkategori och momssats', longDesc: 'BAS-konton per produktkategori och momssats. Nyckelfunktioner: Intäktskonton per varugrupp, separata konton 25%/12%/6% moms' },
{ slug: 'lagervardering', name: 'Lagervärdering', cat: 'bokforing', desc: 'FIFO och vägt genomsnitt för bokslut', longDesc: 'Löpande beräkning av lagervärde för bokslut. Nyckelfunktioner: FIFO och vägt genomsnitt, månadsbokslut, inkuransbedömning, konto 1400' },
{ slug: 'kassaavstamning-butik', name: 'Kassaavstämning butik', cat: 'bokforing', desc: 'Daglig avstämning kassa vs kassarapport', longDesc: 'Daglig avstämning kassa vs kassarapport. Nyckelfunktioner: Z-rapport-import, avvikelse, kontant/kort-split, verifikation per dag' },
{ slug: 'svinnbokforing', name: 'Svinnbokföring', cat: 'bokforing', desc: 'Bokför varusvinn som kostnad (konto 4730)', longDesc: 'Bokför varusvinn korrekt som kostnad. Nyckelfunktioner: Svinnregistrering, konto 4730, period-uppföljning' },
{ slug: 'personalliggare-butik', name: 'Personalliggare butik', cat: 'bokforing', desc: 'Personalliggare för livsmedelsbutik', longDesc: 'Elektronisk personalliggare (obligatorisk livsmedelsbutik). Nyckelfunktioner: In/ut-registrering, Skatteverket-export' },
{ slug: 'bruttomarginal-per-varugrupp', name: 'Bruttomarginal per varugrupp', cat: 'rapport', desc: 'Inpris vs utpris per kategori', longDesc: 'Inpris vs utpris per kategori. Nyckelfunktioner: Marginal %, jämförelse kampanjperiod vs normal, trendvy' },
{ slug: 'lageromsattningshastighet', name: 'Lageromsättningshastighet', cat: 'rapport', desc: 'Hur snabbt lagret omsätts', longDesc: 'Hur snabbt lagret omsätts. Nyckelfunktioner: Per varugrupp, kapitalbindning, beställningspunkt-förslag' },
{ slug: 'svinnprocent', name: 'Svinnprocent', cat: 'rapport', desc: 'Svinn i kronor och procent', longDesc: 'Svinn i kronor och procent av omsättning. Nyckelfunktioner: Per kategori, trend, benchmark mot branschsnitt' },
{ slug: 'forsaljning-per-m2', name: 'Försäljning per m²', cat: 'rapport', desc: 'Butikseffektivitet', longDesc: 'Butikseffektivitet. Nyckelfunktioner: Intäkt per kvadratmeter, jämförelse butiker' },
{ slug: 'pos-z-rapport-import', name: 'POS Z-rapport-import', cat: 'import', desc: 'Dagskassarapport från kassasystem', longDesc: 'Dagskassarapport från kassasystem. Nyckelfunktioner: CSV/Excel från iZettle, Sitoo, Caspeco; auto-kontosättning med momssplit' },
{ slug: 'inventeringsimport', name: 'Inventeringsimport', cat: 'import', desc: 'Importera inventeringsresultat', longDesc: 'Importera inventeringsresultat. Nyckelfunktioner: Avvikelseberäkning, lageruppdatering, svinnbokföring' },
{ slug: 'leverantorsfaktura-import-butik', name: 'Leverantörsfaktura-import', cat: 'import', desc: 'Importera leverantörsfakturor', longDesc: 'Importera leverantörsfakturor. Nyckelfunktioner: Parsning, inprisuppdatering, auto-kontosättning' },
{ slug: 'lagerhantering', name: 'Lagerhantering', cat: 'operativ', desc: 'Lagernivåer och rörelser', longDesc: 'Lagernivåer och rörelser. Nyckelfunktioner: Inleverans, bristlista, beställningspunkt, lagerplatser' },
{ slug: 'kampanjer-rabatter', name: 'Kampanjer & Rabatter', cat: 'operativ', desc: 'Priskampanjer och erbjudanden', longDesc: 'Priskampanjer och erbjudanden. Nyckelfunktioner: Rabattregler, tidsbegränsade kampanjer, marginalbevakning' },
{ slug: 'kundklubb', name: 'Kundklubb', cat: 'operativ', desc: 'Lojalitetsprogram med poäng', longDesc: 'Lojalitetsprogram. Nyckelfunktioner: Poängintjäning, belöningar, riktade kampanjer' },
{ slug: 'prishantering', name: 'Prishantering', cat: 'operativ', desc: 'Prissättning med marginalbevakning', longDesc: 'Centraliserad prissättning. Nyckelfunktioner: Inpris vs utpris, marginalkalkyl, prisändringslogg' },
{ slug: 'butiksdrift-schema', name: 'Butiksdrift & Schema', cat: 'operativ', desc: 'Bemanning och öppettider', longDesc: 'Bemanning och öppettider. Nyckelfunktioner: Schemaläggning, timrapport, budgetjämförelse' },
],
},
{
slug: 'ehandel', name: 'E-handel', icon: ShoppingCart,
description: 'Ordrar, frakt & returer',
modules: [
{ slug: 'ehandelskontoplan', name: 'E-handelskontoplan', cat: 'bokforing', desc: 'BAS-konton för order, frakt, returer, avgifter', longDesc: 'BAS-konton för orderintäkter, fraktintäkt, returer, plattformsavgifter. Nyckelfunktioner: Konton 3001 (varuförsäljning), 3540 (fraktintäkt), 3740 (returkostnad), 6590 (plattformsavgift)' },
{ slug: 'lagervardering-ehandel', name: 'Lagervärdering', cat: 'bokforing', desc: 'FIFO/vägt genomsnitt för e-handel', longDesc: 'Löpande lagervärde för bokslut. Nyckelfunktioner: FIFO, vägt genomsnitt, månadsbokslut, inkuransbedömning' },
{ slug: 'returbokforing', name: 'Returbokföring', cat: 'bokforing', desc: 'Automatisk kreditering vid returer', longDesc: 'Automatisk kreditering vid returer. Nyckelfunktioner: Lageråterföring, konto 3740, momskorrigering, periodsummering' },
{ slug: 'multi-currency', name: 'Multi-currency', cat: 'bokforing', desc: 'Bokför utländsk valuta i SEK', longDesc: 'Bokför försäljning i utländsk valuta i SEK. Nyckelfunktioner: Valutaomvandling via Riksbanken-kurs, kursdifferens konto 3960/7960' },
{ slug: 'eu-moms-oss', name: 'EU-moms (OSS)', cat: 'bokforing', desc: 'One Stop Shop för EU-distansförsäljning', longDesc: 'One Stop Shop för moms vid EU-distansförsäljning. Nyckelfunktioner: Moms per destinationsland, OSS-deklarationsunderlag, tröskelvärdebevakning' },
{ slug: 'plattformsavgifter', name: 'Plattformsavgifter', cat: 'bokforing', desc: 'Bokför Shopify/Stripe/Klarna-avgifter', longDesc: 'Bokför avgifter från Shopify, Stripe, Klarna korrekt. Nyckelfunktioner: Bruttoförsäljning → avgift → nettoutbetalning, periodisering' },
{ slug: 'returprocent', name: 'Returprocent', cat: 'rapport', desc: 'Returer som andel av försäljning', longDesc: 'Returer som andel av försäljning. Nyckelfunktioner: Per produktkategori, kostnad per retur, trend' },
{ slug: 'genomsnittligt-ordervarde', name: 'Genomsnittligt ordervärde', cat: 'rapport', desc: 'Snittorder i kronor', longDesc: 'Snittorder i kronor. Nyckelfunktioner: Trend, kanalfördelning, kampanjeffekt' },
{ slug: 'kanalfordelning', name: 'Kanalfördelning', cat: 'rapport', desc: 'Intäkt per försäljningskanal', longDesc: 'Intäkt per försäljningskanal. Nyckelfunktioner: Egen webb vs marknadsplats, marginal per kanal' },
{ slug: 'fraktkostnad-vs-intakt', name: 'Fraktkostnad vs intäkt', cat: 'rapport', desc: 'Fraktnetto per order', longDesc: 'Fraktnetto per order. Nyckelfunktioner: Fraktsubvention, break-even-analys' },
{ slug: 'shopify-woo-import', name: 'Shopify/Woo-import', cat: 'import', desc: 'Importera ordrar från Shopify/WooCommerce', longDesc: 'Importera ordrar och utbetalningsrapporter. Nyckelfunktioner: CSV-export, auto-bokföring per order inkl. moms, frakt, rabatter' },
{ slug: 'klarna-rapport-import', name: 'Klarna-rapport-import', cat: 'import', desc: 'Importera Klarna-utbetalningar', longDesc: 'Importera Klarna-utbetalningar och avgifter. Nyckelfunktioner: Bruttobelopp, avgiftsavdrag, nettoutbetalning, periodmatchning' },
{ slug: 'fraktrapport-import', name: 'Fraktrapport-import', cat: 'import', desc: 'Importera fraktkostnader per order', longDesc: 'Importera fraktkostnader per order. Nyckelfunktioner: Postnord/DHL/Budbee-rapporter, kostnadsfördelning' },
{ slug: 'orderhantering', name: 'Orderhantering', cat: 'operativ', desc: 'Central ordervy från alla kanaler', longDesc: 'Central ordervy. Nyckelfunktioner: Orderstatus, plocklista, batch-hantering' },
{ slug: 'frakthantering', name: 'Frakthantering', cat: 'operativ', desc: 'Hantera frakt med transportörer', longDesc: 'Hantera frakt. Nyckelfunktioner: Fraktsedlar, spårning, returlogistik' },
{ slug: 'returhantering-operativ', name: 'Returhantering', cat: 'operativ', desc: 'Hantera returer och byten', longDesc: 'Hantera returer och byten. Nyckelfunktioner: Returgodkännande, lageråterföring, återbetalning' },
{ slug: 'produktdatahantering', name: 'Produktdatahantering', cat: 'operativ', desc: 'Central produktinformation', longDesc: 'Central produktinfo. Nyckelfunktioner: Beskrivningar, bilder, attribut' },
],
},
{
slug: 'frisor', name: 'Frisör & Skönhet', icon: Scissors,
description: 'Tidsbokning, kunder & produkter',
modules: [
{ slug: 'salongkontoplan', name: 'Salongkontoplan', cat: 'bokforing', desc: 'BAS-konton: behandling, produktförsäljning, presentkort', longDesc: 'BAS-konton uppdelade: tjänsteintäkter, produktförsäljning, presentkort. Nyckelfunktioner: Konton 3010 (behandling), 3020 (produktförsäljning), 2420 (presentkortsskuld)' },
{ slug: 'provisionsberakning', name: 'Provisionsberäkning', cat: 'bokforing', desc: 'Provision per frisör/terapeut som lönekostnad', longDesc: 'Räkna provision per frisör/terapeut och bokför som lönekostnad. Nyckelfunktioner: Procent på tjänst/produkt, trappsteg, konto 7010/7210, månadssammanställning' },
{ slug: 'presentkort-som-skuld', name: 'Presentkort som skuld', cat: 'bokforing', desc: 'Presentkort som förutbetald intäkt', longDesc: 'Bokför sålda presentkort som förutbetald intäkt tills inlösen. Nyckelfunktioner: Konto 2420 (skuld), intäktsföring vid inlösen, förfallohantering' },
{ slug: 'kassaavstamning-salong', name: 'Kassaavstämning salong', cat: 'bokforing', desc: 'Daglig avstämning kort/Swish/kontant', longDesc: 'Daglig avstämning kort/Swish/kontant. Nyckelfunktioner: Z-rapport, avvikelse, verifikation per dag' },
{ slug: 'personalliggare-salong', name: 'Personalliggare salong', cat: 'bokforing', desc: 'Personalliggare enligt Skatteverket', longDesc: 'Elektronisk personalliggare enligt Skatteverket. Nyckelfunktioner: In/ut-registrering, export vid kontroll' },
{ slug: 'intakt-per-stol', name: 'Intäkt per stol', cat: 'rapport', desc: 'Omsättning per arbetsstation', longDesc: 'Omsättning per arbetsstation. Nyckelfunktioner: Per dag/vecka, beläggningsgrad, optimeringsförslag' },
{ slug: 'provisionsandel', name: 'Provisionsandel', cat: 'rapport', desc: 'Provisionskostnad som andel av intäkt', longDesc: 'Provisionskostnad som andel av tjänsteintäkt. Nyckelfunktioner: Per frisör, trend, lönsamhet per anställd' },
{ slug: 'produktforsaljning-per-besok', name: 'Produktförsäljning per besök', cat: 'rapport', desc: 'Merförsäljning av produkter', longDesc: 'Merförsäljning av produkter. Nyckelfunktioner: Snitt per kund, andel av omsättning' },
{ slug: 'kassarapport-import-salong', name: 'Kassarapport-import', cat: 'import', desc: 'Importera dagskassa från iZettle', longDesc: 'Importera dagskassa från kassasystem. Nyckelfunktioner: CSV från iZettle/Zettle, auto-kontosättning' },
{ slug: 'bokningssystem-import', name: 'Bokningssystem-import', cat: 'import', desc: 'Importera från Timma, Fresha, Planway', longDesc: 'Importera bokningsdata och intäkter. Nyckelfunktioner: Export från Timma, Fresha, Planway; intäktsfördelning per behandlare' },
{ slug: 'tidsbokning', name: 'Tidsbokning', cat: 'operativ', desc: 'Online och receptionsbokning', longDesc: 'Online och receptionsbokning. Nyckelfunktioner: Bokningswidget, SMS-påminnelse, väntelista' },
{ slug: 'kundkort', name: 'Kundkort', cat: 'operativ', desc: 'Profil med behandlingshistorik', longDesc: 'Profil med behandlingshistorik. Nyckelfunktioner: Favoritbehandlare, allergier, färgrecept' },
{ slug: 'sms-paminnelser', name: 'SMS-påminnelser', cat: 'operativ', desc: 'Automatiska bokningspåminnelser', longDesc: 'Automatiska påminnelser. Nyckelfunktioner: Bokningspåminnelse, återbesökspåminnelse' },
{ slug: 'skiftschema-salong', name: 'Skiftschema', cat: 'operativ', desc: 'Schemalägg personal med stolsfördelning', longDesc: 'Schemalägg personal. Nyckelfunktioner: Stolsfördelning, ledighetsönskemål' },
],
},
{
slug: 'transport', name: 'Transport & Logistik', icon: Truck,
description: 'Flotta, rutter & leveranser',
modules: [
{ slug: 'transportkontoplan', name: 'Transportkontoplan', cat: 'bokforing', desc: 'BAS-konton: bränsle, leasing, underhåll, försäkring', longDesc: 'BAS-konton per kostnadskategori: bränsle, leasing, underhåll, försäkring. Nyckelfunktioner: Konton per fordon möjligt, kostnadskonton 5610/5620/5611/5612' },
{ slug: 'fordonsavskrivning', name: 'Fordonsavskrivning', cat: 'bokforing', desc: 'Avskrivning per fordon (räkenskapsenlig)', longDesc: 'Hantera avskrivning per fordon. Nyckelfunktioner: Linjär/degressiv, räkenskapsenlig avskrivning, konto 1240/7832' },
{ slug: 'leasinghantering', name: 'Leasinghantering', cat: 'bokforing', desc: 'Operationell vs finansiell leasing', longDesc: 'Bokför leasing korrekt (operationell vs finansiell). Nyckelfunktioner: Leasingavgift som driftskostnad, restvärde, avtalsperiod' },
{ slug: 'trangselskatt', name: 'Trängselskatt', cat: 'bokforing', desc: 'Avdragsgill kostnad (konto 5615)', longDesc: 'Bokför trängselskatt som avdragsgill kostnad. Nyckelfunktioner: Auto-kontosättning konto 5615, per fordon, periodsammanställning' },
{ slug: 'milersattning-vs-faktisk-kostnad', name: 'Milersättning vs faktisk kostnad', cat: 'bokforing', desc: 'Jämför schablon (25 kr/mil) mot verklig kostnad', longDesc: 'Jämför schablon (25 kr/mil) mot verklig fordonskostnad. Nyckelfunktioner: Per fordon, rekommendation avdragsmetod, Skatteverket-schabloner' },
{ slug: 'kostnad-per-mil', name: 'Kostnad per mil', cat: 'rapport', desc: 'Total driftskostnad per mil och fordon', longDesc: 'Total driftskostnad per mil och per fordon. Nyckelfunktioner: Bränsle + underhåll + avskrivning + försäkring, trend' },
{ slug: 'intakt-per-fordon', name: 'Intäkt per fordon', cat: 'rapport', desc: 'Omsättning fördelad per fordon', longDesc: 'Omsättning fördelad per fordon. Nyckelfunktioner: Lönsamhet per fordon, underutnyttjade resurser' },
{ slug: 'bransleeffektivitet', name: 'Bränsleeffektivitet', cat: 'rapport', desc: 'Bränsleförbrukning per fordon', longDesc: 'Bränsleförbrukning per fordon och period. Nyckelfunktioner: Liter/mil, trend, avvikelse mot förväntat' },
{ slug: 'branslekort-import', name: 'Bränslekort-import', cat: 'import', desc: 'Importera tankningar (Circle K, OKQ8, Preem)', longDesc: 'Importera tankningar från bränslekort (Circle K, OKQ8, Preem). Nyckelfunktioner: CSV, auto-kontosättning per fordon' },
{ slug: 'vagtulls-import', name: 'Vägtulls-import', cat: 'import', desc: 'Importera trängselskatt (Transportstyrelsen)', longDesc: 'Importera trängselskatt och broavgifter. Nyckelfunktioner: Transportstyrelsen-format, auto-kontosättning' },
{ slug: 'flottahantering', name: 'Flottahantering', cat: 'operativ', desc: 'Fordonsregister och översikt', longDesc: 'Fordonsregister och översikt. Nyckelfunktioner: Besiktning, försäkring, leasingperioder' },
{ slug: 'ruttplanering', name: 'Ruttplanering', cat: 'operativ', desc: 'Optimera körrutter', longDesc: 'Optimera körrutter. Nyckelfunktioner: Adressklustring, tidsestimering, kapacitet' },
{ slug: 'leveranssparning', name: 'Leveransspårning', cat: 'operativ', desc: 'Realtidsspårning av leveranser', longDesc: 'Realtidsspårning av leveranser. Nyckelfunktioner: GPS, ETA, leveransbevis, kundnotifiering' },
{ slug: 'fordonsunderhall', name: 'Fordonsunderhåll', cat: 'operativ', desc: 'Planera service och reparationer', longDesc: 'Planera service och reparationer. Nyckelfunktioner: Serviceintervall, reservdelshistorik' },
{ slug: 'chauforshantering', name: 'Chaufförshantering', cat: 'operativ', desc: 'Personal- och behörighetsregister', longDesc: 'Personal och behörigheter. Nyckelfunktioner: Körkortsklass, YKB, timrapport' },
{ slug: 'fraktsedlar-dokument', name: 'Fraktsedlar & Dokument', cat: 'operativ', desc: 'Generera fraktdokument', longDesc: 'Generera fraktdokument. Nyckelfunktioner: CMR, följesedel, digital signatur' },
],
},
{
slug: 'juridik', name: 'Juridik & Redovisning', icon: Scale,
description: 'Ärenden, tidrapport & klienter',
modules: [
{ slug: 'juristkontoplan', name: 'Juristkontoplan', cat: 'bokforing', desc: 'BAS-konton: arvoden, klientmedel, utlägg', longDesc: 'BAS-konton för arvodesintäkter, klientmedel, utlägg. Nyckelfunktioner: Konton 3010 (arvoden), 1690 (klientmedel i förvaltning), 6990 (utlägg för klients räkning)' },
{ slug: 'klientmedelskonto', name: 'Klientmedelskonto', cat: 'bokforing', desc: 'Separat hantering av klientmedel', longDesc: 'Separat hantering av klientmedel (disciplinnämndsregler). Nyckelfunktioner: Konto 1690/2890, klientmedelslista, avstämning mot bankkonto, rapportering' },
{ slug: 'wip-vardering', name: 'WIP-värdering', cat: 'bokforing', desc: 'Pågående arbete ej fakturerat', longDesc: 'Värdera pågående arbete (nedlagd tid ej fakturerad). Nyckelfunktioner: Beräkning av WIP, konto 1470, månatlig omvärdering' },
{ slug: 'a-conto-bokforing', name: 'A conto-bokföring', cat: 'bokforing', desc: 'A conto-fakturor och slutavräkning', longDesc: 'Bokför a conto-fakturor och slutavräkning. Nyckelfunktioner: Förskottsbokföring konto 2420, avräkning vid slutfaktura' },
{ slug: 'debiteringsgrad-juridik', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Debiterbara timmar av total arbetstid', longDesc: 'Andel debiterbara timmar av total arbetstid. Nyckelfunktioner: Per jurist/konsult, mål vs utfall, trend' },
{ slug: 'realisationsgrad', name: 'Realisationsgrad', cat: 'rapport', desc: 'Fakturerat vs tidvärde', longDesc: 'Fakturerat belopp vs tidvärde (nedskrivning av tid). Nyckelfunktioner: Per ärende, mönsteranalys, lönsamhet per ärendetyp' },
{ slug: 'genomsnittlig-timintakt', name: 'Genomsnittlig timintäkt', cat: 'rapport', desc: 'Faktiskt fakturerat per timme', longDesc: 'Faktiskt fakturerat per timme. Nyckelfunktioner: Per jurist, klient, ärendetyp; jämförelse mot prislista' },
{ slug: 'wip-rapport', name: 'WIP-rapport', cat: 'rapport', desc: 'Pågående arbete med åldringsanalys', longDesc: 'Totalt pågående arbete ej fakturerat. Nyckelfunktioner: Per klient/ärende, åldringsanalys, risk för nedskrivning' },
{ slug: 'tidrapport-import-juridik', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Clio, Maconomy', longDesc: 'Importera tid från ärendehanteringssystem. Nyckelfunktioner: CSV från Time, Clio, Maconomy; koppling till ärende och klient' },
{ slug: 'arendehantering-juridik', name: 'Ärendehantering', cat: 'operativ', desc: 'Administrera klientärenden', longDesc: 'Administrera klientärenden. Nyckelfunktioner: Ärendetyper, statusflöde, deadline-bevakning' },
{ slug: 'tidrapportering-juridik', name: 'Tidrapportering', cat: 'operativ', desc: 'Registrera tid per ärende', longDesc: 'Registrera tid per ärende. Nyckelfunktioner: Debiterbara vs interna timmar, godkännande' },
{ slug: 'dokumenthantering', name: 'Dokumenthantering', cat: 'operativ', desc: 'Versionshanterade dokument', longDesc: 'Versionshanterade dokument per ärende. Nyckelfunktioner: Mallbibliotek, e-signering, klientdelning' },
{ slug: 'deadlinebevakning', name: 'Deadlinebevakning', cat: 'operativ', desc: 'Påminnelser för tidsfrister', longDesc: 'Påminnelser för tidsfrister. Nyckelfunktioner: Preskriptionstider, eskaleringsregler' },
{ slug: 'intressekonfliktskontroll', name: 'Intressekonfliktskontroll', cat: 'operativ', desc: 'Kontrollera jäv vid nya ärenden', longDesc: 'Kontrollera jäv vid nya ärenden. Nyckelfunktioner: Partsregister, automatisk sökning, logg' },
],
},
{
slug: 'utbildning', name: 'Utbildning & Förskola', icon: GraduationCap,
description: 'Schema, elever & närvaro',
modules: [
{ slug: 'utbildningskontoplan', name: 'Utbildningskontoplan', cat: 'bokforing', desc: 'BAS-konton: kommunal peng, avgift, statsbidrag', longDesc: 'BAS-konton för kommunal peng, statsbidrag, föräldraavgift. Nyckelfunktioner: Konton 3010 (kommunal ersättning), 3020 (föräldraavgift), 3910 (statsbidrag)' },
{ slug: 'maxtaxa-fakturering', name: 'Maxtaxa-fakturering', cat: 'bokforing', desc: 'Avgift enligt maxtaxa-reglerna', longDesc: 'Automatisk beräkning av avgift enligt maxtaxa-reglerna. Nyckelfunktioner: Inkomstkontroll, syskonrabatt, schema-avdrag, frånvaroavdrag' },
{ slug: 'statsbidragsperiodisering', name: 'Statsbidragsperiodisering', cat: 'bokforing', desc: 'Periodisera statsbidrag korrekt', longDesc: 'Periodisera statsbidrag korrekt över den period de avser. Nyckelfunktioner: Konto 2970 (förutbetald intäkt), villkorsuppfyllnad, återbetalningsrisk' },
{ slug: 'momsfrihet-utbildning', name: 'Momsfrihet utbildning', cat: 'bokforing', desc: 'Momsfria utbildningstjänster', longDesc: 'Utbildningstjänster är momsfria. Nyckelfunktioner: Korrekt momsbedömning, sidoverksamhet (cafeteria, uthyrning) kan vara momspliktig' },
{ slug: 'kostnad-per-barn-elev', name: 'Kostnad per barn/elev', cat: 'rapport', desc: 'Driftskostnad per inskrivet barn', longDesc: 'Total driftskostnad per inskrivet barn. Nyckelfunktioner: Personal, lokal, mat, material; jämförelse med kommunal peng' },
{ slug: 'personaltathet', name: 'Personaltäthet', cat: 'rapport', desc: 'Antal barn per heltidsanställd', longDesc: 'Antal barn per heltidsanställd. Nyckelfunktioner: Trend, lagkrav (förskolans riktlinje), schemaanalys' },
{ slug: 'kommunal-peng-import', name: 'Kommunal peng-import', cat: 'import', desc: 'Importera utbetalningsbesked', longDesc: 'Importera utbetalningsbesked från kommun. Nyckelfunktioner: Periodisering, kontosättning, avstämning' },
{ slug: 'schemalaeggning', name: 'Schemaläggning', cat: 'operativ', desc: 'Bygg undervisningsschema', longDesc: 'Undervisningsschema. Nyckelfunktioner: Per klass/lärare/sal, konfliktkontroll' },
{ slug: 'elevregister', name: 'Elevregister', cat: 'operativ', desc: 'Elev- och barnuppgifter', longDesc: 'Elev- och barnuppgifter. Nyckelfunktioner: Kontakt, vårdnadshavare, allergi, specialbehov' },
{ slug: 'narvarohantering', name: 'Närvarohantering', cat: 'operativ', desc: 'Registrera frånvaro', longDesc: 'Registrera frånvaro. Nyckelfunktioner: Daglig närvaro, mönsteranalys, föräldraavisering' },
{ slug: 'foraldrakommunikation', name: 'Föräldrakommunikation', cat: 'operativ', desc: 'Meddelanden till vårdnadshavare', longDesc: 'Meddelanden till vårdnadshavare. Nyckelfunktioner: Push/e-post/SMS, nyhetsflöde' },
{ slug: 'matsedel-allergikost', name: 'Matsedel & Allergikost', cat: 'operativ', desc: 'Publicera matsedel', longDesc: 'Publicera matsedel. Nyckelfunktioner: Veckomatsdel, allergiflaggor' },
{ slug: 'vikariebokning', name: 'Vikariebokning', cat: 'operativ', desc: 'Hantera vikarier', longDesc: 'Hantera vikarier. Nyckelfunktioner: Vikariepool, snabbbokning, kostnadsspårning' },
],
},
{
slug: 'jordbruk', name: 'Jordbruk & Livsmedel', icon: Wheat,
description: 'Skörd, djurhållning & spårbarhet',
modules: [
{ slug: 'jordbrukskontoplan', name: 'Jordbrukskontoplan', cat: 'bokforing', desc: 'BAS-konton: gröda, djur, skog, EU-stöd', longDesc: 'BAS-konton anpassade för gröda, djur, skogsbruk, EU-stöd. Nyckelfunktioner: Konton 3010 (växtodling), 3020 (djurförsäljning), 3910 (EU-stöd), 1280 (biologiska tillgångar)' },
{ slug: 'skogskonto', name: 'Skogskonto', cat: 'bokforing', desc: 'Insättning/uttag skogskonto (max 60%)', longDesc: 'Hantera insättning och uttag från skogskonto. Nyckelfunktioner: Insättning (max 60% av skogsintäkt), beskattning vid uttag, konto 1760' },
{ slug: 'expansionsfond', name: 'Expansionsfond', cat: 'bokforing', desc: 'Avsättning för enskild firma (20.6% skatt)', longDesc: 'Beräkna avsättning till expansionsfond för enskild firma. Nyckelfunktioner: Beräkning av underlag, expansionsfondsskatt 20,6%, NE-bilaga' },
{ slug: 'rantefordelning', name: 'Räntefördelning', cat: 'bokforing', desc: 'Positiv/negativ räntefördelning', longDesc: 'Positiv/negativ räntefördelning på kapital. Nyckelfunktioner: Kapitalunderlag, statslåneränta, inkomstfördelning tjänst/kapital' },
{ slug: 'eu-stod-som-intakt', name: 'EU-stöd som intäkt', cat: 'bokforing', desc: 'Periodisera EU-jordbruksstöd', longDesc: 'Periodisera EU-jordbruksstöd korrekt. Nyckelfunktioner: Intäktsföring per period, villkorsskuld, jämförelse ansökt vs utbetalt' },
{ slug: 'biologiska-tillgangar', name: 'Biologiska tillgångar', cat: 'bokforing', desc: 'Värdera djur och grödor', longDesc: 'Värdera djur och grödor som tillgång. Nyckelfunktioner: Anskaffningsvärde vs verkligt värde, konto 1280, årlig omvärdering' },
{ slug: 'momssplit-livsmedel', name: 'Momssplit livsmedel', cat: 'bokforing', desc: 'Moms 12% livsmedel, 25% övrigt', longDesc: 'Moms 12% vid livsmedelsförsäljning, 25% övriga varor. Nyckelfunktioner: Automatisk momskod per produkttyp' },
{ slug: 'avkastning-per-hektar', name: 'Avkastning per hektar', cat: 'rapport', desc: 'Intäkt minus kostnad per yta', longDesc: 'Intäkt minus kostnad per odlad yta. Nyckelfunktioner: Per gröda, säsongsjämförelse, vädereffekt' },
{ slug: 'bidragsberoende', name: 'Bidragsberoende', cat: 'rapport', desc: 'EU-stöd som andel av intäkt', longDesc: 'EU-stöd som andel av total intäkt. Nyckelfunktioner: Trend, risk vid policyändring' },
{ slug: 'djurkostnad-per-enhet', name: 'Djurkostnad per enhet', cat: 'rapport', desc: 'Total kostnad per djur', longDesc: 'Total kostnad per djur (foder, veterinär, stall). Nyckelfunktioner: Per djurtyp, jämförelse med intäkt vid slakt/försäljning' },
{ slug: 'sam-utbetalningsimport', name: 'SAM-utbetalningsimport', cat: 'import', desc: 'Importera EU-stödutbetalningar', longDesc: 'Importera utbetalningsbesked för EU-jordbruksstöd. Nyckelfunktioner: Jordbruksverket-format, periodisering, auto-kontosättning' },
{ slug: 'skordeplanering', name: 'Skördeplanering', cat: 'operativ', desc: 'Planera odlingscykler', longDesc: 'Planera odlingscykler. Nyckelfunktioner: Fältregister, grödor, sådd/skörd-datum' },
{ slug: 'djurhallning', name: 'Djurhållning', cat: 'operativ', desc: 'Register och uppföljning', longDesc: 'Register och uppföljning av besättning. Nyckelfunktioner: Individregister, hälsologg, Jordbruksverket-rapportering' },
{ slug: 'sparbarhet', name: 'Spårbarhet', cat: 'operativ', desc: 'Spåra livsmedel från jord till bord', longDesc: 'Spåra livsmedel från jord till bord. Nyckelfunktioner: Batchnummer, leveranskedja, återkallning' },
{ slug: 'maskinlogg', name: 'Maskinlogg', cat: 'operativ', desc: 'Drifttid och underhåll', longDesc: 'Drifttid och underhåll. Nyckelfunktioner: Maskinregister, timlogg, servicepåminnelse' },
{ slug: 'certifieringar', name: 'Certifieringar', cat: 'operativ', desc: 'KRAV, ekologisk certifiering', longDesc: 'KRAV, ekologisk, EU-ekologisk. Nyckelfunktioner: Certifikatregister, giltighetstid, revisionsförberedelse' },
],
},
{
slug: 'media', name: 'Media & Kommunikation', icon: Film,
description: 'Projekt, innehåll & publicering',
modules: [
{ slug: 'mediakontoplan', name: 'Mediakontoplan', cat: 'bokforing', desc: 'BAS-konton: projekt, licens, royalty', longDesc: 'BAS-konton per intäktstyp: projektarvoden, licensintäkt, royalties. Nyckelfunktioner: Konton 3010 (projektintäkt), 3020 (licensintäkt), 3910 (royalty)' },
{ slug: 'projektredovisning-media', name: 'Projektredovisning', cat: 'bokforing', desc: 'Intäkter och kostnader per kundprojekt', longDesc: 'Intäkter och kostnader per kundprojekt. Nyckelfunktioner: Budget vs utfall, WIP vid pågående projekt, projektbokslut' },
{ slug: 'freelancer-bokforing', name: 'Freelancer-bokföring', cat: 'bokforing', desc: 'Frilansarfakturor med F-skatt-kontroll', longDesc: 'Hantera fakturor från frilansare korrekt. Nyckelfunktioner: F-skatt-kontroll, auto-kontosättning konto 4010, kostnad per projekt' },
{ slug: 'kulturmoms', name: 'Kulturmoms', cat: 'bokforing', desc: 'Moms 6% på publicering/film/kultur', longDesc: 'Hantera moms 6% vid publicering, film, kultur. Nyckelfunktioner: Korrekt momssats per tjänsttyp, blandad verksamhet' },
{ slug: 'ip-tillgangar', name: 'IP-tillgångar', cat: 'bokforing', desc: 'Immateriella rättigheter', longDesc: 'Aktivera och avskriva immateriella rättigheter. Nyckelfunktioner: Aktivering, avskrivningsplan, konto 1010/1020' },
{ slug: 'projektlonsamhet-media', name: 'Projektlönsamhet', cat: 'rapport', desc: 'Intäkt minus kostnad per projekt', longDesc: 'Intäkt minus kostnad per projekt. Nyckelfunktioner: Budget vs utfall, timkostnad vs debiterat' },
{ slug: 'freelancerandel', name: 'Freelancerandel', cat: 'rapport', desc: 'Freelancerkostnad av produktion', longDesc: 'Andel av produktionskostnad som går till freelancers. Nyckelfunktioner: Trend, lönsamhetspåverkan, per projekttyp' },
{ slug: 'kundlonsamhet', name: 'Kundlönsamhet', cat: 'rapport', desc: 'Intäkt och marginal per kund', longDesc: 'Intäkt och marginal per kund. Nyckelfunktioner: Tidsförbrukning vs fakturerat, återkommande kunder' },
{ slug: 'freelancer-fakturaimport', name: 'Freelancer-fakturaimport', cat: 'import', desc: 'Importera frilansarfakturor', longDesc: 'Importera fakturor från frilansare. Nyckelfunktioner: Parsning, F-skatt-verifiering, projektkoppling' },
{ slug: 'kampanjrapport-import', name: 'Kampanjrapport-import', cat: 'import', desc: 'Importera kampanjresultat', longDesc: 'Importera kampanjresultat. Nyckelfunktioner: Intäkter per kampanj, provisionskostnader' },
{ slug: 'projekthantering-media', name: 'Projekthantering', cat: 'operativ', desc: 'Styra kundprojekt', longDesc: 'Styra kundprojekt. Nyckelfunktioner: Projekttavla, tidslinje, kundgodkännande' },
{ slug: 'innehallsplanering', name: 'Innehållsplanering', cat: 'operativ', desc: 'Redaktionskalender', longDesc: 'Redaktionskalender. Nyckelfunktioner: Kanalvy, statusflöde, deadlines' },
{ slug: 'mediebank', name: 'Mediebank', cat: 'operativ', desc: 'Lagring av bilder, video, grafik', longDesc: 'Central lagring av media. Nyckelfunktioner: Taggning, sök, rättighetsspårning' },
{ slug: 'tidrapport-debitering-media', name: 'Tidrapport & Debitering', cat: 'operativ', desc: 'Logga tid per kund', longDesc: 'Logga tid per kund. Nyckelfunktioner: Timer, budget vs utfall, fakturaunderlag' },
],
},
{
slug: 'fitness', name: 'Fitness & Sport', icon: Dumbbell,
description: 'Medlemskap, klasser & pass',
modules: [
{ slug: 'fitnesskontoplan', name: 'Fitnesskontoplan', cat: 'bokforing', desc: 'BAS-konton: medlemskap, PT, klippkort, shop', longDesc: 'BAS-konton per intäktstyp: medlemskap, PT, klippkort, shop. Nyckelfunktioner: Konton 3010 (medlemsavgift), 3020 (PT), 2420 (klippkortsskuld), 3030 (shop)' },
{ slug: 'momssplit-idrott-pt', name: 'Momssplit idrott/PT', cat: 'bokforing', desc: 'Moms 6% idrott, 25% PT/spa/shop', longDesc: 'Moms 6% på idrottslig verksamhet, 25% på PT, spa, shop. Nyckelfunktioner: Automatisk momssats per tjänsttyp, blandad verksamhet' },
{ slug: 'autogiro-periodisering', name: 'Autogiro-periodisering', cat: 'bokforing', desc: 'Autogiro som intäkt rätt period', longDesc: 'Bokför autogiro-dragningar som intäkt rätt period. Nyckelfunktioner: Förskottsbetalning, periodisering konto 2970, misslyckade dragningar' },
{ slug: 'klippkort-som-skuld', name: 'Klippkort som skuld', cat: 'bokforing', desc: 'Klippkort som förutbetald intäkt', longDesc: 'Sålda klippkort/startpaket bokförs som förutbetald intäkt. Nyckelfunktioner: Konto 2420, intäktsföring per nyttjat tillfälle, förfallohantering' },
{ slug: 'friskvardsbidrag', name: 'Friskvårdsbidrag', cat: 'bokforing', desc: 'Friskvårdsbidrag (max 5 000 SEK)', longDesc: 'Hantera friskvårdsbidrag från arbetsgivare. Nyckelfunktioner: Skatteverkets gräns (5 000 SEK), kvittogenerering, dokumentation' },
{ slug: 'churn-rate', name: 'Churn rate', cat: 'rapport', desc: 'Medlemsavgång per månad', longDesc: 'Medlemsavgång per månad. Nyckelfunktioner: Trend, orsaksanalys, retention per avtalstyp' },
{ slug: 'intakt-per-medlem', name: 'Intäkt per medlem', cat: 'rapport', desc: 'Genomsnittlig intäkt per medlem', longDesc: 'Genomsnittlig intäkt per aktiv medlem. Nyckelfunktioner: Inklusive PT, shop, extra tjänster' },
{ slug: 'belaggningsgrad-klasser', name: 'Beläggningsgrad klasser', cat: 'rapport', desc: 'Fyllnadsgrad per träningspass', longDesc: 'Fyllnadsgrad per gruppträningspass. Nyckelfunktioner: Per klass/tid, optimering av schema' },
{ slug: 'autogiro-rapport-import', name: 'Autogiro-rapport-import', cat: 'import', desc: 'Importera autogiro (Bankgirot)', longDesc: 'Importera autogiro-dragningsrapport (Bankgirot). Nyckelfunktioner: Lyckade/misslyckade, auto-kontosättning, period-matchning' },
{ slug: 'kassarapport-import-fitness', name: 'Kassarapport-import', cat: 'import', desc: 'Importera dagskassa', longDesc: 'Importera dagskassa. Nyckelfunktioner: Drop-in, PT-paket, shop; auto-kontosättning' },
{ slug: 'medlemshantering', name: 'Medlemshantering', cat: 'operativ', desc: 'Registrera och hantera medlemskap', longDesc: 'Registrera och hantera medlemskap. Nyckelfunktioner: Avtalstyper, förnyelse, uppsägning' },
{ slug: 'klassbokning', name: 'Klassbokning', cat: 'operativ', desc: 'Boka platser i träningsklasser', longDesc: 'Boka platser i träningspass. Nyckelfunktioner: Schema, platsbegränsning, väntelista' },
{ slug: 'pt-bokning', name: 'PT-bokning', cat: 'operativ', desc: 'Boka personlig tränare', longDesc: 'Boka personlig tränare. Nyckelfunktioner: Paket, kundhistorik, kalender' },
{ slug: 'tilltradeskontroll', name: 'Tillträdeskontroll', cat: 'operativ', desc: 'Inpassering till anläggning', longDesc: 'Inpassering till anläggning. Nyckelfunktioner: Nyckelbricka/QR, besökslogg' },
],
},
{
slug: 'fordon', name: 'Fordon & Verkstad', icon: Wrench,
description: 'Arbetsorder, delar & fordon',
modules: [
{ slug: 'verkstadskontoplan', name: 'Verkstadskontoplan', cat: 'bokforing', desc: 'BAS-konton: arbete, reservdelar, besiktning', longDesc: 'BAS-konton per intäktstyp: arbete, reservdelar, besiktning. Nyckelfunktioner: Konton 3010 (arbetsintäkt), 3020 (reservdelar), 4010 (inköp reservdelar)' },
{ slug: 'arbetsorder-till-faktura', name: 'Arbetsorder → Faktura', cat: 'bokforing', desc: 'Faktura direkt från arbetsorder', longDesc: 'Generera faktura direkt från arbetsorder. Nyckelfunktioner: Timpris + delar, automatisk summering, konto-koppling' },
{ slug: 'reservdelslager', name: 'Reservdelslager', cat: 'bokforing', desc: 'Lagervärdering reservdelar (FIFO)', longDesc: 'Lagervärdering av reservdelar. Nyckelfunktioner: FIFO, konto 1460, månadsbokslut, inkuransavdrag' },
{ slug: 'vmb-begagnade-delar', name: 'VMB (begagnade delar)', cat: 'bokforing', desc: 'Vinstmarginalbeskattning', longDesc: 'Vinstmarginalbeskattning vid försäljning av begagnade reservdelar. Nyckelfunktioner: Beräkning av vinstmarginal, moms enbart på marginalen, konto 2640' },
{ slug: 'garantiavsattning', name: 'Garantiavsättning', cat: 'bokforing', desc: 'Avsätt för garantikostnader', longDesc: 'Avsätt för framtida garantikostnader. Nyckelfunktioner: Avsättning konto 2290, upplösning vid faktisk kostnad' },
{ slug: 'genomsnittligt-ordervarde', name: 'Genomsnittligt ordervärde', cat: 'rapport', desc: 'Snittfaktura per arbetsorder', longDesc: 'Snittfaktura per arbetsorder. Nyckelfunktioner: Arbete vs delar, trend, kundtypsanalys' },
{ slug: 'reservdelsmarginal', name: 'Reservdelsmarginal', cat: 'rapport', desc: 'Inpris vs utpris på delar', longDesc: 'Inpris vs utpris på delar. Nyckelfunktioner: Per kategori, trend' },
{ slug: 'verkstadsbelaggning', name: 'Verkstadsbeläggning', cat: 'rapport', desc: 'Utnyttjandegrad lyftar/mekaniker', longDesc: 'Utnyttjandegrad av lyftar och mekaniker. Nyckelfunktioner: Per resurs, optimering' },
{ slug: 'reservdelsleverantor-import', name: 'Reservdelsleverantör-import', cat: 'import', desc: 'Importera från Mekonomen, Autoexperten', longDesc: 'Importera priser och leveranser. Nyckelfunktioner: Mekonomen, Autoexperten-format, auto-kontosättning' },
{ slug: 'arbetsorder', name: 'Arbetsorder', cat: 'operativ', desc: 'Skapa och hantera verkstadsarbeten', longDesc: 'Skapa och hantera arbeten. Nyckelfunktioner: Registrering, mekaniker-tilldelning, statusflöde' },
{ slug: 'fordonsregister', name: 'Fordonsregister', cat: 'operativ', desc: 'Register med fordonshistorik', longDesc: 'Register med fordonshistorik. Nyckelfunktioner: Regnummer, servicehistorik, besiktningsdatum' },
{ slug: 'verkstadsplanering', name: 'Verkstadsplanering', cat: 'operativ', desc: 'Beläggning av lyftar och personal', longDesc: 'Beläggning av lyftar och personal. Nyckelfunktioner: Kalendervy, bokningstider, akut-slot' },
{ slug: 'besiktningspaminnelse', name: 'Besiktningspåminnelse', cat: 'operativ', desc: 'Påminnelser inför besiktning', longDesc: 'Automatiska påminnelser. Nyckelfunktioner: SMS/e-post, historik' },
],
},
{
slug: 'bemanning', name: 'Bemanning & HR', icon: UserPlus,
description: 'Kandidater, uppdrag & timmar',
modules: [
{ slug: 'bemanningskontoplan', name: 'Bemanningskontoplan', cat: 'bokforing', desc: 'BAS-konton: uthyrning, rekrytering, UE, löner', longDesc: 'BAS-konton per intäkts- och kostnadstyp: uthyrning, rekrytering, underkonsulter. Nyckelfunktioner: Konton 3010 (uthyrningsintäkt), 3020 (rekryteringsarvode), 4010 (UE-kostnad), 7010 (löner)' },
{ slug: 'tidrapport-dubbelbokforing', name: 'Tidrapport → Dubbelbokföring', cat: 'bokforing', desc: 'Genererar kundfaktura och löneunderlag', longDesc: 'Tidrapport genererar både kundfaktura och löneunderlag. Nyckelfunktioner: Timrapport, kunddebitering, lönekostnad, marginalberäkning per uppdrag' },
{ slug: 'arbetsgivaravgifter-periodisering', name: 'Arbetsgivaravgifter-periodisering', cat: 'bokforing', desc: 'Periodisera arbetsgivaravgifter (31.42%)', longDesc: 'Periodisera arbetsgivaravgifter korrekt per månad. Nyckelfunktioner: Beräkning per anställd, konto 7510, semesterlöneskuld, period-vy' },
{ slug: 'ue-verifiering-bemanning', name: 'UE-verifiering', cat: 'bokforing', desc: 'F-skatt och försäkring för underkonsulter', longDesc: 'Kontrollera F-skatt och försäkring för underkonsulter. Nyckelfunktioner: Skatteverkets API-kontroll, dokumentation, fakturamottagning' },
{ slug: 'traktamente-vid-uthyrning', name: 'Traktamente vid uthyrning', cat: 'bokforing', desc: 'Traktamente per Skatteverkets schabloner', longDesc: 'Beräkna traktamente för uthyrd personal. Nyckelfunktioner: Skatteverkets schabloner, skattefri del, förmånsbeskattning vid övernattning' },
{ slug: 'marginal-per-konsult', name: 'Marginal per konsult', cat: 'rapport', desc: 'Debiterat minus lönekostnad', longDesc: 'Debiterat minus lönekostnad per uthyrd person. Nyckelfunktioner: Per uppdrag, trend, jämförelse' },
{ slug: 'belaggningsgrad-bemanning', name: 'Beläggningsgrad', cat: 'rapport', desc: 'Andel uthyrd tid', longDesc: 'Andel uthyrd tid av total tid. Nyckelfunktioner: Per konsult, team, totalt' },
{ slug: 'fakturerat-per-konsult', name: 'Fakturerat per konsult', cat: 'rapport', desc: 'Total fakturering per person', longDesc: 'Total fakturering per person och period. Nyckelfunktioner: Ranking, budget vs utfall' },
{ slug: 'tidrapport-import-bemanning', name: 'Tidrapport-import', cat: 'import', desc: 'Importera timmar från kundsystem', longDesc: 'Importera timmar från kundsystem. Nyckelfunktioner: CSV, kundgodkännande-status, auto-koppling till uppdrag' },
{ slug: 'lonesystem-import', name: 'Lönesystem-import', cat: 'import', desc: 'Importera från Hogia/Visma Lön', longDesc: 'Importera löneunderlag. Nyckelfunktioner: Export från Hogia, Visma Lön; auto-bokföring av lön + sociala avgifter' },
{ slug: 'kandidatregister', name: 'Kandidatregister', cat: 'operativ', desc: 'Rekryteringsdatabas', longDesc: 'Rekryteringsdatabas. Nyckelfunktioner: CV, kompetens, status, GDPR-rensning' },
{ slug: 'uppdragshantering-bemanning', name: 'Uppdragshantering', cat: 'operativ', desc: 'Matcha kandidater med uppdrag', longDesc: 'Matcha kandidater med uppdrag. Nyckelfunktioner: Kravprofil, matchning, förlängning' },
{ slug: 'avtalshantering-bemanning', name: 'Avtalshantering', cat: 'operativ', desc: 'Kundavtal och konsultavtal', longDesc: 'Kundavtal och konsultavtal. Nyckelfunktioner: Templates, förnyelsepåminnelser' },
{ slug: 'kompetensregister', name: 'Kompetensregister', cat: 'operativ', desc: 'Certifieringar och utbildningar', longDesc: 'Certifieringar och utbildningar. Nyckelfunktioner: Per person, giltighetstid' },
{ slug: 'compliance-bemanning', name: 'Compliance', cat: 'operativ', desc: 'ID06, arbetstillstånd, försäkringar', longDesc: 'Lagar och branschkrav. Nyckelfunktioner: ID06-kontroll, arbetstillstånd' },
],
},
{
slug: 'tillverkning', name: 'Tillverkning & Industri', icon: Factory,
description: 'Produktion, lager & kvalitet',
modules: [
{ slug: 'tillverkningskontoplan', name: 'Tillverkningskontoplan', cat: 'bokforing', desc: 'BAS-konton per lagertyp och kostnadsställe', longDesc: 'BAS-konton per lagertyp och kostnadsställe. Nyckelfunktioner: Konton 1410 (råvarulager), 1420 (PIA), 1450 (färdigvarulager), 4010 (materialinköp)' },
{ slug: 'trestegslagervarderin', name: 'Trestegslagervärdering', cat: 'bokforing', desc: 'Värdera råvaror, PIA och färdigvaror', longDesc: 'Värdera råvaror, halvfabrikat (PIA) och färdigvaror separat. Nyckelfunktioner: FIFO per lagernivå, omvärdering vid produktion, konto 1410/1420/1450' },
{ slug: 'bom-kalkyl-lagervarde', name: 'BOM-kalkyl → Lagervärde', cat: 'bokforing', desc: 'Strukturlista styr självkostnad', longDesc: 'Strukturlista (BOM) styr självkostnad per tillverkad enhet. Nyckelfunktioner: Materialkomponenter, arbetskostnad, OH-pålägg, lagervärde per BOM' },
{ slug: 'produktionsavvikelse', name: 'Produktionsavvikelse', cat: 'bokforing', desc: 'Avvikelse standard vs faktisk kostnad', longDesc: 'Bokför avvikelse mellan kalkylerad och faktisk kostnad. Nyckelfunktioner: Standard- vs verklig kostnad, avvikelsekonto 4900' },
{ slug: 'maskinavskrivning-industri', name: 'Maskinavskrivning', cat: 'bokforing', desc: 'Avskrivning produktionsutrustning', longDesc: 'Avskrivningsplan för produktionsutrustning. Nyckelfunktioner: Räkenskapsenlig avskrivning, konto 1210/7831, restvärdeavskrivning' },
{ slug: 'energiskatteavdrag', name: 'Energiskatteavdrag', cat: 'bokforing', desc: 'Avdrag för energiskatt vid industri', longDesc: 'Avdrag för energiskatt vid industriell tillverkning. Nyckelfunktioner: Nedsättning av energiskatt, ansökan till Skatteverket, periodisering' },
{ slug: 'materialeffektivitet', name: 'Materialeffektivitet', cat: 'rapport', desc: 'Faktisk vs BOM-förbrukning', longDesc: 'Faktisk materialförbrukning vs BOM-kalkyl. Nyckelfunktioner: Spill %, kostnad för avvikelse, trend' },
{ slug: 'kostnad-per-producerad-enhet', name: 'Kostnad per producerad enhet', cat: 'rapport', desc: 'Total enhetskostnad', longDesc: 'Total kostnad (material + arbete + OH) per enhet. Nyckelfunktioner: Per produkt, period-jämförelse' },
{ slug: 'oee', name: 'OEE', cat: 'rapport', desc: 'Overall Equipment Effectiveness', longDesc: 'Maskinutnyttjandegrad. Nyckelfunktioner: Tillgänglighet × prestanda × kvalitet' },
{ slug: 'produktionsrapport-import', name: 'Produktionsrapport-import', cat: 'import', desc: 'Importera utfallsdata per order', longDesc: 'Importera utfallsdata per tillverkningsorder. Nyckelfunktioner: Godkänt/kasserat, materialförbrukning, auto-kontosättning' },
{ slug: 'lagerexport-import', name: 'Lagerexport-import', cat: 'import', desc: 'Importera lagerförändringar', longDesc: 'Importera lagerförändringar. Nyckelfunktioner: Per lagernivå, inventeringsresultat' },
{ slug: 'produktionsplanering', name: 'Produktionsplanering', cat: 'operativ', desc: 'Schemalägga tillverkningsorder', longDesc: 'Schemalägga tillverkningsorder. Nyckelfunktioner: Ordervy, maskinbeläggning, kapacitet' },
{ slug: 'strukturlista-bom', name: 'Strukturlista (BOM)', cat: 'operativ', desc: 'Materialstruktur per produkt', longDesc: 'Definiera materialstruktur. Nyckelfunktioner: Komponenter, nivåer, alternativa delar' },
{ slug: 'kvalitetskontroll', name: 'Kvalitetskontroll', cat: 'operativ', desc: 'Säkerställ produktkvalitet', longDesc: 'Säkerställ produktkvalitet. Nyckelfunktioner: Kontrollplaner, mätprotokoll, avvikelser' },
{ slug: 'maskinunderhall', name: 'Maskinunderhåll', cat: 'operativ', desc: 'Förebyggande underhåll', longDesc: 'Förebyggande och avhjälpande underhåll. Nyckelfunktioner: Schema, felanmälan, reservdelsförbrukning' },
{ slug: 'sparbarhet-batch', name: 'Spårbarhet & Batch', cat: 'operativ', desc: 'Spåra råvaror till färdig produkt', longDesc: 'Spåra råvaror till färdig produkt. Nyckelfunktioner: Batchnummer, komponentspårning, återkallning' },
],
},
{
slug: 'konsult', name: 'Konsult & Rådgivning', icon: Briefcase,
description: 'Uppdrag, tid & debitering',
modules: [
{ slug: 'konsultkontoplan', name: 'Konsultkontoplan', cat: 'bokforing', desc: 'BAS-konton: arvode, resor, utlägg', longDesc: 'BAS-konton för konsultintäkt, utlägg, resor. Nyckelfunktioner: Konton 3010 (konsultarvode), 5800 (resekostnad), 6990 (vidarefakturerade utlägg)' },
{ slug: 'traktamente', name: 'Traktamente', cat: 'bokforing', desc: 'Traktamente per Skatteverkets schabloner', longDesc: 'Beräkna traktamente vid tjänsteresor enligt Skatteverkets schabloner. Nyckelfunktioner: Hel/halvdag, reducering vid fri måltid, skattefri del, förmånsvärde' },
{ slug: 'hemmakontor-avdrag', name: 'Hemmakontor-avdrag', cat: 'bokforing', desc: 'Schablonavdrag (2000/4000 SEK)', longDesc: 'Schablonavdrag för hemmakontor. Nyckelfunktioner: Fast belopp (2 000/4 000 SEK), alternativt verklig kostnad med yta-beräkning' },
{ slug: 'wip-bevakning-konsult', name: 'WIP-bevakning', cat: 'bokforing', desc: 'Pågående ej fakturerat arbete', longDesc: 'Pågående ej fakturerat arbete. Nyckelfunktioner: Konto 1470, per uppdrag, åldringsanalys' },
{ slug: 'debiteringsgrad-konsult', name: 'Debiteringsgrad', cat: 'rapport', desc: 'Debiterbara timmar av total', longDesc: 'Debiterbara timmar / total arbetstid. Nyckelfunktioner: Mål vs utfall, trend' },
{ slug: 'intakt-per-konsult', name: 'Intäkt per konsult', cat: 'rapport', desc: 'Total fakturering per person', longDesc: 'Total fakturering per person. Nyckelfunktioner: Ranking, budget vs utfall' },
{ slug: 'tidrapport-import-konsult', name: 'Tidrapport-import', cat: 'import', desc: 'Importera från Harvest, Toggl', longDesc: 'Importera tid från Harvest, Toggl, Clockify. Nyckelfunktioner: CSV, koppling till uppdrag, fakturaunderlag' },
{ slug: 'uppdragshantering-konsult', name: 'Uppdragshantering', cat: 'operativ', desc: 'Administrera kunduppdrag', longDesc: 'Administrera kunduppdrag. Nyckelfunktioner: Milstolpar, statusvy, lönsamhetsanalys' },
{ slug: 'tidrapportering-konsult', name: 'Tidrapportering', cat: 'operativ', desc: 'Tidregistrering per uppdrag', longDesc: 'Detaljerad tidregistrering. Nyckelfunktioner: Timer, kategorier, veckoöversikt' },
{ slug: 'offert-avtal', name: 'Offert & Avtal', cat: 'operativ', desc: 'Skapa offerter och avtal', longDesc: 'Skapa offerter. Nyckelfunktioner: Mallar, godkännande, avtalsstatus' },
],
},
{
slug: 'event', name: 'Event & Underhållning', icon: PartyPopper,
description: 'Evenemang, biljetter & artister',
modules: [
{ slug: 'eventkontoplan', name: 'Eventkontoplan', cat: 'bokforing', desc: 'BAS-konton: biljetter, sponsring, F&B, merch', longDesc: 'BAS-konton per intäktstyp: biljetter, sponsring, F&B, merch. Nyckelfunktioner: Konton 3010 (biljettintäkt), 3020 (sponsorintäkt), 3030 (F&B), 3040 (merch)' },
{ slug: 'biljettintakt-som-forskott', name: 'Biljettintäkt som förskott', cat: 'bokforing', desc: 'Förskottssålda biljetter som skuld', longDesc: 'Bokför förskottssålda biljetter som skuld tills eventet äger rum. Nyckelfunktioner: Konto 2420, intäktsföring vid genomfört event, avbokningshantering' },
{ slug: 'kulturmoms-event', name: 'Kulturmoms event', cat: 'bokforing', desc: 'Moms 6% kultur, 25% F&B/merch', longDesc: 'Hantera reducerad moms på kulturella föreställningar. Nyckelfunktioner: Moms 6% på biljetter, 25% på mat/dryck/merch, blandad verksamhet' },
{ slug: 'artistskatt-sink', name: 'Artistskatt (SINK)', cat: 'bokforing', desc: 'SINK-skatt (15%) utländska artister', longDesc: 'Hantera källskatt för utländska artister. Nyckelfunktioner: Beräkning av SINK (15%), inbetalning till Skatteverket, underlag per artist' },
{ slug: 'sponsorintaktsbokforing', name: 'Sponsorintäktsbokföring', cat: 'bokforing', desc: 'Sponsoravtal med periodisering', longDesc: 'Bokför sponsoravtal med motprestationsperiodisering. Nyckelfunktioner: Periodisering per event, konto 3910, avtalsvärde vs motprestation' },
{ slug: 'intakt-per-besokare', name: 'Intäkt per besökare', cat: 'rapport', desc: 'Total intäkt per besökare', longDesc: 'Total intäkt delat på antal besökare. Nyckelfunktioner: Biljett + F&B + merch, jämförelse mellan event' },
{ slug: 'budget-vs-utfall', name: 'Budget vs utfall', cat: 'rapport', desc: 'Eventbudget vs verkligt resultat', longDesc: 'Eventbudget jämfört med verkligt resultat. Nyckelfunktioner: Per kostnadspost, totalt, vinstmarginal' },
{ slug: 'sponsorandel', name: 'Sponsorandel', cat: 'rapport', desc: 'Sponsorintäkt som andel av total', longDesc: 'Sponsorintäkt som andel av total intäkt. Nyckelfunktioner: Per event, trend, beroendeanalys' },
{ slug: 'biljettsystem-import', name: 'Biljettsystem-import', cat: 'import', desc: 'Importera från Ticketmaster, Eventbrite', longDesc: 'Importera försäljningsdata från biljettsystem. Nyckelfunktioner: Ticketmaster, Eventbrite, NORTIC; auto-kontosättning inkl avgifter' },
{ slug: 'pos-rapport-import-event', name: 'POS-rapport-import', cat: 'import', desc: 'Importera F&B- och merch-försäljning', longDesc: 'Importera F&B- och merch-försäljning. Nyckelfunktioner: Kassarapport från eventbar/shop, momssplit' },
{ slug: 'evenemangsplanering', name: 'Evenemangsplanering', cat: 'operativ', desc: 'Planera och koordinera event', longDesc: 'Planera och koordinera event. Nyckelfunktioner: Checklista, tidslinje, budget' },
{ slug: 'biljettforsaljning', name: 'Biljettförsäljning', cat: 'operativ', desc: 'Sälj och hantera biljetter', longDesc: 'Sälj och hantera biljetter. Nyckelfunktioner: Biljettyper, priskategorier, QR-validering' },
{ slug: 'artist-talangbokning', name: 'Artist- & Talangbokning', cat: 'operativ', desc: 'Boka artister och föreläsare', longDesc: 'Boka artister och föreläsare. Nyckelfunktioner: Kontrakt, riderhantering, schema' },
{ slug: 'sponsorhantering', name: 'Sponsorhantering', cat: 'operativ', desc: 'Hantera sponsoravtal', longDesc: 'Hantera sponsoravtal. Nyckelfunktioner: Avtalsnivåer, motprestationer, uppföljning' },
{ slug: 'volontarhantering', name: 'Volontärhantering', cat: 'operativ', desc: 'Koordinera frivilliga', longDesc: 'Koordinera frivilliga. Nyckelfunktioner: Registrering, skifttilldelning, kommunikation' },
],
},
{
slug: 'fastighet', name: 'Fastighetsförvaltning', icon: Building,
description: 'Objekt, hyresgäster & underhåll',
modules: [
{ slug: 'fastighetskontoplan', name: 'Fastighetskontoplan', cat: 'bokforing', desc: 'BAS-konton per fastighet och kostnadstyp', longDesc: 'BAS-konton per fastighet och kostnadstyp. Nyckelfunktioner: Konton 1110 (byggnader), 3010 (hyresintäkt), 5010 (fastighetsskötsel), 7720 (fastighetsskatt)' },
{ slug: 'hyresintakt-periodisering', name: 'Hyresintäkt-periodisering', cat: 'bokforing', desc: 'Periodisera hyresintäkter per månad', longDesc: 'Periodisera hyresintäkter korrekt per månad. Nyckelfunktioner: Förskottshyra (konto 2970), hyresrabatt, tomgångskostnad' },
{ slug: 'fastighetsskatt', name: 'Fastighetsskatt', cat: 'bokforing', desc: 'Beräkna fastighetsskatt/avgift', longDesc: 'Beräkna och bokföra fastighetsskatt/avgift. Nyckelfunktioner: Taxeringsvärde, skattesats per fastighetstyp, konto 7720, periodisering' },
{ slug: 'fastighetsavskrivning', name: 'Fastighetsavskrivning', cat: 'bokforing', desc: 'Avskrivning per byggnad (K2/K3)', longDesc: 'Avskrivningsplan per byggnad (lång löptid). Nyckelfunktioner: Komponentavskrivning (K3) eller linjär (K2), konto 1119/7820' },
{ slug: 'underhallsfond', name: 'Underhållsfond', cat: 'bokforing', desc: 'Avsätt medel för planerat underhåll', longDesc: 'Avsätt medel för planerat underhåll. Nyckelfunktioner: Avsättning per år, upplösning vid genomfört underhåll, 10-årsplan kopplad' },
{ slug: 'rot-vid-renovering', name: 'ROT vid renovering', cat: 'bokforing', desc: 'ROT-avdrag vid renovering', longDesc: 'Hantera ROT-avdrag för hyresgäster vid renovering. Nyckelfunktioner: Beräkning, personnummer, Skatteverket-rapportering' },
{ slug: 'indexupprakning', name: 'Indexuppräkning', cat: 'bokforing', desc: 'Automatisk hyreshöjning vid index', longDesc: 'Automatisk beräkning av hyreshöjning vid index. Nyckelfunktioner: KPI-koppling, beräkning per kontrakt, underlag för avisering' },
{ slug: 'driftnetto-per-fastighet', name: 'Driftnetto per fastighet', cat: 'rapport', desc: 'NOI per fastighet', longDesc: 'Hyresintäkt minus driftskostnad. Nyckelfunktioner: Per fastighet/objekt, jämförelse, trend' },
{ slug: 'vakansgrad', name: 'Vakansgrad', cat: 'rapport', desc: 'Andel outhyrda ytor', longDesc: 'Andel outhyrda ytor. Nyckelfunktioner: Per fastighet, trendanalys, intäktsbortfall' },
{ slug: 'underhallskostnad-per-m2', name: 'Underhållskostnad per m²', cat: 'rapport', desc: 'Underhåll per kvadratmeter', longDesc: 'Total underhållskostnad per kvadratmeter. Nyckelfunktioner: Plan vs utfall, per fastighet, kategori' },
{ slug: 'hyresreskontra-import', name: 'Hyresreskontra-import', cat: 'import', desc: 'Importera hyresreskontra', longDesc: 'Importera hyresreskontra. Nyckelfunktioner: Avier, betalningar, restbelopp; auto-kontosättning' },
{ slug: 'energirapport-import', name: 'Energirapport-import', cat: 'import', desc: 'Importera energiförbrukning', longDesc: 'Importera energiförbrukning. Nyckelfunktioner: El/vatten/fjärrvärme, per fastighet, kostnadsfördelning' },
{ slug: 'objektregister', name: 'Objektregister', cat: 'operativ', desc: 'Fastighets- och lokalöversikt', longDesc: 'Fastighets- och lokalöversikt. Nyckelfunktioner: Adresser, ytor, byggnadsår, dokument' },
{ slug: 'hyresgasthantering', name: 'Hyresgästhantering', cat: 'operativ', desc: 'Administrera hyresavtal', longDesc: 'Administrera hyresavtal. Nyckelfunktioner: Avtalstider, kontakt, uppsägningsflöde' },
{ slug: 'hyresavier', name: 'Hyresavier', cat: 'operativ', desc: 'Generera och skicka avier', longDesc: 'Generera och skicka avier. Nyckelfunktioner: Automatisk avisering, tillägg, autogiro' },
{ slug: 'felanmalan', name: 'Felanmälan', cat: 'operativ', desc: 'Hantera felanmälningar', longDesc: 'Hantera felanmälningar. Nyckelfunktioner: Webformulär, prioritet, hantverkarstyrning' },
{ slug: 'underhallsplanering', name: 'Underhållsplanering', cat: 'operativ', desc: 'Långsiktig underhållsplanering', longDesc: 'Långsiktig och akut. Nyckelfunktioner: 10-årsplan, kostnadsprognos, status' },
{ slug: 'besiktning-rondering', name: 'Besiktning & Rondering', cat: 'operativ', desc: 'Dokumentera besiktningar', longDesc: 'Dokumentera besiktningar. Nyckelfunktioner: Protokoll, foto, avvikelserapport' },
{ slug: 'energiovervakning', name: 'Energiövervakning', cat: 'operativ', desc: 'Följ energiförbrukning', longDesc: 'Följ energiförbrukning. Nyckelfunktioner: El/vatten/värme, jämförelse, energideklaration' },
],
},
]
export function getSectorBySlug(slug: string): Sector | undefined {
return sectors.find(s => s.slug === slug)
}
export function getModuleBySlug(sectorSlug: string, moduleSlug: string): { sector: Sector; module: ModuleItem } | undefined {
const sector = sectors.find(s => s.slug === sectorSlug)
if (!sector) return undefined
const module = sector.modules.find(m => m.slug === moduleSlug)
if (!module) return undefined
return { sector, module }
}
-377
View File
@@ -1,377 +0,0 @@
/**
* Notification scheduling and quiet hours handling
*/
import { SupabaseClient } from '@supabase/supabase-js'
import {
sendPushToMultiple,
createTaxDeadlinePayload,
createInvoicePayload,
type PushSubscriptionKeys,
} from './web-push'
import type { NotificationSettings, NotificationType } from '@/types'
/**
* Check if current time is within quiet hours (Sweden timezone)
*/
export function isWithinQuietHours(
settings: NotificationSettings,
now: Date = new Date()
): boolean {
// Convert to Sweden timezone
const swedenTime = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now)
const [hours, minutes] = swedenTime.split(':').map(Number)
const currentMinutes = hours * 60 + minutes
// Parse quiet hours
const [startHours, startMinutes] = settings.quiet_start.split(':').map(Number)
const [endHours, endMinutes] = settings.quiet_end.split(':').map(Number)
const quietStartMinutes = startHours * 60 + startMinutes
const quietEndMinutes = endHours * 60 + endMinutes
// Handle overnight quiet hours (e.g., 21:00 to 08:00)
if (quietStartMinutes > quietEndMinutes) {
// Quiet if after start OR before end
return currentMinutes >= quietStartMinutes || currentMinutes < quietEndMinutes
}
// Normal range (e.g., 01:00 to 06:00)
return currentMinutes >= quietStartMinutes && currentMinutes < quietEndMinutes
}
/**
* Check if a notification has already been sent (prevent duplicates)
*/
async function wasNotificationSent(
supabase: SupabaseClient,
userId: string,
notificationType: NotificationType,
referenceId: string,
daysBefore: number
): Promise<boolean> {
const { data } = await supabase
.from('notification_log')
.select('id')
.eq('user_id', userId)
.eq('notification_type', notificationType)
.eq('reference_id', referenceId)
.eq('days_before', daysBefore)
.single()
return !!data
}
/**
* Log a sent notification
*/
async function logNotification(
supabase: SupabaseClient,
userId: string,
notificationType: NotificationType,
referenceId: string,
daysBefore: number
): Promise<void> {
await supabase.from('notification_log').insert({
user_id: userId,
notification_type: notificationType,
reference_id: referenceId,
days_before: daysBefore,
delivery_status: 'sent',
})
}
/**
* Get user's push subscriptions
*/
async function getUserSubscriptions(
supabase: SupabaseClient,
userId: string
): Promise<PushSubscriptionKeys[]> {
const { data } = await supabase
.from('push_subscriptions')
.select('endpoint, p256dh, auth')
.eq('user_id', userId)
.eq('is_active', true)
return data || []
}
/**
* Get user's notification settings
*/
async function getUserNotificationSettings(
supabase: SupabaseClient,
userId: string
): Promise<NotificationSettings | null> {
const { data } = await supabase
.from('notification_settings')
.select('*')
.eq('user_id', userId)
.single()
return data
}
/**
* Disable gone (unsubscribed) push subscriptions
*/
async function disableGoneSubscriptions(
supabase: SupabaseClient,
endpoints: string[]
): Promise<void> {
if (endpoints.length === 0) return
await supabase
.from('push_subscriptions')
.update({ is_active: false })
.in('endpoint', endpoints)
}
/**
* Send tax deadline notifications
*/
export async function sendTaxDeadlineNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
// Calculate dates for 7 days and 1 day before
const in7Days = new Date(today)
in7Days.setDate(in7Days.getDate() + 7)
const in7DaysStr = in7Days.toISOString().split('T')[0]
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const tomorrowStr = tomorrow.toISOString().split('T')[0]
// Fetch tax deadlines due in 7 days or 1 day
const { data: deadlines } = await supabase
.from('deadlines')
.select('id, user_id, title, due_date')
.eq('deadline_type', 'tax')
.eq('is_completed', false)
.in('status', ['upcoming', 'action_needed'])
.in('due_date', [in7DaysStr, tomorrowStr, todayStr])
if (!deadlines || deadlines.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userDeadlines = new Map<string, typeof deadlines>()
for (const deadline of deadlines) {
const list = userDeadlines.get(deadline.user_id) || []
list.push(deadline)
userDeadlines.set(deadline.user_id, list)
}
for (const [userId, userDls] of userDeadlines) {
// Get user settings
const settings = await getUserNotificationSettings(supabase, userId)
// Skip if tax deadlines disabled
if (settings && !settings.tax_deadlines_enabled) {
skipped += userDls.length
continue
}
// Skip if push disabled
if (settings && !settings.push_enabled) {
skipped += userDls.length
continue
}
// Skip if within quiet hours
if (settings && isWithinQuietHours(settings)) {
skipped += userDls.length
continue
}
// Get subscriptions
const subscriptions = await getUserSubscriptions(supabase, userId)
if (subscriptions.length === 0) {
skipped += userDls.length
continue
}
for (const deadline of userDls) {
const daysUntil = Math.ceil(
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
)
// Check if already sent for this day count
const alreadySent = await wasNotificationSent(
supabase,
userId,
'tax_deadline',
deadline.id,
daysUntil
)
if (alreadySent) {
skipped++
continue
}
// Create and send notification
const payload = createTaxDeadlinePayload(
deadline.title,
deadline.due_date,
daysUntil,
deadline.id
)
const result = await sendPushToMultiple(subscriptions, payload)
// Log the notification
await logNotification(supabase, userId, 'tax_deadline', deadline.id, daysUntil)
// Disable gone subscriptions
if (result.goneSubscriptions.length > 0) {
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
}
if (result.successful > 0) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
/**
* Send invoice reminder notifications
*/
export async function sendInvoiceNotifications(
supabase: SupabaseClient
): Promise<{ sent: number; skipped: number }> {
let sent = 0
let skipped = 0
const today = new Date()
today.setHours(0, 0, 0, 0)
const todayStr = today.toISOString().split('T')[0]
// Calculate dates
const in3Days = new Date(today)
in3Days.setDate(in3Days.getDate() + 3)
const in3DaysStr = in3Days.toISOString().split('T')[0]
const daysAgo3 = new Date(today)
daysAgo3.setDate(daysAgo3.getDate() - 3)
const daysAgo3Str = daysAgo3.toISOString().split('T')[0]
const daysAgo7 = new Date(today)
daysAgo7.setDate(daysAgo7.getDate() - 7)
const daysAgo7Str = daysAgo7.toISOString().split('T')[0]
// Fetch invoices: due soon (3d, 0d) or overdue (+3d, +7d)
const { data: invoices } = await supabase
.from('invoices')
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
.in('status', ['sent', 'overdue'])
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
if (!invoices || invoices.length === 0) {
return { sent: 0, skipped: 0 }
}
// Group by user
const userInvoices = new Map<string, typeof invoices>()
for (const invoice of invoices) {
const list = userInvoices.get(invoice.user_id) || []
list.push(invoice)
userInvoices.set(invoice.user_id, list)
}
for (const [userId, userInvs] of userInvoices) {
const settings = await getUserNotificationSettings(supabase, userId)
if (settings && !settings.invoice_reminders_enabled) {
skipped += userInvs.length
continue
}
if (settings && !settings.push_enabled) {
skipped += userInvs.length
continue
}
if (settings && isWithinQuietHours(settings)) {
skipped += userInvs.length
continue
}
const subscriptions = await getUserSubscriptions(supabase, userId)
if (subscriptions.length === 0) {
skipped += userInvs.length
continue
}
for (const invoice of userInvs) {
const dueDate = new Date(invoice.due_date)
const daysUntil = Math.ceil((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
const isOverdue = daysUntil < 0
const notificationType: NotificationType = isOverdue ? 'invoice_overdue' : 'invoice_due'
const alreadySent = await wasNotificationSent(
supabase,
userId,
notificationType,
invoice.id,
Math.abs(daysUntil)
)
if (alreadySent) {
skipped++
continue
}
// customer is a single object from the join, but TypeScript sees it as an array from select()
const customer = invoice.customer as unknown as { name: string } | null
const customerName = customer?.name || 'Okänd kund'
const payload = createInvoicePayload(
invoice.invoice_number,
customerName,
invoice.total,
invoice.due_date,
isOverdue,
invoice.id
)
const result = await sendPushToMultiple(subscriptions, payload)
await logNotification(supabase, userId, notificationType, invoice.id, Math.abs(daysUntil))
if (result.goneSubscriptions.length > 0) {
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
}
if (result.successful > 0) {
sent++
} else {
skipped++
}
}
}
return { sent, skipped }
}
-203
View File
@@ -1,203 +0,0 @@
/**
* Web Push configuration and sending utilities
*/
import webpush from 'web-push'
// Configure web-push with VAPID keys
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
if (vapidPublicKey && vapidPrivateKey) {
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
}
/**
* Push subscription keys from the browser
*/
export interface PushSubscriptionKeys {
endpoint: string
p256dh: string
auth: string
}
/**
* Notification payload
*/
export interface NotificationPayload {
title: string
body: string
icon?: string
badge?: string
tag?: string
data?: {
url?: string
type?: string
id?: string
}
actions?: Array<{
action: string
title: string
icon?: string
}>
}
/**
* Send a push notification to a subscription
*/
export async function sendPushNotification(
subscription: PushSubscriptionKeys,
payload: NotificationPayload
): Promise<{ success: boolean; error?: string }> {
if (!vapidPublicKey || !vapidPrivateKey) {
console.error('VAPID keys not configured')
return { success: false, error: 'VAPID keys not configured' }
}
const pushSubscription = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}
try {
await webpush.sendNotification(
pushSubscription,
JSON.stringify(payload),
{
TTL: 86400, // 24 hours
urgency: 'normal',
}
)
return { success: true }
} catch (error: unknown) {
const err = error as { statusCode?: number; message?: string }
// Handle subscription gone (user unsubscribed)
if (err.statusCode === 410) {
return { success: false, error: 'subscription_gone' }
}
console.error('Push notification error:', err)
return { success: false, error: err.message || 'Unknown error' }
}
}
/**
* Send push notifications to multiple subscriptions
*/
export async function sendPushToMultiple(
subscriptions: PushSubscriptionKeys[],
payload: NotificationPayload
): Promise<{
successful: number
failed: number
goneSubscriptions: string[]
}> {
let successful = 0
let failed = 0
const goneSubscriptions: string[] = []
const results = await Promise.allSettled(
subscriptions.map((sub) => sendPushNotification(sub, payload))
)
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value.success) {
successful++
} else {
failed++
if (result.status === 'fulfilled' && result.value.error === 'subscription_gone') {
goneSubscriptions.push(subscriptions[index].endpoint)
}
}
})
return { successful, failed, goneSubscriptions }
}
/**
* Get the public VAPID key for client-side subscription
*/
export function getVapidPublicKey(): string | null {
return vapidPublicKey || null
}
/**
* Create notification payload for tax deadline
*/
export function createTaxDeadlinePayload(
title: string,
dueDate: string,
daysUntil: number,
deadlineId: string
): NotificationPayload {
const urgencyText = daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
return {
title: `📋 ${title}`,
body: `Deadline ${urgencyText} (${formatDate(dueDate)})`,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `tax-deadline-${deadlineId}`,
data: {
url: '/calendar',
type: 'tax_deadline',
id: deadlineId,
},
actions: [
{ action: 'view', title: 'Visa' },
{ action: 'dismiss', title: 'Avfärda' },
],
}
}
/**
* Create notification payload for invoice reminder
*/
export function createInvoicePayload(
invoiceNumber: string,
customerName: string,
amount: number,
dueDate: string,
isOverdue: boolean,
invoiceId: string
): NotificationPayload {
const title = isOverdue
? `⚠️ Obetald faktura #${invoiceNumber}`
: `💰 Faktura #${invoiceNumber} förfaller`
const body = isOverdue
? `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`
: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`
return {
title,
body,
icon: '/icons/icon-192.png',
badge: '/icons/badge-72.png',
tag: `invoice-${invoiceId}`,
data: {
url: `/invoices/${invoiceId}`,
type: isOverdue ? 'invoice_overdue' : 'invoice_due',
id: invoiceId,
},
actions: [
{ action: 'view', title: 'Visa faktura' },
],
}
}
/**
* Format date for notifications
*/
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('sv-SE', {
day: 'numeric',
month: 'short',
})
}
@@ -1,154 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
mapSuggestedCategory,
getBASAccount,
categorizeLineItem,
processLineItems,
calculateReceiptSplit,
getDefaultClassification,
} from '../receipt-categorizer'
import type { ExtractedLineItem } from '@/types'
describe('mapSuggestedCategory', () => {
it('maps AI categories to TransactionCategory', () => {
expect(mapSuggestedCategory('equipment')).toBe('expense_equipment')
expect(mapSuggestedCategory('software')).toBe('expense_software')
expect(mapSuggestedCategory('travel')).toBe('expense_travel')
expect(mapSuggestedCategory('office')).toBe('expense_office')
expect(mapSuggestedCategory('marketing')).toBe('expense_marketing')
expect(mapSuggestedCategory('professional_services')).toBe('expense_professional_services')
expect(mapSuggestedCategory('education')).toBe('expense_education')
expect(mapSuggestedCategory('other')).toBe('expense_other')
})
it('returns null for unknown categories', () => {
expect(mapSuggestedCategory('nonexistent')).toBeNull()
expect(mapSuggestedCategory(null)).toBeNull()
})
})
describe('getBASAccount', () => {
it('returns correct BAS account per category', () => {
expect(getBASAccount('expense_equipment')).toBe('5410')
expect(getBASAccount('expense_software')).toBe('5420')
expect(getBASAccount('expense_travel')).toBe('5800')
expect(getBASAccount('expense_office')).toBe('5010')
expect(getBASAccount('expense_marketing')).toBe('5910')
expect(getBASAccount('expense_professional_services')).toBe('6530')
expect(getBASAccount('expense_education')).toBe('6991')
expect(getBASAccount('expense_bank_fees')).toBe('6570')
expect(getBASAccount('income_services')).toBe('3001')
})
})
describe('categorizeLineItem', () => {
it('keyword patterns match Swedish terms — dator → expense_equipment', () => {
const result = categorizeLineItem('MacBook Pro dator')
expect(result.category).toBe('expense_equipment')
expect(result.confidence).toBe(0.7)
})
it('matches software patterns', () => {
const result = categorizeLineItem('Adobe Creative Cloud prenumeration')
expect(result.category).toBe('expense_software')
})
it('matches travel patterns', () => {
const result = categorizeLineItem('SJ tåg Stockholm-Malmö')
expect(result.category).toBe('expense_travel')
})
it('returns null category for unrecognized descriptions', () => {
const result = categorizeLineItem('xyzzy foobarbaz')
expect(result.category).toBeNull()
expect(result.confidence).toBe(0)
})
})
describe('processLineItems', () => {
it('prefers AI suggestion over pattern match', () => {
const items: ExtractedLineItem[] = [
{
description: 'MacBook Pro dator', // pattern → equipment
quantity: 1,
unitPrice: 15000,
lineTotal: 15000,
vatRate: 25,
suggestedCategory: 'software', // AI says software
confidence: 0.9,
},
]
const result = processLineItems(items)
expect(result[0].category).toBe('expense_software') // AI wins
expect(result[0].basAccount).toBe('5420')
})
it('falls back to pattern match when no AI suggestion', () => {
const items: ExtractedLineItem[] = [
{
description: 'MacBook Pro dator',
quantity: 1,
unitPrice: 15000,
lineTotal: 15000,
vatRate: 25,
suggestedCategory: null,
},
]
const result = processLineItems(items)
expect(result[0].category).toBe('expense_equipment') // pattern match
expect(result[0].basAccount).toBe('5410')
})
})
describe('calculateReceiptSplit', () => {
it('correct business/private/unclassified totals', () => {
const items = [
{ lineTotal: 100, is_business: true as boolean | null },
{ lineTotal: 50, is_business: false as boolean | null },
{ lineTotal: 25, is_business: null as boolean | null },
]
const result = calculateReceiptSplit(items)
expect(result.businessTotal).toBe(100)
expect(result.privateTotal).toBe(50)
expect(result.unclassifiedTotal).toBe(25)
// 100 / 175 * 100 = 57.142... → 57.1
expect(result.businessPercentage).toBeCloseTo(57.1, 1)
})
it('handles rounding correctly', () => {
const items = [
{ lineTotal: 33.333, is_business: true as boolean | null },
{ lineTotal: 66.667, is_business: false as boolean | null },
]
const result = calculateReceiptSplit(items)
expect(result.businessTotal).toBe(33.33)
expect(result.privateTotal).toBe(66.67)
})
})
describe('getDefaultClassification', () => {
it('Systembolaget defaults to private', () => {
const result = getDefaultClassification(false, true)
expect(result.defaultIsBusiness).toBe(false)
expect(result.requiresReview).toBe(true)
expect(result.warningMessage).toContain('Alkohol')
})
it('restaurant requires review', () => {
const result = getDefaultClassification(true, false)
expect(result.defaultIsBusiness).toBeNull()
expect(result.requiresReview).toBe(true)
expect(result.warningMessage).toContain('Restaurangbesök')
})
it('non-restaurant, non-systembolaget has no warning', () => {
const result = getDefaultClassification(false, false)
expect(result.defaultIsBusiness).toBeNull()
expect(result.requiresReview).toBe(false)
expect(result.warningMessage).toBeNull()
})
})
@@ -1,286 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
findTransactionMatches,
autoMatchReceipts,
filterUnmatchedTransactions,
filterUnmatchedReceipts,
} from '../receipt-matcher'
import { makeReceipt, makeTransaction } from '@/tests/helpers'
describe('findTransactionMatches', () => {
it('exact date + exact amount → high confidence', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 299,
merchant_name: 'ICA Maxi',
})
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -299,
merchant_name: 'ICA Maxi',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(1)
expect(matches[0].confidence).toBeGreaterThanOrEqual(0.8)
expect(matches[0].dateVariance).toBe(0)
})
it('date within ±3 days → matches with lower confidence', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: '',
})
const transactions = [
makeTransaction({
date: '2024-06-17',
amount: -500,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches.length).toBeGreaterThanOrEqual(1)
expect(matches[0].dateVariance).toBeCloseTo(2, 0)
})
it('date outside ±3 days → no match', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: '',
})
const transactions = [
makeTransaction({
date: '2024-06-25',
amount: -500,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
it('amount within 5% tolerance → matches', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 1000,
merchant_name: '',
})
// 4% off = 960
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -960,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches.length).toBeGreaterThanOrEqual(1)
})
it('amount outside tolerance → no match', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 1000,
merchant_name: '',
is_foreign_merchant: false,
})
// 10% off = 900 (way above 5%)
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -900,
merchant_name: '',
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
it('merchant name similarity boosts confidence', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: 'Coop Konsum',
})
const txWithMerchant = makeTransaction({
id: 'tx-with',
date: '2024-06-15',
amount: -500,
merchant_name: 'Coop Konsum Stockholm',
receipt_id: null,
})
const txWithout = makeTransaction({
id: 'tx-without',
date: '2024-06-15',
amount: -500,
merchant_name: '',
receipt_id: null,
})
const matchesWithMerchant = findTransactionMatches(receipt, [txWithMerchant])
const matchesWithout = findTransactionMatches(receipt, [txWithout])
// Both should match since date+amount are exact
expect(matchesWithMerchant.length).toBeGreaterThanOrEqual(1)
expect(matchesWithout.length).toBeGreaterThanOrEqual(1)
// Merchant match should have higher confidence
expect(matchesWithMerchant[0].confidence).toBeGreaterThan(
matchesWithout[0].confidence
)
})
it('skips already-matched transactions (receipt_id set)', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 299,
})
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -299,
receipt_id: 'already-matched',
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
it('skips income transactions (amount >= 0)', () => {
const receipt = makeReceipt({
receipt_date: '2024-06-15',
total_amount: 299,
})
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: 299, // income, positive
receipt_id: null,
}),
]
const matches = findTransactionMatches(receipt, transactions)
expect(matches).toHaveLength(0)
})
})
describe('autoMatchReceipts', () => {
it('returns matches above threshold', () => {
const receipts = [
makeReceipt({
id: 'r1',
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: 'Coop',
matched_transaction_id: null,
}),
]
const transactions = [
makeTransaction({
id: 'tx1',
date: '2024-06-15',
amount: -500,
merchant_name: 'Coop',
receipt_id: null,
}),
]
const results = autoMatchReceipts(receipts, transactions, 0.5)
expect(results).toHaveLength(1)
expect(results[0].receipt.id).toBe('r1')
expect(results[0].match.confidence).toBeGreaterThanOrEqual(0.5)
})
it('respects custom threshold', () => {
const receipts = [
makeReceipt({
id: 'r1',
receipt_date: '2024-06-15',
total_amount: 500,
merchant_name: '',
matched_transaction_id: null,
}),
]
const transactions = [
makeTransaction({
id: 'tx1',
date: '2024-06-17', // 2 days off, no merchant → moderate confidence
amount: -500,
merchant_name: '',
receipt_id: null,
}),
]
// With a very high threshold, it should not match
const highThreshold = autoMatchReceipts(receipts, transactions, 0.99)
expect(highThreshold).toHaveLength(0)
// With a lower threshold, it should match
const lowThreshold = autoMatchReceipts(receipts, transactions, 0.4)
expect(lowThreshold).toHaveLength(1)
})
it('skips already-matched receipts', () => {
const receipts = [
makeReceipt({
id: 'r1',
receipt_date: '2024-06-15',
total_amount: 500,
matched_transaction_id: 'existing-tx',
}),
]
const transactions = [
makeTransaction({
date: '2024-06-15',
amount: -500,
receipt_id: null,
}),
]
const results = autoMatchReceipts(receipts, transactions)
expect(results).toHaveLength(0)
})
})
describe('filterUnmatchedTransactions', () => {
it('returns only unmatched expenses', () => {
const transactions = [
makeTransaction({ id: 't1', receipt_id: null, amount: -100 }),
makeTransaction({ id: 't2', receipt_id: 'r1', amount: -200 }), // matched
makeTransaction({ id: 't3', receipt_id: null, amount: 300 }), // income
]
const result = filterUnmatchedTransactions(transactions)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('t1')
})
})
describe('filterUnmatchedReceipts', () => {
it('returns only confirmed unmatched receipts', () => {
const receipts = [
makeReceipt({ id: 'r1', status: 'confirmed', matched_transaction_id: null }),
makeReceipt({ id: 'r2', status: 'confirmed', matched_transaction_id: 'tx1' }),
makeReceipt({ id: 'r3', status: 'extracted', matched_transaction_id: null }),
]
const result = filterUnmatchedReceipts(receipts)
expect(result).toHaveLength(1)
expect(result[0].id).toBe('r1')
})
})
-460
View File
@@ -1,460 +0,0 @@
/**
* Receipt Analyzer using Claude Haiku Vision API
*
* SERVER-ONLY: This module uses the Anthropic SDK and must only be imported
* in server components or API routes.
*
* Analyzes receipt images and extracts line items, merchant info,
* and special flags (restaurant, Systembolaget, foreign merchant).
*/
import 'server-only'
import Anthropic from '@anthropic-ai/sdk'
import { ChatAnthropic } from '@langchain/anthropic'
import { HumanMessage, SystemMessage } from '@langchain/core/messages'
import { z } from 'zod'
import type { ReceiptExtractionResult, ExtractedLineItem } from '@/types'
import {
SYSTEMBOLAGET_PATTERNS,
RESTAURANT_PATTERNS,
RESTAURANT_MCC_CODES,
} from './receipt-utils'
// Re-export client-safe functions for convenience
export {
calculateRepresentationLimits,
isRestaurantMCC,
detectSystembolaget,
detectRestaurant,
} from './receipt-utils'
const anthropic = new Anthropic()
// Maximum retries for API calls
const MAX_RETRIES = 3
const RETRY_DELAY_MS = 1000
/**
* Analyze a receipt image using Claude Haiku Vision
*/
export async function analyzeReceipt(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<ReceiptExtractionResult> {
const systemPrompt = `Du är expert på att extrahera data från svenska kvitton och fakturor.
Din uppgift är att noggrant analysera kvittobilden och extrahera all relevant information.
VIKTIGT:
- Extrahera VARJE artikelrad, inte bara summan
- Identifiera momssats per rad om möjligt (25%, 12%, 6%)
- Flagga om detta är: restaurang, Systembolaget, eller utländsk handlare
- Svenska organisationsnummer är i format XXXXXX-XXXX
- Momsregistreringsnummer börjar med SE
- Datum ska vara i ISO-format (YYYY-MM-DD)
- Belopp ska vara numeriska värden utan valutasymboler
- Ange konfidenstal (0.0-1.0) för hela extraheringen baserat på bildkvalitet`
const userPrompt = `Analysera detta kvitto och extrahera strukturerad data.
Returnera ett JSON-objekt med följande struktur:
{
"merchant": {
"name": "Handlarens namn",
"orgNumber": "XXXXXX-XXXX eller null",
"vatNumber": "SE... eller null",
"isForeign": false
},
"receipt": {
"date": "YYYY-MM-DD",
"time": "HH:MM eller null",
"currency": "SEK"
},
"lineItems": [
{
"description": "Artikelbeskrivning",
"quantity": 1,
"unitPrice": 100.00,
"lineTotal": 100.00,
"vatRate": 25,
"suggestedCategory": "equipment|software|travel|office|marketing|professional_services|education|other"
}
],
"totals": {
"subtotal": 100.00,
"vatAmount": 25.00,
"total": 125.00
},
"flags": {
"isRestaurant": false,
"isSystembolaget": false,
"isForeignMerchant": false
},
"confidence": 0.95
}
KATEGORIER för suggestedCategory:
- equipment: Datorer, telefoner, kameror, teknikprylar
- software: Program, appar, molntjänster, prenumerationer
- travel: Flyg, tåg, hotell, taxi
- office: Kontorsmaterial, möbler, hyra
- marketing: Reklam, marknadsföring, PR
- professional_services: Konsulter, redovisning, juridik
- education: Kurser, böcker, utbildning
- other: Övrigt
Returnera ENDAST JSON-objektet, ingen annan text.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const message = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: mimeType,
data: imageBase64,
},
},
{
type: 'text',
text: userPrompt,
},
],
},
],
system: systemPrompt,
})
// Extract the text content
const content = message.content[0]
if (content.type !== 'text') {
throw new Error('Unexpected response type from AI')
}
// Parse the JSON response - strip markdown code blocks if present
let jsonText = content.text.trim()
if (jsonText.startsWith('```json')) {
jsonText = jsonText.slice(7)
} else if (jsonText.startsWith('```')) {
jsonText = jsonText.slice(3)
}
if (jsonText.endsWith('```')) {
jsonText = jsonText.slice(0, -3)
}
jsonText = jsonText.trim()
const parsed = JSON.parse(jsonText)
// Validate and enhance the result
return validateAndEnhanceResult(parsed)
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
// Don't retry on JSON parse errors
if (error instanceof SyntaxError) {
throw new Error(`Failed to parse AI response: ${lastError.message}`)
}
// Wait before retrying
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Receipt analysis failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
/**
* Validate and enhance the extraction result
*/
function validateAndEnhanceResult(raw: unknown): ReceiptExtractionResult {
if (!raw || typeof raw !== 'object') {
throw new Error('Invalid extraction result: not an object')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = raw as any
const merchant = data.merchant || {}
const receipt = data.receipt || {}
const totals = data.totals || {}
const flags = data.flags || {}
// Validate line items
const lineItems = validateLineItems(data.lineItems)
// Detect special merchants if not already flagged
const merchantName = (merchant.name || '').toLowerCase()
const isSystembolaget = flags.isSystembolaget || detectSystembolagetLocal(merchantName)
const isRestaurant = flags.isRestaurant || detectRestaurantLocal(merchantName)
const isForeign = flags.isForeignMerchant || detectForeignMerchant(merchant, receipt.currency)
const result: ReceiptExtractionResult = {
merchant: {
name: validateString(merchant.name),
orgNumber: validateOrgNumber(merchant.orgNumber),
vatNumber: validateVatNumber(merchant.vatNumber),
isForeign,
},
receipt: {
date: validateDate(receipt.date),
time: validateTime(receipt.time),
currency: validateString(receipt.currency) || 'SEK',
},
lineItems,
totals: {
subtotal: validateNumber(totals.subtotal),
vatAmount: validateNumber(totals.vatAmount),
total: validateNumber(totals.total),
},
flags: {
isRestaurant,
isSystembolaget,
isForeignMerchant: isForeign,
},
confidence: validateNumber(data.confidence) || 0.5,
}
return result
}
/**
* Validate line items array
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function validateLineItems(data: any): ExtractedLineItem[] {
if (!Array.isArray(data)) return []
return data
.filter((item) => item && typeof item === 'object' && item.description)
.map((item) => ({
description: String(item.description).trim(),
quantity: validateNumber(item.quantity) || 1,
unitPrice: validateNumber(item.unitPrice),
lineTotal: validateNumber(item.lineTotal) || 0,
vatRate: validateNumber(item.vatRate),
suggestedCategory: validateCategory(item.suggestedCategory),
confidence: validateNumber(item.confidence) || undefined,
}))
.filter((item) => item.lineTotal > 0 || item.description.length > 0)
}
/**
* Validate expense category suggestion
*/
function validateCategory(value: unknown): string | null {
const validCategories = [
'equipment',
'software',
'travel',
'office',
'marketing',
'professional_services',
'education',
'other',
]
if (typeof value === 'string' && validCategories.includes(value)) {
return value
}
return null
}
/**
* Detect if merchant is Systembolaget (local version using imported patterns)
*/
function detectSystembolagetLocal(merchantName: string): boolean {
return SYSTEMBOLAGET_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is a restaurant (local version using imported patterns)
*/
function detectRestaurantLocal(merchantName: string): boolean {
return RESTAURANT_PATTERNS.some((pattern) => merchantName.includes(pattern))
}
/**
* Detect if merchant is foreign (non-Swedish)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function detectForeignMerchant(merchant: any, currency?: string): boolean {
// Check currency (non-SEK suggests foreign)
if (currency && currency !== 'SEK') {
return true
}
// Check org number format (Swedish org numbers are 10 digits)
const orgNumber = merchant.orgNumber || ''
if (orgNumber && !isSwedishOrgNumber(orgNumber)) {
return true
}
// Check VAT number (Swedish starts with SE)
const vatNumber = merchant.vatNumber || ''
if (vatNumber && !vatNumber.toUpperCase().startsWith('SE')) {
return true
}
return false
}
/**
* Check if org number is Swedish format
*/
function isSwedishOrgNumber(value: string): boolean {
const digits = value.replace(/\D/g, '')
return digits.length === 10
}
// Validation helpers
function validateString(value: unknown): string | null {
if (typeof value === 'string' && value.trim()) {
return value.trim()
}
return null
}
function validateNumber(value: unknown): number | null {
if (typeof value === 'number' && !isNaN(value)) {
return value
}
if (typeof value === 'string') {
const parsed = parseFloat(value.replace(/[^\d.-]/g, ''))
if (!isNaN(parsed)) {
return parsed
}
}
return null
}
function validateDate(value: unknown): string | null {
if (typeof value !== 'string') return null
const date = new Date(value)
if (isNaN(date.getTime())) return null
return date.toISOString().split('T')[0]
}
function validateTime(value: unknown): string | null {
if (typeof value !== 'string') return null
// Match HH:MM or HH:MM:SS
const match = value.match(/^(\d{2}):(\d{2})(:\d{2})?$/)
if (match) {
return `${match[1]}:${match[2]}`
}
return null
}
function validateOrgNumber(value: unknown): string | null {
if (typeof value !== 'string') return null
const digits = value.replace(/\D/g, '')
// Swedish org numbers are 10 digits
if (digits.length === 10) {
return `${digits.slice(0, 6)}-${digits.slice(6)}`
}
return null
}
function validateVatNumber(value: unknown): string | null {
if (typeof value !== 'string') return null
const cleaned = value.trim().toUpperCase()
if (cleaned.startsWith('SE') && cleaned.length >= 12) {
return cleaned
}
return null
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const ProductEstimationSchema = z.object({
estimatedValue: z.number().describe('Uppskattat marknadsvärde i SEK'),
confidence: z.number().min(0).max(1).describe('Hur säker du är på uppskattningen (0.0-1.0)'),
description: z.string().describe('Kort beskrivning av produkten'),
brand: z.string().nullable().describe('Varumärke om identifierbart, annars null'),
})
/**
* Estimate product value from image using Claude Vision via LangChain
* Used for gift/product registration without receipt
*/
export async function estimateProductValue(
imageBase64: string,
mimeType: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' = 'image/jpeg'
): Promise<{
estimatedValue: number
confidence: number
description: string
brand: string | null
}> {
const model = new ChatAnthropic({
modelName: 'claude-haiku-4-5-20251001',
maxTokens: 1024,
temperature: 0,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
})
const structuredModel = model.withStructuredOutput(ProductEstimationSchema)
const systemPrompt = `Du är expert på att uppskatta marknadsvärdet på produkter baserat på bilder.
Din uppgift är att identifiera produkten och ge en rimlig uppskattning av dess marknadsvärde i svenska kronor.`
const userPrompt = `Analysera produkten i bilden och uppskatta dess marknadsvärde i SEK. Identifiera varumärke om möjligt.`
let lastError: Error | null = null
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await structuredModel.invoke([
new SystemMessage(systemPrompt),
new HumanMessage({
content: [
{
type: 'image_url',
image_url: { url: `data:${mimeType};base64,${imageBase64}` },
},
{
type: 'text',
text: userPrompt,
},
],
}),
])
return {
estimatedValue: result.estimatedValue || 0,
confidence: result.confidence || 0.5,
description: result.description || 'Okänd produkt',
brand: result.brand,
}
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error')
if (attempt < MAX_RETRIES - 1) {
await sleep(RETRY_DELAY_MS * (attempt + 1))
}
}
}
throw new Error(`Product value estimation failed after ${MAX_RETRIES} attempts: ${lastError?.message}`)
}
-297
View File
@@ -1,297 +0,0 @@
/**
* Receipt Categorizer - Auto-categorization of receipt line items
*
* Maps line item descriptions to BAS accounts and expense categories
* using pattern matching and AI-suggested categories.
*/
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
// Category mappings from suggested category to TransactionCategory
const CATEGORY_MAPPING: Record<string, TransactionCategory> = {
equipment: 'expense_equipment',
software: 'expense_software',
travel: 'expense_travel',
office: 'expense_office',
marketing: 'expense_marketing',
professional_services: 'expense_professional_services',
education: 'expense_education',
other: 'expense_other',
}
// BAS account mappings for expense categories
const BAS_ACCOUNT_MAPPING: Record<TransactionCategory, string> = {
expense_equipment: '5410', // Förbrukningsinventarier
expense_software: '5420', // Programvaror
expense_travel: '5800', // Resekostnader
expense_office: '5010', // Lokalhyra / Kontorsmaterial
expense_marketing: '5910', // Annonsering
expense_professional_services: '6530', // Redovisningstjänster
expense_education: '6991', // Övriga avdragsgilla kostnader
expense_bank_fees: '6570', // Bankavgifter
expense_card_fees: '6570', // Kortavgifter
expense_currency_exchange: '7960', // Valutakursförluster
expense_other: '6991', // Övriga avdragsgilla kostnader
// Non-expense categories (for completeness)
income_services: '3001',
income_products: '3001',
income_other: '3900',
private: '2013',
uncategorized: '6991',
}
// Keyword patterns for auto-categorization
const CATEGORY_PATTERNS: Array<{
category: TransactionCategory
patterns: RegExp[]
}> = [
{
category: 'expense_equipment',
patterns: [
/\b(dator|laptop|macbook|ipad|tablet|telefon|iphone|samsung|kamera|camera|mikrofon|microphone|belysning|lighting|stativ|tripod|usb|kabel|adapter|hörlurar|headphones|airpods)\b/i,
/\b(apple|logitech|sony|canon|nikon|rode|elgato|razer)\b/i,
],
},
{
category: 'expense_software',
patterns: [
/\b(adobe|microsoft|spotify|netflix|youtube premium|dropbox|google|icloud|canva|notion|slack|zoom|teams|subscription|prenumeration|licens|license)\b/i,
/\b(app|program|software|saas|cloud)\b/i,
],
},
{
category: 'expense_travel',
patterns: [
/\b(flyg|flight|hotell|hotel|taxi|uber|bolt|tåg|train|sj|arlanda|bromma|landvetter|bensin|fuel|parkering|parking|biljett|ticket)\b/i,
/\b(resa|travel|transport|resekostnad)\b/i,
],
},
{
category: 'expense_office',
patterns: [
/\b(hyra|rent|el|electricity|internet|bredband|vatten|försäkring|insurance|städning|cleaning|kontorsmaterial|papper|toner|skrivare|printer)\b/i,
/\b(ikea|clas ohlson|biltema|staples|lyreco)\b/i,
],
},
{
category: 'expense_marketing',
patterns: [
/\b(reklam|advertising|annons|ad|meta ads|facebook ads|instagram ads|google ads|tiktok ads|influencer|sponsor|kampanj|campaign|pr|press)\b/i,
/\b(marknadsföring|marketing|promotion)\b/i,
],
},
{
category: 'expense_professional_services',
patterns: [
/\b(konsult|consultant|redovisning|accounting|bokföring|bookkeeping|juridik|legal|advokat|lawyer|revisor|auditor)\b/i,
/\b(tjänst|service|arvode|fee)\b/i,
],
},
{
category: 'expense_education',
patterns: [
/\b(kurs|course|utbildning|training|bok|book|seminar|konferens|conference|workshop|webinar|certifiering|certification)\b/i,
/\b(lärande|learning|studie|study)\b/i,
],
},
]
/**
* Get the TransactionCategory from AI-suggested category
*/
export function mapSuggestedCategory(suggestedCategory: string | null): TransactionCategory | null {
if (!suggestedCategory) return null
return CATEGORY_MAPPING[suggestedCategory] || null
}
/**
* Get BAS account for a category
*/
export function getBASAccount(category: TransactionCategory): string {
return BAS_ACCOUNT_MAPPING[category] || '6991'
}
/**
* Auto-categorize a line item based on description
*/
export function categorizeLineItem(description: string): {
category: TransactionCategory | null
confidence: number
} {
const normalizedDescription = description.toLowerCase()
for (const { category, patterns } of CATEGORY_PATTERNS) {
for (const pattern of patterns) {
if (pattern.test(normalizedDescription)) {
return {
category,
confidence: 0.7, // Pattern-based confidence
}
}
}
}
return {
category: null,
confidence: 0,
}
}
/**
* Process extracted line items and add category suggestions
*/
export function processLineItems(
lineItems: ExtractedLineItem[]
): Array<ExtractedLineItem & { category: TransactionCategory | null; basAccount: string | null }> {
return lineItems.map((item) => {
// First try the AI-suggested category
let category = mapSuggestedCategory(item.suggestedCategory)
let confidence = item.confidence || 0.8
// If no AI suggestion, try pattern matching
if (!category) {
const patternResult = categorizeLineItem(item.description)
category = patternResult.category
confidence = patternResult.confidence
}
return {
...item,
category,
basAccount: category ? getBASAccount(category) : null,
confidence,
}
})
}
/**
* Calculate split amounts for mixed business/private receipt
*/
export function calculateReceiptSplit(
lineItems: Array<{ lineTotal: number; is_business: boolean | null }>
): {
businessTotal: number
privateTotal: number
unclassifiedTotal: number
businessPercentage: number
} {
let businessTotal = 0
let privateTotal = 0
let unclassifiedTotal = 0
for (const item of lineItems) {
if (item.is_business === true) {
businessTotal += item.lineTotal
} else if (item.is_business === false) {
privateTotal += item.lineTotal
} else {
unclassifiedTotal += item.lineTotal
}
}
const total = businessTotal + privateTotal + unclassifiedTotal
const businessPercentage = total > 0 ? (businessTotal / total) * 100 : 0
return {
businessTotal: Math.round(businessTotal * 100) / 100,
privateTotal: Math.round(privateTotal * 100) / 100,
unclassifiedTotal: Math.round(unclassifiedTotal * 100) / 100,
businessPercentage: Math.round(businessPercentage * 10) / 10,
}
}
/**
* Aggregate line items by category for summary display
*/
export function aggregateByCategory(
lineItems: Array<{ lineTotal: number; category: TransactionCategory | null; is_business: boolean | null }>
): Array<{ category: TransactionCategory | null; total: number; count: number }> {
const aggregation = new Map<TransactionCategory | null, { total: number; count: number }>()
for (const item of lineItems) {
// Only aggregate business items
if (item.is_business !== true) continue
const existing = aggregation.get(item.category) || { total: 0, count: 0 }
aggregation.set(item.category, {
total: existing.total + item.lineTotal,
count: existing.count + 1,
})
}
return Array.from(aggregation.entries())
.map(([category, data]) => ({
category,
total: Math.round(data.total * 100) / 100,
count: data.count,
}))
.sort((a, b) => b.total - a.total)
}
/**
* Generate default line item classifications based on receipt flags
*/
export function getDefaultClassification(
isRestaurant: boolean,
isSystembolaget: boolean
): {
defaultIsBusiness: boolean | null
requiresReview: boolean
warningMessage: string | null
} {
if (isSystembolaget) {
return {
defaultIsBusiness: false, // Default to private for Systembolaget
requiresReview: true,
warningMessage: 'Alkohol från Systembolaget är normalt en privat utgift. Om det är representation, ange syfte och antal personer.',
}
}
if (isRestaurant) {
return {
defaultIsBusiness: null, // Need user input
requiresReview: true,
warningMessage: 'Restaurangbesök kräver uppgift om antal personer och syfte för att räknas som representation.',
}
}
return {
defaultIsBusiness: null, // User decides per item
requiresReview: false,
warningMessage: null,
}
}
/**
* Category labels in Swedish
*/
export const CATEGORY_LABELS: Record<TransactionCategory, string> = {
expense_equipment: 'Utrustning',
expense_software: 'Programvara',
expense_travel: 'Resa',
expense_office: 'Kontor',
expense_marketing: 'Marknadsföring',
expense_professional_services: 'Konsulttjänster',
expense_education: 'Utbildning',
expense_bank_fees: 'Bankavgift',
expense_card_fees: 'Kortavgift',
expense_currency_exchange: 'Valutaväxling',
expense_other: 'Övrigt',
income_services: 'Tjänsteintäkt',
income_products: 'Varuintäkt',
income_other: 'Övrig intäkt',
private: 'Privat',
uncategorized: 'Ej kategoriserad',
}
/**
* Get expense categories only (for dropdown)
*/
export function getExpenseCategories(): Array<{ value: TransactionCategory; label: string }> {
return Object.entries(CATEGORY_LABELS)
.filter(([key]) => key.startsWith('expense_'))
.map(([value, label]) => ({
value: value as TransactionCategory,
label,
}))
}
-288
View File
@@ -1,288 +0,0 @@
/**
* Receipt Matcher - Fuzzy matching between receipts and transactions
*
* Uses date variance, amount tolerance, and merchant name similarity
* to find potential transaction matches for receipts.
*/
import type { Transaction, Receipt, ReceiptMatchCandidate } from '@/types'
// Matching configuration
const DATE_TOLERANCE_DAYS = 3 // Allow ±3 days variance
const AMOUNT_TOLERANCE_PERCENT = 0.05 // Allow ±5% variance (for currency conversion)
const MIN_MATCH_CONFIDENCE = 0.4 // Minimum confidence to consider a match
/**
* Find potential transaction matches for a receipt
*/
export function findTransactionMatches(
receipt: Receipt,
transactions: Transaction[]
): ReceiptMatchCandidate[] {
if (!receipt.receipt_date || !receipt.total_amount) {
return []
}
const receiptDate = new Date(receipt.receipt_date)
const receiptAmount = Math.abs(receipt.total_amount)
const merchantName = (receipt.merchant_name || '').toLowerCase()
const candidates: ReceiptMatchCandidate[] = []
for (const transaction of transactions) {
// Skip already matched transactions
if (transaction.receipt_id) continue
// Only match expense transactions (negative amounts)
if (transaction.amount >= 0) continue
const transactionDate = new Date(transaction.date)
const transactionAmount = Math.abs(transaction.amount)
// Calculate date variance
const dateVariance = Math.abs(
(receiptDate.getTime() - transactionDate.getTime()) / (1000 * 60 * 60 * 24)
)
// Skip if outside date tolerance
if (dateVariance > DATE_TOLERANCE_DAYS) continue
// Calculate amount variance
const amountVariance = Math.abs(receiptAmount - transactionAmount) / receiptAmount
// Skip if outside amount tolerance (unless it's a foreign receipt with currency conversion)
if (amountVariance > AMOUNT_TOLERANCE_PERCENT && !receipt.is_foreign_merchant) continue
if (amountVariance > 0.15) continue // Hard limit even for foreign
// Calculate merchant similarity
const transactionMerchant = (transaction.merchant_name || transaction.description || '').toLowerCase()
const merchantSimilarity = calculateMerchantSimilarity(merchantName, transactionMerchant)
// Calculate overall confidence
const { confidence, matchReasons } = calculateMatchConfidence(
dateVariance,
amountVariance,
merchantSimilarity,
receipt,
transaction
)
if (confidence >= MIN_MATCH_CONFIDENCE) {
candidates.push({
transaction,
confidence,
matchReasons,
dateVariance,
amountVariance,
})
}
}
// Sort by confidence (highest first)
return candidates.sort((a, b) => b.confidence - a.confidence)
}
/**
* Calculate merchant name similarity using Levenshtein distance
*/
function calculateMerchantSimilarity(name1: string, name2: string): number {
if (!name1 || !name2) return 0
// Normalize names
const n1 = normalizeMerchantName(name1)
const n2 = normalizeMerchantName(name2)
// Check for exact match
if (n1 === n2) return 1
// Check if one contains the other
if (n1.includes(n2) || n2.includes(n1)) return 0.9
// Check for word overlap
const words1 = n1.split(/\s+/)
const words2 = n2.split(/\s+/)
const commonWords = words1.filter((w) => words2.includes(w))
if (commonWords.length > 0) {
const overlapScore = commonWords.length / Math.max(words1.length, words2.length)
if (overlapScore >= 0.5) return 0.7 + overlapScore * 0.2
}
// Calculate Levenshtein similarity
const distance = levenshteinDistance(n1, n2)
const maxLength = Math.max(n1.length, n2.length)
const similarity = 1 - distance / maxLength
return similarity
}
/**
* Normalize merchant name for comparison
*/
function normalizeMerchantName(name: string): string {
return name
.toLowerCase()
.replace(/[^\w\såäöé]/g, '') // Remove special chars except Swedish letters
.replace(/\b(ab|hb|kb|ek|för|stiftelse)\b/g, '') // Remove company suffixes
.replace(/\s+/g, ' ')
.trim()
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const m = str1.length
const n = str2.length
// Create matrix
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0))
// Initialize first row and column
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
// Fill the matrix
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = str1[i - 1] === str2[j - 1] ? 0 : 1
dp[i][j] = Math.min(
dp[i - 1][j] + 1, // deletion
dp[i][j - 1] + 1, // insertion
dp[i - 1][j - 1] + cost // substitution
)
}
}
return dp[m][n]
}
/**
* Calculate overall match confidence and reasons
*/
function calculateMatchConfidence(
dateVariance: number,
amountVariance: number,
merchantSimilarity: number,
receipt: Receipt,
transaction: Transaction
): { confidence: number; matchReasons: string[] } {
const matchReasons: string[] = []
let totalWeight = 0
let weightedScore = 0
// Date score (weight: 25%)
const dateScore = Math.max(0, 1 - dateVariance / DATE_TOLERANCE_DAYS)
if (dateScore >= 0.8) {
matchReasons.push(dateVariance === 0 ? 'Exakt datum' : `Datum ±${Math.round(dateVariance)} dagar`)
}
weightedScore += dateScore * 0.25
totalWeight += 0.25
// Amount score (weight: 40%)
const amountScore = Math.max(0, 1 - amountVariance / AMOUNT_TOLERANCE_PERCENT)
if (amountVariance < 0.01) {
matchReasons.push('Exakt belopp')
} else if (amountVariance < AMOUNT_TOLERANCE_PERCENT) {
matchReasons.push(`Belopp ±${Math.round(amountVariance * 100)}%`)
}
weightedScore += amountScore * 0.4
totalWeight += 0.4
// Merchant score (weight: 35%)
if (merchantSimilarity > 0) {
if (merchantSimilarity >= 0.9) {
matchReasons.push('Handlare matchar')
} else if (merchantSimilarity >= 0.6) {
matchReasons.push('Trolig handlarmatch')
}
weightedScore += merchantSimilarity * 0.35
totalWeight += 0.35
}
// Bonus for MCC match (if transaction has MCC and receipt is flagged)
if (receipt.is_restaurant && transaction.mcc_code) {
const restaurantMCCs = [5812, 5813, 5814]
if (restaurantMCCs.includes(transaction.mcc_code)) {
matchReasons.push('Restaurang MCC matchar')
weightedScore += 0.1
}
}
const confidence = totalWeight > 0 ? weightedScore / totalWeight : 0
return {
confidence: Math.round(confidence * 100) / 100,
matchReasons,
}
}
/**
* Get unmatched transactions for a user
* (transactions without linked receipts)
*/
export function filterUnmatchedTransactions(transactions: Transaction[]): Transaction[] {
return transactions.filter((t) => !t.receipt_id && t.amount < 0)
}
/**
* Get unmatched receipts for a user
* (confirmed receipts without linked transactions)
*/
export function filterUnmatchedReceipts(receipts: Receipt[]): Receipt[] {
return receipts.filter((r) => !r.matched_transaction_id && r.status === 'confirmed')
}
/**
* Auto-match receipts to transactions
* Returns matches with confidence >= threshold
*/
export function autoMatchReceipts(
receipts: Receipt[],
transactions: Transaction[],
confidenceThreshold: number = 0.8
): Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> {
const results: Array<{ receipt: Receipt; match: ReceiptMatchCandidate }> = []
for (const receipt of receipts) {
if (receipt.matched_transaction_id) continue
const matches = findTransactionMatches(receipt, transactions)
const bestMatch = matches[0]
if (bestMatch && bestMatch.confidence >= confidenceThreshold) {
results.push({ receipt, match: bestMatch })
// Mark the transaction as used so it's not matched again
// (Note: this mutates the transaction, caller should be aware)
}
}
return results
}
/**
* Calculate match statistics for queue display
*/
export function calculateMatchStats(
receipts: Receipt[],
transactions: Transaction[]
): {
unmatchedReceiptsCount: number
unmatchedTransactionsCount: number
pendingReviewCount: number
matchedCount: number
} {
const unmatchedReceipts = filterUnmatchedReceipts(receipts)
const unmatchedTransactions = filterUnmatchedTransactions(transactions)
const pendingReview = receipts.filter((r) => r.status === 'extracted')
const matched = receipts.filter((r) => r.matched_transaction_id)
return {
unmatchedReceiptsCount: unmatchedReceipts.length,
unmatchedTransactionsCount: unmatchedTransactions.length,
pendingReviewCount: pendingReview.length,
matchedCount: matched.length,
}
}
-94
View File
@@ -1,94 +0,0 @@
/**
* Receipt utility functions (client-safe)
*
* These functions can be used in both client and server components.
*/
// Known Swedish merchants for special handling
export const SYSTEMBOLAGET_PATTERNS = [
'systembolaget',
'systemet',
'bolaget',
]
export const RESTAURANT_MCC_CODES = [5812, 5813, 5814]
export const RESTAURANT_PATTERNS = [
'restaurang',
'restaurant',
'café',
'cafe',
'bistro',
'pizzeria',
'sushi',
'thai',
'wok',
'grill',
'bar',
'pub',
'krog',
'brasserie',
'trattoria',
'osteria',
'matsal',
'lunch',
'middag',
]
/**
* Check if a transaction MCC code indicates restaurant
*/
export function isRestaurantMCC(mccCode: number | null): boolean {
if (!mccCode) return false
return RESTAURANT_MCC_CODES.includes(mccCode)
}
/**
* Calculate restaurant representation limits
*
* Swedish rules (2024):
* - Max 60 kr/person för intern representation (lunch/middag)
* - Max 180 kr/person för extern representation
* - Max 180 kr/person för representationsgåvor
*
* For simplicity, we use 60 kr as the safe deductible limit per person
*/
export function calculateRepresentationLimits(
totalAmount: number,
persons: number
): {
deductibleAmount: number
nonDeductibleAmount: number
perPersonAmount: number
maxDeductiblePerPerson: number
} {
const MAX_DEDUCTIBLE_PER_PERSON = 60
const perPersonAmount = totalAmount / persons
const deductiblePerPerson = Math.min(perPersonAmount, MAX_DEDUCTIBLE_PER_PERSON)
const deductibleAmount = deductiblePerPerson * persons
const nonDeductibleAmount = totalAmount - deductibleAmount
return {
deductibleAmount: Math.round(deductibleAmount * 100) / 100,
nonDeductibleAmount: Math.round(nonDeductibleAmount * 100) / 100,
perPersonAmount: Math.round(perPersonAmount * 100) / 100,
maxDeductiblePerPerson: MAX_DEDUCTIBLE_PER_PERSON,
}
}
/**
* Detect if merchant is Systembolaget
*/
export function detectSystembolaget(merchantName: string): boolean {
const name = merchantName.toLowerCase()
return SYSTEMBOLAGET_PATTERNS.some((pattern) => name.includes(pattern))
}
/**
* Detect if merchant is a restaurant
*/
export function detectRestaurant(merchantName: string): boolean {
const name = merchantName.toLowerCase()
return RESTAURANT_PATTERNS.some((pattern) => name.includes(pattern))
}
-337
View File
@@ -1,337 +0,0 @@
import { createClient } from '@/lib/supabase/server'
import type {
NEDeclaration,
NEDeclarationRutor,
NEAccountMapping,
FiscalPeriod,
JournalEntry,
JournalEntryLine,
} from '@/types'
/**
* NE-bilaga (Enskild Firma / Sole Proprietorship Declaration)
*
* Maps BAS account balances to NE declaration rutor (R1-R11) for
* tax reporting to Skatteverket.
*
* Account mappings:
* R1: Försäljning med moms (3000-3499 excl 3100)
* R2: Momsfria intäkter (3100, 3900, 3970, 3980) - inkl gåvor utan motprestation
* R3: Bil/bostadsförmån (3200)
* R4: Ränteintäkter (8310-8330)
* R5: Varuinköp (4000-4990)
* R6: Övriga kostnader (5000-6990, 7970) - inkl avdragsgilla gåvor (5460)
* R7: Lönekostnader (7000-7699)
* R8: Räntekostnader (8400-8499)
* R9: Avskrivningar fastighet (7820)
* R10: Avskrivningar övrigt (7700-7899 excl 7820)
* R11: Årets resultat (calculated)
*
* Gift handling:
* - Gåvor MED motprestation: R1 (momspliktig bytestransaktion)
* - Gåvor UTAN motprestation: R2 via konto 3900
* - Avdragsgilla gåvor: R6 via konto 5460
*/
/**
* Account mapping configuration for NE declaration
*/
export const NE_ACCOUNT_MAPPINGS: NEAccountMapping[] = [
{
ruta: 'R1',
description: 'Försäljning med moms (25%)',
accountRanges: [
{ start: '3000', end: '3499', exclude: ['3100'] },
],
isExpense: false,
},
{
ruta: 'R2',
description: 'Momsfria intäkter',
accountRanges: [
{ start: '3100', end: '3100' },
{ start: '3900', end: '3900' }, // Övriga rörelseintäkter (inkl gåvor utan motprestation)
{ start: '3970', end: '3980' },
],
isExpense: false,
},
{
ruta: 'R3',
description: 'Bil/bostadsförmån',
accountRanges: [
{ start: '3200', end: '3299' },
],
isExpense: false,
},
{
ruta: 'R4',
description: 'Ränteintäkter',
accountRanges: [
{ start: '8310', end: '8330' },
],
isExpense: false,
},
{
ruta: 'R5',
description: 'Varuinköp',
accountRanges: [
{ start: '4000', end: '4990' },
],
isExpense: true,
},
{
ruta: 'R6',
description: 'Övriga kostnader',
accountRanges: [
{ start: '5000', end: '6990' },
{ start: '7970', end: '7970' },
],
isExpense: true,
},
{
ruta: 'R7',
description: 'Lönekostnader',
accountRanges: [
{ start: '7000', end: '7699' },
],
isExpense: true,
},
{
ruta: 'R8',
description: 'Räntekostnader',
accountRanges: [
{ start: '8400', end: '8499' },
],
isExpense: true,
},
{
ruta: 'R9',
description: 'Avskrivningar fastighet',
accountRanges: [
{ start: '7820', end: '7820' },
],
isExpense: true,
},
{
ruta: 'R10',
description: 'Avskrivningar övrigt',
accountRanges: [
{ start: '7700', end: '7899', exclude: ['7820'] },
],
isExpense: true,
},
]
/**
* Check if an account number falls within a mapping's ranges
*/
function isAccountInMapping(accountNumber: string, mapping: NEAccountMapping): boolean {
for (const range of mapping.accountRanges) {
const num = accountNumber
if (num >= range.start && num <= range.end) {
// Check exclusions
if (range.exclude && range.exclude.includes(num)) {
continue
}
return true
}
}
return false
}
/**
* Round to nearest krona (whole number) for NE declaration
*/
function roundToKrona(value: number): number {
return Math.round(value)
}
/**
* Generate NE declaration for a fiscal period
*/
export async function generateNEDeclaration(
userId: string,
fiscalPeriodId: string
): Promise<NEDeclaration> {
const supabase = await createClient()
// Fetch fiscal period
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', fiscalPeriodId)
.eq('user_id', userId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
// Fetch company settings
const { data: settings } = await supabase
.from('company_settings')
.select('company_name, org_number, entity_type')
.eq('user_id', userId)
.single()
// Check if it's enskild firma
if (settings?.entity_type !== 'enskild_firma') {
throw new Error('NE declaration is only for enskild firma (sole proprietorship)')
}
// Fetch all posted journal entries with lines for this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('*, lines:journal_entry_lines(*)')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
if (entriesError) {
throw new Error(`Failed to fetch journal entries: ${entriesError.message}`)
}
// Fetch chart of accounts for account names
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('user_id', userId)
const accountNameMap = new Map<string, string>()
for (const acc of accounts || []) {
accountNameMap.set(acc.account_number, acc.account_name)
}
// Calculate balances per account
const accountBalances = new Map<string, number>()
for (const entry of (entries as JournalEntry[]) || []) {
const lines = (entry.lines as JournalEntryLine[]) || []
for (const line of lines) {
const current = accountBalances.get(line.account_number) || 0
// Net amount: debit - credit
const netAmount = (Number(line.debit_amount) || 0) - (Number(line.credit_amount) || 0)
accountBalances.set(line.account_number, current + netAmount)
}
}
// Map account balances to NE rutor
const rutor: NEDeclarationRutor = {
R1: 0,
R2: 0,
R3: 0,
R4: 0,
R5: 0,
R6: 0,
R7: 0,
R8: 0,
R9: 0,
R10: 0,
R11: 0,
}
const breakdown: Record<keyof NEDeclarationRutor, {
accounts: Array<{ accountNumber: string; accountName: string; amount: number }>
total: number
}> = {
R1: { accounts: [], total: 0 },
R2: { accounts: [], total: 0 },
R3: { accounts: [], total: 0 },
R4: { accounts: [], total: 0 },
R5: { accounts: [], total: 0 },
R6: { accounts: [], total: 0 },
R7: { accounts: [], total: 0 },
R8: { accounts: [], total: 0 },
R9: { accounts: [], total: 0 },
R10: { accounts: [], total: 0 },
R11: { accounts: [], total: 0 },
}
const warnings: string[] = []
// Process each account balance
for (const [accountNumber, balance] of accountBalances) {
// Skip zero balances
if (Math.abs(balance) < 0.01) continue
// Find which ruta this account belongs to
for (const mapping of NE_ACCOUNT_MAPPINGS) {
if (isAccountInMapping(accountNumber, mapping)) {
// For revenue accounts (credit normal), negate the balance
// For expense accounts (debit normal), use as-is
// Net balance is debit - credit, so:
// - Revenue accounts have negative net balance (credit > debit)
// - Expense accounts have positive net balance (debit > credit)
const amount = mapping.isExpense ? balance : -balance
rutor[mapping.ruta] += amount
breakdown[mapping.ruta].accounts.push({
accountNumber,
accountName: accountNameMap.get(accountNumber) || `Konto ${accountNumber}`,
amount: roundToKrona(amount),
})
break // Account matched, no need to check other mappings
}
}
}
// Round all rutor to whole numbers
for (const key of Object.keys(rutor) as (keyof NEDeclarationRutor)[]) {
if (key !== 'R11') {
rutor[key] = roundToKrona(rutor[key])
breakdown[key].total = rutor[key]
}
}
// Calculate R11 (Årets resultat)
// Result = Revenue (R1+R2+R3+R4) - Expenses (R5+R6+R7+R8+R9+R10)
const totalRevenue = rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4
const totalExpenses = rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10
rutor.R11 = totalRevenue - totalExpenses
breakdown.R11.total = rutor.R11
// Add warnings
if (!(period as FiscalPeriod).is_closed) {
warnings.push('Räkenskapsåret är inte stängt. Siffrorna kan ändras.')
}
if (rutor.R11 === 0 && totalRevenue === 0) {
warnings.push('Inga bokförda intäkter eller kostnader hittades för perioden.')
}
return {
fiscalYear: {
id: period.id,
name: period.name,
start: period.period_start,
end: period.period_end,
isClosed: period.is_closed,
},
rutor,
breakdown,
companyInfo: {
companyName: settings?.company_name || 'Okänt företag',
orgNumber: settings?.org_number || null,
},
warnings,
}
}
/**
* Get totals for display
*/
export function getNEDeclarationTotals(declaration: NEDeclaration): {
totalRevenue: number
totalExpenses: number
netResult: number
} {
const { rutor } = declaration
return {
totalRevenue: rutor.R1 + rutor.R2 + rutor.R3 + rutor.R4,
totalExpenses: rutor.R5 + rutor.R6 + rutor.R7 + rutor.R8 + rutor.R9 + rutor.R10,
netResult: rutor.R11,
}
}
-209
View File
@@ -1,209 +0,0 @@
import type { NEDeclaration, SRUFile, SRURecord } from '@/types'
/**
* SRU File Generator
*
* Generates SRU (Standardiserat Räkenskapsutdrag) files for electronic
* submission to Skatteverket. The SRU format is used for tax declarations.
*
* Format specification:
* - Each line starts with # followed by field code
* - Values follow the field code
* - File must be plain text (ISO-8859-1 encoding traditionally, but UTF-8 is often accepted)
*
* NE-bilaga field codes:
* - #BLANKETT NE - Declares this is an NE form
* - #IDENTITET - Organization number + name
* - #UPPGIFT - Individual field values
*/
/**
* SRU field codes for NE declaration
* These are the official Skatteverket field codes for NE-bilaga
*/
const NE_SRU_FIELD_CODES: Record<string, string> = {
// Company identification
ORG_NUMBER: '201',
COMPANY_NAME: '202',
// NE rutor - Income
R1: '7310', // Försäljning och andra intäkter med moms
R2: '7311', // Momsfria intäkter (ej skattepliktiga)
R3: '7312', // Bil- och bostadsförmån m.m.
R4: '7313', // Ränteintäkter
// NE rutor - Expenses
R5: '7320', // Varuinköp
R6: '7321', // Övriga externa kostnader
R7: '7322', // Anställdas löner
R8: '7323', // Räntekostnader
R9: '7324', // Avskrivningar på byggnader och markanläggningar
R10: '7325', // Avskrivningar på maskiner och inventarier
// Result
R11: '7350', // Årets resultat
}
/**
* Generate SRU file content from NE declaration
*/
export function generateSRUFile(declaration: NEDeclaration): SRUFile {
const records: SRURecord[] = []
const now = new Date()
// File header
records.push({ fieldCode: 'PRODUKT', value: 'KONTROLLUPPGIFTER' })
records.push({ fieldCode: 'SESSION', value: '1' })
records.push({ fieldCode: 'PROGRAMNAMN', value: 'ERPBase' })
records.push({ fieldCode: 'PROGRAMVERSION', value: '1.0' })
records.push({
fieldCode: 'SKAPAT',
value: formatSRUDate(now),
})
// Form declaration
records.push({ fieldCode: 'BLANKETT', value: 'NE' })
// Company identification
if (declaration.companyInfo.orgNumber) {
// Remove any dashes from org number
const cleanOrgNumber = declaration.companyInfo.orgNumber.replace(/-/g, '')
records.push({
fieldCode: 'IDENTITET',
value: cleanOrgNumber,
})
}
// Fiscal year
records.push({
fieldCode: 'UPPGIFT',
value: `7000 ${formatSRUDateRange(declaration.fiscalYear.start, declaration.fiscalYear.end)}`,
})
// NE rutor values
const rutaEntries: [keyof typeof NE_SRU_FIELD_CODES, number][] = [
['R1', declaration.rutor.R1],
['R2', declaration.rutor.R2],
['R3', declaration.rutor.R3],
['R4', declaration.rutor.R4],
['R5', declaration.rutor.R5],
['R6', declaration.rutor.R6],
['R7', declaration.rutor.R7],
['R8', declaration.rutor.R8],
['R9', declaration.rutor.R9],
['R10', declaration.rutor.R10],
['R11', declaration.rutor.R11],
]
for (const [ruta, value] of rutaEntries) {
// Only include non-zero values
if (value !== 0) {
const fieldCode = NE_SRU_FIELD_CODES[ruta]
records.push({
fieldCode: 'UPPGIFT',
value: `${fieldCode} ${formatSRUAmount(value)}`,
})
}
}
// End of form
records.push({ fieldCode: 'BLANKETTSLUT', value: '' })
return {
records,
generatedAt: now.toISOString(),
}
}
/**
* Convert SRU file to string content
*/
export function sruFileToString(sruFile: SRUFile): string {
const lines: string[] = []
for (const record of sruFile.records) {
if (record.value === '') {
lines.push(`#${record.fieldCode}`)
} else {
lines.push(`#${record.fieldCode} ${record.value}`)
}
}
// SRU files should end with a newline
return lines.join('\r\n') + '\r\n'
}
/**
* Format date for SRU: YYYYMMDD
*/
function formatSRUDate(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
}
/**
* Format date string (YYYY-MM-DD) to SRU format (YYYYMMDD)
*/
function dateStringToSRU(dateStr: string): string {
return dateStr.replace(/-/g, '')
}
/**
* Format fiscal year date range for SRU
*/
function formatSRUDateRange(startDate: string, endDate: string): string {
return `${dateStringToSRU(startDate)}-${dateStringToSRU(endDate)}`
}
/**
* Format amount for SRU
* - Whole numbers (no decimals)
* - No thousands separator
* - Negative values with minus sign
*/
function formatSRUAmount(amount: number): string {
return Math.round(amount).toString()
}
/**
* Validate SRU file content
*/
export function validateSRUFile(sruFile: SRUFile): {
isValid: boolean
errors: string[]
} {
const errors: string[] = []
// Check for required records
const hasHeader = sruFile.records.some(r => r.fieldCode === 'PRODUKT')
const hasBlankett = sruFile.records.some(r => r.fieldCode === 'BLANKETT')
const hasBlankettslut = sruFile.records.some(r => r.fieldCode === 'BLANKETTSLUT')
if (!hasHeader) {
errors.push('Missing PRODUKT header')
}
if (!hasBlankett) {
errors.push('Missing BLANKETT declaration')
}
if (!hasBlankettslut) {
errors.push('Missing BLANKETTSLUT')
}
return {
isValid: errors.length === 0,
errors,
}
}
/**
* Get filename for SRU file download
*/
export function getSRUFilename(declaration: NEDeclaration): string {
const year = declaration.fiscalYear.start.substring(0, 4)
const orgNumber = declaration.companyInfo.orgNumber?.replace(/-/g, '') || 'unknown'
return `NE_${orgNumber}_${year}.sru`
}
+6 -12
View File
@@ -1,4 +1,4 @@
import type { EntityType, TaxEstimate, TaxWarningLevel, TaxWarningStatus, SchablonavdragSummary } from '@/types'
import type { EntityType, TaxEstimate, TaxWarningLevel, TaxWarningStatus } from '@/types'
// Current Swedish tax rates (2026)
const TAX_RATES = {
@@ -71,20 +71,16 @@ export function calculateGrundavdrag(taxableIncome: number): number {
* Calculate tax estimates for Enskild Firma
* @param netIncome - Net income after expenses
* @param preliminaryTaxPaidYTD - Preliminary tax paid year to date
* @param schablonavdrag - Optional schablonavdrag summary for deductions
* @param _reserved - Reserved for future use (deductions extension)
* @param momsFromUnpaidInvoices - VAT from unpaid invoices that needs to be paid
*/
export function calculateEFTax(
netIncome: number,
preliminaryTaxPaidYTD: number = 0,
schablonavdrag?: SchablonavdragSummary | null,
_reserved?: unknown,
momsFromUnpaidInvoices: number = 0
): TaxEstimate {
// Apply schablonavdrag deductions to net income
const schablonavdragDeduction = schablonavdrag?.total_deduction || 0
const adjustedNetIncome = netIncome - schablonavdragDeduction
if (adjustedNetIncome <= 0) {
if (netIncome <= 0) {
return {
egenavgifter: 0,
income_tax: 0,
@@ -93,17 +89,16 @@ export function calculateEFTax(
total_tax_liability: momsFromUnpaidInvoices,
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: momsFromUnpaidInvoices - preliminaryTaxPaidYTD,
schablonavdrag_deduction: schablonavdragDeduction,
}
}
// Egenavgifter (self-employment contributions) - 28.97%
const egenavgifter = adjustedNetIncome * TAX_RATES.egenavgifter
const egenavgifter = netIncome * TAX_RATES.egenavgifter
// Taxable income (after egenavgifter deduction)
// 25% of egenavgifter is deductible from taxable income
const egenavgifterDeduction = egenavgifter * 0.25
const taxableIncome = adjustedNetIncome - egenavgifterDeduction
const taxableIncome = netIncome - egenavgifterDeduction
// Calculate progressive grundavdrag based on income level
const grundavdrag = calculateGrundavdrag(taxableIncome)
@@ -128,7 +123,6 @@ export function calculateEFTax(
total_tax_liability: Math.round(totalTax),
preliminary_paid_ytd: preliminaryTaxPaidYTD,
difference: Math.round(totalTax - preliminaryTaxPaidYTD),
schablonavdrag_deduction: schablonavdragDeduction,
grundavdrag: Math.round(grundavdrag),
}
}
-199
View File
@@ -1,199 +0,0 @@
/**
* Schablonavdrag (Standard Deductions) Calculator
* Swedish tax deductions that don't require detailed receipts
*/
import type { SchablonavdragSettings, SchablonavdragSummary, MileageEntry } from '@/types'
// Housing types for hemmakontor deduction
export type HousingType = 'villa' | 'apartment' // villa = privatbostadsfastighet, apartment = hyresrätt/bostadsrätt
// Current Swedish schablonavdrag rates (2026)
export const SCHABLONAVDRAG_RATES = {
hemmakontor: {
// Different rates based on housing type
villa: 2000, // 2,000 kr/year for villa/privatbostadsfastighet
apartment: 4000, // 4,000 kr/year for hyresrätt/bostadsrätt
min_hours_per_year: 800, // Minimum 800 hours working from home required
description: 'Schablonavdrag för hemmakontor',
},
bil: {
rate_per_mil: 25, // 25 kr per Swedish mile (10 km)
rate_per_km: 2.5, // 2.50 kr per kilometer
description: 'Schablonavdrag för bilkostnader (milersättning)',
},
}
/**
* Calculate hemmakontor (home office) deduction
* Rate depends on housing type:
* - Villa/privatbostadsfastighet: 2,000 kr/year
* - Hyresrätt/Bostadsrätt: 4,000 kr/year
* Prorated by months active
*/
export function calculateHemmakontorDeduction(
monthsActive: number = 12,
housingType: HousingType = 'apartment'
): number {
const validMonths = Math.min(Math.max(monthsActive, 0), 12)
const annualAmount = housingType === 'villa'
? SCHABLONAVDRAG_RATES.hemmakontor.villa
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
return Math.round((annualAmount / 12) * validMonths)
}
/**
* Calculate mileage deduction from total kilometers
* Rate: 25 kr/mil = 2.50 kr/km
*/
export function calculateMileageDeduction(totalKm: number): number {
if (totalKm <= 0) return 0
// 2.50 kr per kilometer (25 kr per Swedish mile)
return Math.round(totalKm * SCHABLONAVDRAG_RATES.bil.rate_per_km * 100) / 100
}
/**
* Calculate mileage deduction from entries
*/
export function calculateMileageDeductionFromEntries(entries: MileageEntry[]): number {
const totalKm = entries.reduce((sum, entry) => sum + Number(entry.distance_km), 0)
return calculateMileageDeduction(totalKm)
}
/**
* Get complete schablonavdrag summary for a user and year
*/
export function getSchablonavdragSummary(
settings: SchablonavdragSettings | null,
mileageEntries: MileageEntry[],
year: number = new Date().getFullYear(),
monthsInBusiness: number = 12
): SchablonavdragSummary {
// Default settings if none provided
const schablonavdragSettings: SchablonavdragSettings = settings || {
hemmakontor_enabled: false,
hemmakontor_housing_type: 'apartment',
bil_enabled: false,
}
// Get housing type (default to apartment for higher deduction)
const housingType: HousingType = schablonavdragSettings.hemmakontor_housing_type || 'apartment'
// Calculate hemmakontor deduction based on housing type
const hemmakontorDeduction = schablonavdragSettings.hemmakontor_enabled
? calculateHemmakontorDeduction(monthsInBusiness, housingType)
: 0
// Get the annual amount for this housing type
const hemmakontorAnnualAmount = housingType === 'villa'
? SCHABLONAVDRAG_RATES.hemmakontor.villa
: SCHABLONAVDRAG_RATES.hemmakontor.apartment
// Filter entries for the specified year
const yearEntries = mileageEntries.filter((entry) => {
const entryYear = new Date(entry.date).getFullYear()
return entryYear === year
})
// Calculate mileage totals
const totalKm = yearEntries.reduce((sum, entry) => sum + Number(entry.distance_km), 0)
const mileageDeduction = schablonavdragSettings.bil_enabled
? calculateMileageDeduction(totalKm)
: 0
return {
year,
hemmakontor: {
enabled: schablonavdragSettings.hemmakontor_enabled,
housing_type: housingType,
months_active: monthsInBusiness,
annual_amount: hemmakontorAnnualAmount,
deduction: hemmakontorDeduction,
},
mileage: {
enabled: schablonavdragSettings.bil_enabled,
total_km: totalKm,
rate_per_km: SCHABLONAVDRAG_RATES.bil.rate_per_km,
total_deduction: mileageDeduction,
entries_count: yearEntries.length,
},
total_deduction: hemmakontorDeduction + mileageDeduction,
}
}
/**
* Validate a mileage entry
*/
export function validateMileageEntry(entry: {
date: string
distance_km: number
purpose: string
}): { valid: boolean; errors: string[] } {
const errors: string[] = []
// Check date
if (!entry.date) {
errors.push('Datum krävs')
} else {
const entryDate = new Date(entry.date)
if (isNaN(entryDate.getTime())) {
errors.push('Ogiltigt datum')
} else if (entryDate > new Date()) {
errors.push('Datum kan inte vara i framtiden')
}
}
// Check distance
if (!entry.distance_km || entry.distance_km <= 0) {
errors.push('Avståndet måste vara större än 0 km')
} else if (entry.distance_km > 10000) {
errors.push('Orimligt avstånd (max 10 000 km per resa)')
}
// Check purpose
if (!entry.purpose || entry.purpose.trim().length < 3) {
errors.push('Ändamål krävs (minst 3 tecken)')
}
return {
valid: errors.length === 0,
errors,
}
}
/**
* Get mileage entries grouped by month
*/
export function groupMileageEntriesByMonth(
entries: MileageEntry[]
): Map<string, { entries: MileageEntry[]; totalKm: number; totalDeduction: number }> {
const grouped = new Map<string, { entries: MileageEntry[]; totalKm: number; totalDeduction: number }>()
for (const entry of entries) {
const date = new Date(entry.date)
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
if (!grouped.has(key)) {
grouped.set(key, { entries: [], totalKm: 0, totalDeduction: 0 })
}
const group = grouped.get(key)!
group.entries.push(entry)
group.totalKm += Number(entry.distance_km)
group.totalDeduction += Number(entry.total_deduction)
}
return grouped
}
/**
* Format month key for display (Swedish)
*/
export function formatMonthKey(key: string): string {
const [year, month] = key.split('-')
const monthNames = [
'Januari', 'Februari', 'Mars', 'April', 'Maj', 'Juni',
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December'
]
return `${monthNames[parseInt(month) - 1]} ${year}`
}