feat: UI polish, lint fixes, onboarding redesign, help page expansion, and test improvements

Broad update across dashboard pages, components, extensions, and lib code. Includes ESLint config additions, onboarding flow redesign, settings page refactor, help page content expansion, dead code removal, and test mock fixes. Adds dev docs and public assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-05 23:05:49 +01:00
co-authored by Claude Opus 4.6
parent 02ebe50f52
commit 091d043c85
106 changed files with 1177 additions and 326 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: create-extension
description: "Generate and implement extensions for erp-base: scaffold files, configure manifests, write event handlers, API routes, services, workspace UIs, settings panels, and testing. Use when creating new extensions, adding surfaces to existing extensions, or understanding the extension architecture. Covers the full lifecycle from scaffolding to registration."
description: "Generate and implement extensions for gnubok: scaffold files, configure manifests, write event handlers, API routes, services, workspace UIs, settings panels, and testing. Use when creating new extensions, adding surfaces to existing extensions, or understanding the extension architecture. Covers the full lifecycle from scaffolding to registration."
---
# Extension Generator
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: erp-api-route
description: "Generate Next.js 16 API routes for erp-base with correct auth guards, Supabase client usage, event emission, journal entry creation, and error handling. Use when creating new API endpoints in app/api/. Handles the Next.js 16 async params pattern, ensureInitialized() for events, non-blocking journal entry wrapping, and defense-in-depth user_id filtering."
description: "Generate Next.js 16 API routes for gnubok with correct auth guards, Supabase client usage, event emission, journal entry creation, and error handling. Use when creating new API endpoints in app/api/. Handles the Next.js 16 async params pattern, ensureInitialized() for events, non-blocking journal entry wrapping, and defense-in-depth user_id filtering."
---
# ERP API Route Generator
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: supabase-migration
description: "Generate Supabase database migrations for the erp-base project with correct RLS policies, triggers, indexes, and Swedish accounting constraints. Use when creating new tables, adding columns, modifying constraints (e.g. source_type CHECK), or any DDL operation on the Supabase database. Ensures legal compliance with BFL 7-year retention, immutability triggers, and period lock enforcement."
description: "Generate Supabase database migrations for the gnubok project with correct RLS policies, triggers, indexes, and Swedish accounting constraints. Use when creating new tables, adding columns, modifying constraints (e.g. source_type CHECK), or any DDL operation on the Supabase database. Ensures legal compliance with BFL 7-year retention, immutability triggers, and period lock enforcement."
---
# Supabase Migration Generator
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: swedish-bookkeeping
description: "Swedish double-entry bookkeeping domain knowledge for erp-base: BAS account codes, VAT treatments, journal entry patterns, entity type differences (enskild firma vs aktiebolag), and legal constraints (BFL/BFN). Use when creating journal entry generators, modifying bookkeeping logic, adding VAT handling, working with reports, or any accounting-related code. Prevents illegal accounting operations and ensures correct account/VAT mappings."
description: "Swedish double-entry bookkeeping domain knowledge for gnubok: BAS account codes, VAT treatments, journal entry patterns, entity type differences (enskild firma vs aktiebolag), and legal constraints (BFL/BFN). Use when creating journal entry generators, modifying bookkeeping logic, adding VAT handling, working with reports, or any accounting-related code. Prevents illegal accounting operations and ensures correct account/VAT mappings."
---
# Swedish Bookkeeping Reference
@@ -1,4 +1,4 @@
# BAS Account Quick Reference — erp-base
# BAS Account Quick Reference — gnubok
Accounts used in codebase. Search `dev_docs/BASKONTOPLAN.md` for full chart.
+3 -3
View File
@@ -1,8 +1,8 @@
# CLAUDE.md — erp-base
# CLAUDE.md — gnubok
## Project Overview
erp-base is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention.
gnubok is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention.
**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.
@@ -363,4 +363,4 @@ Hosted on **Vercel**. Cron jobs in `vercel.json` (banking sync daily 05:00, dead
**Core env vars**: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `NEXT_PUBLIC_APP_URL`, `CRON_SECRET`. Extension env vars only needed when that extension is enabled.
## Other
Never create a NUL/nul file: \erp-base\NUL
Never create a NUL/nul file: \gnubok\NUL
+2 -2
View File
@@ -1,6 +1,6 @@
# Contributing to erp-base
# Contributing to gnubok
Thank you for your interest in contributing to erp-base. This guide covers the development workflow, coding standards, and submission process.
Thank you for your interest in contributing to gnubok. This guide covers the development workflow, coding standards, and submission process.
## Getting Started
+5 -5
View File
@@ -1,12 +1,12 @@
# erp-base
# gnubok
Open-source Swedish accounting software for sole traders (enskild firma) and limited companies (aktiebolag).
[![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](LICENSE)
## What is erp-base?
## What is gnubok?
erp-base implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen). It supports the BAS 2026 chart of accounts, handles VAT declarations (momsdeklaration), SIE import/export, and enforces 7-year document retention. Built for sole traders and limited companies operating in Sweden.
gnubok implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen). It supports the BAS 2026 chart of accounts, handles VAT declarations (momsdeklaration), SIE import/export, and enforces 7-year document retention. Built for sole traders and limited companies operating in Sweden.
## Features
@@ -24,8 +24,8 @@ erp-base implements double-entry bookkeeping compliant with Swedish accounting l
```bash
# Clone and configure
git clone https://github.com/erp-mafia/erp-base.git
cd erp-base
git clone https://github.com/erp-mafia/gnubok.git
cd gnubok
cp .env.example .env # Fill in your Supabase credentials
# Start with Docker Compose
+2 -2
View File
@@ -2,9 +2,9 @@
## Reporting Vulnerabilities
If you discover a security vulnerability in erp-base, please report it responsibly. **Do not open a public issue.**
If you discover a security vulnerability in gnubok, please report it responsibly. **Do not open a public issue.**
Email: **security@gnubok.se**
Email: **jakob.wennberg@arcim.io**
Include:
- Description of the vulnerability
+9 -3
View File
@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2, Mail, ArrowLeft } from 'lucide-react'
import Image from 'next/image'
import { getErrorMessage } from '@/lib/errors/get-error-message'
export default function LoginPage() {
@@ -100,9 +101,14 @@ export default function LoginPage() {
<div className="min-h-screen flex flex-col items-center justify-center bg-gradient-to-b from-background to-primary/[0.03] p-4">
<div className="w-full max-w-sm animate-slide-up">
<div className="text-center mb-10">
<h1 className="font-display text-[2.75rem] leading-none font-medium tracking-tight text-foreground">
Gnubok
</h1>
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={240}
height={240}
className="mx-auto mb-2"
priority
/>
<p className="text-muted-foreground text-sm mt-3">
Logga in med din e-post för att hantera din ekonomi
</p>
+5 -5
View File
@@ -47,10 +47,6 @@ export default function CustomersPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchCustomers()
}, [])
async function fetchCustomers() {
setIsLoading(true)
const { data, error } = await supabase
@@ -70,6 +66,10 @@ export default function CustomersPage() {
setIsLoading(false)
}
useEffect(() => {
fetchCustomers()
}, [])
async function handleCreateCustomer(data: CreateCustomerInput) {
setIsCreating(true)
@@ -167,7 +167,7 @@ export default function CustomersPage() {
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga träffar</h3>
<p className="text-muted-foreground text-center mt-1">
Inga kunder matchar "{searchTerm}"
Inga kunder matchar &quot;{searchTerm}&quot;
</p>
</div>
) : (
+6 -7
View File
@@ -13,7 +13,6 @@ import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-rea
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { formatCurrency } from '@/lib/utils'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types'
const statusConfig: Record<string, { label: string; color: string }> = {
@@ -35,18 +34,13 @@ export default function ExpenseDetailPage() {
const router = useRouter()
const { toast } = useToast()
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [, setEntityType] = useState<EntityType>('enskild_firma')
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
@@ -72,6 +66,11 @@ export default function ExpenseDetailPage() {
}
}
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
+4 -4
View File
@@ -67,10 +67,6 @@ export default function ExpensesPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchExpenses()
}, [])
async function fetchExpenses() {
setIsLoading(true)
const { data, error } = await supabase
@@ -86,6 +82,10 @@ export default function ExpensesPage() {
setIsLoading(false)
}
useEffect(() => {
fetchExpenses()
}, [])
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch =
(inv.supplier?.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'
import { getExtensionDefinition, getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { SectorSlug } from '@/lib/extensions/types'
import CategoryBadge from '@/components/extensions/CategoryBadge'
import ExtensionToggleButton from '@/components/extensions/ExtensionToggleButton'
import Link from 'next/link'
@@ -15,7 +16,8 @@ export default async function ExtensionDetailPage({
const definition = getExtensionDefinition(sectorSlug, extensionSlug)
if (!definition) notFound()
const sector = getSector(sectorSlug as any)
const sector = getSector(sectorSlug as SectorSlug)
const Icon = resolveIcon(definition.icon)
const dataPatternLabels: Record<string, string> = {
+3 -1
View File
@@ -1,6 +1,7 @@
import { notFound } from 'next/navigation'
import { getSector } from '@/lib/extensions/sectors'
import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { SectorSlug } from '@/lib/extensions/types'
import ExtensionCard from '@/components/extensions/ExtensionCard'
import Link from 'next/link'
@@ -10,10 +11,11 @@ export default async function SectorExtensionsPage({
params: Promise<{ sector: string }>
}) {
const { sector: sectorSlug } = await params
const sector = getSector(sectorSlug as any)
const sector = getSector(sectorSlug as SectorSlug)
if (!sector) notFound()
const Icon = resolveIcon(sector.icon)
return (
+40 -1
View File
@@ -12,6 +12,7 @@ import {
Calculator,
Building2,
FileText,
FileDown,
ExternalLink,
ChevronDown,
ChevronUp,
@@ -372,7 +373,7 @@ export default function HelpPage() {
<CardContent className="py-12 text-center">
<Search className="h-8 w-8 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
Inga termer hittades för "{searchQuery}"
Inga termer hittades för &quot;{searchQuery}&quot;
</p>
</CardContent>
</Card>
@@ -388,6 +389,44 @@ export default function HelpPage() {
)}
</div>
{/* Document templates */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Dokument & Mallar</CardTitle>
<CardDescription>Lagstadgade mallar för din bokföring — ladda ner, fyll i och spara</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<a
href="/docs/arkivplan-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
<span className="font-medium">Arkivplan</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Mall enligt BFNAR 2013:2 — beskriver var räkenskapsinformation förvaras
</p>
</a>
<a
href="/docs/systemdokumentation-mall.md"
download
className="p-3 rounded-lg border border-border hover:border-primary/50 transition-colors block"
>
<div className="flex items-center gap-2">
<FileDown className="h-4 w-4" />
<span className="font-medium">Systemdokumentation</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Mall enligt BFL 5 kap. 11 § — beskriver bokföringssystemets uppbyggnad
</p>
</a>
</div>
</CardContent>
</Card>
{/* External resources */}
<Card>
<CardHeader>
+2 -2
View File
@@ -311,13 +311,13 @@ function SIEImportWizard() {
const [error, setError] = useState<string | null>(null)
const [file, setFile] = useState<File | null>(null)
const [_parsed, setParsed] = useState<ParsedSIEFile | null>(null)
const [, setParsed] = useState<ParsedSIEFile | null>(null)
const [mappings, setMappings] = useState<AccountMapping[]>([])
const [basAccounts, setBasAccounts] = useState<BASAccount[]>([])
const [preview, setPreview] = useState<ImportPreview | null>(null)
const [issues, setIssues] = useState<ParseIssue[]>([])
const [importResult, setImportResult] = useState<ImportResult | null>(null)
const [_sieAccounts, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [, setSieAccounts] = useState<{ number: string; name: string }[]>([])
const [isCreatingAccounts, setIsCreatingAccounts] = useState(false)
// Skip the mapping step when all accounts are already mapped
-2
View File
@@ -353,8 +353,6 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const isProforma = docType === 'proforma'
const isDeliveryNote = docType === 'delivery_note'
const isRealInvoice = docType === 'invoice'
const docLabel = isProforma ? 'Proformafaktura' : isDeliveryNote ? 'Följesedel' : 'Faktura'
return (
<div className="space-y-6">
{/* Header */}
+2 -4
View File
@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -55,8 +55,6 @@ export default function NewInvoicePage() {
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const searchParams = useSearchParams()
const preselectedCustomerId = searchParams.get('customer_id')
const [customers, setCustomers] = useState<Customer[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -68,7 +66,7 @@ export default function NewInvoicePage() {
const [showSendPrompt, setShowSendPrompt] = useState(false)
const [isSending, setIsSending] = useState(false)
const [isPreviewing, setIsPreviewing] = useState(false)
const [defaultNotes, setDefaultNotes] = useState<string | null>(null)
const [, setDefaultNotes] = useState<string | null>(null)
const [isCreateCustomerOpen, setIsCreateCustomerOpen] = useState(false)
const [isCreatingCustomer, setIsCreatingCustomer] = useState(false)
+6 -6
View File
@@ -7,11 +7,11 @@ import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, AlertTriangle, FileQuestion, Truck } from 'lucide-react'
import { Plus, Search, Receipt, FileText, Send, CheckCircle, Clock, XCircle, ReceiptText, FileQuestion, Truck } from 'lucide-react'
import { EmptyInvoices } from '@/components/ui/empty-state'
import type { Invoice, InvoiceStatus } from '@/types'
@@ -53,10 +53,6 @@ export default function InvoicesPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchInvoices()
}, [])
async function fetchInvoices() {
setIsLoading(true)
const { data, error } = await supabase
@@ -76,6 +72,10 @@ export default function InvoicesPage() {
setIsLoading(false)
}
useEffect(() => {
fetchInvoices()
}, [])
const filteredInvoices = invoices.filter((invoice) => {
const matchesSearch =
invoice.invoice_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
+4 -3
View File
@@ -177,8 +177,9 @@ export default async function DashboardPage() {
}))
// Fetch upcoming deadlines (next 7 days + overdue)
const today = new Date().toISOString().split('T')[0]
const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const now = new Date()
const today = now.toISOString().split('T')[0]
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
const { data: deadlines } = await supabase
.from('deadlines')
@@ -258,7 +259,7 @@ export default async function DashboardPage() {
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
let checkDate = new Date(todayDate)
const checkDate = new Date(todayDate)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
+5 -5
View File
@@ -34,11 +34,6 @@ export default function ReportsPage() {
const [activeTab, setActiveTab] = useState('trial-balance')
const [entityType, setEntityType] = useState<string | null>(null)
useEffect(() => {
fetchPeriods()
fetchEntityType()
}, [])
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
@@ -60,6 +55,11 @@ export default function ReportsPage() {
}
}
useEffect(() => {
fetchPeriods()
fetchEntityType()
}, [])
const isEnskildFirma = entityType === 'enskild_firma'
const isAktiebolag = entityType === 'aktiebolag'
+46 -46
View File
@@ -60,52 +60,6 @@ export default function SettingsPage() {
setMounted(true)
}, [])
useEffect(() => {
fetchData()
// Handle callback messages
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
// Auto-sync transactions after connection
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings?tab=banking')
}
if (bankError) {
toast({
title: 'Anslutning misslyckades',
description: decodeURIComponent(bankError),
variant: 'destructive',
})
router.replace('/settings')
}
}, [searchParams])
async function fetchData() {
setIsLoading(true)
@@ -152,6 +106,52 @@ export default function SettingsPage() {
setIsLoading(false)
}
useEffect(() => {
fetchData()
// Handle callback messages
const bankConnected = searchParams.get('bank_connected')
const bankError = searchParams.get('bank_error')
if (bankConnected === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank är nu kopplad. Transaktioner hämtas...',
})
// Auto-sync transactions after connection
const connectionId = searchParams.get('connection_id')
if (connectionId) {
fetch('/api/extensions/ext/enable-banking/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ connection_id: connectionId, days_back: 90 }),
})
.then(res => res.json())
.then(data => {
if (data.imported > 0) {
toast({
title: 'Transaktioner hämtade',
description: `${data.imported} transaktioner importerade`,
})
}
})
.catch(() => {})
}
router.replace('/settings?tab=banking')
}
if (bankError) {
toast({
title: 'Anslutning misslyckades',
description: decodeURIComponent(bankError),
variant: 'destructive',
})
router.replace('/settings')
}
}, [searchParams])
async function handleSaveSettings(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!settings) return
@@ -50,10 +50,6 @@ export default function SupplierInvoiceDetailPage() {
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchInvoice()
}, [params.id])
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
@@ -67,6 +63,10 @@ export default function SupplierInvoiceDetailPage() {
setIsLoading(false)
}
useEffect(() => {
fetchInvoice()
}, [params.id])
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
+4 -4
View File
@@ -38,10 +38,6 @@ export default function SupplierInvoicesPage() {
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('all')
useEffect(() => {
fetchInvoices()
}, [])
async function fetchInvoices() {
setIsLoading(true)
const res = await fetch('/api/supplier-invoices?status=all')
@@ -50,6 +46,10 @@ export default function SupplierInvoicesPage() {
setIsLoading(false)
}
useEffect(() => {
fetchInvoices()
}, [])
const filteredInvoices = invoices.filter((inv) => {
switch (activeTab) {
case 'registered': return inv.status === 'registered'
+5 -5
View File
@@ -34,11 +34,6 @@ export default function SupplierDetailPage() {
const [isSaving, setIsSaving] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
useEffect(() => {
fetchSupplier()
fetchInvoices()
}, [params.id])
async function fetchSupplier() {
setIsLoading(true)
const res = await fetch(`/api/suppliers/${params.id}`)
@@ -59,6 +54,11 @@ export default function SupplierDetailPage() {
}
}
useEffect(() => {
fetchSupplier()
fetchInvoices()
}, [params.id])
async function handleUpdate(data: CreateSupplierInput) {
setIsSaving(true)
const res = await fetch(`/api/suppliers/${params.id}`, {
+4 -4
View File
@@ -34,10 +34,6 @@ export default function SuppliersPage() {
const { toast } = useToast()
const supabase = createClient()
useEffect(() => {
fetchSuppliers()
}, [])
async function fetchSuppliers() {
setIsLoading(true)
const { data, error } = await supabase
@@ -57,6 +53,10 @@ export default function SuppliersPage() {
setIsLoading(false)
}
useEffect(() => {
fetchSuppliers()
}, [])
async function handleCreateSupplier(data: CreateSupplierInput) {
setIsCreating(true)
+106 -51
View File
@@ -2,10 +2,11 @@
import { useState, useEffect, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { createClient } from '@/lib/supabase/client'
import { Progress } from '@/components/ui/progress'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { validatePeriodDuration } from '@/lib/bookkeeping/validate-period-duration'
import type { CompanySettings, EntityType, MomsPeriod } from '@/types'
@@ -15,12 +16,12 @@ import Step3TaxRegistration from '@/components/onboarding/Step3TaxRegistration'
import Step4PreliminaryTax from '@/components/onboarding/Step4PreliminaryTax'
import Step5ConnectBank from '@/components/onboarding/Step6ConnectBank'
const STEP_TITLES = [
'Verksamhetsform',
'Företagsuppgifter',
'Skatteregistrering',
'F-skatt',
'Anslut bank',
const STEP_INFO = [
{ title: 'Välkommen', subtitle: 'Välj din företagsform för att komma igång.', label: 'Företagsform' },
{ title: 'Ditt företag', subtitle: 'Uppgifterna visas på fakturor och dokument.', label: 'Uppgifter' },
{ title: 'Skatt & bokföring', subtitle: 'F-skatt, räkenskapsår och momsregistrering.', label: 'Skatt' },
{ title: 'Preliminärskatt', subtitle: 'Frivilligt — hjälper dig hålla koll på skatten.', label: 'F-skatt' },
{ title: 'Bankuppgifter', subtitle: 'Dessa visas på dina fakturor.', label: 'Bank' },
]
function translatePeriodError(msg: string): string {
@@ -33,7 +34,11 @@ function translatePeriodError(msg: string): string {
export default function OnboardingPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-64"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
}>
<OnboardingPageContent />
</Suspense>
)
@@ -51,7 +56,6 @@ function OnboardingPageContent() {
const [settings, setSettings] = useState<Partial<CompanySettings>>({})
const totalSteps = 5
const stepTitles = STEP_TITLES
// Load existing settings on mount
useEffect(() => {
@@ -63,7 +67,7 @@ function OnboardingPageContent() {
return
}
const { data, error } = await supabase
const { data } = await supabase
.from('company_settings')
.select('*')
.eq('user_id', user.id)
@@ -82,26 +86,6 @@ function OnboardingPageContent() {
loadSettings()
}, [supabase, router, toast])
// Handle bank_connected callback from PSD2 flow
useEffect(() => {
if (searchParams.get('bank_connected') === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
}, [searchParams])
const saveSettings = async (updates: Partial<CompanySettings>, nextStep?: number) => {
setIsSaving(true)
@@ -146,6 +130,26 @@ function OnboardingPageContent() {
return true
}
// Handle bank_connected callback from PSD2 flow
useEffect(() => {
if (searchParams.get('bank_connected') === 'true') {
toast({
title: 'Bank ansluten!',
description: 'Din bank har kopplats.',
})
// Complete onboarding after bank connection
saveSettings({ onboarding_complete: true }, totalSteps).then((success) => {
if (success) {
toast({
title: 'Välkommen!',
description: 'Din profil är nu redo.',
})
router.push('/')
}
})
}
}, [searchParams])
const handleNext = async (stepData: Partial<CompanySettings>) => {
// Fix org number bug: clear dependent fields when entity type changes
if (currentStep === 1 && stepData.entity_type && stepData.entity_type !== settings.entity_type) {
@@ -268,7 +272,7 @@ function OnboardingPageContent() {
onConflict: 'user_id,period_start,period_end',
})
}
} catch (err) {
} catch {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: 'Ett fel uppstod när räkenskapsåret skulle skapas. Försök igen.',
@@ -307,13 +311,13 @@ function OnboardingPageContent() {
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="min-h-screen flex items-center justify-center bg-background">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}
const progressPercent = ((currentStep - 1) / (totalSteps - 1)) * 100
const stepInfo = STEP_INFO[currentStep - 1]
const renderSteps = () => (
<>
@@ -408,26 +412,77 @@ function OnboardingPageContent() {
)
return (
<div className="min-h-screen bg-gradient-to-br from-primary/5 via-background to-primary/10">
{/* Header */}
<div className="sticky top-0 z-10 bg-background/80 backdrop-blur-sm border-b">
<div className="max-w-2xl mx-auto px-4 py-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-muted-foreground">
Steg {currentStep} av {totalSteps}
</span>
<span className="text-sm font-medium">
{stepTitles[currentStep - 1]}
<div className="min-h-screen flex flex-col bg-background">
{/* ── Branded Header ── */}
<header className="relative bg-[#141414] text-white overflow-hidden">
{/* Decorative elements */}
<div className="absolute inset-0 pointer-events-none" aria-hidden>
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at 30% -20%, rgba(255,255,255,0.04) 0%, transparent 50%)',
}}
/>
<span className="absolute -bottom-4 right-4 md:right-10 text-[120px] md:text-[160px] font-serif font-bold text-white/[0.02] leading-none select-none">
{String(currentStep).padStart(2, '0')}
</span>
</div>
<div className="relative z-10 max-w-2xl mx-auto w-full px-6 md:px-10 pt-5 pb-6 md:pt-6 md:pb-8">
{/* Top row: Logo + step indicator + counter */}
<div className="flex items-center justify-between mb-5 md:mb-6">
<div className="flex items-center gap-2.5">
<Image
src="/gnubokiceon-removebg-preview.png"
alt="Gnubok"
width={30}
height={30}
className="invert opacity-90"
/>
<span className="font-serif text-base tracking-tight">gnubok</span>
</div>
{/* Step indicator — inline with logo row */}
<div className="flex items-center gap-1.5">
{STEP_INFO.map((_, i) => {
const num = i + 1
return (
<div
key={i}
className={cn(
'h-[3px] rounded-full transition-all duration-500',
num === currentStep && 'w-7 bg-white',
num < currentStep && 'w-4 bg-white/50',
num > currentStep && 'w-4 bg-white/[0.1]',
)}
/>
)
})}
</div>
<span className="text-[10px] text-white/30 tracking-[0.15em] uppercase">
{currentStep} / {totalSteps}
</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
</div>
{/* Content */}
<div className="max-w-2xl mx-auto px-4 py-8">
{renderSteps()}
</div>
{/* Step title — compact */}
<div key={`title-${currentStep}`} className="animate-fade-in">
<h1 className="font-serif text-2xl md:text-3xl font-medium tracking-tight leading-[1.1]">
{stepInfo.title}
</h1>
<p className="text-white/40 mt-1.5 text-sm max-w-sm leading-relaxed">
{stepInfo.subtitle}
</p>
</div>
</div>
</header>
{/* ── Form Content ── */}
<main className="flex-1">
<div className="max-w-lg mx-auto px-6 md:px-10 py-6 md:py-8">
<div key={`step-${currentStep}`} className="animate-slide-up">
{renderSteps()}
</div>
</div>
</main>
</div>
)
}
@@ -31,6 +31,7 @@ function mockAuth(userId: string | null) {
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
@@ -23,6 +23,7 @@ function mockAuth(userId: string | null) {
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
@@ -45,9 +46,11 @@ describe('GET /api/audit-trail', () => {
{ id: '1', action: 'INSERT', table_name: 'journal_entries', created_at: '2024-01-01T00:00:00Z' },
{ id: '2', action: 'COMMIT', table_name: 'journal_entries', created_at: '2024-01-02T00:00:00Z' },
]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAuditLog.mockResolvedValue({ data: entries as any, count: 2 })
const req = createMockRequest('/api/audit-trail')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { status, body } = await parseJsonResponse<{ data: any[]; count: number }>(await GET(req))
expect(status).toBe(200)
+1 -1
View File
@@ -60,7 +60,7 @@ export async function PUT(
const body: Partial<CreateDeadlineInput> = await request.json()
// First, get existing deadline to verify ownership
const { data: existing, error: fetchError } = await supabase
const { data: _existing, error: fetchError } = await supabase
.from('deadlines')
.select('*')
.eq('id', id)
+1 -1
View File
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
}
const body: ExecuteRequest = await request.json()
const { transactions, format, filename, file_hash, skip_duplicates = true, auto_categorize = true } = body
const { transactions, format, filename, file_hash, skip_duplicates: _skip_duplicates = true, auto_categorize: _auto_categorize = true } = body
if (!transactions || transactions.length === 0) {
return NextResponse.json({ error: 'No transactions to import' }, { status: 400 })
@@ -89,7 +89,7 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
const request = createMockRequest('/api/invoices/inv-1/mark-paid', { method: 'POST' })
const response = await POST(request, createMockRouteParams({ id: 'inv-1' }))
const { status, body } = await parseJsonResponse<{ error: string }>(response)
const { status, body: _body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
})
+1 -1
View File
@@ -4,7 +4,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { CreateInvoiceSchema, CreateCreditNoteSchema } from '@/lib/api/schemas'
import type { EntityType, AccountingMethod, Invoice, CreditNote, InvoiceDocumentType } from '@/types'
import { getVatRules, calculateVat, calculateTotal, getAvailableVatRates, getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import {
createCreditNoteJournalEntry,
@@ -23,6 +23,7 @@ function mockAuth(userId: string | null) {
data: { user: userId ? { id: userId } : null },
}),
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
}
@@ -67,6 +68,7 @@ describe('GET /api/reports/audit-trail', () => {
it('returns CSV format with correct headers', async () => {
mockAuth('user-1')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
@@ -88,6 +90,7 @@ describe('GET /api/reports/audit-trail', () => {
it('returns JSON format as downloadable file', async () => {
mockAuth('user-1')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockGetAuditLog.mockResolvedValue({ data: sampleEntries as any, count: 2 })
const req = createMockRequest('/api/reports/audit-trail', {
@@ -118,7 +121,9 @@ describe('GET /api/reports/audit-trail', () => {
}))
mockGetAuditLog
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce({ data: bigPage as any, count: 600 })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.mockResolvedValueOnce({ data: lastPage as any, count: 600 })
const req = createMockRequest('/api/reports/audit-trail', {
@@ -1,6 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import {
createSupplierInvoicePaymentEntry,
createSupplierInvoiceCashEntry,
@@ -9,41 +8,6 @@ import { validateBody } from '@/lib/api/validate'
import { MatchSupplierInvoiceSchema } from '@/lib/api/schemas'
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
/**
* Ensure a fiscal period exists for the given date
*/
async function ensureFiscalPeriod(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
date: string
): Promise<string | null> {
const existingPeriodId = await findFiscalPeriod(supabase, userId, date)
if (existingPeriodId) return existingPeriodId
const transactionDate = new Date(date)
const year = transactionDate.getFullYear()
const { data, error } = await supabase
.from('fiscal_periods')
.upsert({
user_id: userId,
name: `Räkenskapsår ${year}`,
period_start: `${year}-01-01`,
period_end: `${year}-12-31`,
}, {
onConflict: 'user_id,period_start,period_end',
})
.select('id')
.single()
if (error) {
console.error('Failed to create fiscal period:', error)
return null
}
return data?.id || null
}
/**
* POST /api/transactions/[id]/match-supplier-invoice
*
@@ -1,7 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { getSuggestedCategories, mergeAiSuggestions, getSuggestedTemplates, type SuggestedCategory, type SuggestedTemplate } from '@/lib/transactions/category-suggestions'
import type { Transaction, TransactionCategory, EntityType } from '@/types'
import type { Transaction, EntityType } from '@/types'
/**
* POST /api/transactions/suggest-categories
+1 -1
View File
@@ -183,7 +183,7 @@ export default async function RootPage() {
recentReceiptActivity.map((r) => new Date(r.created_at).toISOString().split('T')[0])
)
let checkDate = new Date(todayDate)
const checkDate = new Date(todayDate)
while (activityDates.has(checkDate.toISOString().split('T')[0])) {
streakCount++
checkDate.setDate(checkDate.getDate() - 1)
+4 -4
View File
@@ -29,10 +29,6 @@ export default function ChartOfAccounts() {
const [editingSRU, setEditingSRU] = useState<string | null>(null)
const [sruValue, setSruValue] = useState('')
useEffect(() => {
fetchAccounts()
}, [])
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
@@ -40,6 +36,10 @@ export default function ChartOfAccounts() {
setLoading(false)
}
useEffect(() => {
fetchAccounts()
}, [])
async function updateSRUCode(accountId: string, newSruCode: string) {
const supabase = createClient()
const trimmed = newSruCode.trim() || null
+5 -5
View File
@@ -70,11 +70,6 @@ export default function JournalEntryForm({
uploadedFiles.length > 0
useUnsavedChanges(hasContent)
useEffect(() => {
fetchPeriods()
fetchAccounts()
}, [])
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
@@ -90,6 +85,11 @@ export default function JournalEntryForm({
setAccounts(data || [])
}
useEffect(() => {
fetchPeriods()
fetchAccounts()
}, [])
const addLine = () => {
setLines([...lines, { ...BLANK_LINE }])
}
+4 -4
View File
@@ -49,10 +49,6 @@ export default function JournalEntryList({ periodId }: Props) {
}
}, [])
useEffect(() => {
fetchEntries()
}, [periodId, page])
async function fetchEntries() {
setLoading(true)
const params = new URLSearchParams({
@@ -73,6 +69,10 @@ export default function JournalEntryList({ periodId }: Props) {
fetchAttachmentCounts(ids)
}
useEffect(() => {
fetchEntries()
}, [periodId, page])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
}, [])
+2 -2
View File
@@ -5,7 +5,7 @@ import { ChatMessage } from './ChatMessage'
import { ChatInput } from './ChatInput'
import { useChatStream } from './useChatStream'
import { useToast } from '@/components/ui/use-toast'
import { Bot, Trash2, RotateCcw } from 'lucide-react'
import { Bot, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ChatPanelProps {
@@ -20,7 +20,7 @@ export function ChatPanel({ className }: ChatPanelProps) {
messages,
isLoading,
isStreaming,
error,
error: _error,
toolsExecuting,
sendMessage,
clearChat,
@@ -18,8 +18,6 @@ interface ChatAgingBucketsProps {
export function ChatAgingBuckets({ artifact }: ChatAgingBucketsProps) {
const { title, buckets, total } = artifact
const maxAmount = Math.max(...buckets.map((b) => b.amount), 1)
return (
<div className="w-full">
<div className="flex items-baseline justify-between mb-3">
+1 -1
View File
@@ -6,7 +6,7 @@ interface ChatDataTableProps {
artifact: TableArtifact
}
function formatCell(value: string | number, align?: 'left' | 'right'): string {
function formatCell(value: string | number, _align?: 'left' | 'right'): string {
if (typeof value === 'number') {
return new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: value % 1 !== 0 ? 2 : 0,
+1
View File
@@ -8,6 +8,7 @@ import ExtensionToggleButton from './ExtensionToggleButton'
import Link from 'next/link'
export default function ExtensionCard({ extension }: { extension: ExtensionDefinition }) {
const Icon = resolveIcon(extension.icon)
return (
@@ -16,6 +16,7 @@ export default function ExtensionWorkspaceLoader({
definition: ExtensionDefinition
userId: string
}) {
const WorkspaceComponent = getWorkspaceComponent(sector, slug)
return (
@@ -12,6 +12,7 @@ export default function ExtensionWorkspaceShell({
definition: ExtensionDefinition
children: React.ReactNode
}) {
const Icon = resolveIcon(definition.icon)
const sector = getSector(definition.sector)
+1
View File
@@ -4,6 +4,7 @@ import { resolveIcon } from '@/lib/extensions/icon-resolver'
import type { Sector } from '@/lib/extensions/types'
export default function SectorCard({ sector }: { sector: Sector }) {
const Icon = resolveIcon(sector.icon)
return (
-1
View File
@@ -8,7 +8,6 @@ import {
XCircle,
ArrowRight,
RotateCcw,
ExternalLink,
} from 'lucide-react'
import type { IngestResult } from '@/lib/transactions/ingest'
-1
View File
@@ -2,7 +2,6 @@
import { useState, useCallback } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
+2 -9
View File
@@ -45,15 +45,8 @@ export default function Step1EntityType({ initialData, onNext, isSaving }: Step1
}
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Välkommen!</h1>
<p className="text-muted-foreground mt-2">
Låt oss börja med att ställa in din verksamhet. Vilken företagsform har du?
</p>
</div>
<div className="grid gap-3 max-w-lg mx-auto">
<div className="space-y-8">
<div className="grid gap-3">
{entityOptions.map((option) => {
const Icon = option.icon
const isSelected = selected === option.value
@@ -57,13 +57,6 @@ export default function Step2CompanyDetails({
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Företagsuppgifter</h1>
<p className="text-muted-foreground mt-2">
Fyll i uppgifter om din verksamhet. Dessa används på fakturor.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Grunduppgifter</CardTitle>
@@ -263,13 +263,6 @@ export default function Step3TaxRegistration({
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Skatteregistrering</h1>
<p className="text-muted-foreground mt-2">
Ange information om din skatteregistrering.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>F-skatt och räkenskapsår</CardTitle>
@@ -50,13 +50,6 @@ export default function Step4PreliminaryTax({
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Preliminärskatt</h1>
<p className="text-muted-foreground mt-2">
Ange din månatliga F-skatt för att få bättre överblick över din ekonomi.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -57,14 +57,6 @@ export default function Step6ConnectBank({
return (
<div className="space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter</h1>
<p className="text-muted-foreground mt-2">
Ange dina bankuppgifter så visas de på dina fakturor.
Du kan importera transaktioner från din bank efteråt.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
+1 -2
View File
@@ -1,8 +1,7 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
+146
View File
@@ -0,0 +1,146 @@
# Arkivplan
**Mall for användare av erp-base**
Upprättad i enlighet med BFNAR 2013:2 punkt 8.3
---
## Instruktioner
Denna mall ska fyllas i av dig som kund och sparas som del av din systemdokumentation. Bokföringsnämndens allmänna råd (BFNAR 2013:2) kräver att varje bokföringsskyldig upprättar en arkivplan som beskriver vilken räkenskapsinformation som finns, var den förvaras, och vem som ansvarar för arkiveringen.
Fyll i de markerade fälten. Radera denna instruktionssektion innan du arkiverar dokumentet.
---
## 1. Företagsuppgifter
| Fält | Uppgift |
|---|---|
| Företagsnamn | [FÖRETAGSNAMN] |
| Organisationsnummer | [ORG-NR] |
| Företagsform | [ ] Enskild firma [ ] Aktiebolag |
| Räkenskapsår | [STARTMÅNAD] - [SLUTMÅNAD] |
| Bokföringsmetod | [ ] Faktureringsmetoden [ ] Kontantmetoden |
| Momsredovisningsperiod | [ ] Månadsvis [ ] Kvartalsvis [ ] Årsvis |
| Ansvarig för bokföringen | [NAMN, ROLL] |
## 2. Bokföringssystem
| Fält | Uppgift |
|---|---|
| Programvara | erp-base ([DOMÄN]) |
| Leverantör | [BOLAGSNAMN], org.nr [ORG-NR] |
| Lagringsplats | Molnbaserad tjänst, data lagrat inom EU (Supabase/AWS) |
| Åtkomst | Via webbläsare, inloggning med magic link (e-post) |
| Kontoplan | BAS 2025/2026 (konfigurerad i erp-base) |
## 3. Förteckning över räkenskapsinformation
Tabellen nedan anger vilken räkenskapsinformation som finns, i vilken form den förvaras, var, och arkiveringstid.
### 3.1 Löpande bokföring
| Räkenskapsinformation | Form | Lagringsplats | Arkiveringstid |
|---|---|---|---|
| Grundbokföring (registreringsordning) | Elektronisk | erp-base databas | 7 år efter räkenskapsårets utgång |
| Huvudbokföring (systematisk ordning) | Elektronisk | erp-base databas | 7 år efter räkenskapsårets utgång |
| Verifikationer (journalposter) | Elektronisk | erp-base databas | 7 år efter räkenskapsårets utgång |
### 3.2 Verifikationsunderlag
| Räkenskapsinformation | Form | Lagringsplats | Arkiveringstid | Anmärkning |
|---|---|---|---|---|
| Kundfakturor (utgående) | Elektronisk (PDF) | erp-base dokumentarkiv | 7 år | Genereras i erp-base |
| Leverantörsfakturor (inkommande) | Elektronisk (PDF/bild) | erp-base dokumentarkiv | 7 år | Uppladdade/skannade |
| Kvitton | Elektronisk (foto/PDF) | erp-base dokumentarkiv | 7 år | Fotograferade via appen |
| Bankutdrag/kontoutdrag | Elektronisk | erp-base via PSD2-koppling | 7 år | Synkroniserade via Enable Banking |
| Avtal och övriga underlag | [Elektronisk/Papper] | [erp-base / Fysisk pärm] | 7 år | [Ange var dessa förvaras] |
### 3.3 Årsbokslut och årsredovisning
| Räkenskapsinformation | Form | Lagringsplats | Arkiveringstid |
|---|---|---|---|
| Resultaträkning | Elektronisk | erp-base rapportmodul | 7 år |
| Balansräkning | Elektronisk | erp-base rapportmodul | 7 år |
| Årsredovisning (AB) / Årsbokslut (EF) | [Elektronisk/Papper] | [erp-base / Bolagsverket / Fysisk pärm] | 7 år (10 år rekommenderat) |
| NE-bilaga (EF) | Elektronisk | erp-base rapportmodul | 7 år |
| SIE-filer (export) | Elektronisk | [Ange var exporterade filer sparas] | 7 år |
### 3.4 Skattedeklarationer och momsrapporter
| Räkenskapsinformation | Form | Lagringsplats | Arkiveringstid |
|---|---|---|---|
| Momsdeklarationer | Elektronisk | erp-base rapportmodul + Skatteverket | 7 år |
| SRU-filer | Elektronisk | erp-base rapportmodul | 7 år |
| Inkomstdeklaration | [Elektronisk/Papper] | [Skatteverket / Egen kopia] | 7 år |
### 3.5 Systemdokumentation
| Dokument | Form | Lagringsplats | Arkiveringstid |
|---|---|---|---|
| Systemdokumentation | Elektronisk | [erp-base / Egen lagring] | Samma som den räkenskapsinformation den avser |
| Behandlingshistorik | Elektronisk | erp-base (automatiskt genererad) | Samma som den räkenskapsinformation den avser |
| Denna arkivplan | [Elektronisk/Papper] | [Ange lagringsplats] | Samma som den räkenskapsinformation den avser |
## 4. Pappersoriginal
4.1. Räkenskapsinformation som tagits emot i pappersform (kvitton, fakturor) och som har överförts till elektronisk form genom skanning eller fotografering ska bevaras i sin ursprungliga pappersform i minst tre (3) år efter utgången av det kalenderår då räkenskapsåret avslutades, i enlighet med 7 kap. 6 § BFL.
*Notering: Lagändring trädde i kraft 1 juli 2024 som möjliggör omedelbar förstöring av pappersoriginal efter överföring till elektronisk form, under förutsättning att överföringen sker på ett betryggande sätt och att inga uppgifter går förlorade. Se BFNAR 2024:1 och uppdaterad vägledning (2024-09-16) for detaljer om vilka krav som gäller vid sådan överföring.*
4.2. Dokument som tas emot elektroniskt (e-fakturor, digitala kvitton) arkiveras i elektronisk form. Inget pappersoriginal finns.
4.3. Förvaring av pappersoriginal:
- Plats: [ANGE PLATS, t.ex. kontor, bankfack]
- Ansvarig: [NAMN]
## 5. Säkerhetskopiering och redundans
5.1. erp-base sköter automatisk daglig säkerhetskopiering av databasen via Supabase-infrastrukturen.
5.2. Kunden rekommenderas att regelbundet exportera SIE4-filer och spara dessa på en separat lagringsplats som kompletterande säkerhetskopia.
Kundens kompletterande säkerhetskopiering:
- Frekvens: [t.ex. månadsvis, kvartalsvis]
- Lagringsplats: [t.ex. extern hårddisk, molnlagring]
- Ansvarig: [NAMN]
## 6. Åtkomst efter avslutad prenumeration
6.1. Vid uppsägning av erp-base-kontot har Kunden nittio (90) dagar att exportera all räkenskapsinformation i enlighet med Användarvillkoren avsnitt 8.
6.2. Räkenskapsinformation som omfattas av sjuårig arkiveringsskyldighet bevaras i skrivskyddat läge av erp-base, alternativt tillhandahålls som fullständig dataexport.
6.3. Det är Kundens ansvar att planera för dataportabilitet och säkerställa tillgång till räkenskapsinformation under hela arkiveringsperioden, oavsett om Tjänsten fortfarande används.
## 7. Geografisk lagring
7.1. All data i erp-base lagras inom EU/EES via Supabase (AWS-infrastruktur, region eu-central eller eu-west).
7.2. Viss behandling sker hos underbiträden i USA (se Personuppgiftsbiträdesavtalet, avsnitt 6.2) med stöd av EU-U.S. Data Privacy Framework eller standardavtalsklausuler.
7.3. I enlighet med 7 kap. 3a § BFL får räkenskapsinformation i elektronisk form förvaras i annat EU-land under förutsättning att detta har anmälts till Skatteverket.
**Anmälan till Skatteverket:** [ ] Har gjorts [ ] Behöver göras [ ] Ej tillämpligt (data lagras i Sverige)
## 8. Ansvar och kontakt
| Roll | Namn | Kontakt |
|---|---|---|
| Bokföringsansvarig | [NAMN] | [E-POST / TELEFON] |
| Extern redovisningskonsult (om tillämpligt) | [NAMN / BYRÅ] | [E-POST / TELEFON] |
| Revisor (om tillämpligt) | [NAMN / BYRÅ] | [E-POST / TELEFON] |
## 9. Uppdatering av arkivplanen
Denna arkivplan ska granskas och vid behov uppdateras minst en gång per räkenskapsår, samt vid byte av bokföringsprogram, ändring av företagsform, eller ändring av lagringsrutiner.
| Datum | Ändring | Utförd av |
|---|---|---|
| [DATUM] | Första version upprättad | [NAMN] |
| | | |
---
*Denna arkivplan uppfyller kraven i BFNAR 2013:2 punkt 8.3 och Exempel 8.1 i vägledningen. Anpassa innehållet till ditt företags specifika förhållanden. Platshållare markerade med hakparenteser ska fyllas i.*
+259
View File
@@ -0,0 +1,259 @@
# Systemdokumentation
**Mall for användare av erp-base**
Upprättad i enlighet med 5 kap. 11 § BFL och BFNAR 2013:2 kapitel 9
---
## Instruktioner
Varje bokföringsskyldig ska upprätta en systemdokumentation som beskriver bokföringssystemets organisation och uppbyggnad. Dokumentationen ska göra det möjligt att utan svårighet överblicka systemet och förstå hur bokföringen är organiserad (BFNAR 2013:2 punkt 9.1).
Denna mall är förifylld med uppgifter som gäller för erp-base. Avsnitt markerade med hakparenteser ska anpassas till ditt företags förhållanden. Radera denna instruktionssektion innan du arkiverar dokumentet.
Systemdokumentationen ska förvaras tillsammans med övrig räkenskapsinformation under hela arkiveringsperioden (7 år).
---
## 1. Företagsuppgifter
| Fält | Uppgift |
|---|---|
| Företagsnamn | [FÖRETAGSNAMN] |
| Organisationsnummer | [ORG-NR] |
| Företagsform | [ ] Enskild firma [ ] Aktiebolag |
| Räkenskapsår | [STARTMÅNAD] - [SLUTMÅNAD] |
| Tillämpat K-regelverk | [ ] K1 (förenklat årsbokslut, EF under 3 MSEK) [ ] K2 (årsredovisning, mindre AB) [ ] K3 (årsredovisning, huvudregelverk) |
## 2. Bokföringsprogram
| Fält | Uppgift |
|---|---|
| Programnamn | erp-base |
| Version | [ANGE VERSION, t.ex. 1.0] |
| Leverantör | [BOLAGSNAMN], org.nr [ORG-NR] |
| Webbplats | [DOMÄN] |
| Typ | Molnbaserad SaaS-tjänst (webbläsarbaserad) |
| Databasplattform | PostgreSQL via Supabase (AWS, EU-region) |
| Autentisering | Magic link via e-post (lösenordsfri) |
## 3. Kontoplan (BFNAR 2013:2 punkt 9.2)
3.1. Kontoplanen bygger på BAS-kontoplanen (BAS 2025/2026) utgiven av BAS-intressenternas Förening.
3.2. Kontona är indelade i klasser enligt BAS-standard:
| Klass | Beskrivning | Exempel på konton |
|---|---|---|
| 1 | Tillgångar | 1510 Kundfordringar, 1930 Företagskonto |
| 2 | Eget kapital och skulder | 2013 Egna uttag (EF), 2440 Leverantörsskulder, 2611-2631 Utgående moms, 2641 Ingående moms |
| 3 | Intäkter | 3001 Försäljning 25%, 3002 Försäljning 12%, 3003 Försäljning 6%, 3305 Exportförsäljning |
| 4-7 | Kostnader | Konfigureras efter verksamhet |
| 8 | Finansiella poster och skatt | Konfigureras efter verksamhet |
3.3. Kontoplanen kan ses och exporteras i erp-base under Inställningar > Kontoplan.
3.4. Företagsspecifika anpassningar av kontoplanen:
[BESKRIV EVENTUELLA TILLAGDA ELLER BORTTAGNA KONTON, t.ex. "Konto 4010 Inköp varor, 5010 Lokalhyra har lagts till. Inga standardkonton har tagits bort."]
## 4. Samlingsplan (BFNAR 2013:2 punkt 9.3-9.5)
Samlingsplanen beskriver hur bokföringen är organiserad i form av delsystem, grundbokföring och huvudbokföring.
### 4.1 Översikt
```
Affärshändelse
|
v
Verifikation skapas (manuellt eller automatiskt)
|
v
Journalpost registreras (grundbokföring, registreringsordning)
|
v
Konteras på BAS-konton (huvudbokföring, systematisk ordning)
|
v
Status: Utkast (draft)
|
v
Bekräftas av användaren
|
v
Status: Bokförd (posted), verifikationsnummer tilldelas
```
### 4.2 Grundbokföring (registreringsordning)
Samtliga affärshändelser registreras kronologiskt i erp-base journalen. Varje post innehåller:
- Verifikationsnummer (sekventiellt, tilldelat automatiskt vid bokföring)
- Registreringsdatum (datum då posten skapades i systemet)
- Bokföringsdatum (datum för affärshändelsen)
- Beskrivning
- Konteringsrader med konto, debet, kredit
Grundbokföringen kan visas under Bokföring > Journal i erp-base.
### 4.3 Huvudbokföring (systematisk ordning)
Huvudbokföringen presenterar affärshändelserna sorterade per konto. Varje konto visar ingående saldo, periodens transaktioner och utgående saldo.
Huvudbokföringen kan visas och exporteras under Rapporter > Huvudbok i erp-base.
### 4.4 Delsystem
Följande delsystem matar journalen:
| Delsystem | Beskrivning | Automatisk kontering |
|---|---|---|
| Kundfakturering | Utgående fakturor med per-rad momssats | Debet 1510, kredit 30xx + 26xx |
| Kundbetalningar | Inbetalningar mot fakturor | Debet 1930, kredit 1510 |
| Leverantörsfakturor | Inkommande fakturor, registrering och betalning | Debet kostnadskonto + 2641, kredit 2440 |
| Leverantörsbetalningar | Utbetalningar mot leverantörsfakturor | Debet 2440, kredit 1930 |
| Banktransaktioner | Synkroniserade via PSD2 (Enable Banking) | Kontering via kategoriseringsregler |
| Kvittohantering | OCR-bearbetade kvitton | Kontering efter granskning |
| Kreditnotor | Kreditering av utgående fakturor | Omvänd kontering av originalfaktura |
### 4.5 Avstämningsordning
Bankkonto 1930 avstäms via erp-base bankavstämningsmodul (4-stegs matchning: exakt belopp+datum, referensmatchning, datumintervall, fuzzy-matchning).
## 5. Verifikationer (BFNAR 2013:2 punkt 9.6-9.8)
### 5.1 Verifikationsnumrering
Verifikationsnummer tilldelas sekventiellt av systemet vid bokföring. Numreringen är unik per räkenskapsår och användare. Numren tilldelas via databas-RPC (concurrent-safe) och kan inte sättas manuellt.
En enda verifikationsnummerserie används: [A1, A2, A3, ...].
[OM FÖRETAGET ANVÄNDER FLERA SERIER, BESKRIV HÄR.]
### 5.2 Verifikationens innehåll
Varje verifikation i erp-base innehåller:
- Verifikationsnummer
- Bokföringsdatum (affärshändelsens datum)
- Registreringsdatum (datum då posten skapades)
- Beskrivning av affärshändelsen
- Konteringsrader (konto, debet, kredit)
- Referens till underlag (bifogat dokument, fakturanummer, etc.)
- Status (utkast / bokförd / reverserad)
- Vid rättelse: referens till reverserad/reverserande verifikation
### 5.3 Underlag
Underlag kopplas till verifikationer som bifogade dokument (PDF, bild). Dokumenten lagras i erp-base dokumentarkiv med SHA-256 checksumma for integritetskontroll.
Typer av underlag:
- Kundfakturor (genererade i systemet)
- Leverantörsfakturor (uppladdade)
- Kvitton (fotograferade/skannade)
- Bankbekräftelser (synkroniserade)
- Övriga avtal och dokument (uppladdade)
## 6. Rättelser (BFNAR 2013:2 punkt 9.9)
6.1. Bokförda verifikationer (status: posted) kan inte ändras eller raderas. Detta upprätthålls av databastriggrar i enlighet med bokföringslagens krav på oföränderlighet.
6.2. Rättelse sker genom stornobokning: en ny verifikation skapas som reverserar den felaktiga posten (byter debet/kredit). Den nya verifikationen länkas till originalet via referens (reverses_id / reversed_by_id).
6.3. Därefter skapas en ny korrekt verifikation vid behov.
6.4. Rättelseverifikationen innehåller uppgift om vilken verifikation som rättats, när rättelsen gjordes, och vem som utförde rättelsen (BFNAR 2013:2 punkt 2.17).
## 7. Periodavstängning
7.1. Räkenskapsperioder kan stängas (låsas) i erp-base. En låst period tillåter inte nya bokföringsposter. Periodlåsning upprätthålls av databastriggrar (enforce_period_lock).
7.2. Årsbokslut registreras som bokföringsposter i systemet.
## 8. Momshantering
8.1. Följande momssatser hanteras:
| Momssats | Beskrivning | Utgående moms-konto | Ingående moms-konto |
|---|---|---|---|
| 25 % | Standardsats | 2611 | 2641 |
| 12 % | Reducerad (livsmedel, hotell m.m.) | 2621 | 2641 |
| 6 % | Reducerad (böcker, tidningar, kultur m.m.) | 2631 | 2641 |
| 0 % (export) | Varuexport utanför EU | - | 2641 |
| 0 % (omvänd skattskyldighet) | Försäljning med omvänd skattskyldighet | - | 2641/2645 |
| Momsfri | Undantagna transaktioner | - | - |
*Notering: Livsmedel sänks till 6 % från 1 april 2026 t.o.m. 31 december 2027.*
8.2. Fakturor stödjer blandade momssatser (per fakturarad).
8.3. Momsrapport genereras under Rapporter > Momsdeklaration och mappas till Skatteverkets rutor.
## 9. Behandlingshistorik (BFNAR 2013:2 punkt 9.16)
9.1. erp-base registrerar automatiskt en behandlingshistorik som inkluderar:
- Registreringsdatum och tidpunkt for varje journalpost
- Tidpunkt för statusändring (utkast till bokförd)
- Vem som utförde bokningen (användar-ID kopplat till e-postadress)
- Stornobokningar med referens till originalverifikation
- Tidpunkt och utförare av periodlåsning
9.2. Behandlingshistoriken genereras automatiskt av systemet och kan inte ändras av användaren.
9.3. Behandlingshistoriken kan exporteras under Rapporter > Audit trail.
## 10. Import och export
| Funktion | Format | Beskrivning |
|---|---|---|
| SIE-import | SIE4 | Import av bokföringsdata från annat system |
| Bankfil-import | CSV (10 svenska bankformat) | Import av banktransaktioner |
| SIE-export | SIE4 | Export av komplett bokföring per räkenskapsår |
| Huvudbok | PDF/skärm | Export av huvudbok |
| Resultaträkning | PDF/skärm | Export av resultaträkning |
| Balansräkning | PDF/skärm | Export av balansräkning |
| Momsdeklaration | PDF/skärm | Underlag för momsdeklaration |
| SRU-export | SRU | Export for inkomstdeklaration |
| NE-bilaga | PDF/skärm | Bilaga till inkomstdeklaration (EF) |
| Verifikationsunderlag | PDF/bild | Nedladdning av bifogade dokument |
## 11. Integrationer
| Integration | Beskrivning | Dataflöde |
|---|---|---|
| Enable Banking (PSD2) | Bankkontosynkronisering | Bank -> erp-base (läsning av transaktioner och saldon) |
| Anthropic API | AI-kategorisering av transaktioner, OCR | erp-base -> Anthropic -> erp-base (transaktionsdata skickas, kategoriseringsförslag returneras) |
| OpenAI API | Embeddingar for likhetsmatchning | erp-base -> OpenAI -> erp-base (transaktionsbeskrivningar skickas, vektorer returneras) |
| Resend | E-postutskick | erp-base -> Resend -> mottagare (fakturor, påminnelser) |
[ANGE YTTERLIGARE INTEGRATIONER OM TILLÄMPLIGT]
## 12. Behörigheter och åtkomstkontroll
12.1. Varje konto i erp-base är isolerat via Row Level Security (RLS) i databasen. En användare kan enbart se och redigera sin egen data.
12.2. Nuvarande behörighetsstruktur:
| Roll | Beskrivning |
|---|---|
| Kontoägare | Full åtkomst till all data och funktionalitet |
[OM YTTERLIGARE ROLLER FINNS, BESKRIV HÄR]
12.3. Ansvarig for att tilldela och granska behörigheter: [NAMN]
## 13. Uppdatering av systemdokumentationen
Systemdokumentationen ska uppdateras vid:
- Byte eller uppgradering av bokföringsprogram
- Ändringar i kontoplan
- Ändringar i momshantering
- Nya integrationer eller delsystem
- Minst en gång per räkenskapsår
| Datum | Ändring | Utförd av |
|---|---|---|
| [DATUM] | Första version upprättad | [NAMN] |
| | | |
---
*Denna systemdokumentation uppfyller kraven i 5 kap. 11 § BFL och BFNAR 2013:2 kapitel 9 (punkterna 9.1-9.16) samt Exempel 9.1-9.4 i vägledningen. Anpassa innehållet till ditt företags specifika förhållanden.*
+9
View File
@@ -5,6 +5,15 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
rules: {
"@typescript-eslint/no-unused-vars": ["warn", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
}],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
+2 -2
View File
@@ -2,7 +2,7 @@
## The App
erp-base is a Swedish accounting platform for sole traders (enskild firma) and limited companies (aktiebolag). It handles the legally required bookkeeping and financial management that every Swedish business needs.
gnubok is a Swedish accounting platform for sole traders (enskild firma) and limited companies (aktiebolag). It handles the legally required bookkeeping and financial management that every Swedish business needs.
## Core Functionality
@@ -678,7 +678,7 @@ Core runs with zero extensions. This gives you the standard accounting system: b
```bash
# 1. Clone and install
git clone <repo-url> && cd erp-base
git clone <repo-url> && cd gnubok
npm install
# 2. Set environment variables (minimum 4)
@@ -10,7 +10,6 @@ import {
type CategorizationSuggestion,
type AccountUsageEntry,
type MerchantHistoryEntry,
type TrackingContext,
} from './categorizer'
import type { BookingTemplate } from '@/lib/bookkeeping/booking-templates'
@@ -198,9 +197,9 @@ async function handleTransactionSynced(
// Helpers
// ============================================================
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function buildEnrichedContext(
userId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
supabase: any,
transactions: TransactionForCategorization[]
): Promise<EnrichedCategorizationContext> {
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server'
import type { ApiRouteDefinition, ExtensionContext } from '@/lib/extensions/types'
import { generateChatResponse, streamChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { generateChatResponse, streamRoutedResponse } from '@/extensions/general/ai-chat/chatbot/chain'
import { CHATBOT_CONFIG } from '@/extensions/general/ai-chat/chatbot/config'
import type { ChatMessage, ChatRequest, SourceReference, ArtifactSpec } from '@/types/chat'
+1 -2
View File
@@ -9,7 +9,6 @@ import {
import {
retrieveRelevantDocuments,
documentsToSources,
type RetrievedDocument,
} from './retriever'
import { routeMessage, type RouteType } from './router'
import { createAccountingTools } from './tools'
@@ -171,7 +170,7 @@ export async function* streamRoutedResponse(
conversationHistory: ChatMessage[],
supabase: SupabaseClient,
userId: string,
sessionId?: string
_sessionId?: string
): AsyncGenerator<RoutedStreamEvent> {
// 1. Route the message
const { route, rewrittenQuery } = await routeMessage(userMessage, conversationHistory)
+1 -1
View File
@@ -65,7 +65,7 @@ export function createAccountingTools(supabase: SupabaseClient, userId: string)
if (date_from) query = query.gte('invoice_date', date_from)
if (date_to) query = query.lte('invoice_date', date_to)
const { data, error, count } = await supabase
const { data: _data, error: _countError, count } = await supabase
.from('invoices')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
@@ -144,7 +144,7 @@ function chunkText(text: string, maxSize: number, overlap: number): string[] {
*/
function extractMetadata(
content: string,
sectionTitle: string
_sectionTitle: string
): Record<string, unknown> {
const metadata: Record<string, unknown> = {}
@@ -1,7 +1,7 @@
'use client'
import { cn } from '@/lib/utils'
import { Invoice, Deadline, DeadlineStatus } from '@/types'
import { Invoice, Deadline } from '@/types'
import { isInvoiceOverdue, isSameDay, formatDateISO, STATUS_COLORS } from '@/lib/calendar/utils'
interface CalendarDayCellProps {
@@ -131,7 +131,7 @@ export function CalendarWeekView({
{/* Time grid */}
<div className="max-h-[500px] overflow-y-auto">
{timeSlots.map((time, timeIndex) => (
{timeSlots.map((time, _timeIndex) => (
<div key={time} className="grid grid-cols-[60px_repeat(7,1fr)] border-b last:border-b-0">
<div className="p-2 border-r text-xs text-muted-foreground text-right pr-2">
{time}
@@ -10,7 +10,7 @@ import {
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Invoice, Deadline } from '@/types'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils'
import {
isInvoiceOverdue,
isDeadlineOverdue,
@@ -1,6 +1,6 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Flame, Receipt, CreditCard, Camera, ArrowRight } from 'lucide-react'
@@ -1,13 +1,13 @@
'use client'
import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { formatCurrency, formatDate } from '@/lib/utils'
import { X, Check, Link2, AlertCircle, Search, Loader2 } from 'lucide-react'
import type { Receipt, ReceiptMatchCandidate, Transaction } from '@/types'
import type { Receipt, ReceiptMatchCandidate } from '@/types'
interface TransactionMatcherProps {
receipt: Receipt
@@ -5,7 +5,7 @@
* using pattern matching and AI-suggested categories.
*/
import type { TransactionCategory, ReceiptLineItem, ExtractedLineItem } from '@/types'
import type { TransactionCategory, ExtractedLineItem } from '@/types'
import { getTemplateById } from '@/lib/bookkeeping/booking-templates'
// Category mappings from suggested category to TransactionCategory
@@ -9,7 +9,7 @@
import 'server-only'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { Receipt, ReceiptMatchCandidate, ReceiptExtractionResult } from '@/types'
import type { Receipt, ReceiptExtractionResult } from '@/types'
import { analyzeReceipt } from './receipt-analyzer'
import { processLineItems, getDefaultClassification } from './receipt-categorizer'
import { autoMatchReceipts } from './receipt-matcher'
@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
+1 -1
View File
@@ -451,7 +451,7 @@ function validateVatBreakdown(data: any): VatBreakdownItem[] {
*/
export function validateExtractionConsistency(
extraction: ReceiptExtractionResult | InvoiceExtractionResult,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_type: 'receipt' | 'invoice'
): ConsistencyResult {
const issues: string[] = []
-3
View File
@@ -13,9 +13,6 @@ const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected YYYY-MM-DD dat
/** BAS account number — always a string of 4 digits */
const accountNumber = z.string().regex(/^\d{4}$/, 'Account number must be exactly 4 digits')
/** Positive monetary amount (> 0) */
const positiveAmount = z.number().positive()
/** Non-negative monetary amount (>= 0) */
const nonNegativeAmount = z.number().nonnegative()
+2 -2
View File
@@ -1,3 +1,5 @@
import { getBASReference, ACCOUNT_CLASS_LABELS } from './bas-reference'
export type AccountType = 'asset' | 'liability' | 'equity' | 'revenue' | 'expense' | 'untaxed_reserves'
export interface AccountDescription {
@@ -332,8 +334,6 @@ export function getAccountDescription(accountNumber: string): AccountDescription
// Fall back to BAS reference data for accounts not in the hardcoded list
try {
// Dynamic import avoided — use lazy require pattern
const { getBASReference, ACCOUNT_CLASS_LABELS } = require('./bas-reference')
const ref = getBASReference(accountNumber)
if (ref) {
const classLabel = ACCOUNT_CLASS_LABELS[ref.account_class] || ''
-2
View File
@@ -173,10 +173,8 @@ export async function previewCurrencyRevaluation(
let credit1510 = 0 // Receivable loss (revalue down)
let debit2440 = 0 // Payable gain (liability shrank)
let credit2440 = 0 // Payable loss (liability grew)
let debit3960 = 0 // Placeholder — we won't debit 3960
let credit3960 = 0 // Gains
let debit7960 = 0 // Losses
let credit7960 = 0 // Placeholder — we won't credit 7960
for (const item of items) {
if (item.type === 'receivable') {
+1 -1
View File
@@ -1,6 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateSalesVatLines, generateReverseChargeLines } from './vat-entries'
import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
-3
View File
@@ -2,14 +2,11 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import {
generateInputVatLine,
generateReverseChargeLines,
extractNetAmount,
extractVatAmount,
} from './vat-entries'
import { findMatchingTemplates, buildMappingResultFromTemplate } from './booking-templates'
import type {
MappingRule,
MappingResult,
RiskLevel,
Transaction,
EntityType,
VatJournalLine,
+1 -1
View File
@@ -1,6 +1,6 @@
import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateInputVatLine, generateReverseChargeLines, extractNetAmount, extractVatAmount } from './vat-entries'
import { extractNetAmount, extractVatAmount } from './vat-entries'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
+1 -1
View File
@@ -1,7 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClient } from '@/lib/supabase/server'
import { eventBus } from '@/lib/events'
import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadSource } from '@/types'
import type { DocumentAttachment, DocumentUploadSource } from '@/types'
/**
* Document Service - WORM-style document archive
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { matchDocumentToTransactions } from '../document-matcher'
import { makeInvoiceInboxItem, makeTransaction } from '@/tests/helpers'
import type { InvoiceExtractionResult, ReceiptExtractionResult, Transaction } from '@/types'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
describe('matchDocumentToTransactions', () => {
const mockSupabase = {} as never // Not used when candidateTransactions is provided
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createMockSupabase } from '@/tests/helpers'
import {
+2 -1
View File
@@ -1,4 +1,4 @@
import { resolve, join, dirname } from 'path'
import { resolve, join } from 'path'
import { readdirSync, readFileSync } from 'fs'
/**
@@ -74,6 +74,7 @@ describe('sectors registry', () => {
})
it('getSector returns undefined for unknown slug', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sector = getSector('invalid' as any)
expect(sector).toBeUndefined()
})
+1 -1
View File
@@ -355,7 +355,7 @@ async function importVouchers(
p_series: voucherSeries,
})
let currentVoucherNumber = (startNumber as number) || 1
const currentVoucherNumber = (startNumber as number) || 1
// Batch insert journal entries (in chunks of 100)
const BATCH_SIZE = 100
+1
View File
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -10,6 +10,7 @@ import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = {} as any
beforeEach(() => {
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from 'vitest'
import JSZip from 'jszip'
import { generateFullArchive } from '../full-archive-export'
@@ -21,6 +21,7 @@ function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -10,6 +10,7 @@ import type { TrialBalanceRow } from '@/types'
const mockTrialBalance = vi.mocked(generateTrialBalance)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const supabase = {} as any
beforeEach(() => {
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
+1
View File
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -21,6 +21,7 @@ function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -20,6 +20,7 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'
import { INK2_ACCOUNT_MAPPINGS, isAccountInMapping } from '../ink2-engine'
import type { INK2AccountMapping, INK2SRUCode } from '../types'
import type { INK2SRUCode } from '../types'
/**
* Helper to find which SRU code an account maps to
@@ -1,6 +1,5 @@
import { describe, it, expect } from 'vitest'
import { NE_ACCOUNT_MAPPINGS } from '../ne-engine'
import type { NEAccountMapping } from '@/lib/reports/ne-bilaga/types'
/**
* Helper to check if an account falls into a specific ruta

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