feat: booking template library with system templates and cross-company sharing (#235)

* feat: implement viewer role permissions for bank transaction imports and connections

* feat: add booking_template_library table with 30 system templates

Three-level scoping (system/team/company), RLS policies for
read/write/delete, and pre-seeded templates for EU reverse charge,
tax account, private transfers, salary, representation, year-end,
VAT netting, and bank/finance scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add template library types, helpers, and tests

- BookingTemplateLibrary/Line/Category types in types/index.ts
- applyTemplate() converts template lines + amount into form lines
- Category labels, scope helpers for UI display
- 8 unit tests for amount calculation, VAT, rounding, and scope detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add booking template CRUD, export, and import API routes

- GET/POST/DELETE /api/settings/booking-templates (list, create, soft-delete)
- PUT /api/settings/booking-templates/[id] (update non-system templates)
- GET /api/settings/booking-templates/export (JSON download)
- POST /api/settings/booking-templates/import (bulk import from JSON)

All routes enforce auth, write permissions, and Zod validation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add template picker UI and settings management panel

- BookingTemplatePicker: dialog with search, category/entity-type filter,
  line preview, and amount input — integrated into JournalEntryForm
- BookingTemplatesPanel: settings page with grouped templates
  (system/team/company), create dialog, export/import, soft-delete
- Settings templates page now shows both booking and counterparty templates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Update supabase/migrations/20260413160000_booking_template_library.sql

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Mattsson
2026-04-14 09:42:14 +02:00
committed by GitHub
parent cd376e1cad
commit bf36ebfd88
12 changed files with 1685 additions and 19 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
'use client'
import { BookingTemplatesPanel } from '@/components/settings/BookingTemplatesPanel'
import { CounterpartyTemplatesPanel } from '@/components/settings/CounterpartyTemplatesPanel'
export default function TemplatesSettingsPage() {
return <CounterpartyTemplatesPanel />
return (
<div className="space-y-6">
<BookingTemplatesPanel />
<CounterpartyTemplatesPanel />
</div>
)
}
@@ -0,0 +1,60 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
const UpdateBookingTemplateSchema = z.object({
name: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional(),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).optional(),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).optional(),
lines: z.array(BookingTemplateLineSchema).min(2).optional(),
})
/**
* PUT /api/settings/booking-templates/[id]
* Update a non-system template.
*/
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const result = await validateBody(request, UpdateBookingTemplateSchema)
if (!result.success) return result.response
// RLS prevents updating system templates
const { data, error } = await supabase
.from('booking_template_library')
.update(result.data)
.eq('id', id)
.eq('is_system', false)
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Template not found' }, { status: 404 })
return NextResponse.json({ data })
}
@@ -0,0 +1,34 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
/**
* GET /api/settings/booking-templates/export
* Export company + team templates as JSON (excludes system templates).
* Useful for sharing templates between unrelated companies.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const companyId = await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('booking_template_library')
.select('name, description, category, entity_type, lines')
.eq('company_id', companyId)
.eq('is_active', true)
.eq('is_system', false)
.order('category')
.order('name')
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return new NextResponse(JSON.stringify({ version: 1, templates: data }, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': 'attachment; filename="bokforingsmallar.json"',
},
})
}
@@ -0,0 +1,83 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
const ImportLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
const ImportTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).default(''),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).default('other'),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'),
lines: z.array(ImportLineSchema).min(2),
})
const ImportPayloadSchema = z.object({
version: z.number(),
templates: z.array(ImportTemplateSchema).min(1).max(100),
})
/**
* POST /api/settings/booking-templates/import
* Import templates from JSON (exported from another company).
* Creates company-scoped templates for the active company.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const companyId = await requireCompanyId(supabase, user.id)
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const parsed = ImportPayloadSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid import format', details: parsed.error.issues },
{ status: 400 },
)
}
const rows = parsed.data.templates.map((t) => ({
company_id: companyId,
team_id: null,
created_by: user.id,
name: t.name,
description: t.description,
category: t.category,
entity_type: t.entity_type,
lines: t.lines,
is_system: false,
}))
const { data, error } = await supabase
.from('booking_template_library')
.insert(rows)
.select()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data, imported: data?.length ?? 0 }, { status: 201 })
}
+123
View File
@@ -0,0 +1,123 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
import { z } from 'zod'
import { validateBody } from '@/lib/api/validate'
const BookingTemplateLineSchema = z.object({
account: z.string().regex(/^\d{4}$/),
label: z.string().min(1),
side: z.enum(['debit', 'credit']),
type: z.enum(['business', 'vat', 'settlement']),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
const CreateBookingTemplateSchema = z.object({
name: z.string().min(1).max(200),
description: z.string().max(2000).default(''),
category: z.enum([
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other',
]).default('other'),
entity_type: z.enum(['all', 'enskild_firma', 'aktiebolag']).default('all'),
lines: z.array(BookingTemplateLineSchema).min(2),
team_id: z.string().uuid().optional(),
})
/**
* GET /api/settings/booking-templates
* Returns all templates visible to the current user:
* system + company + team templates.
*/
export async function GET() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// RLS handles scoping (system OR company OR team)
const { data, error } = await supabase
.from('booking_template_library')
.select('*')
.eq('is_active', true)
.order('is_system', { ascending: false })
.order('category')
.order('name')
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data })
}
/**
* POST /api/settings/booking-templates
* Create a company-scoped or team-scoped template.
*/
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
const result = await validateBody(request, CreateBookingTemplateSchema)
if (!result.success) return result.response
const body = result.data
const companyId = body.team_id ? null : await requireCompanyId(supabase, user.id)
const { data, error } = await supabase
.from('booking_template_library')
.insert({
company_id: companyId,
team_id: body.team_id ?? null,
created_by: user.id,
name: body.name,
description: body.description,
category: body.category,
entity_type: body.entity_type,
lines: body.lines,
is_system: false,
})
.select()
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data }, { status: 201 })
}
/**
* DELETE /api/settings/booking-templates
* Soft-delete a template by id (company or team scope only, never system).
*/
export async function DELETE(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const writeCheck = await requireWritePermission(supabase, user.id)
if (!writeCheck.ok) return writeCheck.response
let id: string | undefined
try {
const body = await request.json()
id = body?.id
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
}
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
// RLS prevents deleting system templates (btl_delete policy checks NOT is_system)
const { error } = await supabase
.from('booking_template_library')
.update({ is_active: false })
.eq('id', id)
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { success: true } })
}
@@ -0,0 +1,250 @@
'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { BookOpen, Search, Building2, Users, Globe } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS, SCOPE_LABELS, getTemplateScope, applyTemplate } from '@/lib/bookkeeping/template-library'
import type { BookingTemplateLibrary, BookingTemplateCategory, EntityType } from '@/types'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
interface Props {
onApply: (lines: FormLine[], description: string) => void
entityType?: EntityType
}
const SCOPE_ICONS = {
system: Globe,
team: Users,
company: Building2,
} as const
export default function BookingTemplatePicker({ onApply, entityType }: Props) {
const { toast } = useToast()
const [open, setOpen] = useState(false)
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [isLoading, setIsLoading] = useState(false)
const [search, setSearch] = useState('')
const [selectedCategory, setSelectedCategory] = useState<BookingTemplateCategory | 'all'>('all')
const [amount, setAmount] = useState('')
const [selectedId, setSelectedId] = useState<string | null>(null)
const fetchTemplates = useCallback(async (signal?: AbortSignal) => {
setIsLoading(true)
try {
const r = await fetch('/api/settings/booking-templates', { signal })
const { data } = await r.json()
setTemplates(data || [])
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
useEffect(() => {
if (!open) return
const controller = new AbortController()
fetchTemplates(controller.signal)
return () => { controller.abort() }
}, [open, fetchTemplates])
const filtered = useMemo(() => {
let result = templates
// Filter by entity type
if (entityType) {
result = result.filter((t) => t.entity_type === 'all' || t.entity_type === entityType)
}
// Filter by category
if (selectedCategory !== 'all') {
result = result.filter((t) => t.category === selectedCategory)
}
// Filter by search
if (search) {
const lower = search.toLowerCase()
result = result.filter(
(t) =>
t.name.toLowerCase().includes(lower) ||
t.description.toLowerCase().includes(lower),
)
}
return result
}, [templates, entityType, selectedCategory, search])
// Unique categories present in templates
const availableCategories = useMemo(() => {
const cats = new Set(templates.map((t) => t.category))
return Array.from(cats).sort()
}, [templates])
const selected = selectedId ? templates.find((t) => t.id === selectedId) : null
function handleApply() {
if (!selected) return
const totalAmount = parseFloat(amount)
if (!totalAmount || totalAmount <= 0) {
toast({ title: 'Ange belopp', description: 'Ange ett belopp för att använda mallen.', variant: 'destructive' })
return
}
const lines = applyTemplate(selected.lines, totalAmount)
onApply(lines, selected.name)
setOpen(false)
setSelectedId(null)
setAmount('')
setSearch('')
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" type="button">
<BookOpen className="h-3.5 w-3.5 mr-1.5" />
Använd mall
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Bokföringsmallar</DialogTitle>
</DialogHeader>
{/* Search + category filter */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Sök mall..."
className="pl-9"
/>
</div>
<div className="flex gap-1 flex-wrap">
<Button
variant={selectedCategory === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory('all')}
type="button"
>
Alla
</Button>
{availableCategories.map((cat) => (
<Button
key={cat}
variant={selectedCategory === cat ? 'default' : 'outline'}
size="sm"
onClick={() => setSelectedCategory(cat as BookingTemplateCategory)}
type="button"
>
{TEMPLATE_CATEGORY_LABELS[cat as BookingTemplateCategory]}
</Button>
))}
</div>
</div>
{/* Template list */}
<div className="flex-1 overflow-y-auto space-y-1 min-h-0">
{isLoading ? (
<p className="text-sm text-muted-foreground py-8 text-center">Laddar mallar...</p>
) : filtered.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">Inga mallar hittades.</p>
) : (
filtered.map((t) => {
const scope = getTemplateScope(t)
const ScopeIcon = SCOPE_ICONS[scope]
const isSelected = selectedId === t.id
return (
<button
key={t.id}
type="button"
onClick={() => setSelectedId(isSelected ? null : t.id)}
className={`w-full text-left rounded-lg border p-3 transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40 hover:bg-muted/50'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t.name}</span>
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 shrink-0">
<ScopeIcon className="h-3 w-3 mr-0.5" />
{SCOPE_LABELS[scope]}
</Badge>
{t.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0">
{t.entity_type === 'enskild_firma' ? 'EF' : 'AB'}
</Badge>
)}
</div>
{t.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{t.description}
</p>
)}
</div>
</div>
{/* Show lines preview when selected */}
{isSelected && (
<div className="mt-2 pt-2 border-t space-y-1">
{t.lines.map((line, i) => (
<div key={i} className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
<span className="w-10">{line.account}</span>
<span className="flex-1 truncate">{line.label}</span>
<span className={line.side === 'debit' ? 'text-foreground' : ''}>
{line.side === 'debit' ? (line.type === 'vat' && line.vat_rate ? `${(line.vat_rate * 100).toFixed(0)}% moms` : 'D') : ''}
</span>
<span className={line.side === 'credit' ? 'text-foreground' : ''}>
{line.side === 'credit' ? (line.type === 'vat' && line.vat_rate ? `${(line.vat_rate * 100).toFixed(0)}% moms` : 'K') : ''}
</span>
</div>
))}
</div>
)}
</button>
)
})
)}
</div>
{/* Apply section */}
{selected && (
<div className="flex items-end gap-3 pt-3 border-t">
<div className="flex-1">
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Totalt belopp (inkl. moms)
</label>
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0,00"
min="0"
step="0.01"
inputMode="decimal"
autoFocus
/>
</div>
<Button onClick={handleApply} type="button">
Använd mall
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}
+37 -18
View File
@@ -14,10 +14,12 @@ import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { JournalEntryReviewContent } from '@/components/bookkeeping/JournalEntryReviewContent'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import BookingTemplatePicker from '@/components/bookkeeping/BookingTemplatePicker'
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCompany } from '@/contexts/CompanyContext'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
import type { CreateJournalEntryLineInput, FiscalPeriod, BASAccount, JournalEntrySourceType, Currency } from '@/types'
@@ -69,6 +71,7 @@ export default function JournalEntryForm({
}: Props) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
const { company } = useCompany()
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [entryDate, setEntryDate] = useState(initialDate ?? new Date().toISOString().split('T')[0])
@@ -235,6 +238,11 @@ export default function JournalEntryForm({
? Math.round(computedForeignAmount * rate * 100) / 100
: 0
const handleTemplateApply = (templateLines: FormLine[], templateDescription: string) => {
setLines(templateLines)
if (!description) setDescription(templateDescription)
}
const handleReview = () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
const hasDocuments = uploadedFiles.some((f) => f.status === 'uploaded')
@@ -575,15 +583,21 @@ export default function JournalEntryForm({
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={addLine}
className="w-full"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={addLine}
className="flex-1"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
entityType={company?.entity_type}
/>
</div>
</div>
{/* Entry lines — desktop table */}
@@ -678,15 +692,20 @@ export default function JournalEntryForm({
</tfoot>
</table>
<Button
variant="outline"
size="sm"
onClick={addLine}
className="mt-2"
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<div className="flex gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={addLine}
>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
<BookingTemplatePicker
onApply={handleTemplateApply}
entityType={company?.entity_type}
/>
</div>
</div>
{/* Document attachments */}
@@ -0,0 +1,490 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/components/ui/use-toast'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Loader2, Trash2, Plus, ChevronDown, Download, Upload, Building2, Users, Globe } from 'lucide-react'
import { TEMPLATE_CATEGORY_LABELS } from '@/lib/bookkeeping/template-library'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { BookingTemplateLibrary, BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
const ENTITY_LABELS: Record<string, string> = {
all: 'Alla',
enskild_firma: 'Enskild firma',
aktiebolag: 'Aktiebolag',
}
export function BookingTemplatesPanel() {
const { toast } = useToast()
const { canWrite } = useCanWrite()
const [templates, setTemplates] = useState<BookingTemplateLibrary[]>([])
const [isLoading, setIsLoading] = useState(true)
const [deletingId, setDeletingId] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const importRef = useRef<HTMLInputElement>(null)
const fetchTemplates = useCallback(async () => {
try {
const res = await fetch('/api/settings/booking-templates')
const json = await res.json()
if (json.data) setTemplates(json.data)
} catch {
toast({ title: 'Fel', description: 'Kunde inte hämta mallar', variant: 'destructive' })
} finally {
setIsLoading(false)
}
}, [toast])
useEffect(() => { fetchTemplates() }, [fetchTemplates])
async function handleDelete(id: string) {
setDeletingId(id)
try {
const res = await fetch('/api/settings/booking-templates', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
})
if (!res.ok) {
toast({ title: 'Fel', description: 'Kunde inte ta bort mall', variant: 'destructive' })
return
}
setTemplates((prev) => prev.filter((t) => t.id !== id))
toast({ title: 'Mall borttagen' })
} finally {
setDeletingId(null)
}
}
async function handleExport() {
try {
const res = await fetch('/api/settings/booking-templates/export')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'bokforingsmallar.json'
a.click()
URL.revokeObjectURL(url)
} catch {
toast({ title: 'Fel', description: 'Kunde inte exportera mallar', variant: 'destructive' })
}
}
async function handleImport(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const payload = JSON.parse(text)
const res = await fetch('/api/settings/booking-templates/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const json = await res.json()
if (!res.ok) {
toast({ title: 'Importfel', description: json.error || 'Kunde inte importera', variant: 'destructive' })
return
}
toast({ title: 'Import klar', description: `${json.imported} mall(ar) importerade.` })
fetchTemplates()
} catch {
toast({ title: 'Importfel', description: 'Ogiltig fil', variant: 'destructive' })
} finally {
// Reset input so same file can be imported again
if (importRef.current) importRef.current.value = ''
}
}
// Group templates by scope
const systemTemplates = templates.filter((t) => t.is_system)
const teamTemplates = templates.filter((t) => t.team_id && !t.is_system)
const companyTemplates = templates.filter((t) => t.company_id && !t.is_system)
return (
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<CardTitle>Bokföringsmallar</CardTitle>
<CardDescription>
Återanvändbara mallar för vanliga bokföringstransaktioner. Standardmallar visas för alla, egna mallar kan skapas och delas.
</CardDescription>
</div>
{canWrite && (
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-3.5 w-3.5 mr-1.5" />
Exportera
</Button>
<Button variant="outline" size="sm" onClick={() => importRef.current?.click()}>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Importera
</Button>
<input
ref={importRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-3.5 w-3.5 mr-1.5" />
Ny mall
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Skapa bokföringsmall</DialogTitle>
</DialogHeader>
<CreateTemplateForm
onCreated={() => {
setShowCreate(false)
fetchTemplates()
}}
/>
</DialogContent>
</Dialog>
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-12">
Inga mallar hittades.
</p>
) : (
<div className="space-y-6">
{/* System templates */}
{systemTemplates.length > 0 && (
<TemplateSection
title="Standardmallar"
icon={Globe}
templates={systemTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={false}
/>
)}
{/* Team templates */}
{teamTemplates.length > 0 && (
<TemplateSection
title="Teammallar"
icon={Users}
templates={teamTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
/>
)}
{/* Company templates */}
{companyTemplates.length > 0 && (
<TemplateSection
title="Företagsmallar"
icon={Building2}
templates={companyTemplates}
expandedId={expandedId}
onToggle={setExpandedId}
deletingId={deletingId}
onDelete={handleDelete}
canDelete={canWrite}
/>
)}
</div>
)}
</CardContent>
</Card>
)
}
function TemplateSection({
title,
icon: Icon,
templates,
expandedId,
onToggle,
deletingId,
onDelete,
canDelete,
}: {
title: string
icon: React.ComponentType<{ className?: string }>
templates: BookingTemplateLibrary[]
expandedId: string | null
onToggle: (id: string | null) => void
deletingId: string | null
onDelete: (id: string) => void
canDelete: boolean
}) {
return (
<div>
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">{title}</h3>
<Badge variant="secondary" className="text-xs">{templates.length}</Badge>
</div>
<div className="space-y-1">
{templates.map((t) => {
const isExpanded = expandedId === t.id
return (
<div
key={t.id}
className="rounded-lg border"
>
<button
type="button"
onClick={() => onToggle(isExpanded ? null : t.id)}
className="w-full flex items-center gap-3 p-3 text-left hover:bg-muted/50 transition-colors"
>
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isExpanded ? 'rotate-0' : '-rotate-90'}`} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{t.name}</span>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{TEMPLATE_CATEGORY_LABELS[t.category]}
</Badge>
{t.entity_type !== 'all' && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{ENTITY_LABELS[t.entity_type]}
</Badge>
)}
</div>
</div>
{canDelete && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onDelete(t.id)
}}
disabled={deletingId === t.id}
className="h-8 w-8 p-0 shrink-0"
>
{deletingId === t.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</Button>
)}
</button>
{isExpanded && (
<div className="px-3 pb-3 pt-0">
{t.description && (
<p className="text-xs text-muted-foreground mb-2">{t.description}</p>
)}
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 w-14">Konto</th>
<th className="text-left py-1">Beskrivning</th>
<th className="text-center py-1 w-16">Typ</th>
<th className="text-right py-1 w-12">Debet</th>
<th className="text-right py-1 w-12">Kredit</th>
</tr>
</thead>
<tbody>
{t.lines.map((line: BookingTemplateLibraryLine, i: number) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1 font-mono">{line.account}</td>
<td className="py-1">{line.label}</td>
<td className="py-1 text-center">
{line.type === 'vat' && line.vat_rate
? `Moms ${(line.vat_rate * 100).toFixed(0)}%`
: line.type === 'settlement' ? 'Betalning' : 'Kostnad/Intäkt'}
</td>
<td className="py-1 text-right">{line.side === 'debit' ? 'D' : ''}</td>
<td className="py-1 text-right">{line.side === 'credit' ? 'K' : ''}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
</div>
)
}
function CreateTemplateForm({ onCreated }: { onCreated: () => void }) {
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState<BookingTemplateCategory>('other')
const [entityType, setEntityType] = useState<'all' | 'enskild_firma' | 'aktiebolag'>('all')
const [lines, setLines] = useState<BookingTemplateLibraryLine[]>([
{ account: '', label: '', side: 'debit', type: 'business', ratio: 1 },
{ account: '', label: '', side: 'credit', type: 'settlement', ratio: 1 },
])
function updateLine(index: number, field: keyof BookingTemplateLibraryLine, value: string | number) {
setLines((prev) => {
const updated = [...prev]
updated[index] = { ...updated[index], [field]: value }
return updated
})
}
function addLine() {
setLines((prev) => [...prev, { account: '', label: '', side: 'debit', type: 'business', ratio: 1 }])
}
function removeLine(index: number) {
if (lines.length <= 2) return
setLines((prev) => prev.filter((_, i) => i !== index))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!name || lines.some((l) => !l.account || !l.label)) {
toast({ title: 'Fyll i alla fält', variant: 'destructive' })
return
}
setIsSubmitting(true)
try {
const res = await fetch('/api/settings/booking-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, category, entity_type: entityType, lines }),
})
if (!res.ok) {
const json = await res.json()
toast({ title: 'Fel', description: json.error || 'Kunde inte skapa mall', variant: 'destructive' })
return
}
toast({ title: 'Mall skapad' })
onCreated()
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="T.ex. Inköp EU-varor" />
</div>
<div>
<Label>Beskrivning <span className="text-muted-foreground font-normal">(valfritt)</span></Label>
<Textarea value={description} onChange={(e) => setDescription(e.target.value)} placeholder="När ska denna mall användas?" rows={2} className="resize-none" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Kategori</Label>
<Select value={category} onValueChange={(v) => setCategory(v as BookingTemplateCategory)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(TEMPLATE_CATEGORY_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Företagstyp</Label>
<Select value={entityType} onValueChange={(v) => setEntityType(v as typeof entityType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(ENTITY_LABELS).map(([k, v]) => (
<SelectItem key={k} value={k}>{v}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>Rader</Label>
<div className="space-y-2 mt-1">
{lines.map((line, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={line.account}
onChange={(e) => updateLine(i, 'account', e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="Konto"
className="w-20 font-mono"
maxLength={4}
/>
<Input
value={line.label}
onChange={(e) => updateLine(i, 'label', e.target.value)}
placeholder="Beskrivning"
className="flex-1"
/>
<Select value={line.side} onValueChange={(v) => updateLine(i, 'side', v)}>
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="debit">Debet</SelectItem>
<SelectItem value="credit">Kredit</SelectItem>
</SelectContent>
</Select>
<Select value={line.type} onValueChange={(v) => updateLine(i, 'type', v)}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="business">Kostnad</SelectItem>
<SelectItem value="vat">Moms</SelectItem>
<SelectItem value="settlement">Betalning</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLine(i)}
disabled={lines.length <= 2}
className="h-8 w-8 p-0 shrink-0"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="h-3 w-3 mr-1" />
Lägg till rad
</Button>
</div>
</div>
<Button type="submit" disabled={isSubmitting} className="w-full">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Skapa mall
</Button>
</form>
)
}
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import { applyTemplate, getTemplateScope, TEMPLATE_CATEGORY_LABELS } from '../template-library'
import type { BookingTemplateLibraryLine } from '@/types'
describe('applyTemplate', () => {
it('creates simple two-line debit/credit entries', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '1630', label: 'Skattekonto', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
const result = applyTemplate(lines, 10000)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
account_number: '1630',
debit_amount: '10000.00',
credit_amount: '',
line_description: 'Skattekonto',
})
expect(result[1]).toEqual({
account_number: '1930',
debit_amount: '',
credit_amount: '10000.00',
line_description: 'Företagskonto',
})
})
it('calculates VAT correctly for reverse charge (EU purchase)', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2614', label: 'Utgående moms', side: 'credit', type: 'vat', vat_rate: 0.25 },
{ account: '2645', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// Total payment is 10000 SEK (no VAT on the payment itself for reverse charge)
const result = applyTemplate(lines, 10000)
expect(result).toHaveLength(4)
// Business line = 10000 * 1.0
expect(result[0].debit_amount).toBe('10000.00')
// VAT = 10000 * 0.25 / (1 + 0.25) = 2000
expect(result[1].credit_amount).toBe('2000.00')
expect(result[2].debit_amount).toBe('2000.00')
// Settlement = 10000
expect(result[3].credit_amount).toBe('10000.00')
})
it('handles representation with 25% input VAT', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '6072', label: 'Representation', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Företagskonto', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// Total paid = 1250 (1000 + 250 VAT)
const result = applyTemplate(lines, 1250)
expect(result).toHaveLength(3)
// Business = 1250 (the representation cost at full ratio)
expect(result[0].debit_amount).toBe('1250.00')
// VAT = 1250 * 0.25 / 1.25 = 250
expect(result[1].debit_amount).toBe('250.00')
// Settlement = 1250
expect(result[2].credit_amount).toBe('1250.00')
})
it('rounds monetary values to 2 decimal places', () => {
const lines: BookingTemplateLibraryLine[] = [
{ account: '4010', label: 'Varuinköp', side: 'debit', type: 'business', ratio: 1.0 },
{ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25 },
{ account: '1930', label: 'Bank', side: 'credit', type: 'settlement', ratio: 1.0 },
]
// 333.33 should produce clean rounding
const result = applyTemplate(lines, 333.33)
expect(result[0].debit_amount).toBe('333.33')
// 333.33 * 0.25 / 1.25 = 66.666 → 66.67
expect(result[1].debit_amount).toBe('66.67')
expect(result[2].credit_amount).toBe('333.33')
})
})
describe('getTemplateScope', () => {
it('identifies system templates', () => {
expect(getTemplateScope({ is_system: true, team_id: null, company_id: null })).toBe('system')
})
it('identifies team templates', () => {
expect(getTemplateScope({ is_system: false, team_id: 'team-1', company_id: null })).toBe('team')
})
it('identifies company templates', () => {
expect(getTemplateScope({ is_system: false, team_id: null, company_id: 'comp-1' })).toBe('company')
})
})
describe('TEMPLATE_CATEGORY_LABELS', () => {
it('has labels for all categories', () => {
expect(Object.keys(TEMPLATE_CATEGORY_LABELS)).toHaveLength(9)
expect(TEMPLATE_CATEGORY_LABELS.eu_trade).toBe('EU-handel')
expect(TEMPLATE_CATEGORY_LABELS.tax_account).toBe('Skattekonto')
})
})
+78
View File
@@ -0,0 +1,78 @@
import type { BookingTemplateCategory, BookingTemplateLibraryLine } from '@/types'
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
/**
* Category labels in Swedish for UI display.
*/
export const TEMPLATE_CATEGORY_LABELS: Record<BookingTemplateCategory, string> = {
eu_trade: 'EU-handel',
tax_account: 'Skattekonto',
private_transfer: 'Egna transaktioner',
salary: 'Lön',
representation: 'Representation',
year_end: 'Bokslut',
vat: 'Moms',
financial: 'Bank & finans',
other: 'Övrigt',
}
/**
* Convert a template's line pattern + total amount into form lines
* ready for the JournalEntryForm.
*
* The algorithm:
* 1. VAT lines: amount = totalAmount × vat_rate / (1 + vat_rate)
* 2. Settlement lines: amount = totalAmount (the full payment)
* 3. Business lines: amount = totalAmount × ratio (cost/revenue net of VAT handled separately)
*
* For simple two-line templates (no VAT), the ratio is typically 1.0
* on both sides and totalAmount is used directly.
*/
export function applyTemplate(
lines: BookingTemplateLibraryLine[],
totalAmount: number,
): FormLine[] {
const result: FormLine[] = []
for (const line of lines) {
let amount = 0
if (line.type === 'vat' && line.vat_rate) {
// VAT calculated on the total inclusive amount
amount = Math.round(totalAmount * line.vat_rate / (1 + line.vat_rate) * 100) / 100
} else if (line.type === 'settlement') {
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
} else {
// Business lines — use ratio (default 1.0)
amount = Math.round(totalAmount * (line.ratio ?? 1) * 100) / 100
}
result.push({
account_number: line.account,
debit_amount: line.side === 'debit' ? amount.toFixed(2) : '',
credit_amount: line.side === 'credit' ? amount.toFixed(2) : '',
line_description: line.label,
})
}
return result
}
/**
* Scope label for displaying where a template comes from.
*/
export function getTemplateScope(template: {
is_system: boolean
team_id: string | null
company_id: string | null
}): 'system' | 'team' | 'company' {
if (template.is_system) return 'system'
if (template.team_id) return 'team'
return 'company'
}
export const SCOPE_LABELS: Record<ReturnType<typeof getTemplateScope>, string> = {
system: 'Standard',
team: 'Team',
company: 'Företag',
}
@@ -0,0 +1,386 @@
-- =============================================================================
-- Booking Template Library
-- =============================================================================
--
-- Reusable journal entry templates (bokföringsmallar) for common scenarios
-- like EU reverse charge purchases, tax account bookings, private transfers.
--
-- Three scoping levels:
-- 1. System templates (company_id IS NULL, team_id IS NULL, is_system = TRUE)
-- Pre-seeded, visible to all authenticated users. Read-only.
-- 2. Team templates (company_id IS NULL, team_id IS NOT NULL)
-- Shared across all companies in a team. Created by team admins.
-- 3. Company templates (company_id IS NOT NULL)
-- Private to one company. Created by any non-viewer member.
--
-- Template lines use the same ratio-based pattern as categorization_templates
-- line_pattern: user enters total amount, system calculates each line.
CREATE TABLE public.booking_template_library (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID REFERENCES public.companies(id) ON DELETE CASCADE,
team_id UUID REFERENCES public.teams(id) ON DELETE CASCADE,
created_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
-- Template identity
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL DEFAULT 'other'
CHECK (category IN (
'eu_trade', 'tax_account', 'private_transfer',
'salary', 'representation', 'year_end',
'vat', 'financial', 'other'
)),
entity_type TEXT NOT NULL DEFAULT 'all'
CHECK (entity_type IN ('all', 'enskild_firma', 'aktiebolag')),
-- Template lines (ratio-based pattern)
-- Array of: { account, label, side, type, ratio?, vat_rate? }
lines JSONB NOT NULL DEFAULT '[]',
-- Flags
is_system BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Metadata
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Constraints
-- System templates: no company, no team
CHECK (NOT is_system OR (company_id IS NULL AND team_id IS NULL)),
-- Team templates: no company
CHECK (team_id IS NULL OR company_id IS NULL),
-- Company or team or system — at least one scope
CHECK (company_id IS NOT NULL OR team_id IS NOT NULL OR is_system)
);
-- RLS
ALTER TABLE public.booking_template_library ENABLE ROW LEVEL SECURITY;
-- SELECT: system templates + own company + own team templates
CREATE POLICY "btl_select" ON public.booking_template_library
FOR SELECT USING (
is_system
OR company_id IN (SELECT public.user_company_ids())
OR team_id IN (SELECT public.user_team_ids())
);
-- INSERT: company templates (non-viewers) or team templates (team members)
CREATE POLICY "btl_insert" ON public.booking_template_library
FOR INSERT WITH CHECK (
NOT is_system
AND public.current_user_can_write()
AND (
company_id = public.current_active_company_id()
OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids()))
)
);
-- UPDATE: own company or own team templates (non-viewers), never system
CREATE POLICY "btl_update" ON public.booking_template_library
FOR UPDATE USING (
NOT is_system
AND public.current_user_can_write()
AND (
company_id IN (SELECT public.user_company_ids())
OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids()))
)
);
-- DELETE: own company or own team templates (non-viewers), never system
CREATE POLICY "btl_delete" ON public.booking_template_library
FOR DELETE USING (
NOT is_system
AND public.current_user_can_write()
AND (
company_id IN (SELECT public.user_company_ids())
OR (company_id IS NULL AND team_id IN (SELECT public.user_team_ids()))
)
);
-- Indexes
CREATE INDEX idx_btl_company ON public.booking_template_library (company_id)
WHERE company_id IS NOT NULL;
CREATE INDEX idx_btl_team ON public.booking_template_library (team_id)
WHERE team_id IS NOT NULL;
CREATE INDEX idx_btl_system ON public.booking_template_library (is_system)
WHERE is_system = TRUE;
CREATE INDEX idx_btl_category ON public.booking_template_library (category);
CREATE INDEX idx_btl_active ON public.booking_template_library (is_active)
WHERE is_active = TRUE;
-- updated_at trigger
CREATE TRIGGER btl_updated_at
BEFORE UPDATE ON public.booking_template_library
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- =============================================================================
-- Seed system templates
-- =============================================================================
-- Lines format: [{ account, label, side, type, ratio?, vat_rate? }]
-- type: 'business' (main account), 'vat' (VAT line), 'settlement' (bank/cash)
-- ratio: proportion of total amount (business lines should sum to 1.0)
-- vat_rate: decimal (0.25, 0.12, 0.06) — applied via rate/(1+rate) for inclusive
-- EU TRADE
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Inköp EU-varor, omvänd moms 25%',
'Köp av varor från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms.',
'eu_trade', 'all', TRUE,
'[
{"account": "4010", "label": "Varuinköp", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "2614", "label": "Utgående moms omvänd skattskyldighet 25%", "side": "credit", "type": "vat", "vat_rate": 0.25},
{"account": "2645", "label": "Beräknad ingående moms 25%", "side": "debit", "type": "vat", "vat_rate": 0.25},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Inköp EU-tjänster, omvänd moms 25%',
'Köp av tjänster från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms.',
'eu_trade', 'all', TRUE,
'[
{"account": "6540", "label": "IT-tjänster", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "2614", "label": "Utgående moms omvänd skattskyldighet 25%", "side": "credit", "type": "vat", "vat_rate": 0.25},
{"account": "2645", "label": "Beräknad ingående moms 25%", "side": "debit", "type": "vat", "vat_rate": 0.25},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Försäljning EU-tjänster (B2B)',
'Tjänsteförsäljning till annat EU-land (B2B). Ingen moms — kunden redovisar omvänd skattskyldighet.',
'eu_trade', 'all', TRUE,
'[
{"account": "1510", "label": "Kundfordringar", "side": "debit", "type": "settlement", "ratio": 1.0},
{"account": "3308", "label": "Försäljning tjänster EU", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Försäljning export (utanför EU)',
'Försäljning till land utanför EU. Momsfritt.',
'eu_trade', 'all', TRUE,
'[
{"account": "1510", "label": "Kundfordringar", "side": "debit", "type": "settlement", "ratio": 1.0},
{"account": "3305", "label": "Försäljning export", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
);
-- TAX ACCOUNT
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Insättning skattekonto',
'Betalning från företagskonto till skattekontot hos Skatteverket.',
'tax_account', 'all', TRUE,
'[
{"account": "1630", "label": "Skattekonto", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Skatteåterbäring',
'Återbetalning från skattekontot till företagskonto.',
'tax_account', 'all', TRUE,
'[
{"account": "1930", "label": "Företagskonto", "side": "debit", "type": "settlement", "ratio": 1.0},
{"account": "1630", "label": "Skattekonto", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Preliminär F-skatt (EF)',
'Betalning av preliminär F-skatt från skattekontot (enskild firma).',
'tax_account', 'enskild_firma', TRUE,
'[
{"account": "2012", "label": "Egna skatter", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1630", "label": "Skattekonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Preliminär F-skatt (AB)',
'Betalning av preliminär bolagsskatt från skattekontot.',
'tax_account', 'aktiebolag', TRUE,
'[
{"account": "2518", "label": "Betald F-skatt", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1630", "label": "Skattekonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Momsbetalning via skattekonto',
'Moms som dras från skattekontot efter momsdeklaration.',
'tax_account', 'all', TRUE,
'[
{"account": "2650", "label": "Redovisningskonto moms", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1630", "label": "Skattekonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Arbetsgivaravgifter via skattekonto',
'Arbetsgivaravgifter som dras från skattekontot.',
'tax_account', 'all', TRUE,
'[
{"account": "2731", "label": "Avräkning sociala avgifter", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1630", "label": "Skattekonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
);
-- PRIVATE TRANSFERS
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Eget uttag',
'Privat uttag från företagskontot (enskild firma).',
'private_transfer', 'enskild_firma', TRUE,
'[
{"account": "2013", "label": "Egna uttag", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Eget insättning',
'Privat insättning till företagskontot (enskild firma).',
'private_transfer', 'enskild_firma', TRUE,
'[
{"account": "1930", "label": "Företagskonto", "side": "debit", "type": "settlement", "ratio": 1.0},
{"account": "2018", "label": "Egna insättningar", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Aktieägarlån — insättning',
'Ägaren sätter in pengar som lån till bolaget.',
'private_transfer', 'aktiebolag', TRUE,
'[
{"account": "1930", "label": "Företagskonto", "side": "debit", "type": "settlement", "ratio": 1.0},
{"account": "2893", "label": "Skuld till aktieägare", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Aktieägarlån — återbetalning',
'Bolaget betalar tillbaka lån till ägaren.',
'private_transfer', 'aktiebolag', TRUE,
'[
{"account": "2893", "label": "Skuld till aktieägare", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Utdelning till aktieägare',
'Utbetalning av beslutad utdelning till aktieägare.',
'private_transfer', 'aktiebolag', TRUE,
'[
{"account": "2898", "label": "Outtagen utdelning", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
);
-- SALARY
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Löneutbetalning',
'Utbetalning av nettolön till anställd.',
'salary', 'aktiebolag', TRUE,
'[
{"account": "2710", "label": "Personalskatt", "side": "debit", "type": "business", "ratio": 0.3},
{"account": "2920", "label": "Upplupna semesterlöner", "side": "debit", "type": "business", "ratio": 0.12},
{"account": "7010", "label": "Löner", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Arbetsgivaravgifter',
'Bokföring av arbetsgivaravgifter (31,42% av bruttolön).',
'salary', 'aktiebolag', TRUE,
'[
{"account": "7510", "label": "Arbetsgivaravgifter", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "2731", "label": "Avräkning sociala avgifter", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
);
'[
{"account": "6072", "label": "Representation avdragsgill", "side": "debit", "type": "business", "ratio": 0.8},
{"account": "2641", "label": "Ingående moms", "side": "debit", "type": "vat", "vat_rate": 0.25},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
-- YEAR-END / FINANCIAL
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Periodiseringsfond avsättning (AB)',
'Avsättning till periodiseringsfond vid bokslut. Max 25% av överskottet.',
'year_end', 'aktiebolag', TRUE,
'[
{"account": "8811", "label": "Avsättning periodiseringsfond", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "2113", "label": "Periodiseringsfond", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Periodiseringsfond återföring (AB)',
'Återföring av periodiseringsfond (senast efter 6 år).',
'year_end', 'aktiebolag', TRUE,
'[
{"account": "2113", "label": "Periodiseringsfond", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "8819", "label": "Återföring periodiseringsfond", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Beräknad bolagsskatt',
'Bokföring av beräknad inkomstskatt vid bokslut.',
'year_end', 'aktiebolag', TRUE,
'[
{"account": "8910", "label": "Skatt på årets resultat", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "2512", "label": "Beräknad inkomstskatt", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Överavskrivning inventarier',
'Bokföring av överavskrivning (skillnad räkenskapsenlig vs planenlig).',
'year_end', 'aktiebolag', TRUE,
'[
{"account": "8850", "label": "Förändring överavskrivning", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "2150", "label": "Ackumulerade överavskrivningar", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
);
-- VAT
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Momsredovisning (nettning)',
'Nettning av momskonton vid momsdeklaration. Justera konton och belopp efter din deklaration.',
'vat', 'all', TRUE,
'[
{"account": "2611", "label": "Utgående moms 25%", "side": "debit", "type": "business", "ratio": 0.5},
{"account": "2641", "label": "Ingående moms", "side": "credit", "type": "business", "ratio": 0.3},
{"account": "2650", "label": "Redovisningskonto moms", "side": "credit", "type": "business", "ratio": 0.2}
]'::jsonb
);
-- FINANCIAL
INSERT INTO public.booking_template_library (name, description, category, entity_type, is_system, lines) VALUES
(
'Bankavgift',
'Månadsavgift eller transaktionsavgift från banken.',
'financial', 'all', TRUE,
'[
{"account": "6570", "label": "Bankkostnader", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
),
(
'Ränteintäkt',
'Ränta från sparkonto eller bank.',
'financial', 'all', TRUE,
'[
{"account": "1930", "label": "Företagskonto", "side": "debit", "type": "settlement", "ratio": 1.0},
{"account": "8311", "label": "Ränteintäkter", "side": "credit", "type": "business", "ratio": 1.0}
]'::jsonb
),
(
'Räntekostnad',
'Ränta på lån eller kredit.',
'financial', 'all', TRUE,
'[
{"account": "8410", "label": "Räntekostnader", "side": "debit", "type": "business", "ratio": 1.0},
{"account": "1930", "label": "Företagskonto", "side": "credit", "type": "settlement", "ratio": 1.0}
]'::jsonb
);
-- Schema reload for PostgREST
NOTIFY pgrst, 'reload schema';
+39
View File
@@ -1059,6 +1059,45 @@ export interface CategorizationTemplate {
updated_at: string
}
// Booking template library categories
export type BookingTemplateCategory =
| 'eu_trade'
| 'tax_account'
| 'private_transfer'
| 'salary'
| 'representation'
| 'year_end'
| 'vat'
| 'financial'
| 'other'
// Booking template library line
export interface BookingTemplateLibraryLine {
account: string
label: string
side: 'debit' | 'credit'
type: 'business' | 'vat' | 'settlement'
ratio?: number
vat_rate?: number
}
// Booking template library entry (system, team, or company-scoped)
export interface BookingTemplateLibrary {
id: string
company_id: string | null
team_id: string | null
created_by: string | null
name: string
description: string
category: BookingTemplateCategory
entity_type: 'all' | EntityType
lines: BookingTemplateLibraryLine[]
is_system: boolean
is_active: boolean
created_at: string
updated_at: string
}
// Account Balance (cached)
export interface AccountBalance {
id: string